Skip to content
Merged
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-regexp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"capnweb": minor
---

Support serializing `RegExp` objects over RPC.
28 changes: 22 additions & 6 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ let SERIALIZE_TEST_CASES: Record<string, unknown> = {

'["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"}),
Expand Down Expand Up @@ -2990,21 +2993,24 @@ 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<string>({
let stream = new WritableStream<string | RegExp>({
write(chunk) { writesReceived++; },
close() { closeReceived = true; }
});

let writesSent = 0;

class StreamReceiver extends RpcTarget {
async receiveStream(stream: WritableStream<string>) {
async receiveStream(stream: WritableStream<string | RegExp>) {
let writer = stream.getWriter();
let chunk = "x".repeat(40000);
for (let i = 0; i < 20; i++) {
writesSent++;
await writer.write(chunk);
Expand All @@ -3013,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<StreamReceiver>(clientTransport);
new RpcSession(serverTransport, new StreamReceiver());
using clientStub = client.getRemoteMain();
Expand Down Expand Up @@ -3306,6 +3312,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);
});
}
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/concepts/values.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions packages/docs/src/content/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
15 changes: 15 additions & 0 deletions packages/docs/src/content/docs/reference/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1048,6 +1052,7 @@ export class RpcPayload {
case "bytes":
case "blob":
case "url":
case "regexp":
case "error":
case "undefined":
// immutable, no need to copy
Expand Down Expand Up @@ -1430,6 +1435,7 @@ export class RpcPayload {
case "blob":
case "date":
case "url":
case "regexp":
case "error":
case "undefined":
return;
Expand Down Expand Up @@ -1576,6 +1582,7 @@ export class RpcPayload {
case "writable":
case "readable":
case "url":
case "regexp":
case "headers":
case "request":
case "response":
Expand Down Expand Up @@ -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":
Expand Down
4 changes: 4 additions & 0 deletions src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ function estimateEncodedSize(value: unknown, seen?: WeakSet<object>, 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
Expand Down
26 changes: 21 additions & 5 deletions src/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <RegExp>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;
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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.
Expand Down
Loading