Skip to content

Flat Buffers & SSR

The single most common confusion about FlatBuffers in Phaze: they have no SSR-seed role. A FlatBuffer’s value can’t ride the page into the browser — it arrives as client-fetched binary, becomes in-memory data a signal holds, and is read in place by the reactivity model, with no “decode into JS” step.

That’s about seeding (shipping the value with the page), not rendering. Phaze components are isomorphic and always SSR (primer/speed) — so an FB fence’s field reads DO run on the server (and at build under prerender: true), producing the row text that paints at first load. The distinction that governs this whole page: the rendered text ships with the HTML; the FlatBuffer value does not. See Prerendering & the seed/render split.

  • SSR-seed data is JSON, not FlatBuffers. Phaze seeds the page by serialising the loader’s data inline into the HTML (window.__PHAZE_CF__ = …). No separate fetch, no round-trip, and the browser reads it as a JS value — there isn’t even a JSON.parse to optimise away.
  • FlatBuffers cannot ride that path. You can’t inline aligned binary into a <script>; base64-ing it inflates the payload ~33% and forces an atob → Uint8Array decode on the client — which is exactly the parse step FlatBuffers exist to avoid. So for SSR-seed data, JSON wins by definition.
  • FlatBuffers are the client-fetch path. They ride the binary action wire — a response: 'binary' action resolves an ArrayBuffer post-hydration. In phaze that buffer becomes in-memory data a signal holds (s.async): a structured FB table is a View whose fields the bindings read in place; a packed-numeric buffer is a native TypedArray view a Path-2 effect feeds to the GPU. There’s no decode-to-JS pass — the bytes are read where they sit.

Two things happen to a value during SSR, and FlatBuffers do one but not the other:

  • Render — the field reads execute server-side; their text is serialised into the HTML. Happens for FB and JSON, always (isomorphic, always-SSR). Under prerender: true this text bakes into the static file at build — the page ships content-complete, worker never invoked. (A rust:-backed fence needs its Rust worker up during the build/prerender pass; cloudflare({ rustApi }) spawns it so the reads resolve — see Static prerendering.)
  • Seed — the value rides the page in window.__PHAZE_CF__ so the client adopts it on hydrate without a fetch. Two JSON sources seed today: loader data, and (the transport sibling) direct-fence action values the render fetched — captured into __PHAZE_CF__.seed[url] and adopted consume-once by the compiled arrow (signed fences re-verify the seed before adopting). Revalidate reloads find no seed and fetch for real.

FlatBuffers render but never seed — aligned binary can’t sit in a <script> (base64 = +33% + an atob → Uint8Array decode, the parse FB exists to avoid). So an FB page is text-complete at first paint (rendered / baked), and the client then does one fetch post-hydration to obtain the binary the live View reads in place. That single refetch is not waste — it’s the by-design cost of keeping the binary zero-copy while still painting content immediately. A JSON direct fence, by contrast, seeds + adopts and refetches nothing until its next revalidate.

render (text in HTML)seed (value in __PHAZE_CF__)client fetch on hydrate
JSON loader datanone
JSON direct-fence action✅ (.seed)none until revalidate
FlatBuffer✅ (text)❌ (binary can’t ride a script)one — obtains the buffer

So SSR-seed (JSON, inline) and FlatBuffers (binary, fetched) sit on opposite ends of the seed axis and never compete. The benchmark that does matter is FlatBuffer accessor-read vs JSON.parse on the same fetched payload — split by string-heavy vs numeric and full-read vs subset-read. The expected verdict: JSON.parse (native C++) wins on small/string-heavy responses; FlatBuffers win on large, numeric, partially-read payloads. (This is the Tier-2 / FB-table question; Tier 1 — packed-numeric — has no JSON.parse analogue: it’s a TypedArray straight to the GPU, below.)

A FlatBuffer View — and a packed-numeric Float32Array more so — is just in-memory data, which is exactly what a signal holds and what the reactivity model reads. Nothing FB-specific gets “decoded”: the binary action resolves the bytes, a signal holds the View (or the native TypedArray), and the existing reactive paths consume it.

