Skip to content

sark — the typed web framework

: korean leopard cat

Thread-per-core, shared-nothing HTTP. Requests, responses and routes are generated at compile time; a handler is a plain function that runs as a Fiber on the core that accepted the connection.

A handler is (Request, &State) -> Response. State is core-local — here a visit counter that’s a plain Cell, no atomic in sight:

use std::cell::Cell;
use http::StatusCode;
struct State {
index: &'static [u8], // pre-rendered HTML
font: &'static [u8], // a woff2 asset
font_etag: &'static str, // content hash, for 304s
counter: Cell<u64>, // per-core, non-atomic
}
#[sark_gen::response(raw)]
#[header("content-type", "text/html")]
struct IndexResponse { status: StatusCode, body: &'static [u8] }
#[sark_gen::response(raw)]
#[header("content-type", "text/plain")]
struct CountResponse { status: StatusCode, body: o3::buffer::Owned }
#[sark_gen::response(raw)]
#[header("content-type", "font/woff2")]
struct AssetResponse { status: StatusCode, body: &'static [u8] }
// No fields: no query, no path — and the header map is never parsed.
#[sark_gen::request]
struct IndexRequest {}
// Query string: /count?by=5 (defaults to 1 when absent).
#[sark_gen::request]
struct CountRequest {
#[query("by", default = "1")] by: u64,
}
// Path param + one opt-in header. This is the only struct that names a
// header, so it's the only handler that ever parses one.
#[sark_gen::request]
struct AssetRequest {
#[path("name")] name: &str, // matches /assets/:name
#[header("if-none-match")] etag: Option<&str>,
}
// Static body: rendered once at boot, handed out by pointer.
#[sark_gen::handler]
#[response_body(Static)]
#[static_response]
fn index(_req: IndexRequest, state: &State) -> IndexResponse {
IndexResponse { status: StatusCode::OK, body: state.index }
}
// Dynamic body: mutate core-local state with no lock and no atomic.
#[sark_gen::handler]
fn count(req: CountRequest, state: &State) -> CountResponse {
let n = state.counter.get().saturating_add(req.by);
state.counter.set(n);
let body = format!("visit #{n}");
CountResponse {
status: StatusCode::OK,
body: o3::buffer::Owned::from(body.as_bytes()),
}
}
#[sark_gen::handler]
fn asset(req: AssetRequest, state: &State) -> AssetResponse {
// `if-none-match` is parsed only because this request named it.
if req.etag == Some(state.font_etag) {
return AssetResponse {
status: StatusCode::NOT_MODIFIED,
body: b"",
};
}
match req.name {
"font.woff2" =>
AssetResponse { status: StatusCode::OK, body: state.font },
_ =>
AssetResponse { status: StatusCode::NOT_FOUND, body: b"" },
}
}

Headers are parsed lazily — only the fields a request names get read. IndexRequest and CountRequest declare none, so those handlers never touch the header map; only AssetRequest asked for if-none-match, so only it pays to parse one. You parse the headers you name, and nothing else.

define_route! compiles the table to a static-prefix tree plus a parameter DFA — nested scopes, path params, no runtime registry:

sark_gen::define_route! {
pub Site: State => {
GET "/" => index,
GET "/count" => count,
scope "/assets" => [
GET "/:name" => asset,
],
}
}

Launcher runs the same closure on every CPU; each core builds its own State and leaks it to &'static, so nothing is shared:

use dope::launcher::Launcher;
use sark::{Build, ServerCfg};
fn main() -> std::io::Result<()> {
let cfg = ServerCfg {
bind: "0.0.0.0:8080".parse().unwrap(),
max_conn: 1024,
backlog: 4096,
};
Launcher::new(Launcher::allowed_cpus()).run(move |ctx| {
let state: &'static State = Box::leak(Box::new(State::new()));
Build::http(Site::new(state), cfg.clone(), ctx, None)
})
}

The server Dispatcher is dope manifolds — a Listener, a timer, db connectors. cartel drivers plug in as another per-core manifold, so the database client shares the exact same thread-per-core design.