Skip to content

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/*.

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.

SymbolMeaning
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
FeatureHonoActixphazeStatus
Static routesbuilderbuilder#[get("/api/health")] attribute★ attribute beats main() registration
Path params:id{id}:id
Catch-all*rest{tail:.*}*rest
Trailing-slash toleranceyesyesyes (normalised)
HTTP method attrsapp.get/post/...#[get]/#[post]/...#[get/post/put/delete/patch/head/options]
Method-specific guardsyesyesyes (matcher splits by method)
Router microbench~13 nsn/a (native)5.66 ns
Specificity / overlap rulesyesyesyes (static-first, dynamic fallback)
Regex constraints (:id(\d+))yesyes ({id:\d+})#[get("/api/items/:id(\\d+)")] — post-match regex-lite validation, anchored ^…$, failure → 404
Route groups / nestingapp.route('/api', sub)scope / web::scope#[group("/api/v1")] mod v1 { #[get("/users")] async fn ... } — mod attribute prefixes every method-attr inside
FeatureHonoActixphazeStatus
JSON body parsec.req.json()web::Json<T>Json<T> arg + ctx.json::<T>()
URL-encoded formc.req.parseBody()web::Form<T>Form<T> arg via macro
Multipartc.req.parseBody({all:true})actix-multipartctx.multipart().await? -> MultipartForm with .field(name) / .file(name) (workerd host-side parser)
Raw bytesc.req.arrayBuffer()web::Bytesctx.body_bytes()
Text bodyc.req.text()String extractorctx.body_text().await
Streaming body inc.req.raw.bodyweb::Payloadctx.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 middlewareactix-web-validatorserde + Result<T> error path✓ — serde + thiserror covers it
Single body-read enforcementerror on second readextractor moves bodyBadRequest("body already consumed") on second read
FeatureHonoActixphazeStatus
Path param by namec.req.param('id')web::Path<T>ctx.param("id")
Path param typed deserialisec.req.param() returns stringweb::Path<u64> via DeserializePath<T> extractor — serde-driven via Params::to_query_string()
Query param by namec.req.query('n')web::Query<T>ctx.query_param("n")
Query typed deserialisevia Zod middlewareweb::Query<Struct>Query<T> extractor via serde_urlencoded
Header by namec.req.header('cf-ray')HttpRequest::headers().get(...)ctx.header("cf-ray")
All headersc.req.raw.headersHeaderMapctx.headers()
FeatureHonoActixphazeStatus
JSON responsec.json(value, status)Json<T> returnJson::ok(value) / Json::with_status(value, status)
Plain textc.text(...)String / &str returnText::ok(...)
Raw bytes / Bufferc.body(arrayBuffer)Bytes returnresponse::ok(bytes).header(k,v).into_response()
Status-only responsec.status(204)HttpResponse::NoContent()IntoResponse for StatusCode + () → 204
HTMLc.html(...)actix-files / templating crateHtml::ok(body)content-type: text/html; charset=utf-8 set automatically. No templating built in (compose strings, or pair with Maud / Askama).
Streaming responsec.body(readableStream)HttpResponse::Ok().streaming(...)StreamBody<S> — wraps Stream<Item = Bytes> into a ReadableStream response
Server-Sent Eventsc.stream(...) / c.streamSSE(...)actix-web-lab::sseSse<S> + SseEvent builder
FeatureHonoActixphazeStatus
Read cookiegetCookie(c, 'name')req.cookie("name")ctx.cookie(name)
Set cookiesetCookie(c, k, v, opts)HttpResponse::cookie(...)ResponseBuilder::cookie(name, value) + cookie_with(name, value, &opts)
Signed cookiesgetSignedCookie / setSignedCookieCookieIdentityPolicyphaze::crypto::cookie::sign_value / verify_value — Ed25519, hex-encoded sig
Cookie middlewareyesyesinline 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.
FeatureHonoActixphazeStatus
Basic auth middlewarebasicAuthactix-web-httpauth#[get("/admin", auth = "Basic<verify_fn>")] — RFC 7617, 401 + WWW-Authenticate: Basic realm="phaze" on failure
Bearer / JWTjwt, bearerAuthactix-web-httpauth + jsonwebtokenverify = "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-specificverify = "Ed25519<EXPR>" attribute — single body read, sync verify, 401 envelope
CSRFcsrf middlewarevariouscsrf = "strict" or csrf = "<origin>,<origin>" macro attribute — Origin/Referer host vs Host check; GET/HEAD/OPTIONS exempt; 403 on mismatch
CORScors middlewareactix-corscors = "*" / cors = "https://app.tld" macro attribute — appends Access-Control-Allow-* headers post-IntoResponse, automatic Vary: Origin for restricted
Secure headerssecureHeaders middlewareactix-web::middleware::DefaultHeaderssecure = true macro attribute — appends HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin
Rate limitingrate-limiter middlewareactix-governorrate_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
FeatureHonoActixphazeStatus
KVc.env.KV.get(...)n/actx.kv("MY_KV")?KvStore
D1c.env.DB.prepare(...)n/actx.d1("DB")?D1Database
R2c.env.BUCKET.get(...)n/actx.bucket("MY_BUCKET")?Bucket
Queuesc.env.QUEUE.send(...)n/actx.queue("MY_QUEUE")?Queue (workspace worker feature flag enabled)
Durable Objectsyesn/actx.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 / varsc.env.SECRETn/actx.var("MY_VAR")? / ctx.secret("MY_SECRET")?String
request.cf (geo, edge)c.req.raw.cfn/actx.geo() / ctx.edge() (header-derived; lazy)
FeatureHonoActixphazeStatus
Pre-handler hookapp.use(...)wrap(...) + Service<> traitattribute-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 hookyesyesattribute-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 middlewareonErrorerror mapping in extractorsglobal via Error::into_response + per-handler via Result<T, E>
LoggerloggerLoggerphaze::log! / warn! / error! / debug! — workerd routes to console.* (wrangler tail); phaze-native routes to tracing
Pretty JSONprettyJSON middlewaren/aJson::pretty(value) constructor, or .with_pretty(true) builder — uses serde_json::to_vec_pretty (2-space indent)
Request IDrequestId middlewareactix-web-requestidctx.edge().ray (CF’s cf-ray)✓ (CF provides)
Cache headers helpercache middlewareCache-Control manualResponseBuilder::header("cache-control", ...)✓ (manual; helper later)
ETagetag middlewaremanualResponseBuilder::etag(&payload) — strong validator, first 8 hex of SHA-256
Compressioncompress middlewareCompressworkerd does this for you✓ (host-provided)
Timing / Server-Timingn/aactix-web::middleware::LoggerResponseBuilder::server_timing(&[(name, ms), …]) — multi-entry comma-joined header
FeatureHonoActixphazeStatus
End-to-end-typed TS clienthc<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-openapipaperclip / utoipaphaze 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
FeatureHonoActixphazeStatus
WebSocketupgradeWebSocketactix-web-actors::wsphaze::ws::{WebSocketPair, WebsocketEvent, accept} — re-exports worker-rs primitives + a direct-path-friendly 101 response builder
Server-Sent Eventsc.streamSSE(...)actix-web-lab::sseSse<S> + SseEvent builder (same as the Response-building row)
Streaming response bodyyesyesStreamBody<S> — wraps Stream<Item = Bytes> into a ReadableStream response
TargetHonophaze
Cloudflare Workers✓ (phaze)
Cloudflare Pages✓ (same as Workers)
Cloudflare Containers / Linux nativen/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.

Workload (p50, 2000 samples)HonophazeStatus
/api/health (bare)0.486 ms0.465 ms
/api/hash?n=1000 (iterated SHA-256)1.354 ms0.608 ms★ (2.2× < Hono)
/api/flat?n=1000 (FB-encode ~56 KB)0.781 ms0.498 ms★ (1.6× < Hono)
p95 /api/health0.888 ms0.603 ms★ (tighter tail)
Terminal window
# 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 derive

Cargo.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-crypto
serde = { 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.

phaze ships as a small family of crates rather than one monolith — consumers pull only what they enable.

CrateWhatDefault?
phazeThe workerd runtime — #[event(fetch)] entry, dispatcher, response builder. The crate you depend on.Always
phaze-coreBackend-agnostic primitives: Error enum + status mapping, Matcher<R: RouteLike> (the static-map + matchit radix matcher), Geo/Edge cf-header structs, Json<T>/Text wrapper typesPulled by phaze
phaze-macrosThe 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-cryptoEd25519 + 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 cookiesOpt-in via phaze = { features = ["crypto"] }
phaze-authWebAuthn (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-fbFlatbuffer parse/serialize + Flat<T> extractor + content negotiationOpt-in via features = ["fb"] (in development)
phaze-cliInstallable binary (cargo install phaze-cliphaze 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 metadataStandalone — not a runtime dep
phaze-nativeSibling runtime — hyper + tokio + TCP listener. Same handler API; the only Cargo line that changes when you switch to a native binary on Containers/LinuxNot 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.

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 proxy fetch body at build time. No separate tool, no generated file to commit. The response type is inlined directly on the response: field. See Phaze Transport › Proxying a Rust endpoint.
  • phaze gen client (standalone, external consumers) — phaze-cli walks src/api/**/*.rs and emits a standalone TS client for consumers that don’t have a transport.phaze file (e.g. a shared Rust API consumed by a separate frontend, or OpenAPI tooling).

