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
[](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", () => "