Tier 2 — structured FB table. The signal holds the View; JSX bindings read its fields in place — each accessor is a vtable hop into the buffer, no allocation:

const session = s.async(loadManifest) // AsyncSignal<Manifest>
<ul>
{() => {
const m = session.value()
return m && Array.from({ length: m.channelCount() }, (_, i) =>
<li>{m.channels(i).name()}</li>) // read straight off the buffer — never copied to JS
}}
</ul>

Tier 1 — packed-numeric. The signal holds a native Float32Array view over the buffer; a Path-2 direct-write effect (the shape photon uses to write straight to the DOM) hands it to the GPU:

const geom = s.async(loadGeometry) // AsyncSignal<Float32Array>
effect(() => {
const v = geom.value()
if (v) device.queue.writeBuffer(gpuBuffer, 0, v) // the one copy — into GPU memory
})

The anti-pattern in both tiers is the React reflex — fetch → decode the whole buffer into a JS array/object → render that. It copies every field out of the buffer eagerly, defeating the zero-copy the format exists for. Hold the View / TypedArray in the signal; read only what you display, where it sits.

The two real consumers — an encrypted EEG session blob and Fabric (3D / WGSL) geometry — split across two tiers. Tier 1 = packed-numeric (dense uniform scalars → TypedArray); Tier 2 = structured FB tables (vtables, optional/versioned fields, strings).

DataTier (shape)Format on wirePathClient readCryptoKey caveat
SSR-seed loader data + JSON direct-fence values (today)— (text/object)JSON inline (__PHAZE_CF__ — loader data + direct-fence .seed)SSR-seed (no fetch, no parse; direct-fence value adopted consume-once, signed re-verified)— (JS value)sign (Ed25519) verified on adopt when setFB can’t ride this path — base64 inflates + re-adds a decode.
EEG manifestTier 2 — structured FB tableFlatBuffer table → application/x-flatbufclient-fetch (binary)inlined at the read site (strip macro) — no shipped reader, no npm flatbufferssign (Ed25519, manifest hash-list) + encrypt (AES-GCM-256) — both shipped, sign-then-encryptvtable traversal; read once at load.
EEG sample chunksTier 1 — packed-numericpacked Int16/Float32application/octet-streamclient-fetch (range / per-chunk)native TypedArray viewper-chunk hash vs manifest; encrypt per-chunk (AES-GCM, shipped)independently-decodable (scrub / GOP, deltas reset per chunk); decrypt = a mandatory copy before zero-copy.
Fabric 3D geometryTier 1 — packed-numericWGSL-aligned packed → application/octet-streamclient-fetch (binary)native Float32Array viewnone (TLS direct)WGSL alignment authored up front; one writeBuffer copy; cache one Float32Array view.

Reading it: the only SSR-seed row is the first (JSON, no FB) — though every row still renders (text) at SSR/prerender. Every FlatBuffer/binary consumer is on the client-fetch path for its value. Exactly one cell needs a generated FB accessor — the EEG manifest (Tier 2) — and even that ships nothing: the accessor is inlined at the read site (strip macro, below), not a shipped reader. Everything packed-numeric (EEG sample chunks, all of Fabric) is a native TypedArray view (Tier 1) — no accessor at all.

Crypto note: both are shipped. Signing via Ed25519 (browser-native through Web Crypto — a detached X-Phaze-Sig header keeps the body a clean buffer) is the zero-copy baseline — integrity + server-origin over the manifest hash-list. Encryption via AES-GCM-256 (encrypt: 'aes-gcm-256') is confidentiality (for PHI); it composes with signing (sign-then-encrypt). Encryption ≠ signing: a decrypt pass allocates a new buffer before any zero-copy read, and the symmetric AES key is client-shipped — so the Ed25519 signature still does the origin proof the symmetric layer can’t.

The binary action — a declarative pipeline (wire · read · crypto)

Section titled “The binary action — a declarative pipeline (wire · read · crypto)”

A phaze action is a declarative capability stack, not a hand-wired handler. newAction({ … }) compile-erases to the object literal; every field the compiler reads becomes code on both ends — the server dispatch and the inlined client arrow — at zero shipped runtime. The pipeline:

