Skip to content

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.

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.

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 bindings
return row

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

The transport object in a handler (and in ---data page loaders) is your data plane — Cloudflare bindings declared once in src/transport.ts:

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.

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 contractreturn { 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.

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 View

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

FieldConcernStatus
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 handlershipped
rust: "/api/path"the handler — proxy to a Rust endpoint; the compiler generates the whole fetch body (forward the cookie, branch !okActionError, destructure return { … } from the JSON). See belowshipped
gen: [fn]the handler — import-only knob for composition (2+ Rust calls / mixed-with-D1): names the helpers, the body stays hand-writtenshipped
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 readershipped
response: Binaryoutbound — raw bytes (Float32Array / packed-numeric), application/octet-streamshipped
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: aloneshipped
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-channeldesign-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 MoQdesign-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 externalshipped
external: [...] / true / falsethe exposure policy — marks an http/stream fence public and declares its edge policy (CORS allowlist, rate-limit). See Internal vs externalshipped

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
rpcnever valid — a compile errora service binding only (worker→worker); no public route exists at all
httpabsent / falsethe dispatcher wire, same-origin (POST)
httptrue / [...]a public URL — CORS-able, rate-limitable
sse / wsabsent / falsethe dispatcher stream wire, same-origin
sse / wstrue / [...]a public stream URL
moqnot applicablethe 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.

external: false // explicit internal — a DX marker, not a gate; identical at runtime to omitting the field
external: true // public, sugar for `[]` — hardening only, no CORS/rate-limit policy
external: [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 cap

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

A binary action’s success body is the raw bytes, typed by content-type — never a JSON envelope around base64:

Content-TypeBodyUsed for
application/json{ data } / { error }JSON actions; the error path of any binary action
application/octet-streamraw bytespacked-numeric (Float32Array)
application/x-flatbufFlatBuffer blobstructured 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.

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:

KnobClient operationAPIBundle cost
sign: 'ed25519'verify X-Phaze-Sig before readingcrypto.subtle.importKey + crypto.subtle.verify0 bytes
encrypt: 'aes-gcm-256'decrypt { e, iv } before readingcrypto.subtle.importKey + crypto.subtle.decrypt0 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-quantumencrypt: '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.

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 # SSEone-way (serverclient)
transport: sse
out: Json<Session | null> # the per-frame shape
---
return session # a frame = the current value (re-emitted on a trigger)
---Room.socket # WSbidirectional
transport: ws
out: Json<RoomEvent> # serverclient
in: Json<ClientMsg> # clientserver (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 frame

s.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.bind verifies before signal.set (tamper-evident, body visible in DevTools).
  • encrypt: 'aes-gcm-256' — each frame is wrapped as { e, iv } (AES-GCM-256 ciphertext + IV); s.bind decrypts before signal.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.bind decrypts first, then verifies the inner { v, s } before calling signal.set. Because both operations are crypto.subtle calls, the compiled s.bind arrow 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.eeg subscribes to broadcast neuralkit, track eeg, with no broadcast:/track: knobs. Fences that share a Group share a broadcast (Neuralkit.eeg and Neuralkit.tak are two tracks on one broadcast), exactly as Session.request/Session.delete share the Session group.
  • relay: is the broker URL — visible in the fence. It’s the streaming counterpart of an action’s rust: "/api/…": the endpoint the fence’s data comes from, declared where you’re reading, not hidden in config. It carries the canonical URL; env.ts overrides the call in dev (→ a local relay + its self-signed cert) exactly the way RUST_API resolves :8788 in 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. Every transport: moq fence declares protocol: '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 URLvisible; env.ts overrides in dev
out: Flat<NeuroFrame> # the per-frame shape (Tier-1 packed-numeric)
---
// consumed like any stream — inside an ssr={false} component
const eeg = s.bind(streams.Neuralkit.eeg()) // a push signal of FlatBuffer frames
const samples = c(eeg() && eeg().rawDataArray()) // Tier-1 zero-copy Int32Array view

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

The same declarative shape describes a TS Phaze action and a Rust #[get] handler, and the client consumes both identically:

DirectionTS (phaze-cloudflare)Rust (phaze macros)
inbound — verify a signed requestrequire: / use: verifyverify= (Ed25519 / JWT)
outbound — sign the responsesign:sign= (Ed25519)
outbound — encrypt the responseencrypt: '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)
encodingresponse: 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.

  • 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.tsTransport({ … }) → ambient transport.* in handlers + ---data loaders.
  • Authoring imports: @madenowhere/phaze-cloudflare/actions (ActionError, useAction); Json/Flat/Binary are knob wrappers (no import).
  • Consume: phaze:transport (actions.Group.action).
  • Server dispatch: the worker entry, wired automatically by phaze-cloudflare.