Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/serialize-temporal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"capnweb": minor
---

Support serializing `Temporal.Instant`, `Temporal.PlainDate`, and `Temporal.Duration` over RPC, in runtimes that provide `Temporal`.
134 changes: 134 additions & 0 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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<EchoService>(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<string>({
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/src/content/docs/concepts/values.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
7 changes: 4 additions & 3 deletions packages/docs/src/content/docs/reference/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 24 additions & 0 deletions packages/docs/src/content/docs/reference/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encoding isn't lossless for Temporal.Duration. toJSON() folds sub-second units into the seconds field, so the round trip changes the duration's fields even though the total stays the same. Checked with Node --harmony-temporal:

D.from({milliseconds: 1500}).toJSON()          // "PT1.5S"
D.from("PT1.5S")                               // seconds: 1, milliseconds: 500 (was milliseconds: 1500)
D.from({seconds: 59, milliseconds: 1000})      // -> "PT60S" -> seconds: 60, milliseconds: 0

That's a spec limitation of Duration's ISO string form, so the wire format can't avoid it without encoding the fields one by one. Please narrow the claim here (and in the comment at src/serialize.ts:394), for example: "Durations keep their total length, but sub-second units may be rebalanced into seconds." The alternative is a field-wise encoding if callers need exact fields.

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
Expand Down
28 changes: 27 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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";
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1436,6 +1453,9 @@ export class RpcPayload {
case "date":
case "url":
case "regexp":
case "instant":
case "plaindate":
case "duration":
case "error":
case "undefined":
return;
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
41 changes: 41 additions & 0 deletions src/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = C extends { prototype: infer I } ? I : never;
type TemporalGlobal = typeof globalThis extends { Temporal: infer T } ? T : never;
type TemporalType =
| TemporalInstance<TemporalGlobal extends { Instant: infer C } ? C : never>
| TemporalInstance<TemporalGlobal extends { PlainDate: infer C } ? C : never>
| TemporalInstance<TemporalGlobal extends { Duration: infer C } ? C : never>;

// This represents all the types that can be sent as-is over an RPC boundary
type BaseType =
| void
Expand All @@ -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<T>` matches both, and must resolve
Expand Down
9 changes: 9 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading