From ff27c735102df377638ee056d40f303f52942590 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Fri, 11 Sep 2026 07:55:46 -0400 Subject: [PATCH 1/6] feat: send hydration data as tagged JSON text 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 --- docs/state-management.md | 30 ++- src/_serialization.test.ts | 12 +- src/_serialization.ts | 219 ++++++++++++++-- src/_serialization_hydration.test.ts | 359 +++++++++++++++++++++++++++ src/_server.tsx | 3 +- src/_server_hydration.test.tsx | 170 +++++++++++++ 6 files changed, 764 insertions(+), 29 deletions(-) create mode 100644 src/_serialization_hydration.test.ts create mode 100644 src/_server_hydration.test.tsx diff --git a/docs/state-management.md b/docs/state-management.md index 99cdce2..5866e12 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -341,9 +341,33 @@ import "@/serialization/url.ts"; The route can then return a URL instance in loader data. Give each registration a unique name and import the module wherever standalone server code needs it. -Use synchronous serializers that return simple, browser-safe values; do not -assume nested promises or other custom instances in their output will be -processed recursively. +Use synchronous serializers that return plain data: JSON values plus +`undefined`, `Date`, `bigint`, and non-finite numbers. Do not return promises or +other instances (`Map`, `Set`, `URL`, class instances) from a serializer; its +output is not processed recursively, and the first document load reduces such +instances to their enumerable own properties. + +#### How Values Travel + +The first document load embeds the page's data in an inline script as JSON text +(hydration payload version 3). Values JSON cannot express are written as tagged +objects of the form `{"$t": tag, "v": value}`: `Date`, `undefined`, `NaN`, +`Infinity`, `-Infinity`, `-0`, `bigint` values outside the safe-number range, +errors, settled promises, and registered types. A plain object that has its own +`$t` or `__proto__` key is written in an escaped form, so data can never be +mistaken for a tag. Every `<`, U+2028, and U+2029 in the payload is written as a +`\u` escape, so no string in loader data can close the script element or start +an HTML comment. + +Client navigations and fetchers request data as CBOR (`application/cbor`, or +`application/cbor-stream` when deferred promises are still pending). Both paths +decode to the same values, including the `bigint` normalization described above, +so a loader's data has the same types whether it arrived with the document or on +a later navigation. + +A document rendered by an earlier Juniper release carries hydration payload +version 2 (base64-encoded CBOR). The browser client still decodes it, so a page +restored from cache after an upgrade hydrates with the new client bundle. ## React Context diff --git a/src/_serialization.test.ts b/src/_serialization.test.ts index d4f6a5e..119aaf5 100644 --- a/src/_serialization.test.ts +++ b/src/_serialization.test.ts @@ -1,4 +1,10 @@ -import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { + assert, + assertEquals, + assertRejects, + assertStringIncludes, + assertThrows, +} from "@std/assert"; import { delay } from "@std/async/delay"; import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { HttpError } from "@udibo/http-error"; @@ -331,8 +337,8 @@ describe("Serialization Module", () => { const serialized = await serializeHydrationData(hydrationData); - assertEquals(serialized.version, 2); - assertEquals(typeof serialized.data, "string"); + assertEquals(serialized.version, 3); + assertStringIncludes(JSON.stringify(serialized.data), '"title":"Home"'); const deserialized = deserializeHydrationData(serialized); assertEquals(deserialized.matches, hydrationData.matches); diff --git a/src/_serialization.ts b/src/_serialization.ts index c56e026..ead7646 100644 --- a/src/_serialization.ts +++ b/src/_serialization.ts @@ -1,9 +1,11 @@ /** - * Internal serialization module using cbor2. + * Internal serialization module. * - * This module provides internal implementation for serializing/deserializing - * custom types, errors, and context between server and client. - * Public interfaces and registration functions are exported from mod.ts. + * Data requests travel as CBOR (cbor2). The document's hydration payload + * travels as tagged JSON text (version 3) so the page stays readable to the + * compressor; version 2 (base64 of CBOR) is still decoded for documents cached + * before an upgrade. Public interfaces and registration functions are exported + * from mod.ts. * * @internal * @module @@ -762,7 +764,8 @@ export async function deserializeStreamingLoaderData( } /** - * Encode data to a base64 string (for embedding in HTML). + * Encode data as base64 of CBOR, the version 2 hydration payload format. + * Only tests produce it now; documents rendered before version 3 still carry it. * * @param data - The data to encode * @returns The base64 encoded string @@ -837,9 +840,143 @@ export function deserializeAllContext( } /** - * Serialized hydration data structure using CBOR. + * A JSON value as it appears in the version 3 hydration payload: plain JSON, + * with values JSON cannot carry replaced by `{"$t": tag, "v": value}` objects. */ -export interface SerializedHydrationData { +export type TaggedJson = + | null + | boolean + | number + | string + | TaggedJson[] + | { [key: string]: TaggedJson }; + +const JSON_TAG_KEY = "$t"; +const JSON_VALUE_KEY = "v"; + +function tagged(tag: string | number, value?: TaggedJson): TaggedJson { + return value === undefined + ? { [JSON_TAG_KEY]: tag } + : { [JSON_TAG_KEY]: tag, [JSON_VALUE_KEY]: value }; +} + +function collapseBigIntLikeCbor(value: bigint): number | bigint { + return cborDecode(cborEncode(value)); +} + +function numberToTaggedJson(value: number): TaggedJson { + if (Number.isNaN(value)) return tagged("number", "NaN"); + if (value === Infinity) return tagged("number", "Infinity"); + if (value === -Infinity) return tagged("number", "-Infinity"); + if (Object.is(value, -0)) return tagged("number", "-0"); + return value; +} + +function mustEscapeObjectKeys(keys: string[]): boolean { + return keys.includes(JSON_TAG_KEY) || keys.includes("__proto__"); +} + +/** + * Converts a processed value (the output of `processValue`) into JSON-safe + * tagged form. Decodes back through `fromTaggedJson` into exactly what CBOR + * decoding of the same value produces, so `restoreValue` treats both alike. + * + * @throws {TypeError} For functions and symbols, which CBOR also refuses. + */ +export function toTaggedJson(value: unknown): TaggedJson { + if (value === undefined) return tagged("undefined"); + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return value.toWellFormed(); + if (typeof value === "number") return numberToTaggedJson(value); + if (typeof value === "bigint") { + const collapsed = collapseBigIntLikeCbor(value); + return typeof collapsed === "number" + ? collapsed + : tagged("bigint", collapsed.toString()); + } + if (typeof value === "function" || typeof value === "symbol") { + throw new TypeError(`Cannot serialize a ${typeof value} value`); + } + if (value instanceof Tag) { + return tagged(Number(value.tag), toTaggedJson(value.contents)); + } + if (value instanceof Date) { + return tagged("Date", numberToTaggedJson(value.getTime())); + } + if (Array.isArray(value)) return Array.from(value, toTaggedJson); + + const entries = Object.entries(value as Record).map(( + [key, entry], + ): [string, TaggedJson] => [key.toWellFormed(), toTaggedJson(entry)]); + if (mustEscapeObjectKeys(entries.map(([key]) => key))) { + return tagged("object", entries); + } + return Object.fromEntries(entries); +} + +function objectFromEntries( + entries: [string, unknown][], +): Record { + const result: Record = {}; + for (const [key, entry] of entries) { + Object.defineProperty(result, key, { + value: fromTaggedJson(entry), + enumerable: true, + writable: true, + configurable: true, + }); + } + return result; +} + +function fromTag(tag: unknown, value: unknown): unknown { + if (typeof tag === "number") return new Tag(tag, fromTaggedJson(value)); + switch (tag) { + case "undefined": + return undefined; + case "number": + return Number(value); + case "bigint": + return BigInt(value as string); + case "Date": + return new Date(fromTaggedJson(value) as number); + case "object": + return objectFromEntries(value as [string, unknown][]); + } + throw new Error(`Unknown hydration data tag: ${String(tag)}`); +} + +/** + * Converts the tagged JSON of a version 3 payload back into the value CBOR + * decoding would have produced, ready for `restoreValue`. + * + * @throws {Error} On a tag this version of Juniper does not know. + */ +export function fromTaggedJson(value: unknown): unknown { + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(fromTaggedJson); + const record = value as Record; + if (Object.hasOwn(record, JSON_TAG_KEY)) { + return fromTag(record[JSON_TAG_KEY], record[JSON_VALUE_KEY]); + } + return objectFromEntries(Object.entries(record)); +} + +/** + * Serializes a value as JSON text that is safe to place inside an inline + * `\s+(.+):(\d+):(\d+)\s*$/m); if (!location) return false; const file = location[1].startsWith("file:") @@ -102,25 +101,19 @@ async function isTolerated(block: string, sourceDir: string): Promise { .test(file.replaceAll("\\", "/")) && routerReferences.has(reference); } - if (!block.startsWith("error[missing-jsdoc]:") || localFile !== "build.ts") { - return false; - } - const sourceLine = - (await Deno.readTextFile(file)).split(/\r?\n/)[Number(location[2]) - 1]; - return /^\s*private\s+(?:async\s+)?(?:collectWatchPaths|isPathIgnored)\s*\(/ - .test(sourceLine ?? ""); + return false; } /** Classifies one doc invocation; only the named package/type exceptions can pass a lint failure. */ -export async function assessDocLint( +export function assessDocLint( code: number, stderr: string, sourceDir: string, -): Promise<{ passed: boolean; violations: string[] }> { +): { passed: boolean; violations: string[] } { const { blocks, fatal, counts } = splitDiagnostics(stderr); const violations = [...fatal]; for (const block of blocks) { - if (!await isTolerated(block, sourceDir)) violations.push(block); + if (!isTolerated(block, sourceDir)) violations.push(block); } if ( code !== 0 && @@ -162,7 +155,7 @@ export async function lintDocumentation( stderr: "piped", }).output(); const stderr = new TextDecoder().decode(result.stderr); - const assessment = await assessDocLint(result.code, stderr, sourceDir); + const assessment = assessDocLint(result.code, stderr, sourceDir); if (!assessment.passed) { console.error(stderr); console.error( diff --git a/src/build.ts b/src/build.ts index eb293c3..af73430 100644 --- a/src/build.ts +++ b/src/build.ts @@ -244,7 +244,7 @@ export class Builder implements AsyncDisposable { : [this.watchPaths]; const resolved: string[] = []; for (const root of roots) { - await this.collectWatchPaths( + await this.#collectWatchPaths( path.resolve(this.projectRoot, root), resolved, ); @@ -252,11 +252,11 @@ export class Builder implements AsyncDisposable { return resolved; } - private async collectWatchPaths( + async #collectWatchPaths( dir: string, into: string[], ): Promise { - if (this.isPathIgnored(dir)) return; + if (this.#isPathIgnored(dir)) return; const prefix = toPosixPath(dir).replace(/\/+$/, "") + "/"; const containsIgnored = this.ignorePaths.some((ignore) => toPosixPath(ignore).startsWith(prefix) @@ -269,8 +269,8 @@ export class Builder implements AsyncDisposable { for await (const entry of Deno.readDir(dir)) { const child = path.join(dir, entry.name); if (entry.isDirectory) { - await this.collectWatchPaths(child, into); - } else if (!this.isPathIgnored(child)) { + await this.#collectWatchPaths(child, into); + } else if (!this.#isPathIgnored(child)) { into.push(child); } } @@ -279,7 +279,7 @@ export class Builder implements AsyncDisposable { } } - private isPathIgnored(absolutePath: string): boolean { + #isPathIgnored(absolutePath: string): boolean { const target = toPosixPath(absolutePath); return this.ignorePaths.some((ignore) => { const normalized = toPosixPath(ignore);