feat: send hydration data as tagged JSON text - #131
Conversation
The document's hydration payload is now version 3: loader data travels as
JSON text with values JSON cannot carry written as {"$t": tag, "v": value}
objects, instead of base64 of CBOR. Base64 is a third larger and hides the
page's text from the compressor; a prose page drops from ~2.5x to ~1.4x its
own markup in brotli. Data requests keep CBOR and streaming.
The client still decodes version 2, so documents cached before an upgrade
hydrate with the new bundle.
Refs udibo/udibo#974
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deno 2.9.6 panics collecting coverage for a module that has an
anonymous class expression followed by an object literal whose key
is a lone-surrogate escape ("key\uD800"): V8's takePreciseCoverage
response carries an escape serde_json rejects ("unexpected end of
hex escape"), so `deno task test --coverage` dies after every test
passed. Build the surrogates with String.fromCharCode instead; the
row round-trips the same key and value as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
test: build lone surrogates at runtime in hydration table — Fixes the Ubuntu coverage step (run 34596435636), where every test passed and then Deno 2.9.6 panicked at Evidence (Deno 2.9.6 locally, CI's exact
|
|
Security review at
Merge blocker unrelated to security: the Ubuntu job's coverage collection crashes in Deno ( Adoption in udibo/udibo owes threat-model updates: T10's "cannot break out because the payload is base64" and T9's "not a demonstrated request-controlled injection path" must be rewritten now that loader data is readable text protected by escaping. |
Summary
Implements the founder decision on udibo/udibo#974 (2026-09-11): the HTML hydration payload becomes JSON text with type tags (payload
version: 3); data requests keep CBOR and streaming.Base64 of CBOR is about a third larger than the bytes it carries and hides the page's text from brotli, so every docs-style page ships its body twice at a poor ratio. Measured on prose pages built from this repo's own docs (brotli quality 5, same markup, only the payload swapped):
Changes
src/_serialization.ts:toTaggedJson/fromTaggedJsonwrap the existingprocessValue/restoreValue, which are unchanged. The JSON form decodes into exactly the treecbor2decoding produces, so types behave as they do today. Tags coverDate(epoch ms, invalid dates included),undefined,NaN/Infinity/-Infinity/-0,bigint, and Juniper's CBOR tags 40000–40004 carried by number. Other details:$tor__proto__key is written as an escaped entry list, so data can never be read as a tag. The payload is evaluated as a JS object literal, where a"__proto__"key would otherwise set the prototype instead of making a property.bigintcollapses exactly as CBOR does: safe-range values become numbers. The rule is taken fromcbor2itself, so the first document load and later data requests give a loader the same types.SerializedHydrationDatais a union of v2 and v3.deserializeHydrationDatadecodes both and throws on any other version.processHydrationDatais split out so tests can build a v2 payload from the same input.toInlineScriptJson(used bysrc/_server.tsx) keeps escaping<and now also escapes U+2028 / U+2029. Loader data is now readable text in the script, so this escaping is its only defence.docs/state-management.md: new "How Values Travel" section describing v3, the escaping, CBOR for data requests, and v2 compatibility. Tightened the rule for what registered serializers may return.Testing
src/_serialization_hydration.test.ts:-0,undefinedkeys, array holes, errors andHttpErrorexposure,registerError,registerType, promises,$tand__proto__escapes, Map/Set/RegExp/URL/typed-array flattening, and lone surrogates.src/_server_hydration.test.tsx:</script>,<!--,<script>,</SCRIPT >, U+2028 and U+2029 in loader data keeps all of it inside the hydration script. The first</scriptthe HTML parser meets is Juniper's own.application/cborwith native CBOR types, and deferred data still answersapplication/cbor-stream.$tescape__proto__escape<escapedeno task checkanddeno task testboth green.Compatibility
The build handshake from #115 does not cover a cached v2 document.
X-Juniper-Buildis compared only on GET data responses after hydration, while/build/main.jsis not fingerprinted and is servedprivate, no-cache, must-revalidate. So a v2 document restored from cache loads the new bundle and decodes its payload before any handshake. The v2 branch of the decoder keeps that page hydrating; the handshake then reloads it on its first data request.The reverse case is not covered: a new v3 document meeting an old bundle, which can only happen during a rolling deploy when two instances serve different
main.js. An old client cannot read v3, so that page stays server-rendered without hydrating until reload.For the reviewer
Security review. This changes what reaches an inline script: loader data is now readable text rather than base64. It needs the security review udibo/udibo#974 asked for.
Open question. v3 flattens
Map/Set/URL/ typed arrays inside a registered serializer's output (processValue never recurses there), while CBOR data requests keep them. Loader-level values flatten on both paths as before. The docs now tell serializers to return plain data; whether to make v3 refuse such output instead is open.Deno coverage bug (for the founder, not filed upstream). Deno 2.9.6 panics while collecting coverage (
runtime/coverage.rs:54:44,serde_json"unexpected end of hex escape" on theProfiler.takePreciseCoverageresponse) for a module where an anonymous class expression comes before an object literal keyed by a lone-surrogate escape. Minimal repro, run withdeno test --coverage=cov repro.test.ts:Without the class it passes, and so does a lone surrogate in a value or in a key built at runtime. The parity table's lone-surrogate row therefore builds its surrogates with
String.fromCharCode(d821733). This could be reported to denoland/deno with that repro.Closes
Nothing in this repo. udibo/udibo#974 stays open after this merges, until udibo adopts the release.
🤖 Generated with Claude Code