From 3a020d4b8274d8e3191971b969cf55791931930a Mon Sep 17 00:00:00 2001 From: dmitropoulos Date: Fri, 4 Sep 2026 13:32:08 -0400 Subject: [PATCH 1/2] review comments and rebase --- .changeset/serialize-regexp.md | 5 ++++ __tests__/index.test.ts | 13 ++++++++++ .../docs/src/content/docs/concepts/values.md | 2 +- .../docs/src/content/docs/reference/api.md | 4 +-- .../src/content/docs/reference/protocol.md | 15 +++++++++++ src/core.ts | 10 ++++++- src/serialize.ts | 26 +++++++++++++++---- 7 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 .changeset/serialize-regexp.md diff --git a/.changeset/serialize-regexp.md b/.changeset/serialize-regexp.md new file mode 100644 index 00000000..fa26577b --- /dev/null +++ b/.changeset/serialize-regexp.md @@ -0,0 +1,5 @@ +--- +"capnweb": minor +--- + +Support serializing `RegExp` objects over RPC. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index f943e8d6..cd49f37e 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -43,6 +43,9 @@ let SERIALIZE_TEST_CASES: Record = { '["url","https://example.com/path?q=1"]': new URL("https://example.com/path?q=1"), + '["regexp","foo\\\\d+","gi"]': /foo\d+/gi, + '["regexp","^bar$"]': /^bar$/, + '["headers",[]]': new Headers(), '["headers",[["content-type","text/plain"],["x-custom","hello"]]]': new Headers({"Content-Type": "text/plain", "X-Custom": "hello"}), @@ -3306,6 +3309,16 @@ describe("transport encoding levels", () => { expect(date).toBeInstanceOf(Date); expect(date.getTime()).toBe(1234567890); + let re = await stub.echo(/foo\d+/gi) as RegExp; + expect(re).toBeInstanceOf(RegExp); + expect(re.source).toBe("foo\\d+"); + expect(re.flags).toBe("gi"); + + let bare = await stub.echo(/^bar$/) as RegExp; + expect(bare).toBeInstanceOf(RegExp); + expect(bare.source).toBe("^bar$"); + expect(bare.flags).toBe(""); + expect(await stub.echo(123n)).toBe(123n); }); } diff --git a/packages/docs/src/content/docs/concepts/values.md b/packages/docs/src/content/docs/concepts/values.md index f395b04c..1dc57630 100644 --- a/packages/docs/src/content/docs/concepts/values.md +++ b/packages/docs/src/content/docs/concepts/values.md @@ -23,6 +23,7 @@ The following types can be passed over RPC, in arguments or return values: - `ReadableStream` and `WritableStream`, with automatic flow control (see [Streaming](/concepts/streaming/)) - `URL` +- `RegExp` - `Headers`, `Request`, and `Response` from the Fetch API ## Passed by reference @@ -39,7 +40,6 @@ Anything passed by reference produces a **stub** on the far side, and stubs must These may be added in the future: - `Map` and `Set` -- `RegExp` ## Intentionally not supported diff --git a/packages/docs/src/content/docs/reference/api.md b/packages/docs/src/content/docs/reference/api.md index feb2aa60..e3bc7c6b 100644 --- a/packages/docs/src/content/docs/reference/api.md +++ b/packages/docs/src/content/docs/reference/api.md @@ -103,11 +103,11 @@ Passed as the last argument to the session and response helpers. Commonly used f **By value:** primitives, plain objects, arrays, `bigint`, `Date`, `ArrayBuffer`, `DataView`, typed arrays, `Error` and well-known subclasses, `Blob`, `ReadableStream`, `WritableStream`, `URL`, -`Headers`, `Request`, `Response`. +`RegExp`, `Headers`, `Request`, `Response`. **By reference:** `RpcTarget` subclasses, functions, existing stubs and promises. -**Not supported:** `Map`, `Set`, `RegExp` (not yet); non-`RpcTarget` classes and cyclic values +**Not supported:** `Map`, `Set` (not yet); non-`RpcTarget` classes and cyclic values (intentionally). [Docs](/concepts/values/) diff --git a/packages/docs/src/content/docs/reference/protocol.md b/packages/docs/src/content/docs/reference/protocol.md index d6ca24d6..92347d48 100644 --- a/packages/docs/src/content/docs/reference/protocol.md +++ b/packages/docs/src/content/docs/reference/protocol.md @@ -357,6 +357,21 @@ bound parsing cost. A JavaScript `Date` value. The number is milliseconds since the Unix epoch. +### regexp + +```json +["regexp", source, flags?] +``` + +A JavaScript `RegExp` value. `source` and `flags` are the strings from the regular expression's +`source` and `flags` properties. The receiver reconstructs the value via `new RegExp(source, flags)`. +`flags` is omitted when the expression has no flags. For example: + +```json +["regexp", "foo\\d+", "gi"] +["regexp", "^bar$"] +``` + ### error ```json diff --git a/src/core.ts b/src/core.ts index b4109748..2d929ac9 100644 --- a/src/core.ts +++ b/src/core.ts @@ -39,7 +39,8 @@ 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" | "url" | "headers" | "request" | "response"; + "error" | "undefined" | "writable" | "readable" | "regexp" | "url" | "headers" | "request" | + "response"; const AsyncFunction = (async function () {}).constructor; @@ -93,6 +94,9 @@ export function typeForRpc(value: unknown): TypeForRpc { case Date.prototype: return "date"; + case RegExp.prototype: + return "regexp"; + case Uint8Array.prototype: case BUFFER_PROTOTYPE: case ArrayBuffer.prototype: @@ -1048,6 +1052,7 @@ export class RpcPayload { case "bytes": case "blob": case "url": + case "regexp": case "error": case "undefined": // immutable, no need to copy @@ -1430,6 +1435,7 @@ export class RpcPayload { case "blob": case "date": case "url": + case "regexp": case "error": case "undefined": return; @@ -1576,6 +1582,7 @@ export class RpcPayload { case "writable": case "readable": case "url": + case "regexp": case "headers": case "request": case "response": @@ -1730,6 +1737,7 @@ function followPath(value: unknown, parent: object | undefined, case "date": case "error": case "url": + case "regexp": case "headers": case "request": case "response": diff --git a/src/serialize.ts b/src/serialize.ts index 2d489add..3d1da6a6 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -335,6 +335,15 @@ export class Devaluator { return ["date", Number.isNaN(time) ? null : time]; } + case "regexp": { + // At structuredClonable level, keep RegExp as native value. + if (this.encodingLevel === "structuredClonable") { + return value; + } + let re = value; + return re.flags ? ["regexp", re.source, re.flags] : ["regexp", re.source]; + } + case "bytes": { let alternateTypeName = BYTE_CONTAINER_TYPE_BY_PROTOTYPE.get(Object.getPrototypeOf(value)); let bytes: Uint8Array; @@ -806,12 +815,12 @@ export class Evaluator { `Deserialization exceeded maximum allowed message depth of ${maxDepth}.`); } - // At structuredClonable level, some native types pass through devaluation unencoded: Date and - // BigInt (as well as undefined and non-finite numbers, which the generic paths below already - // handle). Note that bytes and errors are tuple-encoded at every level, so raw `Uint8Array` - // and `Error` values are intentionally *not* accepted here. + // At structuredClonable level, some native types pass through devaluation unencoded: Date, + // RegExp, and BigInt (as well as undefined and non-finite numbers, which the generic paths + // below already handle). Note that bytes and errors are tuple-encoded at every level, so raw + // `Uint8Array` and `Error` values are intentionally *not* accepted here. if (this.encodingLevel === "structuredClonable") { - if (value instanceof Date || typeof value === "bigint") { + if (value instanceof Date || value instanceof RegExp || typeof value === "bigint") { return value; } } @@ -845,6 +854,13 @@ export class Evaluator { return new Date(value[1]); } break; + case "regexp": + if (typeof value[1] === "string" && + (value.length === 2 || + (value.length === 3 && typeof value[2] === "string"))) { + return new RegExp(value[1], value[2] as string | undefined); + } + break; case "bytes": { let bytes: Uint8Array; // At jsonCompatibleWithBytes/structuredClonable level, bytes may already be raw. From 4a648ebefe4ba33ecf574be2e3285fbec54beffa Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Tue, 8 Sep 2026 13:45:48 -0400 Subject: [PATCH 2/2] fix: account for RegExp size in stream backpressure --- __tests__/index.test.ts | 15 +++++++++------ src/rpc.ts | 4 ++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index cd49f37e..01bfd651 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -2993,11 +2993,15 @@ describe("WritableStream over RPC", () => { await rpcPromise; }); - it("applies backpressure when custom transport omits stream message size", async () => { + it.each([ + ["jsonCompatible", "x".repeat(40000)], + ["structuredClonable", new RegExp("x".repeat(40000), "gi")], + ] as const)("applies backpressure when custom transport omits stream message size (%s)", + async (encodingLevel, chunk) => { let writesReceived = 0; let closeReceived = false; - let stream = new WritableStream({ + let stream = new WritableStream({ write(chunk) { writesReceived++; }, close() { closeReceived = true; } }); @@ -3005,9 +3009,8 @@ describe("WritableStream over RPC", () => { let writesSent = 0; class StreamReceiver extends RpcTarget { - async receiveStream(stream: WritableStream) { + async receiveStream(stream: WritableStream) { let writer = stream.getWriter(); - let chunk = "x".repeat(40000); for (let i = 0; i < 20; i++) { writesSent++; await writer.write(chunk); @@ -3016,8 +3019,8 @@ describe("WritableStream over RPC", () => { } } - let clientTransport = new ObjectTestTransport(); - let serverTransport = new ObjectTestTransport(clientTransport); + let clientTransport = new ObjectTestTransport(undefined, encodingLevel); + let serverTransport = new ObjectTestTransport(clientTransport, encodingLevel); let client = new RpcSession(clientTransport); new RpcSession(serverTransport, new StreamReceiver()); using clientStub = client.getRemoteMain(); diff --git a/src/rpc.ts b/src/rpc.ts index fba171f1..8896fecc 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -120,6 +120,10 @@ function estimateEncodedSize(value: unknown, seen?: WeakSet, depth: numb return ESTIMATED_BINARY_OVERHEAD + value.size; } if (value instanceof Date) return 16; + if (value instanceof RegExp) { + return ESTIMATED_OBJECT_OVERHEAD + estimateStringSize(value.source) + + estimateStringSize(value.flags); + } // `seen` is only ever added to, never removed, so it dedupes by object identity across the // entire traversal rather than just along the current path. This is intentional: it keeps the