From 6cbd973eeea98ddd22bbd97e2e7890fb1b291bfc Mon Sep 17 00:00:00 2001 From: Sri Krishna <7254698+srikrsna@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:34:24 +0530 Subject: [PATCH] Add support for Temporal types --- .changeset/serialize-temporal.md | 5 + __tests__/index.test.ts | 134 ++++++++++++++++++ .../docs/src/content/docs/concepts/values.mdx | 2 + .../docs/src/content/docs/reference/api.mdx | 7 +- .../src/content/docs/reference/protocol.mdx | 24 ++++ src/core.ts | 28 +++- src/serialize.ts | 41 ++++++ src/types.d.ts | 13 +- vitest.config.ts | 9 ++ 9 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 .changeset/serialize-temporal.md diff --git a/.changeset/serialize-temporal.md b/.changeset/serialize-temporal.md new file mode 100644 index 00000000..69713e2d --- /dev/null +++ b/.changeset/serialize-temporal.md @@ -0,0 +1,5 @@ +--- +"capnweb": minor +--- + +Support serializing `Temporal.Instant`, `Temporal.PlainDate`, and `Temporal.Duration` over RPC, in runtimes that provide `Temporal`. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 01bfd651..2f6a060c 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -116,6 +116,26 @@ describe("simple serialization", () => { ); }) + it("reports a clear error when deserializing Temporal values without Temporal", () => { + // Simulate a runtime without Temporal, regardless of whether this one has it. + let g = globalThis as any; + let descriptor = Object.getOwnPropertyDescriptor(g, "Temporal"); + delete g.Temporal; + try { + expect(() => deserialize('["instant","2020-01-01T00:00:00Z"]')).toThrowError( + new TypeError( + "Cannot deserialize Temporal.Instant: Temporal is not available in this runtime.")); + expect(() => deserialize('["plaindate","2020-01-01"]')).toThrowError( + new TypeError( + "Cannot deserialize Temporal.PlainDate: Temporal is not available in this runtime.")); + expect(() => deserialize('["duration","PT1H"]')).toThrowError( + new TypeError( + "Cannot deserialize Temporal.Duration: Temporal is not available in this runtime.")); + } finally { + if (descriptor) Object.defineProperty(g, "Temporal", descriptor); + } + }) + it("can serialize complex nested structures", () => { let complex = { level1: { @@ -3334,6 +3354,120 @@ describe("transport encoding levels", () => { }); }); +// Temporal isn't available in every runtime we test on (and the TypeScript lib we build against +// doesn't declare it), so access it dynamically and skip where it's missing. +const Temporal = (globalThis as any).Temporal; + +describe.skipIf(!Temporal)("Temporal serialization", () => { + it("serializes Temporal values using toJSON()", () => { + expect(serialize(Temporal.Instant.from("2020-01-02T03:04:05.123456789Z"))) + .toBe('["instant","2020-01-02T03:04:05.123456789Z"]'); + expect(serialize(Temporal.PlainDate.from("2024-02-29"))) + .toBe('["plaindate","2024-02-29"]'); + expect(serialize(Temporal.Duration.from({hours: 1, minutes: 30}))) + .toBe('["duration","PT1H30M"]'); + expect(serialize(Temporal.Duration.from("-P1Y2M3DT4H5M6.007S"))) + .toBe('["duration","-P1Y2M3DT4H5M6.007S"]'); + }); + + it("deserializes Temporal values using from()", () => { + let instant = deserialize('["instant","2020-01-02T03:04:05.123456789Z"]'); + expect(Object.getPrototypeOf(instant)).toBe(Temporal.Instant.prototype); + expect(instant.epochNanoseconds).toBe(1577934245123456789n); + + let date = deserialize('["plaindate","2024-02-29"]'); + expect(Object.getPrototypeOf(date)).toBe(Temporal.PlainDate.prototype); + expect(date.equals(Temporal.PlainDate.from("2024-02-29"))).toBe(true); + + let duration = deserialize('["duration","-P1Y2M3DT4H5M6.007S"]'); + expect(Object.getPrototypeOf(duration)).toBe(Temporal.Duration.prototype); + expect(duration.toString()).toBe("-P1Y2M3DT4H5M6.007S"); + }); + + it("round-trips a PlainDate with a non-ISO calendar", () => { + let date = Temporal.PlainDate.from("2024-02-29[u-ca=japanese]"); + let serialized = serialize(date); + expect(serialized).toBe('["plaindate","2024-02-29[u-ca=japanese]"]'); + let result = deserialize(serialized); + expect(result.toString()).toBe("2024-02-29[u-ca=japanese]"); + expect(result.equals(date)).toBe(true); + }); + + it("round-trips Temporal values nested in objects and arrays", () => { + let value = { + when: Temporal.Instant.fromEpochMilliseconds(1234567890), + dates: [Temporal.PlainDate.from("2000-01-01"), Temporal.PlainDate.from("1999-12-31")], + ttl: Temporal.Duration.from({seconds: 90}), + }; + let result = deserialize(serialize(value)) as typeof value; + expect(result.when.equals(value.when)).toBe(true); + expect(result.dates.map(d => d.toString())).toStrictEqual(["2000-01-01", "1999-12-31"]); + expect(result.ttl.toString()).toBe("PT90S"); + }); + + it("does not serialize subclasses of Temporal types", () => { + class MyDuration extends Temporal.Duration {} + expect(() => serialize(new MyDuration(0, 0, 0, 0, 1))).toThrowError(TypeError); + }); + + it("rejects malformed Temporal values", () => { + for (let tag of ["instant", "plaindate", "duration"]) { + expect(() => deserialize(`["${tag}"]`)).toThrowError(); + expect(() => deserialize(`["${tag}",123]`)).toThrowError(); + expect(() => deserialize(`["${tag}","x","y"]`)).toThrowError(); + // Property bags are accepted by from() but aren't a valid wire encoding. + expect(() => deserialize(`["${tag}",{"years":1}]`)).toThrowError(); + expect(() => deserialize(`["${tag}","not a valid value"]`)).toThrowError(RangeError); + } + }); + + it("round-trips Temporal values over RPC", async () => { + class EchoService extends RpcTarget { + echo(value: unknown): unknown { + return value; + } + } + + await using harness = new TestHarness(new EchoService()); + let instant = Temporal.Instant.from("2020-01-02T03:04:05.123456789Z"); + let result = await harness.stub.echo(instant) as any; + expect(Object.getPrototypeOf(result)).toBe(Temporal.Instant.prototype); + expect(result.equals(instant)).toBe(true); + }); + + // Temporal values aren't structured-clonable, so they must be tuple-encoded at every level. + for (let level of ["jsonCompatible", "jsonCompatibleWithBytes", "structuredClonable"] as const) { + it(`round-trips Temporal values over a ${level} transport`, async () => { + class EchoService extends RpcTarget { + echo(value: unknown): unknown { + return value; + } + } + + let clientTransport = new ObjectTestTransport(undefined, level); + let serverTransport = new ObjectTestTransport(clientTransport, level); + let client = new RpcSession(clientTransport); + new RpcSession(serverTransport, new EchoService()); + using stub = client.getRemoteMain(); + + let instant = Temporal.Instant.from("2020-01-02T03:04:05.123456789Z"); + let instantResult = await stub.echo(instant) as any; + expect(Object.getPrototypeOf(instantResult)).toBe(Temporal.Instant.prototype); + expect(instantResult.equals(instant)).toBe(true); + + let date = Temporal.PlainDate.from("2024-02-29"); + let dateResult = await stub.echo(date) as any; + expect(Object.getPrototypeOf(dateResult)).toBe(Temporal.PlainDate.prototype); + expect(dateResult.equals(date)).toBe(true); + + let duration = Temporal.Duration.from("P1DT12H"); + let durationResult = await stub.echo(duration) as any; + expect(Object.getPrototypeOf(durationResult)).toBe(Temporal.Duration.prototype); + expect(durationResult.toString()).toBe("P1DT12H"); + }); + } +}); + describe("ReadableStream over RPC", () => { it("can send a ReadableStream and read all chunks", async () => { let stream = new ReadableStream({ diff --git a/packages/docs/src/content/docs/concepts/values.mdx b/packages/docs/src/content/docs/concepts/values.mdx index 1dc57630..4bde722c 100644 --- a/packages/docs/src/content/docs/concepts/values.mdx +++ b/packages/docs/src/content/docs/concepts/values.mdx @@ -17,6 +17,8 @@ The following types can be passed over RPC, in arguments or return values: - Arrays - `bigint` - `Date` +- `Temporal.Instant`, `Temporal.PlainDate`, and `Temporal.Duration`, in runtimes that provide + `Temporal` (the receiver must support it too) - `ArrayBuffer`, `DataView`, and typed arrays - `Error` and its well-known subclasses - `Blob` diff --git a/packages/docs/src/content/docs/reference/api.mdx b/packages/docs/src/content/docs/reference/api.mdx index e3bc7c6b..268fff63 100644 --- a/packages/docs/src/content/docs/reference/api.mdx +++ b/packages/docs/src/content/docs/reference/api.mdx @@ -101,9 +101,10 @@ Passed as the last argument to the session and response helpers. Commonly used f ## Value types on the wire -**By value:** primitives, plain objects, arrays, `bigint`, `Date`, `ArrayBuffer`, `DataView`, typed -arrays, `Error` and well-known subclasses, `Blob`, `ReadableStream`, `WritableStream`, `URL`, -`RegExp`, `Headers`, `Request`, `Response`. +**By value:** primitives, plain objects, arrays, `bigint`, `Date`, `Temporal.Instant`, +`Temporal.PlainDate`, `Temporal.Duration`, `ArrayBuffer`, `DataView`, typed arrays, `Error` and +well-known subclasses, `Blob`, `ReadableStream`, `WritableStream`, `URL`, `RegExp`, `Headers`, +`Request`, `Response`. **By reference:** `RpcTarget` subclasses, functions, existing stubs and promises. diff --git a/packages/docs/src/content/docs/reference/protocol.mdx b/packages/docs/src/content/docs/reference/protocol.mdx index 92347d48..068f06dd 100644 --- a/packages/docs/src/content/docs/reference/protocol.mdx +++ b/packages/docs/src/content/docs/reference/protocol.mdx @@ -357,6 +357,30 @@ bound parsing cost. A JavaScript `Date` value. The number is milliseconds since the Unix epoch. +### instant, plaindate, duration + +These encode the following values: + +```json +["instant", string] +["plaindate", string] +["duration", string] +``` + +A `Temporal.Instant`, `Temporal.PlainDate`, or `Temporal.Duration` value, respectively. The string is +the value's `toJSON()` output, an ISO 8601 string, and the receiver reconstructs the value with the +matching `from()`, e.g. `Temporal.Instant.from(string)`. The encoding is lossless: instants keep +nanosecond precision, and a `PlainDate` in a non-ISO calendar keeps its calendar annotation. These +are tuple-encoded at every encoding level, since Temporal values are not structured-clonable. +Receiving one in a runtime without `Temporal` is an error. For example: + +```json +["instant", "2020-01-02T03:04:05.123456789Z"] +["plaindate", "2024-02-29"] +["plaindate", "2024-02-29[u-ca=japanese]"] +["duration", "PT1H30M"] +``` + ### regexp ```json diff --git a/src/core.ts b/src/core.ts index 2d929ac9..42a18297 100644 --- a/src/core.ts +++ b/src/core.ts @@ -40,7 +40,7 @@ export type PropertyPath = (string | number)[]; type TypeForRpc = "unsupported" | "primitive" | "object" | "function" | "array" | "date" | "bigint" | "bytes" | "blob" | "stub" | "rpc-promise" | "rpc-target" | "rpc-thenable" | "error" | "undefined" | "writable" | "readable" | "regexp" | "url" | "headers" | "request" | - "response"; + "response" | "instant" | "plaindate" | "duration"; const AsyncFunction = (async function () {}).constructor; @@ -159,6 +159,20 @@ export function typeForRpc(value: unknown): TypeForRpc { } } + // Temporal isn't available in every runtime we support, and may be installed onto + // `globalThis` by a polyfill after this module loads, so look it up lazily. + let temporal = (globalThis as any).Temporal; + if (temporal) { + switch (prototype) { + case temporal.Instant?.prototype: + return "instant"; + case temporal.PlainDate?.prototype: + return "plaindate"; + case temporal.Duration?.prototype: + return "duration"; + } + } + if (value instanceof RpcTarget) { return "rpc-target"; } @@ -1053,6 +1067,9 @@ export class RpcPayload { case "blob": case "url": case "regexp": + case "instant": + case "plaindate": + case "duration": case "error": case "undefined": // immutable, no need to copy @@ -1436,6 +1453,9 @@ export class RpcPayload { case "date": case "url": case "regexp": + case "instant": + case "plaindate": + case "duration": case "error": case "undefined": return; @@ -1583,6 +1603,9 @@ export class RpcPayload { case "readable": case "url": case "regexp": + case "instant": + case "plaindate": + case "duration": case "headers": case "request": case "response": @@ -1738,6 +1761,9 @@ function followPath(value: unknown, parent: object | undefined, case "error": case "url": case "regexp": + case "instant": + case "plaindate": + case "duration": case "headers": case "request": case "response": diff --git a/src/serialize.ts b/src/serialize.ts index 3d1da6a6..37c3f38a 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -390,6 +390,18 @@ export class Devaluator { // Always tuple-encode URLs; structured-clone support for URL isn't universal. return ["url", (value as URL).href]; + // Temporal values are always tuple-encoded: they are not structured-clonable. `toJSON()` + // produces the ISO 8601 string form, which `from()` parses back losslessly (including + // nanosecond precision and any non-ISO calendar annotation on PlainDate). + case "instant": + return ["instant", (value as {toJSON(): string}).toJSON()]; + + case "plaindate": + return ["plaindate", (value as {toJSON(): string}).toJSON()]; + + case "duration": + return ["duration", (value as {toJSON(): string}).toJSON()]; + case "headers": // The `Headers` TS type apparently doesn't declare itself as being // Iterable<[string, string]>, but it is. @@ -774,6 +786,17 @@ function streamToBlobPromise(stream: ReadableStream, type: string): RpcPromise { return new RpcPromise(new PromiseStubHook(promise), []); } +// Looks up a Temporal class for deserialization. Temporal isn't available in every runtime, so +// this is resolved lazily (which also picks up polyfills installed onto `globalThis`). +function getTemporalClass(name: "Instant" | "PlainDate" | "Duration"): {from(s: string): unknown} { + let cls = (globalThis as any).Temporal?.[name]; + if (!cls) { + throw new TypeError( + `Cannot deserialize Temporal.${name}: Temporal is not available in this runtime.`); + } + return cls; +} + // Takes object trees parse from JSON and converts them into fully-hydrated JavaScript objects for // delivery to the app. This is used to implement deserialization, except that it doesn't actually // start from a raw string. @@ -976,6 +999,24 @@ export class Evaluator { } break; + case "instant": + if (value.length === 2 && typeof value[1] === "string") { + return getTemporalClass("Instant").from(value[1]); + } + break; + + case "plaindate": + if (value.length === 2 && typeof value[1] === "string") { + return getTemporalClass("PlainDate").from(value[1]); + } + break; + + case "duration": + if (value.length === 2 && typeof value[1] === "string") { + return getTemporalClass("Duration").from(value[1]); + } + break; + case "headers": // We only need to validate that the parameter is an array, so as not to invoke an // unexpected variant of the Headers constructor. So long as it is an array then we can diff --git a/src/types.d.ts b/src/types.d.ts index 40cd849e..fafe43c2 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -73,6 +73,16 @@ type TypedArray = | Float32Array | Float64Array; +// Temporal types, resolved from the global `Temporal` declaration if the consumer's TypeScript +// lib provides one (e.g. `esnext.temporal`), and `never` otherwise. This avoids a hard dependency +// on Temporal type declarations, which not every supported TypeScript version/lib ships. +type TemporalInstance = C extends { prototype: infer I } ? I : never; +type TemporalGlobal = typeof globalThis extends { Temporal: infer T } ? T : never; +type TemporalType = + | TemporalInstance + | TemporalInstance + | TemporalInstance; + // This represents all the types that can be sent as-is over an RPC boundary type BaseType = | void @@ -94,7 +104,8 @@ type BaseType = | URL | Request | Response - | Headers; + | Headers + | TemporalType; // Recursively rewrite all `Stubable` types with `Stub`s, and resolve promises. // Arm ordering matters here: // - `Promise` must come before `StubBase`: `RpcPromise` matches both, and must resolve diff --git a/vitest.config.ts b/vitest.config.ts index 4752cede..eb8b62bd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,15 @@ export default defineConfig({ }, test: { globalSetup: ['__tests__/test-server.ts'], + // Node.js 24 implements Temporal but only exposes it behind a V8 flag. Enable it so the + // Temporal serialization tests run under Node. Skip the flag once Temporal is available + // natively. (Vitest only reads pool options from the root config, not per-project, but only + // the Node.js project uses the forks pool.) + poolOptions: { + forks: { + execArgv: 'Temporal' in globalThis ? [] : ['--harmony-temporal'], + }, + }, projects: [ // Node.js {