Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/serialize-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"capnweb": minor
---

Support serializing `Set` objects over RPC.

A `Set` carries plain data only: promises, stubs, and `Blob`s are not allowed as elements, and sending one over a connection throws a `TypeError`.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,19 +199,21 @@ The following types can be passed over RPC (in arguments or return values), and
* Arrays
* `bigint`
* `Date`
* `Set`, except that an element may not be a promise, stub, or `Blob` (see below)
* `ArrayBuffer`, `DataView`, and typed arrays
* `Error` and its well-known subclasses
* `Blob`
* `ReadableStream` and `WritableStream`, with automatic flow control.
* `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`
* `Map`
* `RegExp`

The following are intentionally NOT supported:
* Application-defined classes that do not extend `RpcTarget`.
* Cyclic values. Messages are serialized strictly as trees (like JSON).
* Promises, stubs, and `Blob`s as elements of a `Set`.

### `RpcTarget`

Expand Down
205 changes: 205 additions & 0 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ let SERIALIZE_TEST_CASES: Record<string, unknown> = {
'{"foo":[[123]]}': {foo: [123]},
'{"foo":[[123]],"bar":[[456,789]]}': {foo: [123], bar: [456, 789]},

'["set",[1,2,"abc",[[123]]]]': new Set([1, 2, "abc", [123]]),

'["bigint","123"]': 123n,
'["date",1234]': new Date(1234),
'["bytes","aGVsbG8h"]': new TextEncoder().encode("hello!"),
Expand Down Expand Up @@ -1573,6 +1575,209 @@ describe("promise pipelining", () => {
});
});