inbound: input (zod) · use[] (verify / auth) → handler → outbound: flatbuffer / sign / encrypt

It mirrors the phaze-rust attribute pipeline (csrf → auth → verify → handler → cors / secure) — the same declarative shape on both runtimes. That symmetry is the point: the same model is FlatBuffer- and crypto-native whether the endpoint is a TS phaze action or a Rust #[get] handler, and the client consumes both identically.

1 — the wire is a typed application/*, never bare octet-stream

Section titled “1 — the wire is a typed application/*, never bare octet-stream”
Content-TypeBodyUsed for
application/json{ data } / { error }JSON actions; the error path of any binary action
application/octet-streamraw bytespacked-numeric (Fabric Float32Array)
application/x-flatbuf (opt. ; type=eeg-manifest)FlatBuffer blobstructured FB tables

application/x-flatbuf is the canonical FlatBuffer content-type across both runtimes — phaze-rust’s IntoResponse for Flat<T> emits it, the TS dispatcher tags it, the compiled client arrow branches on it. x-flatbuf / octet-streamawait res.arrayBuffer(); application/json → the { data, error } envelope, so errors stay JSON while the success body is pure bytes. CF can Vary: Accept to partition the edge cache; a shared endpoint negotiates via Accept.

A FlatBuffer response is declared with one word-forward field:

// Tier 2 — EEG manifest: the field names the type, implies binary, tags x-flatbuf
getSession: newAction({ flatbuffer: SessionManifest, handler:}) // handler → Uint8Array
// Tier 1 — Fabric: raw bytes, no FB type; the client makes its own view
getGeometry: newAction({ response: 'binary', handler:}) // handler → Uint8Array

flatbuffer: does three jobs in one line: the recognizable word for DX, names the FB type (the accessor inlines the right reads, data types as the View), and implies response: 'binary' + the x-flatbuf tag. There is no transform: 'flatbuffers' — it would duplicate the schema field and can’t describe Fabric’s raw-binary case. The orthogonal response / flatbuffer fields stack; a fused word doesn’t — which is exactly why this layer extends to sign / encrypt below.

Authoring surface (shipped): in a .phaze transport fence you write the typed wrapper response: Flat<SessionManifest> (sibling wrappers Json<T> / Binary), which lowers to this def-level flatbuffer: field. The <…> mirrors the Rust runtime’s Flat<T> return type — one model, two runtimes. This page shows the newAction({ … }) def shape because it’s the lowering target the compiler reads; the fence is the surface you write.

3 — the accessor: the field read inlines at the site, in every file — no reader ever ships

Section titled “3 — the accessor: the field read inlines at the site, in every file — no reader ever ships”

The FB accessor is never a hand-imported reader, and never a shipped one. flatbuffer: Schema (a server-side artifact — the same contract Env’s server schema and zod follow) names which FB type; phaze-compile resolves the schema from the def and inlines each field read at its call site to DataView / TextDecoder vtable ops — the same strip-macro shape as /list / /match / /numeric. There is exactly one path, not a file-local-vs-escape split:

  • In the fetching componentconst m = session.value(); m.channels(i).name() — the pass traces session to its actions.X call, looks up the schema, and inlines the chain. The View object never materialises.
  • Across a component boundary — phaze idiom passes the signal reference down, not a re-derived value (it’s the reactivity-model rule). The child reads props.session.value().name(), where props.session is typed AsyncSignal<SessionManifest>. The pass reads that local type annotation — exactly as it reads the s<'a' | 'b'>() type-param to cycle inc/dec — and inlines against the same schema registry. Still in the child’s own file, still zero shipped reader.

“Escape to a child” was a React reflex (opaque object crosses a boundary → ship a decoder to read it). In Phaze every read site is a .tsx / .phaze file the compiler already processes, so an escape just moves where the inline happens — never whether a reader ships. The only reads that can’t inline — dynamic field access (view[nameVar]()), a View stuffed into any / JSON.stringify / a non-Phaze boundary — aren’t a “ship a reader” case; they’re the statically-resolvable-call-site diagnostic. The opt-in flatbuffersReaderPlugin (the lean read-only ByteBuffer over native DataView) backs the server-side decode and that degenerate escape hatch — never the default client read.

Either way: reads happen in place (no decode, no object graph), the client imports only actions, and zero npm flatbuffers — and zero shipped reader — ships. “Zero bytes” is the /list sense: no reader library, no runtime growth; the inline at each site is the compiled form of the read you wrote.

Tier-1 geometry is the identity case: new Float32Array(arrayBuffer) (a zero-copy view, not a copy) → a Path-2 effectdevice.queue.writeBuffer. No accessor, no file_identifier; the GPU consumes the view directly.

5 — sign / encrypt are outbound fields (the crypto pipeline)

Section titled “5 — sign / encrypt are outbound fields (the crypto pipeline)”

sign: and encrypt: are response-direction fields: the worker signs / encrypts the outgoing body and the client’s inverse (decrypt / verify) is injected into the compiled arrow — the consumer imports only actions and hand-rolls no crypto. They compose (both can be set on one action or stream), and the compiler emits the order on each end:

out (worker): handler bytes → sign → encrypt → ship // sign-then-encrypt
in (client): fetch → decrypt → verify → View / Float32Array

Sign-then-encrypt (shipped). The signature is computed over the plaintext, then the signed payload is encrypted — so the client decrypts first, then verifies. Two properties stack:

  • AES-GCM is an AEAD — decryption authenticates the ciphertext via its tag, so a tampered { e, iv } frame fails to decrypt and is rejected before any plaintext is read. Tamper-rejection-before-read comes from the AEAD itself, not from an outer signature.
  • The Ed25519 signature adds origin authenticity the symmetric layer can’t. The AES key is shipped to the client (PUBLIC_PHAZE_ENCRYPT_KEY, symmetric) — anyone holding it could mint a valid frame. The Ed25519 signature (private PHAZE_SIGN_KEY, never shipped) proves the response came from the server, surviving CDN / cache / relay. So sign: is not redundant under encrypt: — AEAD gives confidentiality + tamper-evidence; Ed25519 gives server-origin proof.

Wire shapes: sign-only keeps zero-copy — a detached X-Phaze-Sig: ed25519:<b64> header over a clean body buffer, verified in place (composes with flatbuffer:). encrypt: (with or without sign) ships { e, iv } (base64 ciphertext + a fresh 12-byte IV) and breaks zero-copy — decrypt allocates a fresh plaintext buffer the View sits over (decrypt per-chunk for the EEG scrub case). When both are set, the recovered plaintext carries the signature — inline as { v, s } for a JSON / SSE frame, or as the preserved X-Phaze-Sig header for a Rust-signed binary body (Rust.flat = Rust-signs + TS-dispatcher-encrypts).

Cross-runtime symmetry — sign: is outbound, not verify=

Section titled “Cross-runtime symmetry — sign: is outbound, not verify=”

The inbound / outbound split is the spine, and it corrects a tempting error: Rust’s verify = "Ed25519<…>" is inbound — it verifies an incoming signed request (a webhook). sign: is outbound — it signs the response. Opposite directions.

DirectionTS actionRust handler
inbound — verify a signed requestverify / auth (via use:)verify= / auth= / csrf=
outbound — sign the responsesign: (shipped)sign= (shipped, post-handler attr)
outbound — encrypt the responseencrypt: (shipped)the TS dispatcher encrypts even a Rust-backed response — a Rust-side encrypt= attr is not built yet

Both reuse phaze-crypto’s Ed25519 (Web Crypto in worker + browser; native + sync in Rust). AES-GCM-256 encryption is shipped on the TS side — a Rust-backed action (rust: / gen: proxy, or a verbatim forwardRust passthrough) is signed in Rust and encrypted in the TS dispatcher, which re-emits { e, iv } while preserving the Rust X-Phaze-Sig. So crypto can split across the runtime boundary; the client’s inverse (decrypt → verify) is identical regardless of which runtime produced the response.

So the client component imports only actions, holds the View / TypedArray in a signal, and reads in place. (file_identifier — the FB-native in-bytes type tag — is the transport-independent self-ID for the headerless R2-range / MoQ replay path; deferred until that path exists.)