From 4ec4f99b65d80d7cc4c4c5b285d1e8994404a331 Mon Sep 17 00:00:00 2001 From: Dimitri Mitropoulos Date: Sun, 26 Jul 2026 21:29:40 -0400 Subject: [PATCH] feat: support serializing RegExp objects over RPC Closes #222. Adds by-value serialization for RegExp, aligning with Cloudflare Workers RPC structured-clonable type support. Based on the RegExp portion of the prior work in #99. Co-authored-by: Larry Maccherone <927864+lmaccherone@users.noreply.github.com> --- .changeset/serialize-regexp.md | 5 +++++ README.md | 2 +- __tests__/index.test.ts | 3 +++ protocol.md | 4 ++++ src/core.ts | 9 ++++++++- src/serialize.ts | 15 +++++++++++++++ 6 files changed, 36 insertions(+), 2 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 0000000..fa26577 --- /dev/null +++ b/.changeset/serialize-regexp.md @@ -0,0 +1,5 @@ +--- +"capnweb": minor +--- + +Support serializing `RegExp` objects over RPC. diff --git a/README.md b/README.md index 02434c1..1abbcfd 100644 --- a/README.md +++ b/README.md @@ -203,11 +203,11 @@ 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. +* `RegExp` * `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: * `Map` and `Set` -* `RegExp` The following are intentionally NOT supported: * Application-defined classes that do not extend `RpcTarget`. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 722a1ee..52a88c9 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -39,6 +39,9 @@ let SERIALIZE_TEST_CASES: Record = { '["-inf"]': -Infinity, '["nan"]': NaN, + '["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"}), diff --git a/protocol.md b/protocol.md index c6c8abd..a70c0fa 100644 --- a/protocol.md +++ b/protocol.md @@ -191,6 +191,10 @@ bound parsing cost. A JavaScript `Date` value. The number represents milliseconds since the Unix epoch. +`["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 always present, and is an empty string when the expression has no flags. For example, `/foo\d+/gi` is encoded as `["regexp", "foo\\d+", "gi"]`. + `["error", type, message, stack?, props?]` A JavaScript `Error` value. `type` is the name of the specific well-known `Error` subclass, e.g. "TypeError". `message` is a string containing the error message. `stack` may optionally contain the stack trace, though by default stacks will be redacted for security reasons. diff --git a/src/core.ts b/src/core.ts index f60750a..d6a6171 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" | "regexp" | "headers" | "request" | "response"; const AsyncFunction = (async function () {}).constructor; @@ -93,6 +93,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: @@ -963,6 +966,7 @@ export class RpcPayload { case "date": case "bytes": case "blob": + case "regexp": case "error": case "undefined": // immutable, no need to copy @@ -1344,6 +1348,7 @@ export class RpcPayload { case "bytes": case "blob": case "date": + case "regexp": case "error": case "undefined": return; @@ -1489,6 +1494,7 @@ export class RpcPayload { case "rpc-target": case "writable": case "readable": + case "regexp": case "headers": case "request": case "response": @@ -1642,6 +1648,7 @@ function followPath(value: unknown, parent: object | undefined, case "blob": case "date": case "error": + case "regexp": case "headers": case "request": case "response": diff --git a/src/serialize.ts b/src/serialize.ts index 9cd578c..f82a597 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 ["regexp", re.source, re.flags]; + } + case "bytes": { let alternateTypeName = BYTE_CONTAINER_TYPE_BY_PROTOTYPE.get(Object.getPrototypeOf(value)); let bytes: Uint8Array; @@ -841,6 +850,12 @@ export class Evaluator { return new Date(value[1]); } break; + case "regexp": + if (value.length === 3 && typeof value[1] === "string" && + typeof value[2] === "string") { + return new RegExp(value[1], value[2]); + } + break; case "bytes": { let bytes: Uint8Array; // At jsonCompatibleWithBytes/structuredClonable level, bytes may already be raw.