phaze-cli is an installable binary (cargo install phaze-cliphaze on PATH), not a runtime dep.

CommandWalksEmitsStatus
phaze gen clientsrc/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 wrappershipped
phaze gen openapithe same metadataOpenAPI 3.1 JSON — paths keyed by {id}, request/response schemas from Json<T>/Form<T>/Path<T>/Query<T>/return typesshipped
phaze gen (.fbs parser).fbs schemasRust structs + runtime-free DataView reader (today .fbs schemas are flatc-generated by hand into both trees)planned
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(),
})
}
AxumActixphaze
Route registrationRouter::new().route(...) builder at main()App::new().service(...) builder at main()attribute on the handlerinventory::collect! registers at link time, no main() boilerplate
Handler signatureextractors via tupleextractors via tupleattribute macro recognises types, 0-1-2+ args all work
Ctx analogueState<T> + Requestweb::Data<T> + HttpRequestsingle Ctx with lazy accessors — method/path/query/header pulled across the Wasm boundary on demand
Wire error shapeeach project defineseach 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.

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 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);

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`"

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()) }
}

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).

ImplementationEngineMicrobench
Hono RegExpRouterV8 JIT~13 ns / match
phaze-cloudflare TSV8 JIT~8 ns / match
phaze Rust (V2)native Rust5.66 ns / match
phaze-native (V1 nested HashMap — historical)native Rust16.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. Now FxHashMap<(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.

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:

  1. Reads the request body bytes once.
  2. Pulls the X-Signature header, parses it as hex into a 64-byte Signature.
  3. Calls stripe_pubkey().verify(&body_bytes, &sig) — returns Result<(), Error::Unauthorized>.
  4. Json<T> arg deserialises from the SAME bytes — no second body read, no double parse.
  5. Handler runs only if 1-4 all pass.

Error mapping:

FailureStatuscode
Missing X-Signature header401UNAUTHORIZED
Signature not valid hex400BAD_REQUEST
Signature wrong length (≠ 64 bytes)400BAD_REQUEST
Verify rejects signature401UNAUTHORIZED
JSON body parse failure400BAD_REQUEST

The default header name is X-Signature. Header-name + encoding (base64) configurability is on the roadmap.

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();
// Sign
let sig = sk.sign(b"hello");
// Verify (strict mode by default — rejects malleable signatures)
pk.verify(b"hello", &sig)?; // -> Result<(), Error::Unauthorized>
// Hex serialisation
let pk_hex = pk.to_hex(); // 64-char lowercase
let sig_hex = sig.to_hex(); // 128-char lowercase
let 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).

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.

The rest of phaze::crypto beyond the headline ed25519 + p256 sections — the remaining classical (pre-quantum) algorithms, plus the post-quantum additions.

All RustCrypto, audited, pure-Rust, no openssl dep:

  • phaze::crypto::aes — AES-256-GCM seal / open (the encrypt: / encrypt= floor; also the DEM half of ml-kem-768+aes-gcm)
  • phaze::crypto::hkdf — HKDF key derivation
  • phaze::crypto::sha2 — sha256 / sha512 (also backs ResponseBuilder::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 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 both

Shipped:

  • 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-encrypt and /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 dual X-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 by ml_dsa::verify_response_sig. Feature pqc-sig + a PHAZE_ML_DSA_KEY secret (degrades to ed25519-only if absent).
    • #[get(encrypt = "ml-kem-768+aes-gcm")] — seals the body to the configured PUBLIC_PHAZE_KEM_KEY archive var and emits the { kem, e, iv } envelope; the reader/vault holds the ml-kem secret and calls ml_kem::open_sealed. Feature pqc-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-pq and a live e2e (build/pqc.test.mjs, models session.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-generatedkem_primitive! / dsa_primitive! / slh_primitive! / falcon_primitive! are one source of truth, so every level (incl. 768/65) expands the same code:

AlgorithmStandardTypeStatus
ML-KEM-768FIPS 203KEM (L3)shippedphaze::crypto::ml_kem (+ encrypt: field)
ML-KEM-512 / -1024FIPS 203KEM (L1 / L5)shippedml_kem_512 / ml_kem_1024 (primitive)
ML-DSA-65FIPS 204signature (L3)shippedphaze::crypto::ml_dsa (+ sign: field)
ML-DSA-44 / -87FIPS 204signature (L2 / L5)shippedml_dsa_44 / ml_dsa_87 (primitive)
SLH-DSA-SHA2-128sFIPS 205signature (hash-based, L1)shippedphaze::crypto::slh_dsa (via fips205, pqc-slh)
SLH-DSA-192s / -256sFIPS 205signature (L3 / L5)shippedslh_dsa_192s / slh_dsa_256s (primitive)
FN-DSA (Falcon-512 / -1024)FN-DSA — unpublishedsignature (compact)shippedphaze::crypto::falcon / falcon_1024 (via fn-dsa, pqc-falcon)
Classic McEliece 348864code-basedKEM (conservative)shippedphaze::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)byteshex stringvs 5 KB binding
ML-KEM-768 pubkey (var)1,1842.3 KB✅ fits
Falcon-512 pubkey8971.8 KB✅ fits
SLH-DSA-128s secret640.1 KB✅ fits
ML-DSA-65 signing secret4,0327.9 KB⚠️ over the documented 5 KB
McEliece-348864 pubkey (var)261,120510 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 exposes from_bytes / from_hexwhere the bytes live is the consumer’s choice; McEliece simply can’t take the binding path the others do. (Separately, keypair_boxed puts that 255 KB key on the heap, not the wasm stack — a different concern from provisioning: it keeps generate() 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.

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, CSPRNG
let 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:

  1. clientData.type = "webauthn.get" (constant-time)
  2. Challenge match (constant-time)
  3. Origin match
  4. rpIdHash = SHA-256(rpId) match (constant-time)
  5. User-present (UP) flag set
  6. ECDSA-P256 signature verify over authenticatorData ‖ SHA-256(clientDataJSON)
  7. 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.

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 key
let 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.

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 tree
let total = resp.total(); // each accessor follows a vtable offset into the buffer

On 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-free
import { 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 tree

Same 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 runs
  • IntoResponse for Flat<T> — returns application/x-flatbuf with no manual content-type
  • content negotiation — inspect the Accept header, prefer FlatBuffer when the client supports it, fall back to JSON otherwise
  • outbound sign = "ed25519" attribute (post-handler, alongside cors / secure) — signs the response body before the Wasm→JS crossing, attaching X-Phaze-Sig: ed25519:<base64>; the browser verifies natively via Web Crypto (free). Same wire format as sign: 'ed25519' in TS Transport — one compiled verify arrow handles both runtimes. The Flat<T> IntoResponse wrapper 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" and encrypt = "ml-kem-768+aes-gcm" are the shipped PQC counterparts (Post-quantum). Note the split: classical encrypt: 'aes-gcm-256' is the TS dispatcher’s job (Web Crypto AES-GCM-wraps the Rust body as { e, iv }, preserving X-Phaze-Sig); the PQC encrypt = "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.
  • .fbs auto-discovery → a runtime-free DataView reader (phaze-cli parses .fbs → the reader; the browser ships zero npm flatbuffers) + the typed flat() client. Rust structs stay flatc-generated. (Today the bench schema is flatc-generated by hand into both trees.)

End-to-end (workerd local, sequential 2000-sample p50):

WorkloadHonophaze-cloudflare TSphaze RustWinner
/api/health (bare)0.486 ms0.883 ms0.465 msphaze Rust (-21 µs vs Hono)
/api/hash?n=1000 (iter SHA-256)1.354 ms1.678 ms0.608 msphaze Rust (2.2× vs Hono)
/api/flat?n=1000 (FB-encode ~56 KB)0.781 ms1.039 ms0.498 msphaze 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::Requestweb_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 cf object + parses version
  • wraps the body in a worker::Body stream
  • 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, caches
  • ctx.header(name) crosses the boundary once per accessed header
  • ctx.geo() / ctx.edge() are 5-8 crossings each — only run if you call them
  • Response: Json<T> and ResponseBuilder build web_sys::Response directly via new_with_opt_u8_array_and_init — no ReadableStream, 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.