describe("promises, stubs and Blobs inside a Set", () => {
// A Set carries plain data only: no promises, stubs or Blobs as elements. A promise or Blob is
// delivered by substituting the value into its parent, i.e. `parent[property] = value`, and a Set
// has no property that names an element. Rather than mutate the Set behind the application's
// back, we reject it. Stubs are excluded along with them so a Set behaves the same in both
// directions. These tests pin that down on the send path (Devaluator), the local path
// (RpcPayload.deepCopy), and the receive path (Evaluator).
//
// Each path converts the element first and then checks what came back, instead of inspecting the
// element's type up front. That's why a Blob or stub is rejected over a connection but allowed on
// a local call, where nothing is encoded.
const PROMISE_ERROR = "Cannot serialize a promise as an element of a Set";
const BLOB_ERROR = "Cannot serialize a Blob as an element of a Set";
const DESERIALIZE_ERROR = "Cannot deserialize a stub or promise as an element of a Set";
const STUB_ERROR = "Cannot serialize a stub as an element of a Set";

class SetTarget extends RpcTarget {
square(i: number) {
return i * i;
}

// Reports what actually arrived. Deliberately does not await the elements: an unresolved
// promise is still thenable, so awaiting would hide a failure to substitute it.
inspect(container: Set<unknown>) {
return {
isSet: container instanceof Set,
elements: [...container].map(element => {
// RPC stubs and promises are callable, so typeof reports "function", not "object".
let objectLike = element !== null &&
(typeof element === "object" || typeof element === "function");
return objectLike && typeof (<any>element).then === "function"
? "<unresolved>" : element;
}),
// Anything here is a resolved value that was written onto the Set as a property instead
// of replacing the element it belongs to.
strayProps: Object.getOwnPropertyNames(container),
};
}

// Sending a Blob over a connection means streaming it, so it arrives behind a promise and hits
// the same restriction, even though the application never created a promise.
makeBlobSet() {
return new Set([new Blob(["first"])]);
}

// Hands the stub straight back inside a Set. Since the caller is the one that exported it, the
// caller's Evaluator sees the element as an ["import", id] expression.
bounce(stub: any) {
return new Set([stub.dup()]);
}

makeCounter(i: number) {
return new Counter(i);
}

// Stubs are passed by reference rather than substituted, so they remain legal elements.
async incrementAll(container: Set<unknown>) {
let results: number[] = [];
for (let element of container) {
results.push(await (<any>element).increment(1));
}
return {isSet: container instanceof Set, results};
}
}

it("rejects a promise sent inside a Set", async () => {
await using harness = new TestHarness(new SetTarget());
let stub = harness.stub;
using promise = stub.square(3);

// Thrown synchronously at the call site, like any other unserializable argument.
expect(() => stub.inspect(new Set<unknown>(["alpha", promise, "omega"])))
.toThrow(PROMISE_ERROR);

// The failed call must not have poisoned the session.
expect(await stub.inspect(new Set<unknown>(["alpha", "omega"])))
.toStrictEqual({isSet: true, elements: ["alpha", "omega"], strayProps: []});
});

it("rejects a promise in a Set passed to a local stub", async () => {
using stub = new RpcStub(new SetTarget());
using promise = stub.square(3);
let source = new Set<unknown>(["alpha", promise, "omega"]);

// The local path copies at delivery time, so this surfaces as a rejection.
await expect(() => stub.inspect(source)).rejects.toThrow(PROMISE_ERROR);

// The caller's Set is left exactly as it was.
expect([...source]).toStrictEqual(["alpha", promise, "omega"]);
expect(Object.getOwnPropertyNames(source)).toStrictEqual([]);
});

it("rejects a Blob sent inside a Set", async () => {
await using harness = new TestHarness(new SetTarget());

// Caught while encoding, before the Blob's pipe is created. The harness checks at the end of
// the test that no import or export leaked, which is what creating the pipe and then throwing
// would do.
expect(() => harness.stub.inspect(new Set<unknown>([new Blob(["hello"])]))).toThrow(BLOB_ERROR);
});

it("rejects a Blob in a Set returned to the caller", async () => {
await using harness = new TestHarness(new SetTarget());

// Caught by the server as it serializes its result. Note the arrow function: an RpcPromise is
// callable, so passing one to `expect(...).rejects` directly would make vitest invoke it.
await expect(() => harness.stub.makeBlobSet()).rejects.toThrow(BLOB_ERROR);
});

it("rejects a Set containing a Blob in plain serialize()", () => {
expect(() => serialize(new Set([new Blob(["hello"])]))).toThrow(BLOB_ERROR);
});

it("accepts a Blob in a Set passed to a local stub", async () => {
using stub = new RpcStub(new SetTarget());
let blob = new Blob(["hello"]);

// A same-process call streams nothing. The app receives this very Blob, so no promise is
// involved and there is nothing to substitute. Only a Blob crossing a connection is a problem.
let result = await stub.inspect(new Set<unknown>([blob]));

expect(result.elements).toStrictEqual([blob]);
expect(result.strayProps).toStrictEqual([]);
});

it("accepts a promise nested inside a Set element", async () => {
await using harness = new TestHarness(new SetTarget());
let stub = harness.stub;
using promise = stub.square(4);

// Only a promise that is *itself* an element is a problem. Here the promise's parent is the
// inner object, which has a property to write the resolution to.
let result = await stub.inspect(new Set<unknown>([{value: promise}]));

expect(result.elements).toStrictEqual([{value: 16}]);
expect(result.strayProps).toStrictEqual([]);
});

it("rejects a stub sent inside a Set", async () => {
await using harness = new TestHarness(new SetTarget());
using counter = new RpcStub(new Counter(5));

// A Set holds plain data only, so a stub the sender owns is out too. It encodes as ["export"].
expect(() => harness.stub.incrementAll(new Set<unknown>([counter]))).toThrow(STUB_ERROR);
});

it("rejects a stub pointing back at the peer inside a Set", async () => {
await using harness = new TestHarness(new SetTarget());

// The other encoding of a stub: this one is the peer's own capability, so it goes out as
// ["import"] rather than ["export"].
using counter = await harness.stub.makeCounter(5);

expect(() => harness.stub.incrementAll(new Set<unknown>([counter]))).toThrow(STUB_ERROR);
});

it("rejects a stub in a Set returned to the caller", async () => {
await using harness = new TestHarness(new SetTarget());
using counter = new RpcStub(new Counter(5));

// Caught by the server as it serializes its result, and reported as the call's rejection.
await expect(() => harness.stub.bounce(counter)).rejects.toThrow(STUB_ERROR);
});

it("accepts a stub in a Set passed to a local stub", async () => {
using stub = new RpcStub(new SetTarget());
using counter = new RpcStub(new Counter(5));

// Same leniency as a Blob on this path: nothing is encoded, so the app just gets the stub.
expect(await stub.incrementAll(new Set<unknown>([counter])))
.toStrictEqual({isSet: true, results: [6]});
});

it("rejects a promise arriving inside a Set from a peer", async () => {
// The sender-side check above means a well-behaved peer never produces this message, so we
// have to forge it: rewrite the outgoing call so the argument that was an array containing a
// pipelined promise becomes a *Set* containing that same promise. This exercises the
// receiver's own guard, which is what stops a hostile or buggy peer from corrupting a Set.
//
// Not using `await using`: the forged message breaks the session, so the harness's
// end-of-test "everything was disposed" check does not apply.
let harness = new TestHarness(new SetTarget());
let stub = harness.stub;

let origSend = harness.clientTransport.send;
harness.clientTransport.send = function(message: string) {
let rewritten = JSON.stringify(JSON.parse(message), function(_key, value) {
// Match the escaped-array encoding `[[<element>]]` and re-encode it as `["set", [...]]`.
if (value instanceof Array && value.length === 1 &&
value[0] instanceof Array && value[0].length === 1 &&
value[0][0] instanceof Array && value[0][0][0] === "pipeline") {
return ["set", value[0]];
}
return value;
});
return origSend.call(this, rewritten);
};

using promise = stub.square(3);
await expect(() => stub.inspect([promise] as any)).rejects.toThrow(DESERIALIZE_ERROR);
});
});

