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

Support serializing `Set` objects over RPC.

A `Set` can contain stubs, but promises and `Blob`s are not allowed as direct elements. Sending a
`Set` containing either over a connection throws a `TypeError`.
212 changes: 212 additions & 0 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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 @@ -1580,6 +1582,216 @@ describe("promise pipelining", () => {
});
});

describe("promises and Blobs inside a Set", () => {
// 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 need no substitution and remain
// valid Set elements.
const PROMISE_ERROR = "Cannot serialize a promise as an element of a Set";
const LOCAL_PROMISE_ERROR = "Cannot pass 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 promise 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 () => {
let targetDisposed = false;
class DisposableCounter extends Counter {
[Symbol.dispose]() { targetDisposed = true; }
}

{
using stub = new RpcStub(new SetTarget());
using counter = new RpcStub(new DisposableCounter(5));
using promise = stub.square(3);
let source = new Set<unknown>(["alpha", counter, promise, "omega"]);

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

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

// The failed partial copy must not retain its duplicate of the preceding stub.
expect(targetDisposed).toBe(true);
});

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

// All elements are validated before encoding starts, so neither this preceding stream nor the
// Blob creates a pipe. The harness checks at the end of the test that no import leaked.
expect(() => harness.stub.inspect(new Set<unknown>([stream, 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("accepts a stub sent inside a Set", async () => {
await using harness = new TestHarness(new SetTarget());
using counter = new RpcStub(new Counter(5));

expect(await harness.stub.incrementAll(new Set<unknown>([counter])))
.toStrictEqual({isSet: true, results: [6]});
});

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

using counter = await harness.stub.makeCounter(5);

expect(await harness.stub.incrementAll(new Set<unknown>([counter])))
.toStrictEqual({isSet: true, results: [6]});
});

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

using result = await harness.stub.bounce(counter);

// The returned stub remains a stub when the Set is sent over the wire again.
expect(await harness.stub.incrementAll(result))
.toStrictEqual({isSet: true, results: [6]});
});

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));

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
7 changes: 6 additions & 1 deletion packages/docs/src/content/docs/concepts/values.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The following types can be passed over RPC, in arguments or return values:
- Primitive values: strings, numbers, booleans, `null`, `undefined`
- Plain objects (e.g. from object literals)
- Arrays
- `Set`
- `bigint`
- `Date`
- `ArrayBuffer`, `DataView`, and typed arrays
Expand All @@ -26,6 +27,10 @@ The following types can be passed over RPC, in arguments or return values:
- `RegExp`
- `Headers`, `Request`, and `Response` from the Fetch API

A `Set` cannot contain a promise or `Blob` directly when sent over a connection. Stubs are valid set
elements, and promises and `Blob`s may still appear inside a plain object or array used as an
element.

## Passed by reference

- Classes that extend [`RpcTarget`](/concepts/rpc-target/)
Expand All @@ -39,7 +44,7 @@ Anything passed by reference produces a **stub** on the far side, and stubs must

These may be added in the future:

- `Map` and `Set`
- `Map`

## Intentionally not supported

Expand Down
3 changes: 2 additions & 1 deletion packages/docs/src/content/docs/guides/workers-rpc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ RPC. The intent is that the two converge, with Cap'n Web arriving first.

| Capability | Cap'n Web | Workers RPC |
| ------------------------------------------- | --------- | ----------- |
| `Map`, `Set`, and some other built-ins | Not yet | Yes |
| `Map` and some other built-ins | Not yet | Yes |
| `Set` | Yes | Yes |
| Values containing aliases and cycles | No | Yes\* |
| `RpcPromise` in the parameters of a request | Yes | Not yet |
| The magic `.map()` method | Yes | Not yet |
Expand Down
10 changes: 6 additions & 4 deletions packages/docs/src/content/docs/reference/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,15 @@ Passed as the last argument to the session and response helpers. Commonly used f

## Value types on the wire

**By value:** primitives, plain objects, arrays, `bigint`, `Date`, `ArrayBuffer`, `DataView`, typed
arrays, `Error` and well-known subclasses, `Blob`, `ReadableStream`, `WritableStream`, `URL`,
**By value:** primitives, plain objects, arrays, `Set`, `bigint`, `Date`, `ArrayBuffer`, `DataView`,
typed arrays, `Error` and well-known subclasses, `Blob`, `ReadableStream`, `WritableStream`, `URL`,
`RegExp`, `Headers`, `Request`, `Response`.

**By reference:** `RpcTarget` subclasses, functions, existing stubs and promises.

**Not supported:** `Map`, `Set` (not yet); non-`RpcTarget` classes and cyclic values
(intentionally).
**`Set` elements:** stubs are supported. Promises and `Blob`s cannot be direct elements when sent
over a connection, but may be nested inside another value.

**Not supported:** `Map` (not yet); non-`RpcTarget` classes and cyclic values (intentionally).

[Docs](/concepts/values/)
11 changes: 11 additions & 0 deletions packages/docs/src/content/docs/reference/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,17 @@ bound parsing cost.

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

### set

```json
["set", elements]
```

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

An element must not evaluate directly to a promise or `Blob`. Stubs are valid elements, and promises
and `Blob`s may appear nested within another element.

### regexp

```json
Expand Down
Loading
Loading