Phaze Transport
Phaze Transport is how a Phaze app moves data across the client/server boundary: you define a capability once on the server, and call it from the browser with end-to-end types — the compiler generates both ends and ships zero runtime for the call itself.
It is not “Server Actions” in the Next/Astro sense (form-mutation RPC). A Phaze action is a declarative capability stack — inbound validation/middleware → your handler → outbound encoding, signing, and (soon) the wire protocol — where each concern is a field on the definition, and each field compiles to code on both sides at build time.
The capability stack
Section titled “The capability stack”inbound: input: Json(schema) · require:[checks] · use:[functions] → handler → outbound: response: Json<>|Flat<T>|Binary · sign: · encrypt:Every field the compiler reads becomes code on the server dispatch and the inlined client call — zero hand-wiring, zero shipped runtime for the call site. The fields compose because they’re orthogonal: the encoding doesn’t care how the bytes are signed, the signature doesn’t care how they travel.
Defining an action
Section titled “Defining an action”Actions are authored as dotted fences in the reserved src/transport/transport.phaze file. Each ---<Group>.<action> fence is one action: field-style knobs declare the wire contract, the bare --- exits into the handler body.
import { z } from 'zod'import { SessionManifest } from '@/transport/flat_schema/session'
---Session.get input: Json({ id: z.string() }) // inbound — runtime-validated; types `input` require: [auth] // inbound — checks, run FIRST (auth, rate-limit) response: Flat<SessionManifest> // outbound — FlatBuffer encoding (zero-ship reader) sign: 'ed25519' // outbound — integrity (detached X-Phaze-Sig)---const row = await transport.SESSIONS.get(input.id) // `transport.*` = your data bindingsreturn rowJson / Flat / Binary are knob wrappers the compiler recognises in the input:/response: position — not runtime imports (Json({…}) lowers to z.object({…}); Flat<T>/Binary set the outbound encoding + content-type). The same words mirror the Rust runtime’s Json<T> / Flat<T>.
Each fence lowers to <action>: newAction({ …knobs, handler }) nested under its group — newAction is the lowering target, not the authoring surface (a compile-time identity rewritten to a bare object literal; zero shipped bytes from the wrapper). The handler body runs with input (typed from input:) and the ambient context { transport, request, ctx, cookies, edge } (waitUntil is injected from ctx when the body uses it); read env via phaze:env (env.private.*).
Data bindings — transport.*
Section titled “Data bindings — transport.*”The transport object in a handler (and in ---data page loaders) is your data plane — Cloudflare bindings declared once in src/transport.ts:
import { Transport } from '@madenowhere/phaze-cloudflare/transport'import { z } from 'zod'
export default Transport({ DB: z.custom<D1Database>((v) => v != null && typeof v.prepare === 'function'), SESSIONS: z.custom<KVNamespace>((v) => v != null && typeof v.get === 'function'),})Binding names come from wrangler.jsonc; the validators are presence/shape checks so a missing binding fails loudly at first access. Transport is a compile-time identity (zero runtime); the plugin makes transport.DB / transport.SESSIONS / … ambient in action bodies and ---data loaders.
Proxying a Rust endpoint
Section titled “Proxying a Rust endpoint”Most endpoints in a Phaze app are Rust (src/api/*.rs) — the crypto- and parse-heavy work belongs there. The rust: knob makes such an endpoint a first-class transport action without hand-writing the proxy: you declare the path + the response shape, and the compiler generates the entire handler body.
---Totp.enrollFinish rust: "/api/auth/totp/enroll/finish" input: Json({ code }) response: Json<{ ok: boolean }>---return { ok }From the rust: path and the return { … } field list, the compiler emits the full body: a fetch to `${RUST_API}/api/auth/totp/enroll/finish` forwarding the session cookie; an input:-gated body: JSON.stringify(input); an !ok branch that rethrows the Rust error envelope as an ActionError (the import is auto-injected); and the { ok } destructure off the JSON. The fence body is just the shape contract — return { ok } names what comes back; the round-trip is generated. RUST_API (the endpoint base) and COOKIE (the session cookie name) are the two module-scope constants the generated body references — import them at the top of transport.phaze.
rust: is the single-call case. When an action composes 2+ Rust calls — or mixes a Rust call with a direct D1 read — use gen: [fn] instead: it’s import-only (it names the helpers so they stay visible on the fence surface), and you write the orchestration in the body yourself. The rule of thumb: D1 reads go direct; Rust is for the crypto-heavy ops, so a composed action typically reads D1 in the body and calls one Rust helper named in gen:. A Set-Cookie relay (e.g. a passkey */finish that mints the session) also stays a hand-written body — the relay is the intent.
Calling it — phaze:transport
Section titled “Calling it — phaze:transport”The consume side imports the typed proxy from the phaze:transport virtual module. Reads as the pitch — use actions from phaze transport:
import { s } from '@madenowhere/phaze/dsl'import { actions } from 'phaze:transport'
const session = s.async(actions.Session.get({ id })) // typed; resolves the FlatBuffer Viewactions.Session.get(input) is compile-rewritten to an inline fetch arrow at the call site — there is no runtime actions object, no client SDK. The action’s input/output types thread through via ActionsClientFor<typeof server>, so the call is fully typed end-to-end with nothing shipped to back it.
For reactive consumption with pending/error/phase, use useAction(actions.X) — it compiles to an inline state machine, emitting only the fields you read.
The fields
Section titled “The fields”| Field | Concern | Status |
|---|---|---|
input: Json(schema) | inbound — runtime-validated request body; types input (Json({…}) lowers to z.object) | shipped |
require: [...] | inbound — checks/gates, run first (auth, rate-limit) | shipped |
use: [...] | inbound — pre-handler providers/effects; only body-referenced items are destructured into the handler | shipped |
rust: "/api/path" | the handler — proxy to a Rust endpoint; the compiler generates the whole fetch body (forward the cookie, branch !ok → ActionError, destructure return { … } from the JSON). See below | shipped |
gen: [fn] | the handler — import-only knob for composition (2+ Rust calls / mixed-with-D1): names the helpers, the body stays hand-written | shipped |
response: Json<T> | outbound — JSON out, typed T (Json<> = return inferred from the handler body) | shipped |
response: Flat<T> | outbound — FlatBuffer encoding; reads inline to native DataView, zero shipped reader | shipped |
response: Binary | outbound — raw bytes (Float32Array / packed-numeric), application/octet-stream | shipped |
sign: 'ed25519' | outbound — sign the response; the compiled client arrow verifies before reading (Security) | shipped |
encrypt: 'aes-gcm-256' | outbound — confidentiality; wraps the response as { e, iv } (AES-GCM-256, fresh IV per response/frame, AEAD so also tamper-evident); right for streams (SSE frames beyond the workerd boundary) and FlatBuffer actions (binary over wire) — JSON actions that stay within workerd use sign: alone | shipped |
transport: 'sse' · out: · tags: (a stream fence) | the wire — a distinct stream fence kind (open → N frames → close), not an action knob; per-frame type out:, broadcast key tags:, consumed with s.bind (see Streams) | SSE shipped |
transport: 'ws' · in: | the wire — WebSocket bidirectional; in: is the client→server back-channel | design-locked |
transport: 'moq' · relay: · protocol: | the wire — Media over QUIC pub/sub over WebTransport; the dotted fence name is the topic (Group→broadcast, action→track), relay: is the broker URL, protocol: (required) is the wire it speaks ('moq-lite' / 'ietf-15'…'ietf-19') and drives the moqShakePlugin shake. Browser-direct (workerd can’t terminate WebTransport) and no body — the producer is the relay. See MoQ | design-locked |
transport: 'rpc' | the wire — internal Worker RPC over a Cloudflare service binding (worker→worker, in-runtime); the body calls a typed transport.RUST.<method>(...), never a fetch. No public route exists for an rpc fence at all — see Internal vs external | shipped |
external: [...] / true / false | the exposure policy — marks an http/stream fence public and declares its edge policy (CORS allowlist, rate-limit). See Internal vs external | shipped |
Internal vs external
Section titled “Internal vs external”Every fence is internal by default — the dispatcher wire (/transport/action/*, /transport/stream/*) is the app’s own isomorphic channel, never an advertised API. transport: itself carries most of the classification; external: is the one explicit override, and only on http/stream fences:
transport: | external: | Reachable by |
|---|---|---|
rpc | never valid — a compile error | a service binding only (worker→worker); no public route exists at all |
http | absent / false | the dispatcher wire, same-origin (POST) |
http | true / [...] | a public URL — CORS-able, rate-limitable |
sse / ws | absent / false | the dispatcher stream wire, same-origin |
sse / ws | true / [...] | a public stream URL |
moq | not applicable | the external relay directly (browser→broker over WebTransport) — no dispatcher, so no external: policy to apply |
rpc can never carry external:, in any form — a service binding has no public route to police, so declaring it (even external: false) is a compile-time error. Streams are client-facing by construction (a browser holds the connection either way), so external: there is about CORS/rate-limit, not about whether a browser can connect.
The external: value
Section titled “The external: value”external: false // explicit internal — a DX marker, not a gate; identical at runtime to omitting the fieldexternal: true // public, sugar for `[]` — hardening only, no CORS/rate-limit policyexternal: [cors()] // public, no cross-origin JS reads (cors() with no origins)external: [cors('https://app.example'), rateLimit(30)] // public + an allowlisted origin + a per-IP capcors(...origins) and rateLimit(limit) are the only two policy items — the concerns that exist only because a fence is public. Hardening headers (HSTS, X-Frame-Options: DENY, nosniff, Referrer-Policy) are not an array item; they auto-apply to every external response, true or [...] alike — table-stakes, not a decision. Auth is deliberately excluded from this axis: access control stays in require:/use:, applying to every caller regardless of exposure.
cors lives in @madenowhere/phaze-cloudflare/actions (a pure edge/CORS concern); rateLimit lives in @madenowhere/phaze-auth (the same KV-backed keyed-counter primitive backs both the external: policy value and a require: gate for app-level throttling — one implementation, two faces).
The binary wire
Section titled “The binary wire”A binary action’s success body is the raw bytes, typed by content-type — never a JSON envelope around base64:
| Content-Type | Body | Used for |
|---|---|---|
application/json | { data } / { error } | JSON actions; the error path of any binary action |
application/octet-stream | raw bytes | packed-numeric (Float32Array) |
application/x-flatbuf | FlatBuffer blob | structured FB tables |
application/json | { e, iv } | any action with encrypt: 'aes-gcm-256' — the binary/FB bytes are wrapped in an AES-GCM envelope before leaving the worker |
The compiled client arrow branches on the content-type: binary → await res.arrayBuffer(); JSON → the { data, error } envelope. So errors stay JSON while the success body is pure bytes, and the two are discriminable on the wire. When encrypt: is set the body is always JSON { e, iv } regardless of the underlying encoding — the compiled arrow decrypts first, then dispatches on the inner bytes.
Encoding: the zero-ship FlatBuffer accessor
Section titled “Encoding: the zero-ship FlatBuffer accessor”response: Flat<T> names a FlatBuffer type. The client never imports a reader and ships no npm flatbuffers: phaze-compile inlines each view.field() read at its call site to native DataView / TextDecoder ops — the same strip-macro discipline as /list / /numeric. The View is held in a signal and read in place (zero-copy), no decode-to-JS pass.
See Flat Buffers & SSR for the full path-split (FlatBuffers ride the client-fetch path, never SSR-seed) and the per-data-class tier map.
Crypto: sign: / encrypt:
Section titled “Crypto: sign: / encrypt:”sign: and encrypt: are outbound — the worker signs/encrypts the response and the client’s inverse (verify/decrypt) is injected into the compiled arrow; the consumer imports only actions. Both algorithms compile to inline Web Crypto API calls — zero shipped library, zero polyfill:
| Knob | Client operation | API | Bundle cost |
|---|---|---|---|
sign: 'ed25519' | verify X-Phaze-Sig before reading | crypto.subtle.importKey + crypto.subtle.verify | 0 bytes |
encrypt: 'aes-gcm-256' | decrypt { e, iv } before reading | crypto.subtle.importKey + crypto.subtle.decrypt | 0 bytes |
crypto.subtle is browser-native — and equally native in workerd and Node 18+. The compiled arrow references it directly; there is nothing to import, nothing to ship. The only extra bytes are the key literals: a 32-byte Ed25519 public key and a 32-byte AES-256 key each inline as 64 hex characters, compressing near-perfectly. Enabling both together costs the same as enabling one.
sign: 'ed25519' — integrity/tamper-evidence. The worker signs the response bytes and attaches a detached X-Phaze-Sig: ed25519:<base64> header; the compiled client verifies before reading. Right for JSON actions that stay within the workerd boundary — the body is readable in DevTools but its integrity is proven.
encrypt: 'aes-gcm-256' — confidentiality. The worker wraps the response as { e: base64(ciphertext+authTag), iv: base64(12-byte-iv) } (AES-GCM-256, fresh IV per response or per frame). AES-GCM is AEAD so it is also tamper-evident. Two cases where it belongs:
- Streams (
transport: sse) — SSE frames leave the workerd boundary and travel to the browser as a long-lived connection;encrypt:makes each frame opaque in DevTools. - FlatBuffer actions (
response: Flat<T>) — the binary payload rides the client-fetch path over the wire;encrypt:wraps the FB bytes before they leave the worker.
sign: and encrypt: compose — they are not mutually exclusive. When both appear on a fence, the composition is sign-then-encrypt: the signed { v, s } payload becomes the plaintext that AES-GCM encrypts, so the wire frame is always the opaque { e, iv }. The client decrypts first, then checks for { v, s } and verifies before calling signal.set. For FlatBuffer actions signed by Rust, the same applies: Rust’s X-Phaze-Sig header is preserved on the encrypted response and the client verifies it over the decrypted bytes.
The field is an algorithm selector. sign: and encrypt: name the algorithm, not a fixed primitive. Classical (ed25519 / aes-gcm-256) is Web-Crypto-native and ships 0 bytes; the same field extends to post-quantum — encrypt: 'ml-kem-768+aes-gcm', sign: 'ed25519+ml-dsa', where + composes a KEM+DEM (ML-KEM establishes a key, AES-GCM encrypts the body with it) or a classical+PQC signature. PQC is heavy (~70–80 KB WASM), so it lives server-side / at-rest, never the browser edge — the field wiring ships on the Rust runtime (sign = "ed25519+ml-dsa" + encrypt = "ml-kem-768+aes-gcm" + compose, runtime-proven), with ML-KEM-512/1024 + ML-DSA-44/87 variants also available. See the PQC design note for the measured cost and the placement decision (server/at-rest vs browser).
Full model — the X-Phaze-Sig header, the per-algo emission table, and key management — lives in Security.
Streams
Section titled “Streams”The transport: field is the seam where the wire stops being fetch. SSE is shipped — the stream-fence compiler, per-frame Ed25519 signing, and the s.bind consume side all land today (the live session + kill switch runs on it); WebSocket is design-locked (the in: back-channel shape is decided, compiler pending). The SSE/WS wire also exists natively on the Rust runtime (Sse<S>, ws::accept). A third wire, MoQ (transport: moq), is design-locked — pub/sub over WebTransport for the media/EEG firehose, where the producer is an external relay rather than the worker.
A stream is its own fence kind, not an action knob — because the lifecycle differs: an action is input → response, once; a stream is open → many frames → close. The parser tells them apart by the transport: field (present ⇒ stream).
---Session.live # SSE — one-way (server → client) transport: sse out: Json<Session | null> # the per-frame shape---return session # a frame = the current value (re-emitted on a trigger)
---Room.socket # WS — bidirectional transport: ws out: Json<RoomEvent> # server → client in: Json<ClientMsg> # client → server (WS-only)---The request-shaped knobs dissolve: there’s no single input:; response: becomes the per-frame out: (plus in: for a WebSocket back-channel); and the body is the frame producer (declarative return one frame, re-emitted on a trigger — or a generator that yields them). Consume it with s.bind — a fourth method alongside transport.X / useAction / s.async:
import { s } from '@madenowhere/phaze/dsl'import { streams } from 'phaze:transport'
const session = s.bind(streams.Session.live()) // a push signal — .value() updates per frames.bind compiles to in-core primitives only (new EventSource + listen + signal.set + cleanup), so — exactly like actions.X() and Flat<T> — it ships zero new runtime; the wire is inlined per call. A push is fired server-side with broadcast(tag, data) (from @madenowhere/phaze-cloudflare/transport) keyed by the fence’s tags: — e.g. revokeUserSession broadcasts to the session:<token> key to kick every open connection on a revoke. This shares one invalidation concept with the CDN (revalidateTag), backed by a per-token coordinator (in-process in dev, a Durable Object in production).
SSE fits one-way pushes (live sessions, notifications, progress); WebSocket adds the in: back-channel (collab, chat) and pairs with Cloudflare’s WebSocket Hibernation at scale.
Per-frame crypto is a field on the stream fence. Both operations compile to browser-native crypto.subtle (zero bundle cost — see above), so you can combine them freely:
sign: 'ed25519'— each frame is wrapped as{ v, s }(payload + detached Ed25519 signature);s.bindverifies beforesignal.set(tamper-evident, body visible in DevTools).encrypt: 'aes-gcm-256'— each frame is wrapped as{ e, iv }(AES-GCM-256 ciphertext + IV);s.binddecrypts beforesignal.set(confidential + AEAD tamper-evident, body opaque in DevTools). Use this for streams that carry sensitive data — session state, user-specific events — since SSE frames travel beyond the workerd boundary to the browser.- Both — they compose: the signed
{ v, s }is the plaintext that AES-GCM encrypts, so the wire frame is always{ e, iv }.s.binddecrypts first, then verifies the inner{ v, s }before callingsignal.set. Because both operations arecrypto.subtlecalls, the compileds.bindarrow is two sequential awaits with no extra import.
See Security.
Media over QUIC is the third stream wire, and the first whose producer lives outside the worker. SSE and WebSocket frames are produced in the worker — a handler body emits them. MoQ frames come from an external relay: a real pub/sub broker running on WebTransport/QUIC. That one fact shapes the whole fence.
- The dotted fence name is the topic. MoQ addresses a broadcast → track; a fence addresses a Group → action — the same shape.
---Neuralkit.eegsubscribes to broadcastneuralkit, trackeeg, with nobroadcast:/track:knobs. Fences that share a Group share a broadcast (Neuralkit.eegandNeuralkit.takare two tracks on one broadcast), exactly asSession.request/Session.deleteshare the Session group. relay:is the broker URL — visible in the fence. It’s the streaming counterpart of an action’srust: "/api/…": the endpoint the fence’s data comes from, declared where you’re reading, not hidden in config. It carries the canonical URL;env.tsoverrides the call in dev (→ a local relay + its self-signed cert) exactly the wayRUST_APIresolves:8788in dev and the production origin otherwise. The URL is the source of truth; the environment swap is env’s job.protocol:is required — the wire the relay speaks. Everytransport: moqfence declaresprotocol: 'moq-lite'(our relays) or'ietf-15'…'ietf-19'(an IETF relay — Cloudflare’s interop relay is'ietf-16'). It’s required so the wire is explicit — no ambiguity about what @moq/net negotiates — and it drives the build:moqShakePlugin(from@madenowhere/phaze-moq/vite) reads the fences and drops @moq/net’s ~7 KB IETF transport when every moq fence is'moq-lite', keeping it only when one declares'ietf-*'. So the fence, not a build flag, decides what ships — you can’t strip a transport a fence needs, or ship one it doesn’t.- No body. The producer is the relay, so a MoQ fence is declaration-only — identity, wire, and the per-frame
out:shape. - Browser-direct. workerd cannot terminate WebTransport, so there is no dispatcher and no TS-side step — the browser connects straight to the broker. A MoQ stream is consumed inside an
ssr={false}component (the wire needs a browser API at mount).
---Neuralkit.eeg transport: moq protocol: 'moq-lite' # the wire — 'moq-lite' | 'ietf-15'..'ietf-19' (required) relay: "https://relay.neuralkit.ai:4443" # the broker URL — visible; env.ts overrides in dev out: Flat<NeuroFrame> # the per-frame shape (Tier-1 packed-numeric)---// consumed like any stream — inside an ssr={false} componentconst eeg = s.bind(streams.Neuralkit.eeg()) // a push signal of FlatBuffer framesconst samples = c(eeg() && eeg().rawDataArray()) // Tier-1 zero-copy Int32Array viewEverything else is the universal stream contract — out:/in:, sign:/encrypt: — unchanged from SSE and WebSocket. The only surface MoQ adds is relay:, protocol:, and the name-as-topic mapping; SSE and WebSocket are untouched. And the encoding rides inside the MoQ frames, independent of the wire: one neuralkit broadcast can carry an eeg track in FlatBuffers and a tak track in protobuf on the same session — the relay only ever sees opaque bytes.
One model, two runtimes
Section titled “One model, two runtimes”The same declarative shape describes a TS Phaze action and a Rust #[get] handler, and the client consumes both identically:
| Direction | TS (phaze-cloudflare) | Rust (phaze macros) |
|---|---|---|
| inbound — verify a signed request | require: / use: verify | verify= (Ed25519 / JWT) |
| outbound — sign the response | sign: | sign= (Ed25519) |
| outbound — encrypt the response | encrypt: 'aes-gcm-256' | done by the TS dispatcher — a Rust-side encrypt= attr isn’t built; a Rust-backed action is signed in Rust, encrypted in TS ({ e, iv }, X-Phaze-Sig preserved) |
| encoding | response: Flat<T> | Flat<T> → application/x-flatbuf |
Heavy encode + PQC crypto can live on Rust endpoints (native, fast), consumed through the generated client; SSR/hydration-tied capabilities stay TS actions. Classical sign/encrypt (Ed25519, AES-GCM) are Web-Crypto-native — done in the TS dispatcher (it signs and encrypts) or Rust, verified/decrypted native in the browser, so they aren’t the “heavy → Rust” case. The browser’s decode + verify is the same either way — it reads an ArrayBuffer and checks an X-Phaze-Sig, regardless of which runtime produced them.
Where it lives
Section titled “Where it lives”- Define:
src/transport/transport.phaze— the reserved actions file;---<Group>.<action>dotted fences. (src/api/file-routed Rust endpoints are a separate convention.) - Data bindings:
src/transport.ts—Transport({ … })→ ambienttransport.*in handlers +---dataloaders. - Authoring imports:
@madenowhere/phaze-cloudflare/actions(ActionError,useAction);Json/Flat/Binaryare knob wrappers (no import). - Consume:
phaze:transport(actions.Group.action). - Server dispatch: the worker entry, wired automatically by
phaze-cloudflare.