diff --git a/.releaserc.json b/.releaserc.json index 5d49281..8c0f8fd 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -7,7 +7,7 @@ { "preset": "angular", "releaseRules": [ - { "breaking": true, "release": "major" }, + { "breaking": true, "release": "minor" }, { "revert": true, "release": "patch" }, { "type": "feat", "release": "minor" }, { "type": "fix", "release": "patch" }, diff --git a/README.md b/README.md index a6b0ee6..8e80a17 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,3 @@ ---- -title: Juniper -last_verified: 2026-09-09 ---- - # Juniper [![JSR](https://jsr.io/badges/@udibo/juniper)](https://jsr.io/@udibo/juniper) @@ -27,6 +22,8 @@ seamless full-stack development experience. initial page loads and SEO benefits. - **Data Loading and Actions** - Fetch data with loaders and handle form submissions with actions, on either server or client. +- **Tagged JSON Transport** - One encoding for hydration, data requests, and + streamed deferred promises, with shared custom type and error registration. - **Hot Reload** - See changes instantly during development. - **TypeScript First** - Full TypeScript support with type-safe route parameters, loader data, and action data. diff --git a/deno.lock b/deno.lock index 7d8f2cf..9cbcbd0 100644 --- a/deno.lock +++ b/deno.lock @@ -34,7 +34,6 @@ "npm:@types/pg@^8.23.1": "8.23.1", "npm:@types/react@^19.2.18": "19.2.18", "npm:babel-plugin-react-compiler@1": "1.0.0", - "npm:cbor2@^2.3.0": "2.3.0", "npm:drizzle-kit@*": "0.31.10", "npm:drizzle-kit@~0.31.10": "0.31.10", "npm:drizzle-orm@~0.45.2": "0.45.2_@opentelemetry+api@1.9.1_@types+pg@8.23.1_pg@8.23.0", @@ -353,9 +352,6 @@ "@csstools/css-tokenizer@4.0.0": { "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==" }, - "@cto.af/wtf8@0.0.5": { - "integrity": "sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==" - }, "@drizzle-team/brocli@0.10.2": { "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==" }, @@ -986,12 +982,6 @@ "caniuse-lite@1.0.30001799": { "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==" }, - "cbor2@2.3.0": { - "integrity": "sha512-76WB3hq8BoaGkMkBVJ27fW5LJU+qqDLEpgRNCG/SYKhODWXpVPOTD4UcUto3IEzYLA52nsvbhb0wabhHDn3qXg==", - "dependencies": [ - "@cto.af/wtf8" - ] - }, "convert-source-map@2.0.0": { "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, @@ -1760,7 +1750,6 @@ "npm:@testing-library/user-event@^14.6.7", "npm:@types/react@^19.2.18", "npm:babel-plugin-react-compiler@1", - "npm:cbor2@^2.3.0", "npm:esbuild@~0.28.2", "npm:global-jsdom@30", "npm:hono@^4.13.7", diff --git a/docs/error-handling.md b/docs/error-handling.md index f647d81..b859144 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -197,7 +197,11 @@ export function ErrorBoundary({ Errors thrown on the server are serialized for the client. Juniper handles this automatically, but you can customize the representation of registered error types. See [serializable values](state-management.md#serializable-values) for -supported data types, numeric normalization, and custom type registration. +supported data types and custom type registration. Data-request errors use the +same tagged JSON codec and `X-Juniper: data` marker as successful data. +Unexpected server failures remain sanitized outside development, and +`HttpError.exposedMessage` determines the message sent to the browser, including +deferred rejections. ### Custom Error Serialization @@ -257,6 +261,18 @@ Import this shared module from the root **client** route so registration occurs on the server and in the browser before hydration data is deserialized. An import only from `routes/main.ts` never registers the browser-side decoder. +Unknown registered error names throw during decoding. On a deferred stream, only +the affected promise rejects. Error envelopes keep the registered name in +`__errorType` and serializer output in `data`; an output property named +`__errorType` remains data and cannot choose a different deserializer. Output is +processed recursively and may contain supported values, promises, or registered +types. A serializer whose output matches its own `is` predicate throws. The +first matching error registration wins. + +Non-Error thrown values use a `null` error type in the envelope, leaving every +string name available for registered errors. A missing string name always +throws, including a custom registration named `Unknown`. + ```typescript // routes/main.tsx import "@/errors/custom.ts"; diff --git a/docs/forms.md b/docs/forms.md index b47b4c6..eba4733 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -101,11 +101,11 @@ export async function action({ Actions can return data, redirects, or throw redirects. Action data is automatically serialized when sent to the client. JSON-shaped data, `undefined`, -`Date`, and `Error` have built-in handling; `bigint` values are accepted with -the numeric normalization described in -[serializable values](state-management.md#serializable-values). Promise values -can defer data. Register other classes with `registerType` or convert them to -plain data before returning them. +`Date`, `Error`, and `bigint` have built-in handling. The same tagged JSON codec +carries action data in hydration and fetcher responses. Promise values can defer +data through an NDJSON stream. See +[How Values Travel](state-management.md#how-values-travel). Register other +classes with `registerType` or convert them to plain data before returning them. ```typescript // Return data (available in component via actionData) diff --git a/docs/routing.md b/docs/routing.md index 8249312..b2059ec 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -193,11 +193,12 @@ Export `publicEnvKeys` from the root **server** module, `routes/main.ts`, to allowlist additional environment values in hydration data. See [configuration](configuration.md#public-environment-variables). -Juniper serializes JSON-shaped data, `undefined`, `Date`, and `Error`. It also -accepts `bigint` and promise values, with numeric and deferred-data behavior -described in [serializable values](state-management.md#serializable-values). -Register other classes with `registerType`; unregistered objects do not retain -their class identity. +Juniper carries JSON-shaped data, `undefined`, `Date`, `Error`, `bigint`, and +promises through one tagged JSON codec. Client data responses carry +`X-Juniper: data`: settled values use JSON and deferred values stream as NDJSON. +See [How Values Travel](state-management.md#how-values-travel). Register other +classes with `registerType`; unregistered objects do not retain their class +identity. ### Layout Wrapper Pattern diff --git a/docs/state-management.md b/docs/state-management.md index 99cdce2..1d9b9fb 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -306,10 +306,8 @@ loader/action data. See [error handling](error-handling.md#error-serialization) for the distinction between thrown errors, returned error data, and custom error serializers. -`bigint` values are accepted, but the underlying numeric encoding can change -their JavaScript type: `123n` decodes as the number `123`, while integers beyond -the safe-number range remain `bigint`. Send a decimal string or register a -wrapper type if the consumer requires an exact `bigint` contract. +`bigint` values keep their JavaScript type, including small values such as +`123n`. `NaN`, `Infinity`, `-Infinity`, and `-0` also round-trip unchanged. `Map`, `Set`, `RegExp`, and `URL` are **not** built-in round-trip types. Without registration, objects are reduced to their enumerable own properties; typical @@ -341,9 +339,48 @@ 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. +Serializer output is processed recursively: it can contain `Date`, `undefined`, +promises, and other registered types. A serializer whose output matches its own +`is` predicate throws. Avoid cycles between serializers or in their data. + +Registered `is` predicates run before the Array and Date checks. When several +registrations match a value, the first registration wins. Duplicate names throw. +An unknown registered type or error name throws during decoding; in a deferred +resolution, only that promise rejects and other resolutions continue. Import +every registration on both sides. Development hydration includes sorted +registration names, and the browser logs names missing from its registry. + +#### How Values Travel + +Juniper uses tagged JSON text for document hydration, client data requests, and +deferred resolutions. Values JSON cannot express use string tags of the form +`{"$t": tag, "v": value}`. The tags are `undefined`, `number`, `bigint`, `Date`, +`object`, `pending`, `promise`, `rejected`, `type`, and `error`; unknown tags +throw. Plain objects with an own `$t` or `__proto__` key use escaped entry +lists, including inside serializer output, so their keys remain ordinary data. + +The first document load embeds one tagged hydration value containing +loader/action data, errors, context, and public environment values. Application +registrations do not transform public environment strings or diagnostic +registration names; these still travel inside the same tagged value. Every `<`, +U+2028, and U+2029 in that script is written as a `\u` escape. Deploy server +code and browser assets from the same build. If their hydration formats do not +match, Juniper uses a guarded document reload. + +Client navigations and fetchers receive settled data as `application/json` with +a UTF-8 `Content-Length`. Deferred data uses `application/x-ndjson`: the first +line contains tagged data with pending placeholders, followed by lines shaped as +`{"id":"p0","status":"resolved","value":...}` or +`{"id":"p0","status":"rejected","error":...}`. Each value or error uses the same +codec. Resolutions arrive as they become ready, including nested promises. The +stream respects consumer back-pressure and stops on request cancellation. + +Both response kinds, including data-request errors, carry `X-Juniper: data`. Use +this marker to identify framework data in middleware; JSON content type also +occurs on ordinary API responses and redirect envelopes. Deferred streams carry +`Cache-Control: no-transform` and must remain uncompressed at the origin so +buffering does not delay individual resolutions. Settled JSON can use normal +HTTP compression. The client uses one text-line decoder for both response kinds. ## React Context diff --git a/example/routes/features/data/server-deferred.tsx b/example/routes/features/data/server-deferred.tsx index d9c0814..ea76d2b 100644 --- a/example/routes/features/data/server-deferred.tsx +++ b/example/routes/features/data/server-deferred.tsx @@ -34,9 +34,9 @@ export default function ServerDeferredDataDemo({

Server loaders can also return promises for deferred data. The promises - are serialized using CBOR and streamed to the client for progressive - hydration. This demonstrates the full server-to-client data flow with - {" "} + are serialized using tagged JSON and streamed to the client for + progressive hydration. This demonstrates the full server-to-client data + flow with{" "} Suspense {" "} @@ -104,13 +104,13 @@ export default function ServerDeferredDataDemo({ Fast data is included in the initial HTML response

  • - Promises are serialized using CBOR with custom tags + As promises resolve, the server streams HTML for each section
  • - Client hydrates immediately with Suspense fallbacks + The client hydrates after the document's tagged JSON data is ready
  • - As server promises resolve, data streams to the client + Later client data requests stream tagged JSON resolutions as NDJSON
  • diff --git a/scripts/doc-lint.test.ts b/scripts/doc-lint.test.ts index 6b11250..1e6f94b 100644 --- a/scripts/doc-lint.test.ts +++ b/scripts/doc-lint.test.ts @@ -98,17 +98,26 @@ describe("public documentation gate", () => { ); }); - it("tolerates the named private Builder methods but rejects making one public without JSDoc", async () => { + it("keeps private identifiers out of public docs and rejects undocumented methods", async () => { await using project = await fixture({ ".": "./build.ts" }, { "build.ts": - "/** Fixture API. @module */\n/** Builds the fixture. */\nexport class Builder {\n private collectWatchPaths(): void {}\n}\n", + "/** Fixture API. @module */\n/** Builds the fixture. */\nexport class Builder {\n #collectWatchPaths(): void {}\n}\n", }); assertEquals((await runGate(project.config)).success, true); - await Deno.writeTextFile( - join(project.directory, "build.ts"), - "/** Fixture API. @module */\n/** Builds the fixture. */\nexport class Builder {\n collectWatchPaths(): void {}\n}\n", - ); - assertEquals((await runGate(project.config)).success, false); + for (const name of ["collectWatchPaths", "isPathIgnored"]) { + for (const access of ["private ", ""]) { + await Deno.writeTextFile( + join(project.directory, "build.ts"), + `/** Fixture API. @module */\n/** Builds the fixture. */\nexport class Builder {\n ${access}${name}(): void {}\n}\n`, + ); + const result = await runGate(project.config); + assertEquals(result.success, false, `${access}${name}`); + assertStringIncludes( + new TextDecoder().decode(result.stderr), + "error[missing-jsdoc]", + ); + } + } }); it("does not hide a plain fatal error beside an allowed external reference", async () => { diff --git a/scripts/doc-lint.ts b/scripts/doc-lint.ts index f6c5792..1767164 100644 --- a/scripts/doc-lint.ts +++ b/scripts/doc-lint.ts @@ -1,8 +1,7 @@ /** * Checks every published entrypoint for undocumented API and optionally checks * its JSDoc examples. Known external-type diagnostics are scoped to their file, - * public symbol, and referenced type; the named private Builder methods are - * exempt only while their source declarations remain private. + * public symbol, and referenced type. * @module */ import { fromFileUrl, relative, resolve, toFileUrl } from "@std/path"; @@ -85,7 +84,7 @@ function splitDiagnostics( return { blocks, fatal, counts }; } -async function isTolerated(block: string, sourceDir: string): Promise { +function isTolerated(block: string, sourceDir: string): boolean { const location = block.match(/^\s*-->\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/_client.tsx b/src/_client.tsx index 859d83e..94ceb3a 100644 --- a/src/_client.tsx +++ b/src/_client.tsx @@ -25,7 +25,6 @@ import { delay } from "@std/async/delay"; import { deserializeError, deserializeHydrationData, - deserializeLoaderData, deserializeStreamingLoaderData, type HydrationData, type SerializedHydrationData, @@ -341,6 +340,16 @@ function scheduleDocumentNavigation( return promise; } +export function reloadUnsupportedHydration(version: number): Promise { + if (!shouldReload(BUILD_SKEW_RELOAD_KEY)) { + throw new Error(`Unsupported hydration data version: ${version}`); + } + return scheduleDocumentNavigation( + BUILD_SKEW_RELOAD_KEY, + () => globalThis.location.reload(), + ); +} + async function fetchServerData( request: Request, method: "GET" | "POST", @@ -379,29 +388,15 @@ async function fetchServerData( } } - const contentType = response.headers.get("Content-Type"); - - if (contentType === "application/cbor-stream") { - if (!response.ok) { - const buffer = await response.arrayBuffer(); - const deserialized = deserializeLoaderData(new Uint8Array(buffer)); - throw deserializeError(deserialized as Record); - } - return await deserializeStreamingLoaderData(response); - } - - if (contentType === "application/cbor") { - const buffer = await response.arrayBuffer(); - const deserialized = deserializeLoaderData(new Uint8Array(buffer)); - + const responseType = response.headers.get("X-Juniper"); + if (responseType === "data") { + const deserialized = await deserializeStreamingLoaderData(response); if (!response.ok) { throw deserializeError(deserialized as Record); } - return deserialized; } - const responseType = response.headers.get("X-Juniper"); if (responseType === "redirect") { const redirectData = await response.json(); request.signal.throwIfAborted(); diff --git a/src/_client_wire.test.tsx b/src/_client_wire.test.tsx new file mode 100644 index 0000000..3593c61 --- /dev/null +++ b/src/_client_wire.test.tsx @@ -0,0 +1,179 @@ +import "./utils/global-jsdom.ts"; +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; +import { stub } from "@std/testing/mock"; +import { FakeTime } from "@std/testing/time"; +import { Client } from "@udibo/juniper/client"; +import { HttpError } from "@udibo/juniper"; +import { createRoute } from "./_client.tsx"; +import { createLoaderDataResponse, serializeError } from "./_serialization.ts"; +import type { SerializedHydrationData } from "./_serialization.ts"; +import { env } from "./utils/_env.ts"; + +describe("client tagged JSON data dispatch", () => { + for (const method of ["GET", "POST"]) { + it(`decodes settled and deferred ${method} data and the same error envelope`, async () => { + const route = createRoute({}, { loader: true, action: true }, "/wire"); + const handler = method === "GET" ? route.loader : route.action; + assertExists(handler); + const args = () => ({ + context: {} as never, + params: {}, + request: new Request("http://localhost/wire", { + method, + ...(method === "POST" ? { body: new FormData() } : {}), + }), + url: new URL("http://localhost/wire"), + pattern: "/wire", + }); + for (const deferred of [false, true]) { + const value = { at: new Date(5), missing: undefined, $t: "Date" }; + using _fetch = stub( + globalThis, + "fetch", + () => + Promise.resolve( + createLoaderDataResponse({ + value: deferred ? Promise.resolve(value) : value, + }), + ), + ); + const result = await handler(args()) as { value: unknown }; + assertEquals(await result.value, value); + } + using _fetch = stub(globalThis, "fetch", () => { + const encoded = createLoaderDataResponse( + serializeError(new HttpError(403, "Denied")), + ); + return Promise.resolve( + new Response(encoded.body, { status: 403, headers: encoded.headers }), + ); + }); + await assertRejects( + async () => await handler(args()), + HttpError, + "Denied", + ); + }); + } + + it("leaves JSON API responses without the data marker as Responses", async () => { + const response = Response.json({ $t: "Date", v: 5 }); + using _fetch = stub(globalThis, "fetch", () => Promise.resolve(response)); + const route = createRoute({}, { loader: true }, "/wire"); + assertExists(route.loader); + const result = await route.loader({ + context: {} as never, + params: {}, + request: new Request("http://localhost/wire"), + url: new URL("http://localhost/wire"), + pattern: "/wire", + }); + assertEquals(result, response); + assertEquals(response.bodyUsed, false); + await response.body?.cancel(); + }); +}); + +describe("hydration version recovery", () => { + const key = "__juniper_build_skew_reload"; + let originalLocation: Location; + let reloads: number; + beforeEach(() => { + reloads = 0; + originalLocation = globalThis.location; + Object.defineProperty(globalThis, "location", { + configurable: true, + value: { + href: "http://localhost/", + reload: () => { + reloads++; + }, + }, + }); + sessionStorage.removeItem(key); + }); + afterEach(() => { + Object.defineProperty(globalThis, "location", { + configurable: true, + value: originalLocation, + }); + sessionStorage.removeItem(key); + }); + + for (const version of [2, 4, 0]) { + it(`reloads version ${version} before reading its data and coalesces concurrent attempts`, async () => { + using time = new FakeTime(); + let decoded = 0; + let loaded = 0; + using _data = stub(env, "getHydrationData", () => ({ + version, + get data() { + decoded++; + throw new Error("must not decode"); + }, + } as unknown as SerializedHydrationData)); + const client = new Client({ path: "/" }); + using _routes = stub(client, "loadLazyMatches", () => { + loaded++; + return Promise.resolve(); + }); + const outcomes: unknown[] = []; + for (let i = 0; i < 3; i++) { + client.hydrate().then( + () => outcomes.push("resolved"), + (e) => outcomes.push(e), + ); + } + await time.tickAsync(1); + assertEquals(decoded, 0); + assertEquals(loaded, 0); + assertEquals(reloads, 1); + assertEquals(outcomes, []); + assertEquals(JSON.parse(sessionStorage.getItem(key)!).count, 1); + }); + } + + it("surfaces an unsupported version after the guarded reload budget is exhausted", async () => { + sessionStorage.setItem( + key, + JSON.stringify({ count: 2, timestamp: Date.now() }), + ); + using _data = stub( + env, + "getHydrationData", + () => ({ + version: 2, + data: "obsolete", + } as unknown as SerializedHydrationData), + ); + await assertRejects( + () => new Client({ path: "/" }).hydrate(), + Error, + "Unsupported hydration data version: 2", + ); + assertEquals(reloads, 0); + }); + + it("surfaces missing type registrations during hydration", async () => { + using _data = stub( + env, + "getHydrationData", + () => ({ + version: 3 as const, + data: { + matches: [], + loaderData: { + root: { $t: "type", v: { __type: "Absent", data: 1 } }, + }, + }, + }), + ); + await assertRejects( + () => new Client({ path: "/" }).hydrate(), + Error, + "No deserializer registered for type", + ); + assertEquals(reloads, 0); + }); +}); diff --git a/src/_serialization.test.ts b/src/_serialization.test.ts index d4f6a5e..d979ca9 100644 --- a/src/_serialization.test.ts +++ b/src/_serialization.test.ts @@ -1,21 +1,25 @@ -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"; import { - cborDecode, - cborEncode, containsPromises, createStreamingLoaderData, - decodeFromBase64, deserializeError, deserializeHydrationData, deserializeStreamingLoaderData, - encodeToBase64, + fromTaggedJson, resetRegistries, serializeError, serializeHydrationData, + toTaggedJson, } from "./_serialization.ts"; import { registerError, registerType } from "./mod.ts"; @@ -29,58 +33,32 @@ describe("Serialization Module", () => { resetRegistries(); }); - describe("cborEncode/cborDecode", () => { + describe("toTaggedJson/fromTaggedJson", () => { it("should encode and decode primitive values", () => { - assertEquals(cborDecode(cborEncode(42)), 42); - assertEquals(cborDecode(cborEncode("hello")), "hello"); - assertEquals(cborDecode(cborEncode(true)), true); - assertEquals(cborDecode(cborEncode(null)), null); - assertEquals(cborDecode(cborEncode(undefined)), undefined); + assertEquals(fromTaggedJson(toTaggedJson(42)), 42); + assertEquals(fromTaggedJson(toTaggedJson("hello")), "hello"); + assertEquals(fromTaggedJson(toTaggedJson(true)), true); + assertEquals(fromTaggedJson(toTaggedJson(null)), null); + assertEquals(fromTaggedJson(toTaggedJson(undefined)), undefined); }); it("should encode and decode arrays", () => { const input = [1, 2, 3, "a", "b"]; - assertEquals(cborDecode(cborEncode(input)), input); + assertEquals(fromTaggedJson(toTaggedJson(input)), input); }); it("should encode and decode objects", () => { const input = { name: "test", value: 123, nested: { a: 1 } }; - assertEquals(cborDecode(cborEncode(input)), input); + assertEquals(fromTaggedJson(toTaggedJson(input)), input); }); it("should encode and decode Dates", () => { const date = new Date("2025-01-01T00:00:00Z"); - const decoded = cborDecode(cborEncode(date)); + const decoded = fromTaggedJson(toTaggedJson(date)) as Date; assertEquals(decoded.toISOString(), date.toISOString()); }); }); - describe("encodeToBase64/decodeFromBase64", () => { - it("round-trips hydration data larger than the function argument limit", () => { - const input = { text: "水🌿".repeat(200_000) }; - assertEquals(decodeFromBase64(encodeToBase64(input)), input); - }); - - it("should encode and decode to base64 strings", () => { - const input = { message: "hello world", count: 42 }; - const base64 = encodeToBase64(input); - assertEquals(typeof base64, "string"); - assertEquals(decodeFromBase64(base64), input); - }); - - it("should handle complex nested structures", () => { - const input = { - users: [ - { name: "Alice", age: 30 }, - { name: "Bob", age: 25 }, - ], - metadata: { version: 1 }, - }; - const base64 = encodeToBase64(input); - assertEquals(decodeFromBase64(base64), input); - }); - }); - describe("registerType", () => { it("should register a custom type serializer", async () => { class Money { @@ -163,8 +141,8 @@ describe("Serialization Module", () => { const serialized = serializeError(error); assertEquals(serialized.__errorType, "ValidationError"); - assertEquals(serialized.message, "Invalid input"); - assertEquals(serialized.fields, ["email", "name"]); + assertEquals(serialized.data.message, "Invalid input"); + assertEquals(serialized.data.fields, ["email", "name"]); const deserialized = deserializeError(serialized) as ValidationError; assertEquals(deserialized instanceof ValidationError, true); @@ -202,8 +180,8 @@ describe("Serialization Module", () => { const serialized = serializeError(error); assertEquals(serialized.__errorType, "HttpError"); - assertEquals(serialized.status, 404); - assertEquals(serialized.message, "Not Found"); + assertEquals(serialized.data.status, 404); + assertEquals(serialized.data.message, "Not Found"); const deserialized = deserializeError(serialized) as HttpError; assertEquals(deserialized instanceof HttpError, true); @@ -219,12 +197,12 @@ describe("Serialization Module", () => { const serialized = serializeError(error); assertEquals( - serialized.message, + serialized.data.message, error.exposedMessage, "the wire carries what the rendering layer would show", ); assertEquals( - (serialized.message as string).includes("connection refused"), + (serialized.data.message as string).includes("connection refused"), false, "and never the internal detail — this payload reaches the browser", ); @@ -242,7 +220,7 @@ describe("Serialization Module", () => { const error = new HttpError(404, "Tenant not found"); const serialized = serializeError(error); - assertEquals(serialized.message, "Tenant not found"); + assertEquals(serialized.data.message, "Tenant not found"); assertEquals( (deserializeError(serialized) as HttpError).message, "Tenant not found", @@ -256,9 +234,12 @@ describe("Serialization Module", () => { }); const serialized = serializeError(error); - assertEquals(serialized.message, "You don't have permission to do that."); assertEquals( - serialized.expose, + serialized.data.message, + "You don't have permission to do that.", + ); + assertEquals( + serialized.data.expose, true, "what reached the wire is exposable by construction, so the flag says so", ); @@ -276,7 +257,7 @@ describe("Serialization Module", () => { const serialized = serializeError(error); assertEquals(serialized.__errorType, "TypeError"); - assertEquals(serialized.message, "Invalid type"); + assertEquals(serialized.data.message, "Invalid type"); const deserialized = deserializeError(serialized) as TypeError; assertEquals(deserialized instanceof TypeError, true); @@ -288,7 +269,7 @@ describe("Serialization Module", () => { const serialized = serializeError(error); assertEquals(serialized.__errorType, "RangeError"); - assertEquals(serialized.message, "Out of range"); + assertEquals(serialized.data.message, "Out of range"); const deserialized = deserializeError(serialized) as RangeError; assertEquals(deserialized instanceof RangeError, true); @@ -300,7 +281,7 @@ describe("Serialization Module", () => { const serialized = serializeError(error); assertEquals(serialized.__errorType, "Error"); - assertEquals(serialized.message, "Something went wrong"); + assertEquals(serialized.data.message, "Something went wrong"); const deserialized = deserializeError(serialized) as Error; assertEquals(deserialized instanceof Error, true); @@ -311,8 +292,8 @@ describe("Serialization Module", () => { const thrown = { custom: "error object" }; const serialized = serializeError(thrown); - assertEquals(serialized.__errorType, "Unknown"); - assertEquals(serialized.value, thrown); + assertEquals(serialized.__errorType, null); + assertEquals(serialized.data.value, thrown); const deserialized = deserializeError(serialized); assertEquals(deserialized, thrown); @@ -331,8 +312,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); @@ -547,8 +528,7 @@ describe("Serialization Module", () => { for (const framing of ["coalesced", "split", "bytes"] as const) { it(`resolves deferred data with ${framing} transport chunks`, async () => { const bytes = await deferredFrames(); - const firstFrameEnd = new DataView(bytes.buffer).getUint32(0, false) + - 4; + const firstFrameEnd = bytes.indexOf(10) + 1; const chunks = framing === "coalesced" ? [bytes] : framing === "bytes" @@ -574,7 +554,7 @@ describe("Serialization Module", () => { it("rejects every unresolved value when the stream ends between frames", async () => { const bytes = await deferredFrames(); - const firstFrameEnd = new DataView(bytes.buffer).getUint32(0, false) + 4; + const firstFrameEnd = bytes.indexOf(10) + 1; const response = chunkedResponse([bytes.slice(0, firstFrameEnd)]); const data = await deserializeStreamingLoaderData<{ first: Promise; @@ -589,12 +569,12 @@ describe("Serialization Module", () => { assertEquals(response.body!.locked, false); }); - it("cancels and unlocks a response whose initial CBOR frame cannot decode", async () => { + it("cancels and unlocks a response whose initial JSON line cannot decode", async () => { let canceled = false; const response = new Response( new ReadableStream({ start(controller) { - controller.enqueue(Uint8Array.of(0, 0, 0, 1, 0xff)); + controller.enqueue(new TextEncoder().encode("{invalid\n")); }, cancel() { canceled = true; @@ -608,13 +588,13 @@ describe("Serialization Module", () => { it("cancels and unlocks a malformed resolution stream after rejecting its values", async () => { const bytes = await deferredFrames(); - const firstFrameEnd = new DataView(bytes.buffer).getUint32(0, false) + 4; + const firstFrameEnd = bytes.indexOf(10) + 1; let canceled = false; const response = new Response( new ReadableStream({ start(controller) { controller.enqueue(bytes.slice(0, firstFrameEnd)); - controller.enqueue(Uint8Array.of(0, 0, 0, 1, 0xff)); + controller.enqueue(new TextEncoder().encode("{invalid\n")); }, cancel() { canceled = true; @@ -640,7 +620,7 @@ describe("Serialization Module", () => { const stream = createStreamingLoaderData(data); const response = new Response(stream, { - headers: { "Content-Type": "application/cbor-stream" }, + headers: { "Content-Type": "application/x-ndjson" }, }); const result = await deserializeStreamingLoaderData(response); @@ -655,7 +635,7 @@ describe("Serialization Module", () => { const stream = createStreamingLoaderData(data); const response = new Response(stream, { - headers: { "Content-Type": "application/cbor-stream" }, + headers: { "Content-Type": "application/x-ndjson" }, }); const result = await deserializeStreamingLoaderData<{ @@ -677,7 +657,7 @@ describe("Serialization Module", () => { const stream = createStreamingLoaderData(data); const response = new Response(stream, { - headers: { "Content-Type": "application/cbor-stream" }, + headers: { "Content-Type": "application/x-ndjson" }, }); const result = await deserializeStreamingLoaderData<{ @@ -698,7 +678,7 @@ describe("Serialization Module", () => { const stream = createStreamingLoaderData(data); const response = new Response(stream, { - headers: { "Content-Type": "application/cbor-stream" }, + headers: { "Content-Type": "application/x-ndjson" }, }); const result = await deserializeStreamingLoaderData<{ @@ -719,7 +699,7 @@ describe("Serialization Module", () => { const stream = createStreamingLoaderData(data); const response = new Response(stream, { - headers: { "Content-Type": "application/cbor-stream" }, + headers: { "Content-Type": "application/x-ndjson" }, }); const result = await deserializeStreamingLoaderData<{ @@ -746,7 +726,7 @@ describe("Serialization Module", () => { const stream = createStreamingLoaderData(data); const response = new Response(stream, { - headers: { "Content-Type": "application/cbor-stream" }, + headers: { "Content-Type": "application/x-ndjson" }, }); const result = await deserializeStreamingLoaderData<{ @@ -778,7 +758,7 @@ describe("Serialization Module", () => { const stream = createStreamingLoaderData(data); const response = new Response(stream, { - headers: { "Content-Type": "application/cbor-stream" }, + headers: { "Content-Type": "application/x-ndjson" }, }); const result = await deserializeStreamingLoaderData<{ @@ -804,7 +784,7 @@ describe("Serialization Module", () => { const stream = createStreamingLoaderData(data); const response = new Response(stream, { - headers: { "Content-Type": "application/cbor-stream" }, + headers: { "Content-Type": "application/x-ndjson" }, }); const result = await deserializeStreamingLoaderData<{ diff --git a/src/_serialization.ts b/src/_serialization.ts index c56e026..af6f4fe 100644 --- a/src/_serialization.ts +++ b/src/_serialization.ts @@ -1,14 +1,17 @@ -/** - * Internal serialization module using cbor2. - * - * 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. - * - * @internal - * @module - */ -import { decode, encode, Tag } from "cbor2"; +import { + decodeHydrationPayload, + defineOwnValue, + fromTaggedJson, + Tagged, + toTaggedJson, +} from "./_tagged-json.ts"; +import type { TaggedJson } from "./_tagged-json.ts"; +export { + fromTaggedJson, + toInlineScriptJson, + toTaggedJson, +} from "./_tagged-json.ts"; +export type { TaggedJson } from "./_tagged-json.ts"; import type { RouterContext, RouterContextProvider } from "react-router"; import { HttpError, isHttpErrorLike } from "@udibo/http-error"; @@ -47,12 +50,6 @@ export interface ContextSerializer { deserialize: (data: S | undefined) => T; } -const PROMISE_RESOLVED_TAG = 40000; -const PROMISE_REJECTED_TAG = 40001; -const CUSTOM_TYPE_TAG = 40002; -const ERROR_TAG = 40003; -const PROMISE_PENDING_TAG = 40004; - // deno-lint-ignore no-explicit-any const typeRegistry = new Map>(); // deno-lint-ignore no-explicit-any @@ -188,109 +185,88 @@ export function sanitizeServerData(data: T): T { ) as T; } -/** - * Serialize an error using the registered error serializers. - * - * @param error - The error to serialize - * @returns The serialized error data - */ -export function serializeError(error: unknown): Record { +export interface ErrorEnvelope extends Record { + __errorType: string | null; + data: Record; +} + +export function serializeError(error: unknown): ErrorEnvelope { const serializer = findErrorSerializer(error); if (serializer) { - return { - __errorType: serializer.name, - ...serializer.serialize(error as Error), - }; + const data = serializer.serialize(error as Error); + if (serializer.is(data)) { + throw new Error( + `Error serializer "${serializer.name}" output matches its own is predicate`, + ); + } + return { __errorType: serializer.name, data }; } - if (error instanceof Error) { - const serialized: Record = { + return { __errorType: "Error", - message: error.message, - name: error.name, + data: { + message: error.message, + name: error.name, + ...(isDevelopment() ? { stack: error.stack } : {}), + }, }; - if (isDevelopment()) { - serialized.stack = error.stack; - } - return serialized; } - - return { __errorType: "Unknown", value: error }; + return { __errorType: null, data: { value: error } }; } -/** - * Deserialize an error from serialized data. - * - * @param data - The serialized error data - * @returns The deserialized error - */ -export function deserializeError(data: Record): unknown { - const errorType = data.__errorType as string; - - const serializer = errorRegistry.get(errorType); - if (serializer) { - return serializer.deserialize(data); - } - - if (errorType === "Unknown") { - return data.value; +export function deserializeError(envelope: Record): unknown { + const name = envelope.__errorType; + const data = envelope.data as Record; + if (name === null) return data.value; + const serializer = errorRegistry.get(name as string); + if (!serializer) { + throw new Error(`No deserializer registered for error "${String(name)}"`); } - - const error = new Error(data.message as string); - if (data.name) error.name = data.name as string; - if (data.stack) error.stack = data.stack as string; - return error; + return serializer.deserialize(data); } -// Thenable check (not instanceof Promise) for cross-realm compatibility. function isThenable(value: unknown): value is PromiseLike { - return ( - value !== null && - typeof value === "object" && - "then" in value && - typeof (value as { then: unknown }).then === "function" - ); + return value !== null && typeof value === "object" && "then" in value && + typeof (value as { then: unknown }).then === "function"; } -async function processValue(value: unknown): Promise { - if (value === null || value === undefined) { - return value; +function serializeType( + value: unknown, + serializer: TypeSerializer, +): unknown { + const data = serializer.serialize(value); + if (serializer.is(data)) { + throw new Error( + `Type serializer "${serializer.name}" output matches its own is predicate`, + ); } + return data; +} +async function processValue(value: unknown): Promise { + if (value === null || value === undefined) return value; if (isThenable(value)) { try { - const resolved = await value; - const processedValue = await processValue(resolved); - return new Tag(PROMISE_RESOLVED_TAG, processedValue); + return new Tagged("promise", await processValue(await value)); } catch (error) { - return new Tag( - PROMISE_REJECTED_TAG, - serializeError(sanitizeServerError(error)), + return new Tagged( + "rejected", + await processValue(serializeError(sanitizeServerError(error))), ); } } - if (value instanceof Error || isHttpErrorLike(value)) { - return new Tag(ERROR_TAG, serializeError(value)); + return new Tagged("error", await processValue(serializeError(value))); } - - const typeSerializer = findTypeSerializer(value); - if (typeSerializer) { - return new Tag(CUSTOM_TYPE_TAG, { - __type: typeSerializer.name, - data: typeSerializer.serialize(value), + const serializer = findTypeSerializer(value); + if (serializer) { + return new Tagged("type", { + __type: serializer.name, + data: await processValue(serializeType(value, serializer)), }); } - - if (Array.isArray(value)) { - const processed = await Promise.all(value.map(processValue)); - return processed; - } - - if (value instanceof Date) { - return value; - } - + if (Array.isArray(value)) return await Promise.all(value.map(processValue)); + if (value instanceof Date) return value; if (typeof value === "object") { const result: Record = {}; for (const [key, val] of Object.entries(value)) { @@ -298,499 +274,418 @@ async function processValue(value: unknown): Promise { } return result; } - return value; } -function defineOwnValue( - target: Record, - key: string, - value: unknown, -): void { - Object.defineProperty(target, key, { - value, - enumerable: true, - writable: true, - configurable: true, - }); -} - -function restoreValue(value: unknown): unknown { - if (value === null || value === undefined) { - return value; - } +type PromiseResolvers = Map; + resolve: (value: unknown) => void; + reject: (error: unknown) => void; +}>; - if (value instanceof Tag) { - const tagNum = Number(value.tag); - - if (tagNum === PROMISE_RESOLVED_TAG) { - const restored = restoreValue(value.contents); - return Promise.resolve(restored); +function restoreValue(value: unknown, pending?: PromiseResolvers): unknown { + if (value instanceof Tagged) { + if (value.tag === "pending") { + if (!pending || typeof value.contents !== "string") { + throw new Error("Unexpected pending promise tag"); + } + const existing = pending.get(value.contents); + if (existing) return existing.promise; + const resolver = Promise.withResolvers(); + resolver.promise.catch(() => {}); + pending.set(value.contents, resolver); + return resolver.promise; } - - if (tagNum === PROMISE_REJECTED_TAG) { - const error = deserializeError(value.contents as Record); - return Promise.reject(error); + if (value.tag === "promise") { + return Promise.resolve(restoreValue(value.contents, pending)); } - - if (tagNum === CUSTOM_TYPE_TAG) { + if (value.tag === "rejected") { + const promise = Promise.reject( + deserializeError( + restoreValue(value.contents, pending) as Record, + ), + ); + promise.catch(() => {}); + return promise; + } + if (value.tag === "type") { const { __type, data } = value.contents as { __type: string; data: unknown; }; const serializer = typeRegistry.get(__type); - if (serializer) { - return serializer.deserialize(data); + if (!serializer) { + throw new Error(`No deserializer registered for type "${__type}"`); } - console.warn(`No deserializer registered for type "${__type}"`); - return data; + return serializer.deserialize(restoreValue(data, pending)); } - - if (tagNum === ERROR_TAG) { - return deserializeError(value.contents as Record); + if (value.tag === "error") { + return deserializeError( + restoreValue(value.contents, pending) as Record, + ); } - - return restoreValue(value.contents); - } - - if (Array.isArray(value)) { - return value.map(restoreValue); - } - - if (value instanceof Date) { - return value; + throw new Error(`Unknown tagged JSON tag: ${String(value.tag)}`); } - + if (Array.isArray(value)) return value.map((v) => restoreValue(v, pending)); + if (value instanceof Date || value === null) return value; if (typeof value === "object") { const result: Record = {}; for (const [key, val] of Object.entries(value)) { - defineOwnValue(result, key, restoreValue(val)); + defineOwnValue(result, key, restoreValue(val, pending)); } return result; } - return value; } -export function cborEncode(data: unknown): Uint8Array { - return encode(data); -} - -export function cborDecode(data: Uint8Array): T { - return decode(data) as T; +export async function serializeLoaderData(data: unknown): Promise { + return JSON.stringify(toTaggedJson(await processValue(data))); } -/** - * Serialize loader/action data for client-side data requests. - * Processes promises, errors, and custom types before CBOR encoding. - * - * @param data - The loader/action data to serialize - * @returns The CBOR encoded data as Uint8Array - */ -export async function serializeLoaderData(data: unknown): Promise { - const processed = await processValue(data); - return encode(processed); +export function deserializeLoaderData(data: string): T { + return restoreValue(fromTaggedJson(JSON.parse(data))) as T; } -/** - * Deserialize loader/action data from client-side data requests. - * Decodes CBOR and restores promises, errors, and custom types from tags. - * - * @param data - The CBOR encoded data - * @returns The deserialized data with promises and custom types restored - */ -export function deserializeLoaderData(data: Uint8Array): T { - const decoded = decode(data); - return restoreValue(decoded) as T; +interface PendingPromises { + nextId: number; + entries: { id: string; promise: PromiseLike }[]; } -interface PendingPromise { - id: string; - promise: PromiseLike; +function discardPending(pending: PendingPromises): void { + for (const entry of pending.entries.splice(0)) { + Promise.resolve(entry.promise).catch(() => {}); + } } -/** - * Check if a value contains any promises (thenables). - * Used to determine if streaming should be used. - */ export function containsPromises(value: unknown): boolean { - if (value === null || value === undefined) { - return false; - } - - if (isThenable(value)) { - return true; - } - - if (Array.isArray(value)) { - return value.some(containsPromises); - } - + if (value === null || value === undefined) return false; + if (isThenable(value)) return true; if (typeof value === "object") { return Object.values(value).some(containsPromises); } - return false; } -/** - * Process a value for streaming, replacing promises with pending tags. - * Returns the processed structure and a list of pending promises with their IDs. - */ function processValueForStreaming( value: unknown, - pendingPromises: PendingPromise[], - idPrefix = "p", + pending: PendingPromises, ): unknown { - if (value === null || value === undefined) { - return value; - } - + if (value === null || value === undefined) return value; if (isThenable(value)) { - const id = `${idPrefix}${pendingPromises.length}`; - pendingPromises.push({ id, promise: value }); - return new Tag(PROMISE_PENDING_TAG, id); + const id = `p${pending.nextId++}`; + pending.entries.push({ id, promise: value }); + return new Tagged("pending", id); } - if (value instanceof Error || isHttpErrorLike(value)) { - return new Tag(ERROR_TAG, serializeError(value)); + return new Tagged( + "error", + processValueForStreaming(serializeError(value), pending), + ); } - - const typeSerializer = findTypeSerializer(value); - if (typeSerializer) { - return new Tag(CUSTOM_TYPE_TAG, { - __type: typeSerializer.name, - data: typeSerializer.serialize(value), + const serializer = findTypeSerializer(value); + if (serializer) { + return new Tagged("type", { + __type: serializer.name, + data: processValueForStreaming(serializeType(value, serializer), pending), }); } - if (Array.isArray(value)) { - return value.map((v, i) => - processValueForStreaming(v, pendingPromises, `${idPrefix}${i}_`) - ); - } - - if (value instanceof Date) { - return value; + return value.map((v) => processValueForStreaming(v, pending)); } - + if (value instanceof Date) return value; if (typeof value === "object") { const result: Record = {}; for (const [key, val] of Object.entries(value)) { - defineOwnValue( - result, - key, - processValueForStreaming(val, pendingPromises, `${idPrefix}${key}_`), - ); + defineOwnValue(result, key, processValueForStreaming(val, pending)); } return result; } - return value; } -/** - * Encode a length-prefixed CBOR chunk. - * Format: 4-byte big-endian length + CBOR data - */ -function encodeLengthPrefixedChunk(data: unknown): Uint8Array { - const cborData = encode(data); - const chunk = new Uint8Array(4 + cborData.length); - const view = new DataView(chunk.buffer); - view.setUint32(0, cborData.length, false); - chunk.set(cborData, 4); - return chunk; -} - -/** - * Resolution message sent for each resolved/rejected promise. - */ interface PromiseResolution { id: string; status: "resolved" | "rejected"; value?: unknown; - error?: Record; + error?: unknown; } -/** - * Create a streaming response for loader data with deferred promises. - * Returns a ReadableStream that emits length-prefixed CBOR chunks: - * 1. Initial chunk: data structure with pending promise placeholders - * 2. Subsequent chunks: promise resolutions as they complete - * - * @param data - The loader data (may contain promises) - * @returns A ReadableStream of CBOR chunks - */ -export function createStreamingLoaderData( +function prepareData( data: unknown, -): ReadableStream { - const pendingPromises: PendingPromise[] = []; - const processedData = processValueForStreaming(data, pendingPromises); +): { processed: unknown; pending: PendingPromises } { + const pending: PendingPromises = { nextId: 0, entries: [] }; + try { + return { processed: processValueForStreaming(data, pending), pending }; + } catch (error) { + discardPending(pending); + throw error; + } +} +function createDataStream( + processed: unknown, + pending: PendingPromises, + signal?: AbortSignal, +): ReadableStream { + const encoder = new TextEncoder(); + let ready: PromiseResolution[] = []; + let readIndex = 0; + let waiting: (() => void) | undefined; + let outstanding = 0; + let initial = true; + let stopped = false; + let controller: ReadableStreamDefaultController; + function stop(): void { + stopped = true; + processed = undefined; + ready = []; + signal?.removeEventListener("abort", abort); + waiting?.(); + waiting = undefined; + } + function abort(): void { + if (stopped) return; + controller.error(signal!.reason); + stop(); + } + function settle(resolution: PromiseResolution): void { + outstanding--; + if (stopped) return; + ready.push(resolution); + waiting?.(); + waiting = undefined; + } + function observePending(): void { + for (const { id, promise } of pending.entries.splice(0)) { + outstanding++; + Promise.resolve(promise).then( + (value) => settle({ id, status: "resolved", value }), + (error) => settle({ id, status: "rejected", error }), + ); + } + } return new ReadableStream({ - async start(controller) { - const initialChunk = encodeLengthPrefixedChunk(processedData); - controller.enqueue(initialChunk); - - if (pendingPromises.length === 0) { - controller.close(); - return; - } - - const resolutionPromises = pendingPromises.map( - async ({ id, promise }) => { - try { - const resolved = await promise; - const processedValue = await processValue(resolved); - const resolution: PromiseResolution = { - id, - status: "resolved", - value: processedValue, - }; - return encodeLengthPrefixedChunk(resolution); - } catch (error) { - const resolution: PromiseResolution = { - id, - status: "rejected", - error: serializeError(sanitizeServerError(error)), - }; - return encodeLengthPrefixedChunk(resolution); + start(c) { + controller = c; + observePending(); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) abort(); + }, + async pull(c) { + if (stopped) return; + try { + if (initial) { + initial = false; + c.enqueue( + encoder.encode(JSON.stringify(toTaggedJson(processed)) + "\n"), + ); + processed = undefined; + } else { + while (!stopped && readIndex === ready.length && outstanding > 0) { + const wake = Promise.withResolvers(); + waiting = wake.resolve; + await wake.promise; } - }, - ); - - const remaining = [...resolutionPromises]; - while (remaining.length > 0) { - const { chunk, index } = await Promise.race( - remaining.map((p, i) => p.then((chunk) => ({ chunk, index: i }))), - ); - controller.enqueue(chunk); - remaining.splice(index, 1); + if (stopped) return; + const resolution = ready[readIndex++]; + if (resolution) { + let line: PromiseResolution; + try { + line = resolution.status === "resolved" + ? { + id: resolution.id, + status: "resolved", + value: toTaggedJson( + processValueForStreaming(resolution.value, pending), + ), + } + : { + id: resolution.id, + status: "rejected", + error: toTaggedJson( + processValueForStreaming( + serializeError(sanitizeServerError(resolution.error)), + pending, + ), + ), + }; + } catch (error) { + discardPending(pending); + try { + line = { + id: resolution.id, + status: "rejected", + error: toTaggedJson( + processValueForStreaming( + serializeError(sanitizeServerError(error)), + pending, + ), + ), + }; + } catch (fallbackError) { + discardPending(pending); + throw fallbackError; + } + } + observePending(); + c.enqueue(encoder.encode(JSON.stringify(line) + "\n")); + } + if (readIndex >= ready.length) { + ready = []; + readIndex = 0; + } + } + if (outstanding === 0 && ready.length === 0) { + c.close(); + stop(); + } + } catch (error) { + c.error(error); + stop(); } - - controller.close(); }, - }); + cancel() { + stop(); + }, + }, { highWaterMark: 0 }); } -function restoreValueWithPendingPromises( - value: unknown, - promiseResolvers: Map void; - reject: (error: unknown) => void; - }>, -): unknown { - if (value === null || value === undefined) { - return value; - } - - if (value instanceof Tag) { - const tagNum = Number(value.tag); - - if (tagNum === PROMISE_PENDING_TAG) { - const id = value.contents as string; - const { promise, resolve, reject } = Promise.withResolvers(); - promiseResolvers.set(id, { resolve, reject }); - return promise; - } - - if (tagNum === PROMISE_RESOLVED_TAG) { - const restored = restoreValueWithPendingPromises( - value.contents, - promiseResolvers, - ); - return Promise.resolve(restored); - } - - if (tagNum === PROMISE_REJECTED_TAG) { - const error = deserializeError(value.contents as Record); - return Promise.reject(error); - } - - if (tagNum === CUSTOM_TYPE_TAG) { - const { __type, data } = value.contents as { - __type: string; - data: unknown; - }; - const serializer = typeRegistry.get(__type); - if (serializer) { - return serializer.deserialize(data); - } - console.warn(`No deserializer registered for type "${__type}"`); - return data; - } - - if (tagNum === ERROR_TAG) { - return deserializeError(value.contents as Record); - } - - return restoreValueWithPendingPromises(value.contents, promiseResolvers); - } - - if (Array.isArray(value)) { - return value.map((v) => - restoreValueWithPendingPromises(v, promiseResolvers) - ); - } - - if (value instanceof Date) { - return value; - } +export function createStreamingLoaderData( + data: unknown, + signal?: AbortSignal, +): ReadableStream { + const { processed, pending } = prepareData(data); + return createDataStream(processed, pending, signal); +} - if (typeof value === "object") { - const result: Record = {}; - for (const [key, val] of Object.entries(value)) { - defineOwnValue( - result, - key, - restoreValueWithPendingPromises(val, promiseResolvers), - ); - } - return result; +export function createLoaderDataResponse( + data: unknown, + signal?: AbortSignal, +): Response { + const { processed, pending } = prepareData(data); + if (pending.entries.length) { + return new Response(createDataStream(processed, pending, signal), { + headers: { + "Content-Type": "application/x-ndjson", + "X-Juniper": "data", + "Cache-Control": "no-transform", + }, + }); } - - return value; + const bytes = new TextEncoder().encode( + JSON.stringify(toTaggedJson(processed)), + ); + return new Response(bytes, { + headers: { + "Content-Type": "application/json", + "Content-Length": String(bytes.length), + "X-Juniper": "data", + }, + }); } -function createLengthPrefixedReader( - reader: ReadableStreamDefaultReader, -): () => Promise { - let buffer = new Uint8Array(0); - +function createLineReader( + reader: ReadableStreamDefaultReader, +): () => Promise { + let fragments: string[] = []; + let buffer = ""; + let offset = 0; return async () => { - while (buffer.length < 4) { - const { done, value } = await reader.read(); - if (done) { - if (buffer.length === 0) return null; - throw new Error("Unexpected end of stream while reading chunk length"); + while (true) { + const end = buffer.indexOf("\n", offset); + if (end !== -1) { + fragments.push(buffer.slice(offset, end)); + const line = fragments.join(""); + fragments = []; + offset = end + 1; + return line; } - const newBuffer = new Uint8Array(buffer.length + value.length); - newBuffer.set(buffer); - newBuffer.set(value, buffer.length); - buffer = newBuffer; - } - - const view = new DataView(buffer.buffer, buffer.byteOffset); - const length = view.getUint32(0, false); - - while (buffer.length < 4 + length) { - const { done, value } = await reader.read(); - if (done) { - throw new Error("Unexpected end of stream while reading chunk data"); + if (offset < buffer.length) fragments.push(buffer.slice(offset)); + const next = await reader.read(); + if (next.done) { + const line = fragments.length ? fragments.join("") : null; + fragments = []; + buffer = ""; + offset = 0; + return line; } - const newBuffer = new Uint8Array(buffer.length + value.length); - newBuffer.set(buffer); - newBuffer.set(value, buffer.length); - buffer = newBuffer; + buffer = next.value; + offset = 0; } - - const chunkData = buffer.slice(4, 4 + length); - buffer = buffer.slice(4 + length); - return chunkData; }; } -/** - * Deserialize streaming loader data from a Response. - * Reads the stream and returns the data structure with promises that - * will be resolved as resolution messages arrive. - * - * @param response - The streaming Response from the server - * @returns The deserialized data with live promises - */ + export async function deserializeStreamingLoaderData( response: Response, ): Promise { - const reader = response.body!.getReader(); - const promiseResolvers = new Map< - string, - { resolve: (value: unknown) => void; reject: (error: unknown) => void } - >(); - - const readChunk = createLengthPrefixedReader(reader); + if (!response.body) throw new Error("Empty data response"); + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + const pending: PromiseResolvers = new Map(); + const readLine = createLineReader(reader); + function rejectPending(error: unknown): void { + for (const resolver of pending.values()) resolver.reject(error); + pending.clear(); + } let data: unknown; try { - const initialChunk = await readChunk(); - if (!initialChunk) throw new Error("Empty streaming response"); - data = restoreValueWithPendingPromises( - decode(initialChunk), - promiseResolvers, - ); + const line = await readLine(); + if (line === null) throw new Error("Empty data response"); + data = restoreValue(fromTaggedJson(JSON.parse(line)), pending); } catch (error) { - reader.cancel(error).catch(() => {}); + rejectPending(error); + await reader.cancel(error).catch(() => {}); reader.releaseLock(); throw error; } - - if (promiseResolvers.size > 0) { + if (!pending.size) { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } else { (async () => { try { - let chunk: Uint8Array | null; - while ((chunk = await readChunk()) !== null) { - const resolution = decode(chunk) as PromiseResolution; - const resolver = promiseResolvers.get(resolution.id); - if (resolver) { + let line: string | null; + while ((line = await readLine()) !== null) { + const resolution = JSON.parse(line) as PromiseResolution; + const resolver = pending.get(resolution.id); + if (!resolver) continue; + pending.delete(resolution.id); + try { if (resolution.status === "resolved") { - const restoredValue = restoreValue(resolution.value); - resolver.resolve(restoredValue); + resolver.resolve( + restoreValue(fromTaggedJson(resolution.value), pending), + ); + } else if (resolution.status === "rejected") { + resolver.reject( + deserializeError( + restoreValue( + fromTaggedJson(resolution.error), + pending, + ) as Record, + ), + ); } else { - const error = deserializeError(resolution.error!); - resolver.reject(error); + throw new Error("Invalid promise resolution status"); } - promiseResolvers.delete(resolution.id); + } catch (error) { + resolver.reject(error); } } - if (promiseResolvers.size > 0) { + if (pending.size) { throw new Error( "Unexpected end of stream before all promises resolved", ); } } catch (error) { - for (const resolver of promiseResolvers.values()) { - resolver.reject(error); - } - promiseResolvers.clear(); - reader.cancel(error).catch(() => {}); + rejectPending(error); + await reader.cancel(error).catch(() => {}); } finally { reader.releaseLock(); } })(); - } else { - reader.cancel().catch(() => {}); - reader.releaseLock(); } return data as T; } -/** - * Encode data to a base64 string (for embedding in HTML). - * - * @param data - The data to encode - * @returns The base64 encoded string - */ -export function encodeToBase64(data: unknown): string { - const bytes = cborEncode(data); - let binary = ""; - for (let offset = 0; offset < bytes.length; offset += 32768) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768)); - } - return btoa(binary); -} - -/** - * Decode data from a base64 string. - * - * @param base64 - The base64 string to decode - * @returns The decoded data - */ -export function decodeFromBase64(base64: string): T { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return cborDecode(bytes); -} - /** * Serialize all registered context from RouterContextProvider. * @@ -808,10 +703,10 @@ export function serializeAllContext( const value = routerContext.get(serializer.context as any); const data = serializer.serialize(value); if (data !== undefined) { - result[name] = data; + defineOwnValue(result, name, data); } } catch { - // skip + continue; } } @@ -836,18 +731,10 @@ export function deserializeAllContext( } } -/** - * Serialized hydration data structure using CBOR. - */ export interface SerializedHydrationData { - /** Version identifier for compatibility checking */ - version: 2; - /** Base64 encoded CBOR data */ - data: string; - /** Public environment variables */ - publicEnv?: Record; + version: 3; + data: TaggedJson; } - /** * Hydration data structure. */ @@ -872,59 +759,56 @@ export interface HydrationData { buildId?: string; } -/** - * Serialize hydration data for embedding in HTML. - * - * @param hydrationData - The hydration data to serialize - * @returns The serialized hydration data - */ +function registeredNames(): string[] { + return [ + ...Array.from(typeRegistry.keys(), (name) => `type:${name}`), + ...Array.from(errorRegistry.keys(), (name) => `error:${name}`), + ...Array.from(contextRegistry.keys(), (name) => `context:${name}`), + ].sort(); +} + export async function serializeHydrationData( hydrationData: HydrationData, ): Promise { - const { publicEnv, errors, ...rest } = hydrationData; - - const processedData = await processValue({ + const { errors, publicEnv, ...rest } = hydrationData; + const data = { ...rest, errors: errors && Object.fromEntries( Object.entries(errors).map(( [id, error], ) => [id, sanitizeServerError(error)]), ), - }); - - return { - version: 2, - data: encodeToBase64(processedData), - publicEnv, }; + const processed: Record = {}; + for (const [key, value] of Object.entries(data)) { + defineOwnValue(processed, key, await processValue(value)); + } + defineOwnValue(processed, "publicEnv", publicEnv); + if (isDevelopment()) { + defineOwnValue(processed, "registeredNames", registeredNames()); + } + return { version: 3, data: toTaggedJson(processed) }; } -/** - * Deserialize hydration data from the serialized format. - * - * @param serialized - The serialized hydration data - * @returns The deserialized hydration data - */ export function deserializeHydrationData( serialized: SerializedHydrationData, ): HydrationData { - const decoded = decodeFromBase64>(serialized.data); - - const restored = restoreValue(decoded) as { - serializedContext?: unknown; - matches: { id: string }[]; - errors?: Record | null; - loaderData?: Record | null; - actionData?: Record | null; - buildId?: string; - }; - + const decoded = decodeHydrationPayload(serialized); + if (Array.isArray(decoded.registeredNames)) { + const available = new Set(registeredNames()); + const missing = decoded.registeredNames.filter((name) => + !available.has(name) + ); + if (missing.length) { + console.error(`Missing Juniper registrations: ${missing.join(", ")}`); + } + } + const restored = restoreValue(decoded) as HydrationData; return { - publicEnv: serialized.publicEnv, + publicEnv: restored.publicEnv, serializedContext: restored.serializedContext, buildId: restored.buildId, matches: restored.matches, - // React Router needs undefined, not null, for these fields. errors: restored.errors ?? undefined, loaderData: restored.loaderData ?? undefined, actionData: restored.actionData ?? undefined, @@ -932,23 +816,13 @@ export function deserializeHydrationData( } function initializeBuiltInSerializers(): void { - // Order matters: HttpError before generic Error, generic Error registered last as fallback. _addErrorSerializer({ name: "HttpError", is: (e): e is HttpError => e instanceof HttpError || isHttpErrorLike(e), serialize: (error) => { - // `exposedMessage`, never `message`: this payload reaches the browser - // through client-navigation data errors and the SSR hydration script, and - // `message` is where an app puts detail meant for its own logs. The - // rendering layer already drew this boundary; the wire did not, so an - // internal message shipped to every client on a data-error path. const serialized: Record = { message: error.exposedMessage, status: error.status, - // Everything written above is exposable by construction, so the flag - // says so rather than replaying a server-side decision the client - // cannot act on: `expose: false` here would make the deserialized - // error's `exposedMessage` disagree with the text it was handed. expose: true, }; if (error.instance !== undefined) serialized.instance = error.instance; @@ -960,9 +834,11 @@ function initializeBuiltInSerializers(): void { deserialize: (data) => { const error = new HttpError( data.status as number, - data.message as string, + { + message: data.message as string, + expose: data.expose as boolean | undefined, + }, ); - if (data.expose !== undefined) error.expose = data.expose as boolean; if (data.instance !== undefined) error.instance = data.instance as string; if (data.stack) error.stack = data.stack as string; return error; diff --git a/src/_serialization_hydration.test.ts b/src/_serialization_hydration.test.ts new file mode 100644 index 0000000..5d78f9c --- /dev/null +++ b/src/_serialization_hydration.test.ts @@ -0,0 +1,321 @@ +import { + assertEquals, + assertFalse, + assertRejects, + assertStringIncludes, + assertThrows, +} from "@std/assert"; +import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; +import { HttpError } from "@udibo/http-error"; + +import { registerError, registerType } from "./mod.ts"; +import { + deserializeHydrationData, + resetRegistries, + type SerializedHydrationData, + serializeHydrationData, + toInlineScriptJson, +} from "./_serialization.ts"; + +class Point { + constructor(public x: number, public y: number) {} +} + +class CustomError extends Error { + constructor(message: string, public code: string) { + super(message); + this.name = "CustomError"; + } +} + +function registerFixtures(): void { + registerType({ + name: "Point", + is: (value): value is Point => value instanceof Point, + serialize: (point) => ({ x: point.x, y: point.y }), + deserialize: (data) => new Point(data.x, data.y), + }); + registerType<{ raw: unknown }, unknown>({ + name: "Raw", + is: (value): value is { raw: unknown } => + value !== null && typeof value === "object" && "raw" in value && + Object.keys(value).length === 1, + serialize: (value) => value.raw, + deserialize: (data) => ({ raw: data }), + }); + registerError({ + name: "CustomError", + is: (error): error is CustomError => error instanceof CustomError, + serialize: (error) => ({ message: error.message, code: error.code }), + deserialize: (data) => + new CustomError(data.message as string, data.code as string), + }); +} + +function asEmbeddedInTheDocument( + serialized: SerializedHydrationData, +): SerializedHydrationData { + return new Function(`return ${toInlineScriptJson(serialized)};`)(); +} + +async function serializeV3( + loaderValue: unknown, +): Promise { + return await serializeHydrationData({ + matches: [{ id: "/" }], + loaderData: { "/": { value: loaderValue } }, + }); +} + +function hydratedValue(serialized: SerializedHydrationData): unknown { + const { loaderData } = deserializeHydrationData( + asEmbeddedInTheDocument(serialized), + ); + return (loaderData?.["/"] as { value: unknown }).value; +} + +async function observed(value: unknown): Promise { + if (value instanceof Promise) { + try { + return { resolved: await observed(await value) }; + } catch (error) { + return { rejected: await observed(error) }; + } + } + if (value === null) return null; + switch (typeof value) { + case "number": + return { number: Object.is(value, -0) ? "-0" : String(value) }; + case "bigint": + return { bigint: String(value) }; + case "undefined": + return { undefined: true }; + case "string": + case "boolean": + return value; + } + if (value instanceof Date) return { Date: String(value.getTime()) }; + if (Array.isArray(value)) { + return { + array: await Promise.all(Array.from(value, observed)), + holes: value.length - Object.keys(value).length, + }; + } + if (value instanceof Error) { + return { + name: value.name, + message: value instanceof HttpError + ? value.exposedMessage + : value.message, + ...(value instanceof HttpError ? { status: value.status } : {}), + ...(value instanceof CustomError ? { code: value.code } : {}), + }; + } + const object = value as Record; + const prototype = Object.getPrototypeOf(object); + const own: [string, unknown][] = []; + for (const key of Object.getOwnPropertyNames(object)) { + own.push([ + key, + await observed(Object.getOwnPropertyDescriptor(object, key)?.value), + ]); + } + return { + constructor: prototype === null ? null : prototype.constructor?.name, + plainPrototype: prototype === Object.prototype, + inherited: prototype && prototype !== Object.prototype && + prototype.constructor === Object + ? await observed({ ...prototype }) + : undefined, + message: object instanceof Error ? object.message : undefined, + exposedMessage: object instanceof HttpError + ? object.exposedMessage + : undefined, + own, + }; +} + +function rejectedWith(error: unknown): Promise { + const promise = Promise.reject(error); + promise.catch(() => {}); + return promise; +} + +const loneLeadSurrogate = String.fromCharCode(0xd800); +const loneTrailSurrogate = String.fromCharCode(0xdc00); + +const ROUND_TRIPS: [string, () => unknown, (() => unknown)?][] = [ + ["a Date", () => new Date("2026-09-11T12:34:56.789Z")], + ["the epoch", () => new Date(0)], + ["a Date before the epoch", () => new Date(-1)], + ["an invalid Date", () => new Date(NaN)], + ["a bigint in the safe range", () => 123n], + ["a large negative bigint", () => -(2n ** 53n)], + ["a large positive bigint", () => 2n ** 53n], + ["a bigint beyond 64 bits", () => 2n ** 64n], + ["a negative bigint beyond 64 bits", () => -(2n ** 64n) - 1n], + ["NaN", () => NaN], + ["Infinity", () => Infinity], + ["-Infinity", () => -Infinity], + ["-0", () => -0], + ["an unsafe integer number", () => 2 ** 60], + ["a fraction", () => 1.5], + ["undefined", () => undefined], + ["a key whose value is undefined", () => ({ kept: undefined })], + ["an array hole", () => [1, , 3], () => [1, undefined, 3]], + ["an Error", () => new Error("plain")], + ["a TypeError", () => new TypeError("typed")], + ["an HttpError", () => new HttpError(404, "Missing")], + [ + "an HttpError with an internal message", + () => new HttpError(500, "pool exhausted on shard 7"), + ], + ["an HttpError with an exposed message", () => + new HttpError(400, { + message: "internal", + exposedMessage: "Try again", + instance: "/probe", + })], + ["a registered error", () => new CustomError("custom", "E42")], + ["a resolved promise", () => Promise.resolve(new Date(0))], + ["a rejected promise", () => rejectedWith(new HttpError(403, "Forbidden"))], + ["a promise rejected with a non-error", () => rejectedWith("plain reason")], + ["a registered type", () => new Point(10, 20)], + ["a registered type whose data carries tagged values", () => ({ + raw: { at: new Date(0), big: 2n ** 64n, nan: NaN, gone: undefined }, + })], + ["a plain object that has the tag key", () => ({ $t: 1, v: 2 })], + ["a plain object shaped like a tagged Date", () => ({ $t: "Date", v: 0 })], + ["a tag-keyed object inside registered data", () => ({ + raw: { $t: "undefined" }, + })], + ["an own __proto__ key inside registered data", () => ({ + raw: JSON.parse('{"__proto__":{"polluted":true},"kept":1}'), + })], + [ + "an own __proto__ key in loader data", + () => JSON.parse('{"__proto__":{"polluted":true},"kept":1}'), + ], + ["a Map, flattened", () => new Map([["a", 1]]), () => ({})], + ["a Set, flattened", () => new Set([1]), () => ({})], + ["a RegExp, flattened", () => /x/g, () => ({})], + ["a URL, flattened", () => new URL("https://example.com/"), () => ({})], + [ + "a typed array, flattened", + () => new Uint8Array([1, 2]), + () => ({ 0: 1, 1: 2 }), + ], + ["a class instance, flattened", () => + new (class Box { + inside = 1; + })(), () => ({ inside: 1 })], + ["a lone surrogate", () => ({ + [`key${loneLeadSurrogate}`]: `value${loneTrailSurrogate}`, + }), () => ({ "key�": "value�" })], + ["markup and line separators", () => "