diff --git a/.changeset/serialize-url.md b/.changeset/serialize-url.md new file mode 100644 index 00000000..0699353b --- /dev/null +++ b/.changeset/serialize-url.md @@ -0,0 +1,5 @@ +--- +"capnweb": minor +--- + +Support serializing `URL` objects over RPC. diff --git a/README.md b/README.md index 02434c12..2a868d4f 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ The following types can be passed over RPC (in arguments or return values), and * `Error` and its well-known subclasses * `Blob` * `ReadableStream` and `WritableStream`, with automatic flow control. +* `URL` * `Headers`, `Request`, and `Response` from the Fetch API. The following types are not supported as of this writing, but may be added in the future: diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 722a1eee..0d760bfe 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -39,6 +39,8 @@ let SERIALIZE_TEST_CASES: Record = { '["-inf"]': -Infinity, '["nan"]': NaN, + '["url","https://example.com/path?q=1"]': new URL("https://example.com/path?q=1"), + '["headers",[]]': new Headers(), '["headers",[["content-type","text/plain"],["x-custom","hello"]]]': new Headers({"Content-Type": "text/plain", "X-Custom": "hello"}), @@ -80,7 +82,7 @@ describe("simple serialization", () => { it("can deserialize", () => { for (let key in SERIALIZE_TEST_CASES) { let value = deserialize(key); - if (value instanceof Uint8Array || + if (value instanceof Uint8Array || value instanceof URL || value instanceof Headers || value instanceof Request || value instanceof Response) { // toStrictEqual() won't work for these (e.g. in Node.js, Uint8Array may deserialize as // Buffer), so test by serializing again and making sure they round-trip. diff --git a/packages/capnweb-validate/__tests__/rpc-compatible-types.test.ts b/packages/capnweb-validate/__tests__/rpc-compatible-types.test.ts index ca1abd8f..1e9dbfe7 100644 --- a/packages/capnweb-validate/__tests__/rpc-compatible-types.test.ts +++ b/packages/capnweb-validate/__tests__/rpc-compatible-types.test.ts @@ -134,6 +134,9 @@ class Api extends RpcTarget { expect(accepts(v.regexp, /abc/u)).toBe(true); expect(accepts(v.regexp, "abc")).toBe(false); + expect(accepts(v.url, new URL("https://example.com/"))).toBe(true); + expect(accepts(v.url, "https://example.com/")).toBe(false); + const int16 = v.typedArray("Int16Array"); expect(accepts(int16, new Int16Array(2))).toBe(true); expect(accepts(int16, new Uint8Array(2))).toBe(false); diff --git a/packages/capnweb-validate/src/internal/core.ts b/packages/capnweb-validate/src/internal/core.ts index 8e4b8b69..34dd6acf 100644 --- a/packages/capnweb-validate/src/internal/core.ts +++ b/packages/capnweb-validate/src/internal/core.ts @@ -93,6 +93,7 @@ type BaseType = | Float64Array | ReadableStream | WritableStream + | URL | Request | Response | Headers; @@ -423,6 +424,7 @@ export const v = { blob: exactBrand("Blob"), readableStream: exactBrand("ReadableStream"), writableStream: exactBrand("WritableStream"), + url: exactBrand("URL"), headers: exactBrand("Headers"), request: exactBrand("Request"), response: exactBrand("Response"), diff --git a/packages/capnweb-validate/src/transform/emit.ts b/packages/capnweb-validate/src/transform/emit.ts index 83089a2c..701ebb87 100644 --- a/packages/capnweb-validate/src/transform/emit.ts +++ b/packages/capnweb-validate/src/transform/emit.ts @@ -218,6 +218,7 @@ function emitValidator_(shape: TypeShape, ctx: EmitContext): string { case "blob": case "readableStream": case "writableStream": + case "url": case "headers": case "request": case "response": diff --git a/packages/capnweb-validate/src/transform/type-introspector.ts b/packages/capnweb-validate/src/transform/type-introspector.ts index cf5488db..e6ac1461 100644 --- a/packages/capnweb-validate/src/transform/type-introspector.ts +++ b/packages/capnweb-validate/src/transform/type-introspector.ts @@ -79,6 +79,7 @@ export type TypeShape = | { kind: "blob" } // Blob | { kind: "readableStream" } | { kind: "writableStream" } + | { kind: "url" } | { kind: "headers" } | { kind: "request" } | { kind: "response" } @@ -519,6 +520,7 @@ const BUILTIN_VALUE_TYPES: Record = { Blob: { kind: "blob" }, ReadableStream: { kind: "readableStream" }, WritableStream: { kind: "writableStream" }, + URL: { kind: "url" }, Headers: { kind: "headers" }, Request: { kind: "request" }, Response: { kind: "response" }, diff --git a/protocol.md b/protocol.md index c6c8abd7..3697c5ab 100644 --- a/protocol.md +++ b/protocol.md @@ -199,6 +199,10 @@ A JavaScript `Error` value. `type` is the name of the specific well-known `Error When `props` is present, `stack` is normalised to `null` if absent so that positional indexing for `props` is unambiguous. When there are no extras, the legacy 3- or 4-element form is emitted unchanged. +`["url", href]` + +A `URL` object. `href` is the fully-serialized (and normalized) URL string, i.e. the value of the URL's `href` property. The receiver reconstructs the `URL` via `new URL(href)`. For example: `["url", "https://example.com/path?q=1"]`. + `["headers", pairs]` A `Headers` object from the Fetch API. `pairs` is an array of `[name, value]` pairs, where both `name` and `value` are strings. For example: `["headers", [["content-type", "text/plain"], ["x-custom", "hello"]]]`. diff --git a/src/core.ts b/src/core.ts index f60750a7..86d8c032 100644 --- a/src/core.ts +++ b/src/core.ts @@ -39,7 +39,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" | "headers" | "request" | "response"; + "error" | "undefined" | "writable" | "readable" | "url" | "headers" | "request" | "response"; const AsyncFunction = (async function () {}).constructor; @@ -115,6 +115,9 @@ export function typeForRpc(value: unknown): TypeForRpc { case ReadableStream.prototype: return "readable"; + case URL.prototype: + return "url"; + case Headers.prototype: return "headers"; @@ -963,6 +966,7 @@ export class RpcPayload { case "date": case "bytes": case "blob": + case "url": case "error": case "undefined": // immutable, no need to copy @@ -1344,6 +1348,7 @@ export class RpcPayload { case "bytes": case "blob": case "date": + case "url": case "error": case "undefined": return; @@ -1489,6 +1494,7 @@ export class RpcPayload { case "rpc-target": case "writable": case "readable": + case "url": case "headers": case "request": case "response": @@ -1642,6 +1648,7 @@ function followPath(value: unknown, parent: object | undefined, case "blob": case "date": case "error": + case "url": case "headers": case "request": case "response": diff --git a/src/serialize.ts b/src/serialize.ts index 9cd578c5..2d489add 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -377,6 +377,10 @@ export class Devaluator { return alternateTypeName === undefined ? ["bytes", b64] : ["bytes", b64, alternateTypeName]; } + case "url": + // Always tuple-encode URLs; structured-clone support for URL isn't universal. + return ["url", (value as URL).href]; + case "headers": // The `Headers` TS type apparently doesn't declare itself as being // Iterable<[string, string]>, but it is. @@ -950,6 +954,12 @@ export class Evaluator { case "nan": return NaN; + case "url": + if (value.length === 2 && typeof value[1] === "string") { + return new URL(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 2c043e30..0631de58 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -90,6 +90,7 @@ type BaseType = | Blob | ReadableStream // Chunk type can be any RPC-compatible type | WritableStream // Chunk type can be any RPC-compatible type + | URL | Request | Response | Headers;