describe("map() over RPC", () => {
it("supports map() on nulls", async () => {
let counter = new RpcStub(new Counter(0));
Expand Down
1 change: 1 addition & 0 deletions packages/capnweb-validate/src/internal/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type BaseType =
| bigint
| string
| Date
| Set<unknown>
| Error
| RegExp
| Blob
Expand Down
6 changes: 6 additions & 0 deletions protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ bound parsing cost.

A JavaScript `Date` value. The number represents milliseconds since the Unix epoch.

`["set", elements]`

A JavaScript `Set` value. `elements` is an array of the set elements.

An element must not be a promise, a stub, or a blob.

`["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.
Expand Down
40 changes: 39 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export let RpcTarget = workersModule ? workersModule.RpcTarget : class {};

export type PropertyPath = (string | number)[];

type TypeForRpc = "unsupported" | "primitive" | "object" | "function" | "array" | "date" |
type TypeForRpc = "unsupported" | "primitive" | "object" | "function" | "array" | "date" | "set" |
"bigint" | "bytes" | "blob" | "stub" | "rpc-promise" | "rpc-target" | "rpc-thenable" |
"error" | "undefined" | "writable" | "readable" | "headers" | "request" | "response";

Expand Down Expand Up @@ -93,6 +93,9 @@ export function typeForRpc(value: unknown): TypeForRpc {
case Date.prototype:
return "date";

case Set.prototype:
return "set";

case Uint8Array.prototype:
case BUFFER_PROTOTYPE:
case ArrayBuffer.prototype:
Expand Down Expand Up @@ -981,6 +984,24 @@ export class RpcPayload {
return result;
}

case "set": {
// We have to construct the new set first, then fill it in, so we can pass it as the
// parent.
let set = <Set<unknown>>value;
let result = new Set();
let index = 0;
for (let val of set) {
let copy = this.deepCopy(val, set, index++, result, dupStubs, owner);
if (copy instanceof RpcPromise) {
throw new TypeError(
"Cannot serialize a promise as an element of a Set. Await the value before " +
"adding it to the Set.");
}
result.add(copy);
}
return result;
}

case "object": {
// Plain object. Unfortunately there's no way to pre-allocate the right shape.
let result: Record<string, unknown> = {};
Expand Down Expand Up @@ -1357,6 +1378,14 @@ export class RpcPayload {
return;
}

case "set": {
let set = <Set<unknown>>value;
for (let element of set) {
this.disposeImpl(element, set);
}
return;
}

case "object": {
let object = <Record<string, unknown>>value;
for (let i in object) {
Expand Down Expand Up @@ -1503,6 +1532,14 @@ export class RpcPayload {
return;
}

case "set": {
let set = <Set<unknown>>value;
for (let element of set) {
this.ignoreUnhandledRejectionsImpl(element);
}
return;
}

case "object": {
let object = <Record<string, unknown>>value;
for (let i in object) {
Expand Down Expand Up @@ -1641,6 +1678,7 @@ function followPath(value: unknown, parent: object | undefined,
case "bytes":
case "blob":
case "date":
case "set":
case "error":
case "headers":
case "request":
Expand Down
Loading
Loading