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.
The path split
Section titled “The path 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 aJSON.parseto 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 anatob → Uint8Arraydecode 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 anArrayBufferpost-hydration. In phaze that buffer becomes in-memory data asignalholds (s.async): a structured FB table is a View whose fields the bindings read in place; a packed-numeric buffer is a nativeTypedArrayview a Path-2 effect feeds to the GPU. There’s no decode-to-JS pass — the bytes are read where they sit.
Prerendering & the seed/render split
Section titled “Prerendering & the seed/render split”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: truethis text bakes into the static file at build — the page ships content-complete, worker never invoked. (Arust:-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: loaderdata, 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 data | ✅ | ✅ | none |
| 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.)
In phaze: held in a signal, read in place
Section titled “In phaze: held in a signal, read in place”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.
Per-data-class map
Section titled “Per-data-class map”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).
| Data | Tier (shape) | Format on wire | Path | Client read | Crypto | Key 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 set | FB can’t ride this path — base64 inflates + re-adds a decode. |
| EEG manifest | Tier 2 — structured FB table | FlatBuffer table → application/x-flatbuf | client-fetch (binary) | inlined at the read site (strip macro) — no shipped reader, no npm flatbuffers | sign (Ed25519, manifest hash-list) + encrypt (AES-GCM-256) — both shipped, sign-then-encrypt | vtable traversal; read once at load. |
| EEG sample chunks | Tier 1 — packed-numeric | packed Int16/Float32 → application/octet-stream | client-fetch (range / per-chunk) | native TypedArray view | per-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 geometry | Tier 1 — packed-numeric | WGSL-aligned packed → application/octet-stream | client-fetch (binary) | native Float32Array view | none (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 / encryptIt 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-Type | Body | Used for |
|---|---|---|
application/json | { data } / { error } | JSON actions; the error path of any binary action |
application/octet-stream | raw bytes | packed-numeric (Fabric Float32Array) |
application/x-flatbuf (opt. ; type=eeg-manifest) | FlatBuffer blob | structured 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-stream → await 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.
2 — flatbuffer: Schema is the def field
Section titled “2 — flatbuffer: Schema is the def field”A FlatBuffer response is declared with one word-forward field:
// Tier 2 — EEG manifest: the field names the type, implies binary, tags x-flatbufgetSession: newAction({ flatbuffer: SessionManifest, handler: … }) // handler → Uint8Array
// Tier 1 — Fabric: raw bytes, no FB type; the client makes its own viewgetGeometry: newAction({ response: 'binary', handler: … }) // handler → Uint8Arrayflatbuffer: 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
.phazetransport fence you write the typed wrapperresponse: Flat<SessionManifest>(sibling wrappersJson<T>/Binary), which lowers to this def-levelflatbuffer:field. The<…>mirrors the Rust runtime’sFlat<T>return type — one model, two runtimes. This page shows thenewAction({ … })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 component —
const m = session.value(); m.channels(i).name()— the pass tracessessionto itsactions.Xcall, 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(), whereprops.sessionis typedAsyncSignal<SessionManifest>. The pass reads that local type annotation — exactly as it reads thes<'a' | 'b'>()type-param to cycleinc/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.
4 — Fabric reads nothing field-by-field
Section titled “4 — Fabric reads nothing field-by-field”Tier-1 geometry is the identity case: new Float32Array(arrayBuffer) (a zero-copy view, not a copy) → a Path-2 effect → device.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-encryptin (client): fetch → decrypt → verify → View / Float32ArraySign-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 (privatePHAZE_SIGN_KEY, never shipped) proves the response came from the server, surviving CDN / cache / relay. Sosign:is not redundant underencrypt:— 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.
| Direction | TS action | Rust handler |
|---|---|---|
| inbound — verify a signed request | verify / auth (via use:) | verify= / auth= / csrf= |
| outbound — sign the response | sign: (shipped) | sign= (shipped, post-handler attr) |
| outbound — encrypt the response | encrypt: (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.)