Phaze + Rust
phaze (no suffix) is a Rust HTTP runtime that compiles and deploys to Cloudflare Workers via standard wrangler deploy. Free-tier compatible — no Containers required (Durable Objects are opt-in via ctx.durable_object and phaze::durable). Same phaze brand across npm (TypeScript reactive UI) and crates.io (Rust HTTP runtime); the language context disambiguates.
Inside a phaze-cloudflare app, src/api/*.rs files are the Rust endpoints — same convention as src/api/*.ts in TS-only apps, just with Rust handlers and the URL still routed via /api/*.
Hono, Actix comparison
Section titled “Hono, Actix comparison”This page is the Hono parity checklist for phaze — every column tracks a feature category, the third column is what phaze needs to ship to reach parity. Actix is included as the native-Rust reference point so the phaze-native story (same handler API, hyper backend) sits next to its closest peer.
| Symbol | Meaning |
|---|---|
| ★ | phaze ahead — Hono has nothing equivalent, or phaze’s shape is measurably better |
| ✓ | parity — feature available, shape acceptable |
| ▶ | in progress / roadmap — design locked, implementation pending |
| · | out of scope / not planned |
Routing
Section titled “Routing”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| Static routes | builder | builder | #[get("/api/health")] attribute | ★ attribute beats main() registration |
| Path params | :id | {id} | :id | ✓ |
| Catch-all | *rest | {tail:.*} | *rest | ✓ |
| Trailing-slash tolerance | yes | yes | yes (normalised) | ✓ |
| HTTP method attrs | app.get/post/... | #[get]/#[post]/... | #[get/post/put/delete/patch/head/options] | ✓ |
| Method-specific guards | yes | yes | yes (matcher splits by method) | ✓ |
| Router microbench | ~13 ns | n/a (native) | 5.66 ns | ★ |
| Specificity / overlap rules | yes | yes | yes (static-first, dynamic fallback) | ✓ |
Regex constraints (:id(\d+)) | yes | yes ({id:\d+}) | #[get("/api/items/:id(\\d+)")] — post-match regex-lite validation, anchored ^…$, failure → 404 | ✓ |
| Route groups / nesting | app.route('/api', sub) | scope / web::scope | #[group("/api/v1")] mod v1 { #[get("/users")] async fn ... } — mod attribute prefixes every method-attr inside | ✓ |
Body extraction
Section titled “Body extraction”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| JSON body parse | c.req.json() | web::Json<T> | Json<T> arg + ctx.json::<T>() | ✓ |
| URL-encoded form | c.req.parseBody() | web::Form<T> | Form<T> arg via macro | ✓ |
| Multipart | c.req.parseBody({all:true}) | actix-multipart | ctx.multipart().await? -> MultipartForm with .field(name) / .file(name) (workerd host-side parser) | ✓ |
| Raw bytes | c.req.arrayBuffer() | web::Bytes | ctx.body_bytes() | ✓ |
| Text body | c.req.text() | String extractor | ctx.body_text().await | ✓ |
| Streaming body in | c.req.raw.body | web::Payload | ctx.body_stream()? — Pin<Box<dyn Stream<Item = Result<Bytes>>>> pulled from web_sys::Request::body() via wasm-streams; chunked, never buffers the full body in Wasm memory | ✓ |
| Validation (Zod / serde) | Zod via middleware | actix-web-validator | serde + Result<T> error path | ✓ — serde + thiserror covers it |
| Single body-read enforcement | error on second read | extractor moves body | BadRequest("body already consumed") on second read | ✓ |
Path / query / headers
Section titled “Path / query / headers”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| Path param by name | c.req.param('id') | web::Path<T> | ctx.param("id") | ✓ |
| Path param typed deserialise | c.req.param() returns string | web::Path<u64> via Deserialize | Path<T> extractor — serde-driven via Params::to_query_string() | ✓ |
| Query param by name | c.req.query('n') | web::Query<T> | ctx.query_param("n") | ✓ |
| Query typed deserialise | via Zod middleware | web::Query<Struct> | Query<T> extractor via serde_urlencoded | ✓ |
| Header by name | c.req.header('cf-ray') | HttpRequest::headers().get(...) | ctx.header("cf-ray") | ✓ |
| All headers | c.req.raw.headers | HeaderMap | ctx.headers() | ✓ |
Response building
Section titled “Response building”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| JSON response | c.json(value, status) | Json<T> return | Json::ok(value) / Json::with_status(value, status) | ✓ |
| Plain text | c.text(...) | String / &str return | Text::ok(...) | ✓ |
| Raw bytes / Buffer | c.body(arrayBuffer) | Bytes return | response::ok(bytes).header(k,v).into_response() | ✓ |
| Status-only response | c.status(204) | HttpResponse::NoContent() | IntoResponse for StatusCode + () → 204 | ✓ |
| HTML | c.html(...) | actix-files / templating crate | Html::ok(body) — content-type: text/html; charset=utf-8 set automatically. No templating built in (compose strings, or pair with Maud / Askama). | ✓ |
| Streaming response | c.body(readableStream) | HttpResponse::Ok().streaming(...) | StreamBody<S> — wraps Stream<Item = Bytes> into a ReadableStream response | ✓ |
| Server-Sent Events | c.stream(...) / c.streamSSE(...) | actix-web-lab::sse | Sse<S> + SseEvent builder | ✓ |
Cookies / sessions
Section titled “Cookies / sessions”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| Read cookie | getCookie(c, 'name') | req.cookie("name") | ctx.cookie(name) | ✓ |
| Set cookie | setCookie(c, k, v, opts) | HttpResponse::cookie(...) | ResponseBuilder::cookie(name, value) + cookie_with(name, value, &opts) | ✓ |
| Signed cookies | getSignedCookie / setSignedCookie | CookieIdentityPolicy | phaze::crypto::cookie::sign_value / verify_value — Ed25519, hex-encoded sig | ✓ |
| Cookie middleware | yes | yes | inline accessor — ctx.cookie(name) parses the inbound Cookie: header on each call (single linear scan); phaze::crypto::cookie::verify_value checks the Ed25519 signature when read. The attribute-pipeline shape used by verify/auth/csrf is the place to add an auto-decoding cookie = "..." shortcut later if a use-case earns it. | ✓ |
Auth / security
Section titled “Auth / security”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| Basic auth middleware | basicAuth | actix-web-httpauth | #[get("/admin", auth = "Basic<verify_fn>")] — RFC 7617, 401 + WWW-Authenticate: Basic realm="phaze" on failure | ✓ |
| Bearer / JWT | jwt, bearerAuth | actix-web-httpauth + jsonwebtoken | verify = "Jwt<HS256(EXPR)>" / verify = "Jwt<EdDSA(EXPR)>" macro attribute — primitives in phaze::crypto::jwt::{verify_hs256, verify_eddsa} | ✓ |
| Signed webhook (Ed25519) | manual: read body + sig header + crypto.subtle.verify (~50 µs async per call) | manual or crate-specific | verify = "Ed25519<EXPR>" attribute — single body read, sync verify, 401 envelope | ★ |
| CSRF | csrf middleware | various | csrf = "strict" or csrf = "<origin>,<origin>" macro attribute — Origin/Referer host vs Host check; GET/HEAD/OPTIONS exempt; 403 on mismatch | ✓ |
| CORS | cors middleware | actix-cors | cors = "*" / cors = "https://app.tld" macro attribute — appends Access-Control-Allow-* headers post-IntoResponse, automatic Vary: Origin for restricted | ✓ |
| Secure headers | secureHeaders middleware | actix-web::middleware::DefaultHeaders | secure = true macro attribute — appends HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin | ✓ |
| Rate limiting | rate-limiter middleware | actix-governor | rate_limit = 10 macro attribute — per-IP fixed 60 s window keyed <path>:<ip> (client IP from cf-connecting-ip), 429 TooManyRequests over the cap; runs first in the pipeline, requires a Ctx arg. Per-isolate (mirrors the TS dispatcher’s rateBuckets); the Rust half of external: [rateLimit(n)] so a direct client→Rust fence enforces the same cap | ✓ |
Cloudflare bindings
Section titled “Cloudflare bindings”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| KV | c.env.KV.get(...) | n/a | ctx.kv("MY_KV")? → KvStore | ✓ |
| D1 | c.env.DB.prepare(...) | n/a | ctx.d1("DB")? → D1Database | ✓ |
| R2 | c.env.BUCKET.get(...) | n/a | ctx.bucket("MY_BUCKET")? → Bucket | ✓ |
| Queues | c.env.QUEUE.send(...) | n/a | ctx.queue("MY_QUEUE")? → Queue (workspace worker feature flag enabled) | ✓ |
| Durable Objects | yes | n/a | ctx.durable_object("BINDING")? → ObjectNamespace; phaze::durable::* re-exports #[durable_object] macro + DurableObject trait + State/Stub types. Consumer needs worker+wasm-bindgen direct deps for the macro expansion. | ✓ |
| Secrets / vars | c.env.SECRET | n/a | ctx.var("MY_VAR")? / ctx.secret("MY_SECRET")? → String | ✓ |
request.cf (geo, edge) | c.req.raw.cf | n/a | ctx.geo() / ctx.edge() (header-derived; lazy) | ✓ |
Middleware / cross-cutting
Section titled “Middleware / cross-cutting”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| Pre-handler hook | app.use(...) | wrap(...) + Service<> trait | attribute-based: rate_limit = N, csrf = "...", auth = "Basic<fn>", verify = "Ed25519/Jwt<...>" — pipeline runs rate_limit → csrf → auth → verify → handler (rate limit first: cheapest rejection). cache planned. | ✓ |
| Post-handler hook | yes | yes | attribute-based response-mutation: cors = "..." appends CORS headers, secure = true appends secure-default headers, sign = "ed25519" signs the response body → X-Phaze-Sig: ed25519:<base64> (same wire as sign: 'ed25519' in TS Transport; browser verifies natively via Web Crypto — free). PQC (feature-gated): sign = "ed25519+ml-dsa" adds a second ml-dsa signature and encrypt = "ml-kem-768+aes-gcm" seals the body to an archive key — both server-side / at-rest, and they compose (Post-quantum) | ✓ |
| Error mapping middleware | onError | error mapping in extractors | global via Error::into_response + per-handler via Result<T, E> | ✓ |
| Logger | logger | Logger | phaze::log! / warn! / error! / debug! — workerd routes to console.* (wrangler tail); phaze-native routes to tracing | ✓ |
| Pretty JSON | prettyJSON middleware | n/a | Json::pretty(value) constructor, or .with_pretty(true) builder — uses serde_json::to_vec_pretty (2-space indent) | ✓ |
| Request ID | requestId middleware | actix-web-requestid | ctx.edge().ray (CF’s cf-ray) | ✓ (CF provides) |
| Cache headers helper | cache middleware | Cache-Control manual | ResponseBuilder::header("cache-control", ...) | ✓ (manual; helper later) |
| ETag | etag middleware | manual | ResponseBuilder::etag(&payload) — strong validator, first 8 hex of SHA-256 | ✓ |
| Compression | compress middleware | Compress | workerd does this for you | ✓ (host-provided) |
Timing / Server-Timing | n/a | actix-web::middleware::Logger | ResponseBuilder::server_timing(&[(name, ms), …]) — multi-entry comma-joined header | ✓ |
Type-safe RPC client / OpenAPI
Section titled “Type-safe RPC client / OpenAPI”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| End-to-end-typed TS client | hc<App> — types flow from server (same language) | n/a (TS clients hand-rolled) | rust: knob (primary, in-app) — rust: "/api/path" on a transport.phaze fence; phaze-compile generates the full proxy fetch body + response type is inlined on the fence’s response: field. phaze gen client (standalone) — walks src/api/**/*.rs via syn AST; emits a PhazeError-aware TS client for consumers without a transport.phaze file. See Phaze Transport › Proxying a Rust endpoint | ★ |
| OpenAPI / Swagger | @hono/zod-openapi | paperclip / utoipa | phaze gen openapi — same AST walker as gen client, emits OpenAPI 3.1 JSON: paths keyed by OpenAPI templates ({id} not :id), requestBody/parameters/responses derived from Json<T>/Form<T>/Path<T>/Query<T>/return types; user types referenced via $ref: #/components/schemas/...; Vec<T> → type: array, primitives mapped through | ✓ |
Streaming / WebSocket / SSE
Section titled “Streaming / WebSocket / SSE”| Feature | Hono | Actix | phaze | Status |
|---|---|---|---|---|
| WebSocket | upgradeWebSocket | actix-web-actors::ws | phaze::ws::{WebSocketPair, WebsocketEvent, accept} — re-exports worker-rs primitives + a direct-path-friendly 101 response builder | ✓ |
| Server-Sent Events | c.streamSSE(...) | actix-web-lab::sse | Sse<S> + SseEvent builder (same as the Response-building row) | ✓ |
| Streaming response body | yes | yes | StreamBody<S> — wraps Stream<Item = Bytes> into a ReadableStream response | ✓ |
Adapters / deploy targets
Section titled “Adapters / deploy targets”| Target | Hono | phaze |
|---|---|---|
| Cloudflare Workers | ✓ | ✓ (phaze) |
| Cloudflare Pages | ✓ | ✓ (same as Workers) |
| Cloudflare Containers / Linux native | n/a | ✓ (phaze-native) |
| Deno | ✓ | · not planned |
| Bun | ✓ | · not planned |
| Node.js | ✓ | · not planned |
| Vercel Edge | ✓ | · not planned |
| AWS Lambda | ✓ | · not planned |
phaze is intentionally Cloudflare-first — the brand promise is “Rust on Workers, free tier, no Containers required.” Multi-runtime support adds dep weight and dilutes the perf story; the existing phaze-native covers the “I outgrew Workers” case via hyper, which is the more useful upgrade path than supporting 5 edge runtimes.
Headline numbers
Section titled “Headline numbers”| Workload (p50, 2000 samples) | Hono | phaze | Status |
|---|---|---|---|
/api/health (bare) | 0.486 ms | 0.465 ms | ★ |
/api/hash?n=1000 (iterated SHA-256) | 1.354 ms | 0.608 ms | ★ (2.2× < Hono) |
/api/flat?n=1000 (FB-encode ~56 KB) | 0.781 ms | 0.498 ms | ★ (1.6× < Hono) |
p95 /api/health | 0.888 ms | 0.603 ms | ★ (tighter tail) |
# crates.io will publish soon; today consumers path-dep to the workspace.# In your phaze app's rust-api/ subcrate:cargo add phaze # the runtime (workerd Wasm)cargo add serde --features deriveCargo.toml:
[package]name = "my-api"edition = "2021"
[lib]crate-type = ["cdylib"] # required for wasm-on-workerd
[dependencies]phaze = { version = "0.1", features = ["crypto"] } # crypto pulls in phaze-cryptoserde = { version = "1", features = ["derive"] }wrangler.jsonc:
{ "name": "my-api", "main": "build/worker/shim.mjs", "compatibility_date": "2026-06-09", "build": { "command": "cargo install -q worker-build && worker-build --release" }}pnpm dev then runs wrangler dev; wrangler deploy ships to the edge. No extra build orchestration — phaze emits a single #[wasm_bindgen] fetch export that worker-build wires into the standard worker JS shim.
Workspace layout
Section titled “Workspace layout”phaze ships as a small family of crates rather than one monolith — consumers pull only what they enable.
| Crate | What | Default? |
|---|---|---|
phaze | The workerd runtime — #[event(fetch)] entry, dispatcher, response builder. The crate you depend on. | Always |
phaze-core | Backend-agnostic primitives: Error enum + status mapping, Matcher<R: RouteLike> (the static-map + matchit radix matcher), Geo/Edge cf-header structs, Json<T>/Text wrapper types | Pulled by phaze |
phaze-macros | The 7 HTTP-method attributes (#[get/post/put/delete/patch/head/options]) + #[group("/prefix")] mod attribute + 6 cross-cutting args composable on any method: verify (Ed25519 / Jwt<HS256> / Jwt<EdDSA>), auth (Basic), csrf (strict / allow-list), cors, secure, rate_limit (per-IP window) | Pulled by phaze |
phaze-crypto | Ed25519 + P-256/ES256 (sign/verify/generate, hex, DER, COSE — the passkey floor), AES-GCM, HKDF, sha2, constant-time, X25519, JWT verify (HS256 + EdDSA), Basic-auth parsing, signed cookies | Opt-in via phaze = { features = ["crypto"] } |
phaze-auth | WebAuthn (passkeys) + TOTP + session schema — pure-Rust, no Worker deps. Passkey registration/assertion (P-256/ES256, COSE, CBOR), TOTP HMAC-SHA1, CSPRNG session-token generation. Re-exported via phaze = { features = ["auth"] }. | Opt-in via features = ["auth"] |
phaze-fb | Flatbuffer parse/serialize + Flat<T> extractor + content negotiation | Opt-in via features = ["fb"] (in development) |
phaze-cli | Installable binary (cargo install phaze-cli → phaze on PATH). Today: phaze gen client walks src/api/**/*.rs and emits a typed TS client; phaze gen openapi emits an OpenAPI 3.1 description from the same metadata | Standalone — not a runtime dep |
phaze-native | Sibling runtime — hyper + tokio + TCP listener. Same handler API; the only Cargo line that changes when you switch to a native binary on Containers/Linux | Not pulled by default |
The same #[get("/api/health")] async fn health(ctx: Ctx) -> Json<Health> source compiles against either phaze (workerd) or phaze-native (hyper) — flip a single Cargo.toml line. The boundary lives in phaze-core: error pipeline, matcher algorithm, and wrapper types are shared; only Ctx, the concrete Route, and IntoResponse impls are backend-specific.
CLI & codegen (phaze-cli)
Section titled “CLI & codegen (phaze-cli)”End-to-end Rust → TS types ship via two paths — pick by whether you have a transport.phaze file:
rust:knob (primary, in-app) — declare the path + shape on a fence; phaze-compile generates the full proxyfetchbody at build time. No separate tool, no generated file to commit. The response type is inlined directly on theresponse:field. See Phaze Transport › Proxying a Rust endpoint.phaze gen client(standalone, external consumers) —phaze-cliwalkssrc/api/**/*.rsand emits a standalone TS client for consumers that don’t have atransport.phazefile (e.g. a shared Rust API consumed by a separate frontend, or OpenAPI tooling).
phaze-cli is an installable binary (cargo install phaze-cli → phaze on PATH), not a runtime dep.
| Command | Walks | Emits | Status |
|---|---|---|---|
phaze gen client | src/api/**/*.rs (syn AST) — #[get/post/...] + handler signatures, respecting #[group("/prefix")] | a typed TS client: camelCase fns, path interpolation, body/query handling, a PhazeError-aware fetch wrapper | shipped |
phaze gen openapi | the same metadata | OpenAPI 3.1 JSON — paths keyed by {id}, request/response schemas from Json<T>/Form<T>/Path<T>/Query<T>/return types | shipped |
phaze gen (.fbs parser) | .fbs schemas | Rust structs + runtime-free DataView reader (today .fbs schemas are flatc-generated by hand into both trees) | planned |
Handler shape
Section titled “Handler shape”use phaze::prelude::*;use serde::{Deserialize, Serialize};
#[derive(Serialize)]struct Health { ok: bool, timestamp: String, uptime_ms: i64,}
#[get("/api/health")]async fn health(ctx: Ctx) -> Json<Health> { Json::ok(Health { ok: true, timestamp: ctx.now_iso(), uptime_ms: ctx.uptime_ms(), })}| Axum | Actix | phaze | |
|---|---|---|---|
| Route registration | Router::new().route(...) builder at main() | App::new().service(...) builder at main() | attribute on the handler — inventory::collect! registers at link time, no main() boilerplate |
| Handler signature | extractors via tuple | extractors via tuple | attribute macro recognises types, 0-1-2+ args all work |
Ctx analogue | State<T> + Request | web::Data<T> + HttpRequest | single Ctx with lazy accessors — method/path/query/header pulled across the Wasm boundary on demand |
| Wire error shape | each project defines | each project defines | {"error":{"code","message"}} — same envelope as phaze-cloudflare TS actions, so a single TS client wrapper handles both |
The 7 HTTP-method attributes (#[get], #[post], #[put], #[delete], #[patch], #[head], #[options]) all delegate to a shared expansion that walks the handler’s args and emits the right extractor code per arg type.
Body extraction
Section titled “Body extraction”Two equivalent shapes — pick by ergonomics.
Shape A — method call inside the handler:
#[derive(Deserialize)]struct CreateOrder { sku: String, quantity: u32 }
#[post("/api/orders")]async fn create(ctx: Ctx) -> Result<Json<Order>> { let input: CreateOrder = ctx.json().await?; // ... use input ... Ok(Json::created(order))}Shape B — typed argument (preferred for readability):
#[post("/api/orders")]async fn create(body: Json<CreateOrder>, ctx: Ctx) -> Result<Json<Order>> { let CreateOrder { sku, quantity } = body.value; // ... use sku/quantity ... Ok(Json::created(order))}The macro recognises Json<T> in the signature and emits the body-read + parse code before the handler runs. Malformed JSON short-circuits to a 400 BAD_REQUEST envelope before any handler code executes.
Order is flexible: (ctx: Ctx, body: Json<T>) works the same as (body: Json<T>, ctx: Ctx) — the macro extracts the body first (which needs __ctx internally) and only then rebinds __ctx to the user’s chosen name.
Single body read per request: the JS body stream is single-shot. Calling ctx.json() twice (or combining Json<T> extractor with a manual ctx.body_bytes() call) returns BadRequest("body already consumed") on the second read. The verify attribute combines body-read with verify + Json<T> deserialise so the bytes are reused, not re-fetched.
Path and query params
Section titled “Path and query params”Path params come from dynamic patterns — :name for a single segment, *name for catch-all:
#[get("/api/users/:id")]async fn get_user(ctx: Ctx) -> Result<Json<User>> { let id = ctx .param("id") .ok_or_else(|| Error::BadRequest("missing id".into()))?; // ... look up user ... Ok(Json::ok(user))}Query params are looked up by name from the raw query string:
let tag = ctx.query_param("tag").map(str::to_string);let n: u32 = ctx .query_param("n") .and_then(|s| s.parse().ok()) .unwrap_or(1);Typed extractors
Section titled “Typed extractors”For struct-shaped path / query / form deserialization, the macro
recognises three additional wrapper types — body extraction is
single-shot and they share the same serde_urlencoded deserialiser, so
mixing them just works:
use serde::Deserialize;
#[derive(Deserialize)]struct UserPath { id: u64 }
#[derive(Deserialize)]struct Filters { tag: Option<String>, limit: Option<u32> }
#[derive(Deserialize)]struct Login { email: String, password: String }
#[get("/api/users/:id")]async fn get_user(path: Path<UserPath>) -> Json<User> { let id = path.0.id; // ...}
#[get("/api/posts")]async fn list_posts(query: Query<Filters>) -> Json<Vec<Post>> { let limit = query.0.limit.unwrap_or(10); // ...}
#[post("/api/login")]async fn login(form: Form<Login>) -> Result<Json<Session>> { // form.0.email / form.0.password populated from the urlencoded body}Bad input gets a structured 400 envelope before the handler body runs — same shape as malformed JSON:
GET /api/users/notanumber → 400 BAD_REQUEST "invalid digit found in string"POST /api/login (no password) → 400 BAD_REQUEST "missing field `password`"Errors
Section titled “Errors”Handlers return Result<T> (= Result<T, phaze::Error>). The framework converts Err into the wire envelope automatically:
pub enum Error { BadRequest(String), // 400 Unauthorized(String), // 401 Forbidden(String), // 403 NotFound(String), // 404 MethodNotAllowed(String), // 405 Conflict(String), // 409 PayloadTooLarge(String), // 413 TooManyRequests(String), // 429 Internal(String), // 500}Wire format:
{ "error": { "code": "BAD_REQUEST", "message": "email is required" } }Same shape as phaze-cloudflare’s TS action errors — a single TS client error-handling pipeline covers both backends.
User error types flow through ? via a one-line Into<phaze::Error> impl:
#[derive(thiserror::Error, Debug)]enum AppError { #[error("db query failed")] Db(#[from] sqlx::Error),}
impl From<AppError> for phaze::Error { fn from(e: AppError) -> Self { Self::Internal(e.to_string()) }}Routing
Section titled “Routing”The matcher is two-tier: a static-pattern FxHashMap keyed on (method_tag, &'static str) covers exact-path hits in one probe; dynamic patterns fall through to matchit (the radix tree Axum uses).
| Implementation | Engine | Microbench |
|---|---|---|
Hono RegExpRouter | V8 JIT | ~13 ns / match |
| phaze-cloudflare TS | V8 JIT | ~8 ns / match |
| phaze Rust (V2) | native Rust | 5.66 ns / match |
| phaze-native (V1 nested HashMap — historical) | native Rust | 16.66 ns / match |
The V1→V2 jump (2.95× faster) came from two changes:
- Combined-key static map. Was
HashMap<Method, HashMap<&str, _>>— two hash lookups per match. NowFxHashMap<(u8, &'static str), _>— one probe. - FxHasher instead of SipHash. ~2-3× faster on the short
(method_tag, path)keys we use. SipHash’s DDoS resistance is moot for an application-owned route table.
Compile-time perfect hash (PHF) was tried as V3 and lost to V2 at our route scale — phf 0.11 hardcodes SipHash, and FxHashMap collision-probes at 17 routes are effectively zero. PHF wins at 100+ route tables; below that V2 is the floor.
Route registration happens via inventory::collect! — each #[get(...)] macro emits an inventory::submit! block; the matcher walks the global registry once per isolate at first request.
Signed webhooks
Section titled “Signed webhooks”The verify = "Ed25519<EXPR>" attribute auto-verifies a signed request body before the handler runs. EXPR is any Rust expression that yields something with a verify(&[u8], &Signature) -> Result<(), Error> method — a path to a static PublicKey, a function call returning &'static PublicKey, a method chain, etc.
use phaze::prelude::*;use phaze::crypto::ed25519::PublicKey;
// Loaded once at first request from an env var:static STRIPE_PUBKEY: std::sync::OnceLock<PublicKey> = std::sync::OnceLock::new();fn stripe_pubkey() -> &'static PublicKey { STRIPE_PUBKEY.get_or_init(|| { PublicKey::from_hex(env!("STRIPE_PUBKEY_HEX")).unwrap() })}
#[derive(Deserialize)]struct StripeEvent { id: String, r#type: String, /* ... */ }
#[post("/api/webhooks/stripe", verify = "Ed25519<stripe_pubkey()>")]async fn stripe_webhook(body: Json<StripeEvent>) -> Result<()> { // Body verified before this line runs. process_event(&body.value).await?; Ok(())}The dispatch shim emitted by the macro:
- Reads the request body bytes once.
- Pulls the
X-Signatureheader, parses it as hex into a 64-byteSignature. - Calls
stripe_pubkey().verify(&body_bytes, &sig)— returnsResult<(), Error::Unauthorized>. - Json<T> arg deserialises from the SAME bytes — no second body read, no double parse.
- Handler runs only if 1-4 all pass.
Error mapping:
| Failure | Status | code |
|---|---|---|
Missing X-Signature header | 401 | UNAUTHORIZED |
| Signature not valid hex | 400 | BAD_REQUEST |
| Signature wrong length (≠ 64 bytes) | 400 | BAD_REQUEST |
| Verify rejects signature | 401 | UNAUTHORIZED |
| JSON body parse failure | 400 | BAD_REQUEST |
The default header name is X-Signature. Header-name + encoding (base64) configurability is on the roadmap.
Ed25519 primitives
Section titled “Ed25519 primitives”phaze::crypto::ed25519 wraps ed25519-dalek with phaze-flavoured ergonomics. Errors return phaze::Error directly so handler code ?-propagates straight to the client.
use phaze::crypto::ed25519::{PrivateKey, PublicKey, Signature};
// Generate (uses workerd's crypto.getRandomValues via getrandom's "js" feature)let sk = PrivateKey::generate();let pk = sk.public();
// Signlet sig = sk.sign(b"hello");
// Verify (strict mode by default — rejects malleable signatures)pk.verify(b"hello", &sig)?; // -> Result<(), Error::Unauthorized>
// Hex serialisationlet pk_hex = pk.to_hex(); // 64-char lowercaselet sig_hex = sig.to_hex(); // 128-char lowercaselet pk2 = PublicKey::from_hex(&pk_hex)?;let sig2 = Signature::from_hex(&sig_hex)?;Same crate works on workerd (wasm32 via wasm-bindgen + getrandom’s “js” feature) and on native (where OsRng reads /dev/urandom).
P-256 / ES256 — phaze::crypto::p256
Section titled “P-256 / ES256 — phaze::crypto::p256”phaze::crypto::p256 (RustCrypto p256 + ciborium) is the ECDSA-P-256 counterpart to ed25519, serving two things: outbound sign: 'es256' response signatures (raw r‖s) and passkeys / WebAuthn — DER signatures (Signature::from_der), ES256 = SHA-256 prehash, and COSE_Key parsing (cose_key_to_public). PublicKey (from_sec1 / from_xy / from_hex / verify / to_sec1), PrivateKey (generate / from_hex / sign / public), Signature (from_bytes [raw] / from_der [WebAuthn] / to_der). Verified to compile to wasm32 (workerd) — the whole passkey flow runs in-framework, no webauthn-rs. See Security › Passkeys.
Classical / PQC
Section titled “Classical / PQC”The rest of phaze::crypto beyond the headline ed25519 + p256 sections — the remaining classical (pre-quantum) algorithms, plus the post-quantum additions.
Classical (pre-quantum)
Section titled “Classical (pre-quantum)”All RustCrypto, audited, pure-Rust, no openssl dep:
phaze::crypto::aes— AES-256-GCMseal/open(theencrypt:/encrypt=floor; also the DEM half ofml-kem-768+aes-gcm)phaze::crypto::hkdf— HKDF key derivationphaze::crypto::sha2— sha256 / sha512 (also backsResponseBuilder::etag)phaze::crypto::ct— constant-time equality (timing-safe token comparison)phaze::crypto::x25519— X25519 (the classical half of future hybrid KEMs)phaze::crypto::{jwt, cookie, basic}— JWT verify (HS256 / EdDSA), signed cookies, Basic-auth parsing
The outbound sign= attribute builds on ed25519 here — it signs the response body and emits the same X-Phaze-Sig: ed25519:<base64> wire the TS dispatcher does (cross-runtime parity; see Phaze Transport).
Post-quantum (ML-KEM / ML-DSA)
Section titled “Post-quantum (ML-KEM / ML-DSA)”Post-quantum lives on the Rust runtime — ~70–80 KB wasm, server-side / at-rest, never the browser edge (the PQC design note covers the measured cost and the placement decision). It’s behind opt-in features that each pull a lattice dep tree (dcrypt), so you compile only the family you use:
phaze = { version = "0.1", features = ["crypto", "pqc-kem"] } # ML-KEM only# or "pqc-sig" (ML-DSA only), or "pqc" for bothShipped:
phaze::crypto::ml_kem— ML-KEM-768 (FIPS 203):generate()→ keypair,pk.encapsulate()→(ciphertext, shared_secret),sk.decapsulate(&ct)→shared_secret. The 32-byte shared secret is an AES-256 key.phaze::crypto::ml_dsa— ML-DSA-65 (FIPS 204):generate()→ keypair,sk.sign(msg)→ signature,pk.verify(msg, &sig)→Ok(())iff valid.- The hybrid
ml-kem-768 + aes-gcm=ml_kem+aes::{seal, open}(the KEM establishes the key, AES-256-GCM encrypts the body). Both proven end-to-end server-side at/api/pqc-encryptand/api/pqc-sign(worker-build links them clean in workerd). - The transport-field attributes (post-handler, alongside
sign = "ed25519"):#[get(sign = "ed25519+ml-dsa")]— emits the dualX-Phaze-Sig: ed25519:<b64>, ml-dsa:<b64>. The browser verifies the ed25519 half native (0 WASM); the ml-dsa half is the at-rest/relay assurance, checked server-side byml_dsa::verify_response_sig. Featurepqc-sig+ aPHAZE_ML_DSA_KEYsecret (degrades to ed25519-only if absent).#[get(encrypt = "ml-kem-768+aes-gcm")]— seals the body to the configuredPUBLIC_PHAZE_KEM_KEYarchive var and emits the{ kem, e, iv }envelope; the reader/vault holds the ml-kem secret and callsml_kem::open_sealed. Featurepqc-kem. A missing key errors (no silent plaintext).- The two compose (sign-then-encrypt): sign the body, then seal it; the reader decrypts, then verifies.
- Verified at
/api/signed-pq,/api/encrypted-pq,/api/verify-pq,/api/sealed-signed-pqand a live e2e (build/pqc.test.mjs, modelssession.test.mjs). Mint the keys with the shipped primitives —let (pk, sk) = ml_dsa::generate()?; sk.to_hex()etc. (dcrypt’s wire format isn’t OpenSSL-FIPS-compatible, so keys are Rust-minted, not node one-liners).
The phaze surface names the FIPS-final algorithm (ml_kem / ml_dsa), with dcrypt’s legacy “Kyber”/“Dilithium” naming as the swappable backing (the field/wire names the algorithm, not the crate). The security-level variants are DRY-macro-generated — kem_primitive! / dsa_primitive! / slh_primitive! / falcon_primitive! are one source of truth, so every level (incl. 768/65) expands the same code:
| Algorithm | Standard | Type | Status |
|---|---|---|---|
| ML-KEM-768 | FIPS 203 | KEM (L3) | shipped — phaze::crypto::ml_kem (+ encrypt: field) |
| ML-KEM-512 / -1024 | FIPS 203 | KEM (L1 / L5) | shipped — ml_kem_512 / ml_kem_1024 (primitive) |
| ML-DSA-65 | FIPS 204 | signature (L3) | shipped — phaze::crypto::ml_dsa (+ sign: field) |
| ML-DSA-44 / -87 | FIPS 204 | signature (L2 / L5) | shipped — ml_dsa_44 / ml_dsa_87 (primitive) |
| SLH-DSA-SHA2-128s | FIPS 205 | signature (hash-based, L1) | shipped — phaze::crypto::slh_dsa (via fips205, pqc-slh) |
| SLH-DSA-192s / -256s | FIPS 205 | signature (L3 / L5) | shipped — slh_dsa_192s / slh_dsa_256s (primitive) |
| FN-DSA (Falcon-512 / -1024) | FN-DSA — unpublished | signature (compact) | shipped — phaze::crypto::falcon / falcon_1024 (via fn-dsa, pqc-falcon) |
| Classic McEliece 348864 | code-based | KEM (conservative) | shipped — phaze::crypto::mceliece (via classic-mceliece-rust, pqc-mceliece) |
All modules are primitive-only (generate / encapsulate · decapsulate / sign · verify + hex serialization); the field wiring (sign: / encrypt:) is on ML-KEM-768 / ML-DSA-65. The ML-KEM (Kyber) and ML-DSA (Dilithium) families come from dcrypt 1.2.3. Where dcrypt stubs an algorithm — SLH-DSA and Falcon impl the trait but keygen returns NotImplemented — or doesn’t export it (Classic McEliece), phaze wraps a dedicated pure-Rust provider instead: fips205, fn-dsa, classic-mceliece-rust. Each was chosen because it reuses the dep tree already present (rand_core 0.6, sha2 / sha3, zeroize), adds no browser weight, and compiles clean on wasm32 — all verified by test. Two facts about these two, from the providers themselves: the fn-dsa crate implements Falcon while the FN-DSA standard is unpublished — it warns its signatures may not be interoperable with the eventual standard, so Falcon material here is not standards-stable. And McEliece’s public key is ~255 KB — too large for a Cloudflare binding, so it takes a different provisioning path (see Key provisioning below); its ciphertexts stay tiny (96 bytes).
Key provisioning — hex bindings vs the 5 KB limit
Section titled “Key provisioning — hex bindings vs the 5 KB limit”Every key reaches the Worker the same way: hex-encode the bytes into a Cloudflare binding, decode at runtime. Public keys go in a var (read via ctx.var), private keys in a secret (wrangler secret put / .dev.vars, read via ctx.secret); the field’s load-preamble does hex::decode(..) → from_bytes(..). Two constraints set the ceiling: hex doubles the byte count, and a Cloudflare binding is capped at 5 KB (Workers limits).
For the lattice / hash algorithms that’s comfortable. Same KEM role — the public key the encrypting worker holds — apples-to-apples:
| Key (role) | bytes | hex string | vs 5 KB binding |
|---|---|---|---|
ML-KEM-768 pubkey (var) | 1,184 | 2.3 KB | ✅ fits |
| Falcon-512 pubkey | 897 | 1.8 KB | ✅ fits |
| SLH-DSA-128s secret | 64 | 0.1 KB | ✅ fits |
| ML-DSA-65 signing secret | 4,032 | 7.9 KB | ⚠️ over the documented 5 KB |
McEliece-348864 pubkey (var) | 261,120 | 510 KB | ❌ ~100× over |
Two things this makes explicit:
- McEliece can’t use a binding at all. Its public key is 220× ML-KEM’s (261,120 vs 1,184 bytes) → 510 KB of hex against a 5 KB cap; base64 (~340 KB) doesn’t rescue it either. Store the raw bytes in R2 (object storage) or a KV value and fetch them at request time, then
mceliece::PublicKey::from_bytes(&fetched). The wrapper only exposesfrom_bytes/from_hex— where the bytes live is the consumer’s choice; McEliece simply can’t take the binding path the others do. (Separately,keypair_boxedputs that 255 KB key on the heap, not the wasm stack — a different concern from provisioning: it keepsgenerate()from overflowing the stack.) - ML-DSA’s signing secret already sits at the edge (7.9 KB > 5 KB). It loads under local
wrangler dev— which likely doesn’t enforce the production per-binding ceiling, or treats secrets more permissively than plaintext vars (unverified) — so treat “it loaded” as a local-dev datapoint, not proof it deploys. Worth confirming for a real deploy.
+ composes them with the classical floor: encrypt: 'ml-kem-768+aes-gcm' (KEM+DEM), sign: 'ed25519+ml-dsa' (composite). PQC is off the browser by design — the field-level wiring runs server-side / at-rest (Option A in the design note).
phaze-auth — WebAuthn, TOTP, session schema
Section titled “phaze-auth — WebAuthn, TOTP, session schema”phaze-auth (phaze = { features = ["auth"] }, re-exported at phaze::auth::*) consolidates the three auth primitives that most phaze apps need. All three modules are pure-Rust — no Ctx or Worker dep — so they compile and unit-test on the native target (cargo test) without wrangler.
phaze::auth::session — KV wire contract
Section titled “phaze::auth::session — KV wire contract”The session schema is the cross-runtime contract: Rust writes, TS reads (or vice versa via revokeUserSession). The camelCase field names are pinned by unit tests so a serde rename never silently breaks the TS side.
use phaze::auth::session::{SessionRec, SessionRead, ChallengeRec, session_key, random_b64url};
// Write a new session to KV (Rust → TS readable).let token = random_b64url(32); // 32 bytes → 43-char base64url, CSPRNGlet rec = SessionRec { user_id: &user_id, expires: now_ms + 86_400_000 };kv.put(&session_key(&token), serde_json::to_string(&rec)?).await?;// KV value: {"userId":"...","expires":...} ← camelCase matches TS SessionRecord
// Read a session (e.g. in a server-side check).let raw = kv.get(&session_key(&token)).text().await?;let rec: SessionRead = serde_json::from_str(&raw)?;
// WebAuthn challenge KV value (register stashes the new user_id; login stores none).let chal_rec = ChallengeRec { user_id: Some(user_id.clone()) };kv.put(&format!("webauthn:chal:{challenge}"), serde_json::to_string(&chal_rec)?).await?;random_b64url(n) draws from OsRng (routes to crypto.getRandomValues() on workerd) and encodes as base64url (no padding) — suitable for session tokens, WebAuthn challenges, and TOTP secrets.
phaze::auth::webauthn — passkey registration + assertion
Section titled “phaze::auth::webauthn — passkey registration + assertion”Two functions cover the full passkey flow; both return Result<T, phaze::Error> so handler code ?-propagates to the wire:
Registration finish — parse the browser’s attestationObject, extract and validate the credential:
use phaze::auth::webauthn::{parse_attestation_object, RegistrationData};
let att_bytes = b64url_decode(&payload.attestation_object)?;let RegistrationData { cred_id, cose_public_key, aaguid, sign_count } = parse_attestation_object(&att_bytes)?;
// Store cred_id + cose_public_key + sign_count in D1 under the user id.// cose_public_key is stored as-is; cose_key_to_public() reads it at login.Login finish — verify the assertion (ECDSA-P256 over authenticatorData ‖ SHA-256(clientDataJSON)) and return the new sign-count to persist:
use phaze::auth::webauthn::{verify_assertion, AssertionInput};
let new_count = verify_assertion(AssertionInput { credential_public_key: &cose_public_key, // stored at registration authenticator_data: &b64url_decode(&payload.authenticator_data)?, client_data_json: &b64url_decode(&payload.client_data_json)?, signature: &b64url_decode(&payload.signature)?, expected_challenge_b64url: &challenge, // from KV webauthn:chal:<chal> expected_origin: &origin, // e.g. "https://app.example.com" expected_rp_id: &rp_id, // e.g. "app.example.com" stored_sign_count: prev_count, // from D1; 0 means unknown})?;// Persist new_count to D1; a regression (clone detection) errors before this.Checks performed by verify_assertion, in order:
clientData.type="webauthn.get"(constant-time)- Challenge match (constant-time)
- Origin match
rpIdHash= SHA-256(rpId) match (constant-time)- User-present (UP) flag set
- ECDSA-P256 signature verify over
authenticatorData ‖ SHA-256(clientDataJSON) - Sign-count monotonicity (regression →
Error::Unauthorized("possible cloned authenticator"))
parse_attestation_object uses none attestation — the minimal WebAuthn profile that all platform authenticators (Face ID, Touch ID, Windows Hello) support for first-party login.
phaze::auth::totp — TOTP (RFC 6238)
Section titled “phaze::auth::totp — TOTP (RFC 6238)”HMAC-SHA1, 6 digits, 30-second step — the defaults every authenticator app (Google Authenticator, Authy, 1Password) uses. RFC 6238 test vectors pass.
use phaze::auth::totp::{verify, otpauth_uri, code_at};use phaze::auth::session::random_b64url;
// Enroll — generate a secret and hand the phone an otpauth:// URI.let secret_b64url = random_b64url(20); // 20 random bytes for the HMAC keylet uri = otpauth_uri("MyApp", "user@example.com", secret_b64url.as_bytes());// → "otpauth://totp/MyApp:user@example.com?secret=<BASE32>&issuer=MyApp&algorithm=SHA1&digits=6&period=30"// Render uri as a QR code; store secret_b64url in D1 under the user.
// Confirm / verify — check the code the app shows (±1 window for clock skew).let unix_secs = /* ctx.now_ms() / 1000 */ 1_234_567_890u64;if verify(secret_b64url.as_bytes(), &code_from_user, unix_secs, 1) { // Valid — mark TOTP enrolled in D1.}verify accepts the current window ± skew windows (use skew = 1 for ±30 s clock drift tolerance). It validates that the input is exactly 6 ASCII digits before checking — malformed codes are rejected without HMAC.
Flat Buffers
Section titled “Flat Buffers”phaze-fb (opt-in via features = ["fb"]) re-exports Google’s official flatbuffers crate at phaze::fb::backend::* — the real, battle-tested encode/decode API, available today. FlatBuffers is a zero-copy binary format: the serialized bytes are the object graph. Reading a field follows a vtable offset straight into the buffer — no parse pass, no allocation, no object tree. That’s a natural fit for the edge: a Worker builds the buffer in Wasm memory and hands the raw bytes to the response; the browser reads fields in place over the received ArrayBuffer.
Encode in a handler and serve it as raw binary:
use phaze::prelude::*;use phaze::fb::backend::FlatBufferBuilder;use crate::schema::bench::{Item, ItemArgs, Response as Fb, ResponseArgs}; // flatc-generated
#[get("/api/items")]async fn items(ctx: Ctx) -> Response { // Build bottom-up into a Wasm-memory arena (children before parents). let mut b = FlatBufferBuilder::new(); let name = b.create_string("widget"); let item = Item::create(&mut b, &ItemArgs { id: 1, name: Some(name), value: 3.14, active: true }); let items = b.create_vector(&[item]); let root = Fb::create(&mut b, &ResponseArgs { items: Some(items), total: 1, generated_at_ms: 0 }); b.finish(root, None);
// The finished byte slice ships STRAIGHT to web_sys::Response (direct path — no // ReadableStream wrap, no per-field JS-boundary crossing). This direct // Wasm-bytes → native-Response path is what the /api/flat numbers below measure. phaze::response::ok(b.finished_data().to_vec()) .header("content-type", "application/x-flatbuf") .into_response()}Decode is symmetric — root::<T>() returns a view, not a copy. Fields are read in place on access:
use phaze::fb::backend::root;
let bytes = ctx.body_bytes()?;let resp = root::<Fb>(&bytes)?; // zero-copy: no allocation, no object treelet total = resp.total(); // each accessor follows a vtable offset into the bufferOn the client the same buffer travels verbatim and is read in place over the native ArrayBuffer — the bytes are the object graph, so there’s no parse, no object tree, and no decompression (the allocation the format exists to avoid). In a Phaze app the read ships zero npm flatbuffers: the field reads are generated runtime-free — see Flat Buffers & SSR for the consumption model. The bench below uses the stock flatbuffers JS reader purely to illustrate the in-place read:
import * as flatbuffers from 'flatbuffers' // bench illustration only — the Phaze client path is runtime-freeimport { Response as Fb } from './schema/bench/response.js' // flatc-generated
const buf = new Uint8Array(await (await fetch('/api/items')).arrayBuffer())const resp = Fb.getRootAsResponse(new flatbuffers.ByteBuffer(buf))resp.items(0)?.name() // read in place — no parse, no object treeSame buffer, both ends, native: Rust encodes into Wasm memory → Cloudflare ships the bytes as-is → the browser reads them in place. No JSON object construction, no DataView write tax — the per-item cost stays flat in N while flatbuffers-js’s writes go linear (see the /api/flat row below).
phaze::fb::backend::* (encode/decode) is functional now. The ergonomic wrapper is in development:
Flat<T>extractor —async fn h(body: Flat<MyTable>)auto-parses the request buffer before the handler runsIntoResponse for Flat<T>— returnsapplication/x-flatbufwith no manualcontent-type- content negotiation — inspect the
Acceptheader, prefer FlatBuffer when the client supports it, fall back to JSON otherwise - outbound
sign = "ed25519"attribute (post-handler, alongsidecors/secure) — signs the response body before the Wasm→JS crossing, attachingX-Phaze-Sig: ed25519:<base64>; the browser verifies natively via Web Crypto (free). Same wire format assign: 'ed25519'in TS Transport — one compiled verify arrow handles both runtimes. TheFlat<T>IntoResponsewrapper will carry this automatically once the ergonomic layer lands; today it is set directly on the#[get/post]attribute for raw-byte responses.sign = "ed25519+ml-dsa"andencrypt = "ml-kem-768+aes-gcm"are the shipped PQC counterparts (Post-quantum). Note the split: classicalencrypt: 'aes-gcm-256'is the TS dispatcher’s job (Web Crypto AES-GCM-wraps the Rust body as{ e, iv }, preservingX-Phaze-Sig); the PQCencrypt = "ml-kem-768+aes-gcm"is a Rust-side attribute (Web Crypto has no ML-KEM), emitting{ kem, e, iv }. See Phaze Transport › Proxying a Rust endpoint. .fbsauto-discovery → a runtime-freeDataViewreader (phaze-cliparses.fbs→ the reader; the browser ships zero npmflatbuffers) + the typedflat()client. Rust structs stayflatc-generated. (Today thebenchschema isflatc-generated by hand into both trees.)
Performance
Section titled “Performance”End-to-end (workerd local, sequential 2000-sample p50):
| Workload | Hono | phaze-cloudflare TS | phaze Rust | Winner |
|---|---|---|---|---|
/api/health (bare) | 0.486 ms | 0.883 ms | 0.465 ms | phaze Rust (-21 µs vs Hono) |
/api/hash?n=1000 (iter SHA-256) | 1.354 ms | 1.678 ms | 0.608 ms | phaze Rust (2.2× vs Hono) |
/api/flat?n=1000 (FB-encode ~56 KB) | 0.781 ms | 1.039 ms | 0.498 ms | phaze Rust (1.6× vs Hono) |
The crossover where phaze Rust pulls definitively ahead is at N ≈ 100 iterations or items. Below that, framework wrap costs dominate and the picture is closer; above that, Rust’s inline compute (sha2, flatbuffers crate’s zero-copy appends) leaves V8’s await crypto.subtle.digest() / flatbuffers-js’s DataView writes far behind.
p95 / p99 are also tighter than Hono’s — phaze Rust 0.603 ms p95 on /health vs Hono’s 0.888 ms. Tail latency dominance suggests the JS+Wasm path Hono runs has more variance than phaze Rust’s direct web_sys::Request → web_sys::Response path.
Faster than worker-rs — the direct web_sys path
Section titled “Faster than worker-rs — the direct web_sys path”This is a perf edge phaze holds over worker-rs itself (Cloudflare’s own official Rust Workers SDK): phaze talks to the raw web_sys types directly and skips the eager request/response marshalling worker-rs does on every call — ~90 µs/request, which is what puts phaze Rust below Hono on the bare /health case.
The /health win required bypassing worker-rs’s eager parsing. Earlier shape: phaze took http::Request<worker::Body> — which worker-rs’s from_wasm constructs eagerly on every request:
- copies every header into
http::HeaderMap(N JS↔Wasm crossings) - extracts the
cfobject + parses version - wraps the body in a
worker::Bodystream - pops three extension types we never set
Even when /health doesn’t read any of it — measured at ~50 µs per request. Symmetric on the outbound side (to_wasm wraps Full<Bytes> in a wasm_streams::ReadableStream — overkill for a fixed body).
New shape (v0w4): phaze takes web_sys::Request directly. worker::FromRequest::from_raw for that type is the identity function — no parsing. Phaze extracts only what it needs at dispatch (URL string + method string, ~2 crossings) and the rest is lazy:
ctx.path()parses the URL once on first call, cachesctx.header(name)crosses the boundary once per accessed headerctx.geo()/ctx.edge()are 5-8 crossings each — only run if you call them- Response:
Json<T>andResponseBuilderbuildweb_sys::Responsedirectly vianew_with_opt_u8_array_and_init— noReadableStream, no HeaderMap roundtrip
Result: ~90 µs shaved off every workerd response, putting phaze Rust below Hono on the bare-endpoint case despite still paying the wasm-bindgen FFI floor.