From 71fb6d0061ef3113cc8639619cc979b2526f49e1 Mon Sep 17 00:00:00 2001 From: Sri Krishna <7254698+srikrsna@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:00:04 +0530 Subject: [PATCH 1/4] Support `Map` over the RPC --- .changeset/serialize-map.md | 9 + __tests__/index.test.ts | 286 ++++++++++++++++++ .../docs/src/content/docs/concepts/values.mdx | 10 +- .../src/content/docs/guides/workers-rpc.mdx | 4 +- .../docs/src/content/docs/reference/api.mdx | 13 +- .../src/content/docs/reference/protocol.mdx | 12 + src/core.ts | 68 ++++- src/serialize.ts | 51 +++- 8 files changed, 431 insertions(+), 22 deletions(-) create mode 100644 .changeset/serialize-map.md diff --git a/.changeset/serialize-map.md b/.changeset/serialize-map.md new file mode 100644 index 0000000..b7f7190 --- /dev/null +++ b/.changeset/serialize-map.md @@ -0,0 +1,9 @@ +--- +"capnweb": minor +--- + +Support serializing `Map` objects over RPC. + +`Map` keys follow the same rules as `Set` elements: stubs are allowed, but promises and `Blob`s are +not allowed as direct keys. Sending a `Map` with either as a key over a connection throws a +`TypeError`. `Map` values may be promises, `Blob`s, or stubs. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index e7fe374..5eaf65a 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -31,6 +31,10 @@ let SERIALIZE_TEST_CASES: Record = { '["set",[1,2,"abc",[[123]]]]': new Set([1, 2, "abc", [123]]), + '["map",[]]': new Map(), + '["map",[[1,"one"],["two",[[2]]],[["date",1234],{"nested":true}]]]': + new Map([[1, "one"], ["two", [2]], [new Date(1234), {nested: true}]]), + '["bigint","123"]': 123n, '["date",1234]': new Date(1234), '["bytes","aGVsbG8h"]': new TextEncoder().encode("hello!"), @@ -141,6 +145,8 @@ describe("simple serialization", () => { expect(() => deserialize('["unknown_type", "param"]')).toThrowError(); expect(() => deserialize('["date"]')).toThrowError(); // missing timestamp expect(() => deserialize('["error"]')).toThrowError(); // missing type and message + expect(() => deserialize('["map",[[1]]]')).toThrowError(); // entry isn't a key/value pair + expect(() => deserialize('["map",[1]]')).toThrowError(); // entry isn't an array }) it("can serialize large Uint8Array without stack overflow", () => { @@ -1792,6 +1798,286 @@ describe("promises and Blobs inside a Set", () => { }); }); +describe("promises, Blobs, and stubs inside a Map", () => { + // Map keys follow the same rules as Set elements: a promise or Blob is delivered by substituting + // the value into its parent, i.e. `parent[property] = value`, and a Map has no property that + // names a key. Rather than rebuild the Map behind the application's back, we reject it. Stubs + // need no substitution and remain valid keys. + // + // Values are a different story: once the key is known, a temporary setter can replace the value + // under that key in place, so promises and Blobs remain valid Map values. These tests pin the + // behavior of those setters on both the wire path (Evaluator) and the local path + // (RpcPayload.deepCopy). + const PROMISE_ERROR = "Cannot serialize a promise as a key of a Map"; + const LOCAL_PROMISE_ERROR = "Cannot pass a promise as a key of a Map"; + const BLOB_ERROR = "Cannot serialize a Blob as a key of a Map"; + const DESERIALIZE_ERROR = "Cannot deserialize a promise as a key of a Map"; + + class MapTarget extends RpcTarget { + square(i: number) { + return i * i; + } + + // Reports what actually arrived. Deliberately does not await the entries: an unresolved + // promise is still thenable, so awaiting would hide a failure to substitute it. + inspect(container: Map) { + let render = (value: unknown) => { + // RPC stubs and promises are callable, so typeof reports "function", not "object". + let objectLike = value !== null && + (typeof value === "object" || typeof value === "function"); + return objectLike && typeof (value).then === "function" ? "" : value; + }; + return { + isMap: container instanceof Map, + entries: [...container].map(([key, value]) => [render(key), render(value)]), + // Anything here is a resolved value that was written onto the Map as a property instead + // of replacing the value it belongs to. + strayProps: Object.getOwnPropertyNames(container), + }; + } + + // Blobs are always delivered through the promise machinery, so this exercises the value slot + // in the returning direction without any pipelining on the caller's part. + makeBlobValueMap() { + return new Map([["first", new Blob(["one"])], ["second", new Blob(["two"])]]); + } + + // Sending a Blob over a connection means streaming it, so it arrives behind a promise and hits + // the key restriction, even though the application never created a promise. + makeBlobKeyMap() { + return new Map([[new Blob(["key"]), "value"]]); + } + + // Hands the stub straight back as a Map key. Since the caller is the one that exported it, the + // caller's Evaluator sees the key as an ["import", id] expression. + bounce(stub: any) { + return new Map([[stub.dup(), "counter"]]); + } + + makeCounter(i: number) { + return new Counter(i); + } + + // Stubs are passed by reference rather than substituted, so they remain legal keys. + async incrementAll(container: Map) { + let results: [number, unknown][] = []; + for (let [key, value] of container) { + results.push([await (key).increment(1), value]); + } + return {isMap: container instanceof Map, results}; + } + } + + it("substitutes a promise sent as a Map value", async () => { + await using harness = new TestHarness(new MapTarget()); + let stub = harness.stub; + using promise = stub.square(3); + + let result = await stub.inspect(new Map([ + ["alpha", 1], ["beta", promise], ["omega", 3], + ])); + + expect(result.isMap).toBe(true); + expect(result.entries).toStrictEqual([["alpha", 1], ["beta", 9], ["omega", 3]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("substitutes multiple promise values at their own positions", async () => { + await using harness = new TestHarness(new MapTarget()); + let stub = harness.stub; + using first = stub.square(3); + using second = stub.square(4); + + let result = await stub.inspect(new Map([ + ["a", first], ["b", 0], ["c", second], ["d", first], + ])); + + expect(result.entries).toStrictEqual([["a", 9], ["b", 0], ["c", 16], ["d", 9]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("leaves no residue when a promise is nested inside a Map key or value", async () => { + await using harness = new TestHarness(new MapTarget()); + let stub = harness.stub; + using inKey = stub.square(3); + using inValue = stub.square(4); + + // Only a promise that is *itself* a key is a problem. Here each promise's parent is an inner + // object, which has a property to write the resolution to, so the Map needs no setter. + let result = await stub.inspect(new Map([[{key: inKey}, {value: inValue}]])); + + expect(result.entries).toStrictEqual([[{key: 9}, {value: 16}]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("substitutes Blobs in Map values returned to the caller", async () => { + await using harness = new TestHarness(new MapTarget()); + + let received = await harness.stub.makeBlobValueMap(); + + expect(received).toBeInstanceOf(Map); + expect(Object.getOwnPropertyNames(received)).toStrictEqual([]); + expect([...received.keys()]).toStrictEqual(["first", "second"]); + expect(await received.get("first")!.text()).toBe("one"); + expect(await received.get("second")!.text()).toBe("two"); + }); + + it("substitutes promise values in a Map passed to a local stub", async () => { + using stub = new RpcStub(new MapTarget()); + using first = stub.square(3); + using second = stub.square(4); + let source = new Map([["alpha", first], ["beta", "middle"], ["omega", second]]); + + let result = await stub.inspect(source); + + expect(result.entries).toStrictEqual([["alpha", 9], ["beta", "middle"], ["omega", 16]]); + expect(result.strayProps).toStrictEqual([]); + + // deepCopy() must fix up its copy, not the caller's Map. + expect([...source]).toStrictEqual([["alpha", first], ["beta", "middle"], ["omega", second]]); + expect(Object.getOwnPropertyNames(source)).toStrictEqual([]); + }); + + it("rejects a promise sent as a Map key", async () => { + await using harness = new TestHarness(new MapTarget()); + let stub = harness.stub; + using promise = stub.square(3); + + // Thrown synchronously at the call site, like any other unserializable argument. + expect(() => stub.inspect(new Map([["alpha", 1], [promise, 2]]))) + .toThrow(PROMISE_ERROR); + + // The failed call must not have poisoned the session. + expect(await stub.inspect(new Map([["alpha", 1]]))) + .toStrictEqual({isMap: true, entries: [["alpha", 1]], strayProps: []}); + }); + + it("rejects a promise key in a Map passed to a local stub", async () => { + let targetDisposed = false; + class DisposableCounter extends Counter { + [Symbol.dispose]() { targetDisposed = true; } + } + + { + using stub = new RpcStub(new MapTarget()); + using counter = new RpcStub(new DisposableCounter(5)); + using promise = stub.square(3); + let source = new Map([["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 Map is left exactly as it was. + expect([...source]).toStrictEqual([["alpha", counter], [promise, "omega"]]); + expect(Object.getOwnPropertyNames(source)).toStrictEqual([]); + } + + // The failed copy must not retain a duplicate of the stub in the preceding entry. + expect(targetDisposed).toBe(true); + }); + + it("rejects a Blob sent as a Map key", async () => { + await using harness = new TestHarness(new MapTarget()); + let stream = new ReadableStream(); + + // All keys are validated before encoding starts, so neither the preceding stream value nor + // the Blob creates a pipe. The harness checks at the end of the test that no import leaked. + expect(() => harness.stub.inspect(new Map([ + ["stream", stream], [new Blob(["hello"]), "blob"], + ]))).toThrow(BLOB_ERROR); + }); + + it("rejects a Blob key in a Map returned to the caller", async () => { + await using harness = new TestHarness(new MapTarget()); + + // 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.makeBlobKeyMap()).rejects.toThrow(BLOB_ERROR); + }); + + it("rejects a Map with a Blob key in plain serialize()", () => { + expect(() => serialize(new Map([[new Blob(["hello"]), 1]]))).toThrow(BLOB_ERROR); + }); + + it("accepts a Blob key in a Map passed to a local stub", async () => { + using stub = new RpcStub(new MapTarget()); + 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 Map([[blob, "value"]])); + + expect(result.entries).toStrictEqual([[blob, "value"]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("accepts a stub sent as a Map key", async () => { + await using harness = new TestHarness(new MapTarget()); + using counter = new RpcStub(new Counter(5)); + + expect(await harness.stub.incrementAll(new Map([[counter, "c"]]))) + .toStrictEqual({isMap: true, results: [[6, "c"]]}); + }); + + it("accepts a stub pointing back at the peer as a Map key", async () => { + await using harness = new TestHarness(new MapTarget()); + + using counter = await harness.stub.makeCounter(5); + + expect(await harness.stub.incrementAll(new Map([[counter, "c"]]))) + .toStrictEqual({isMap: true, results: [[6, "c"]]}); + }); + + it("accepts a stub as a Map key returned to the caller", async () => { + await using harness = new TestHarness(new MapTarget()); + using counter = new RpcStub(new Counter(5)); + + using result = await harness.stub.bounce(counter); + + // The returned stub remains a stub when the Map is sent over the wire again. + expect(await harness.stub.incrementAll(result)) + .toStrictEqual({isMap: true, results: [[6, "counter"]]}); + }); + + it("accepts a stub as a Map key passed to a local stub", async () => { + using stub = new RpcStub(new MapTarget()); + using counter = new RpcStub(new Counter(5)); + + expect(await stub.incrementAll(new Map([[counter, "c"]]))) + .toStrictEqual({isMap: true, results: [[6, "c"]]}); + }); + + it("rejects a promise arriving as a Map key 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 *Map* whose key is that same promise. This exercises the + // receiver's own guard, which is what stops a hostile or buggy peer from corrupting a Map. + // + // 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 MapTarget()); + 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 `[[]]` and re-encode it as + // `["map", [[, 1]]]`. + 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 ["map", [[value[0][0], 1]]]; + } + 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)); diff --git a/packages/docs/src/content/docs/concepts/values.mdx b/packages/docs/src/content/docs/concepts/values.mdx index 931c3b9..66322a8 100644 --- a/packages/docs/src/content/docs/concepts/values.mdx +++ b/packages/docs/src/content/docs/concepts/values.mdx @@ -16,6 +16,7 @@ The following types can be passed over RPC, in arguments or return values: - Plain objects (e.g. from object literals) - Arrays - `Set` +- `Map` - `bigint` - `Date` - `ArrayBuffer`, `DataView`, and typed arrays @@ -31,6 +32,9 @@ A `Set` cannot contain a promise or `Blob` directly when sent over a connection. elements, and promises and `Blob`s may still appear inside a plain object or array used as an element. +`Map` keys follow the same rules as `Set` elements. `Map` values have no such restriction: a value +may be a promise, `Blob`, or stub. + ## Passed by reference - Classes that extend [`RpcTarget`](/concepts/rpc-target/) @@ -40,12 +44,6 @@ element. Anything passed by reference produces a **stub** on the far side, and stubs must be [disposed](/concepts/disposal/). -## Not supported yet - -These may be added in the future: - -- `Map` - ## Intentionally not supported - **Application-defined classes that do not extend `RpcTarget`.** There is no safe, general way to diff --git a/packages/docs/src/content/docs/guides/workers-rpc.mdx b/packages/docs/src/content/docs/guides/workers-rpc.mdx index 57855c4..c873b90 100644 --- a/packages/docs/src/content/docs/guides/workers-rpc.mdx +++ b/packages/docs/src/content/docs/guides/workers-rpc.mdx @@ -60,8 +60,8 @@ RPC. The intent is that the two converge, with Cap'n Web arriving first. | Capability | Cap'n Web | Workers RPC | | ------------------------------------------- | --------- | ----------- | -| `Map` and some other built-ins | Not yet | Yes | -| `Set` | Yes | Yes | +| Some other built-ins | Not yet | Yes | +| `Map` and `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 | diff --git a/packages/docs/src/content/docs/reference/api.mdx b/packages/docs/src/content/docs/reference/api.mdx index ff48d61..5153ac5 100644 --- a/packages/docs/src/content/docs/reference/api.mdx +++ b/packages/docs/src/content/docs/reference/api.mdx @@ -101,15 +101,16 @@ 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, `Set`, `bigint`, `Date`, `ArrayBuffer`, `DataView`, -typed arrays, `Error` and well-known subclasses, `Blob`, `ReadableStream`, `WritableStream`, `URL`, -`RegExp`, `Headers`, `Request`, `Response`. +**By value:** primitives, plain objects, arrays, `Set`, `Map`, `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. -**`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. +**`Set` elements and `Map` keys:** stubs are supported. Promises and `Blob`s cannot be direct +elements or keys when sent over a connection, but may be nested inside another value. `Map` values +may be promises, `Blob`s, or stubs. -**Not supported:** `Map` (not yet); non-`RpcTarget` classes and cyclic values (intentionally). +**Not supported:** non-`RpcTarget` classes and cyclic values (intentionally). [Docs](/concepts/values/) diff --git a/packages/docs/src/content/docs/reference/protocol.mdx b/packages/docs/src/content/docs/reference/protocol.mdx index 9a0ac07..5dc804d 100644 --- a/packages/docs/src/content/docs/reference/protocol.mdx +++ b/packages/docs/src/content/docs/reference/protocol.mdx @@ -368,6 +368,18 @@ A JavaScript `Set` value. `elements` is an array of expressions for the set elem 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. +### map + +```json +["map", entries] +``` + +A JavaScript `Map` value. `entries` is an array of `[key, value]` pairs in insertion order, where +`key` and `value` are expressions. + +A key follows the same rules as a `set` element: it must not evaluate directly to a promise or +`Blob`, though stubs are valid keys. A value may evaluate to anything, including a promise or `Blob`. + ### regexp ```json diff --git a/src/core.ts b/src/core.ts index a59c9ce..782717f 100644 --- a/src/core.ts +++ b/src/core.ts @@ -38,7 +38,7 @@ export let RpcTarget = workersModule ? workersModule.RpcTarget : class {}; export type PropertyPath = (string | number)[]; type TypeForRpc = "unsupported" | "primitive" | "object" | "function" | "array" | "date" | "set" | - "bigint" | "bytes" | "blob" | "stub" | "rpc-promise" | "rpc-target" | "rpc-thenable" | + "map" | "bigint" | "bytes" | "blob" | "stub" | "rpc-promise" | "rpc-target" | "rpc-thenable" | "error" | "undefined" | "writable" | "readable" | "regexp" | "url" | "headers" | "request" | "response"; @@ -100,6 +100,9 @@ export function typeForRpc(value: unknown): TypeForRpc { case Set.prototype: return "set"; + case Map.prototype: + return "map"; + case Uint8Array.prototype: case BUFFER_PROTOTYPE: case ArrayBuffer.prototype: @@ -735,7 +738,7 @@ async function pullPromise(promise: RpcPromise): Promise { // ======================================================================================= // RpcPayload -export type LocatedPromise = {parent: object, property: string | number, promise: RpcPromise}; +export type LocatedPromise = {parent: object, property: unknown, promise: RpcPromise}; // Represents the params to an RPC call, or the resolution of an RPC promise, as it passes // through the system. @@ -1041,7 +1044,7 @@ export class RpcPayload { } private deepCopy( - value: unknown, oldParent: object | undefined, property: string | number, parent: object, + value: unknown, oldParent: object | undefined, property: unknown, parent: object, dupStubs: boolean, owner: RpcPayload | null): unknown { let kind = typeForRpc(value); switch (kind) { @@ -1097,6 +1100,30 @@ export class RpcPayload { return result; } + case "map": { + // We have to construct the new map first, then fill it in, so we can pass it as the + // parent. + let map = >value; + let entries = [...map]; + for (let [key] of entries) { + let kind = typeForRpc(key); + if (kind === "rpc-promise" || kind === "rpc-thenable") { + throw new TypeError( + "Cannot pass a promise as a key of a Map. Await the value before using it as a " + + "key."); + } + } + + let result = new Map(); + for (let [key, val] of entries) { + // A key is never a promise (checked above), so it is never delivered to and needs no + // slot. A value's slot is its key: `deliverTo()` delivers into a `Map` with `set()`. + let keyCopy = this.deepCopy(key, map, undefined, result, dupStubs, owner); + result.set(keyCopy, this.deepCopy(val, map, keyCopy, result, dupStubs, owner)); + } + return result; + } + case "object": { // Plain object. Unfortunately there's no way to pre-allocate the right shape. let result: Record = {}; @@ -1252,14 +1279,22 @@ export class RpcPayload { } } - // Resolve all promises in this payload and then assign the final value into `parent[property]`. - private deliverTo(parent: object, property: string | number, promises: Promise[]): void { + // Resolve all promises in this payload and then assign the final value into `parent[property]`, + // or `parent.set(property, value)` if `parent` is a `Map`. + private deliverTo(parent: object, property: unknown, promises: Promise[]): void { this.ensureDeepCopied(); if (this.value instanceof RpcPromise) { RpcPayload.deliverRpcPromiseTo(this.value, parent, property, promises); } else { - (parent)[property] = this.value; + if (parent instanceof Map) { + // A `Map` has no property naming one of its values, so a promise stored as a value is + // located by its key instead. The key is already in the map, so `set()` replaces the value + // in place, keeping the entry's position. + parent.set(property, this.value); + } else { + (parent)[property] = this.value; + } for (let record of this.promises!) { // Note that because we already did ensureDeepCopied(), replacing each promise with its @@ -1272,7 +1307,7 @@ export class RpcPayload { } private static deliverRpcPromiseTo( - promise: RpcPromise, parent: object, property: string | number, + promise: RpcPromise, parent: object, property: unknown, promises: Promise[]) { // deepCopy() should have replaced any property stubs with normal promise stubs. let hook = unwrapStubNoProperties(promise); @@ -1488,6 +1523,15 @@ export class RpcPayload { return; } + case "map": { + let map = >value; + for (let [key, val] of map) { + this.disposeImpl(key, map); + this.disposeImpl(val, map); + } + return; + } + case "object": { let object = >value; for (let i in object) { @@ -1644,6 +1688,15 @@ export class RpcPayload { return; } + case "map": { + let map = >value; + for (let [key, val] of map) { + this.ignoreUnhandledRejectionsImpl(key); + this.ignoreUnhandledRejectionsImpl(val); + } + return; + } + case "object": { let object = >value; for (let i in object) { @@ -1783,6 +1836,7 @@ function followPath(value: unknown, parent: object | undefined, case "blob": case "date": case "set": + case "map": case "error": case "url": case "regexp": diff --git a/src/serialize.ts b/src/serialize.ts index 489cf71..2c0c298 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -345,6 +345,37 @@ export class Devaluator { return ["set", elements]; } + case "map": { + let map = >value; + let mapEntries = [...map]; + let entries: unknown[] = []; + + // Keys follow the same rules as `Set` elements: a promise or Blob would have to be + // substituted into the key position after delivery, which a `Map` cannot do without + // rebuilding itself. Values may be promises or Blobs. As with `Set`, validate all keys + // before encoding anything, since encoded streams and Blobs (including in values) create + // pipes that cannot be rolled back. + for (let [key] of mapEntries) { + switch (typeForRpc(key)) { + case "blob": + throw new TypeError("Cannot serialize a Blob as a key of a Map."); + case "rpc-promise": + case "rpc-thenable": + throw new TypeError( + "Cannot serialize a promise as a key of a Map. Await the value before using it " + + "as a key."); + } + } + + for (let [key, val] of mapEntries) { + entries.push([ + this.devaluateImpl(key, map, depth + 1), + this.devaluateImpl(val, map, depth + 1), + ]); + } + return ["map", entries]; + } + case "bigint": // At structuredClonable level, keep BigInt as native value if (this.encodingLevel === "structuredClonable") { @@ -835,7 +866,7 @@ export class Evaluator { } private evaluateImpl( - value: unknown, parent: object, property: string | number, depth: number): unknown { + value: unknown, parent: object, property: unknown, depth: number): unknown { let maxDepth = this.limits.maxDepth; if (depth >= maxDepth) { throw new TypeError( @@ -902,6 +933,24 @@ export class Evaluator { return set; } break; + case "map": + if (value.length === 2 && value[1] instanceof Array) { + let entries = value[1]; + let map = new Map(); + for (let i = 0; i < entries.length; i++) { + let entry = entries[i]; + if (!(entry instanceof Array) || entry.length !== 2) { + throw new TypeError("Map entries must be serialized as key/value pairs."); + } + let key = this.evaluateImpl(entry[0], map, undefined, depth + 1); + if (key instanceof RpcPromise) { + throw new TypeError("Cannot deserialize a promise as a key of a Map."); + } + map.set(key, this.evaluateImpl(entry[1], map, key, depth + 1)); + } + return map; + } + break; case "bytes": { let bytes: Uint8Array; // At jsonCompatibleWithBytes/structuredClonable level, bytes may already be raw. From 8fea1cd0714f77d6b11fd054c1ff8bf3940ebbf4 Mon Sep 17 00:00:00 2001 From: Sri Krishna <7254698+srikrsna@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:58:33 +0530 Subject: [PATCH 2/4] Remove unnecessary comments --- src/core.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/core.ts b/src/core.ts index 782717f..7e53872 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1116,8 +1116,6 @@ export class RpcPayload { let result = new Map(); for (let [key, val] of entries) { - // A key is never a promise (checked above), so it is never delivered to and needs no - // slot. A value's slot is its key: `deliverTo()` delivers into a `Map` with `set()`. let keyCopy = this.deepCopy(key, map, undefined, result, dupStubs, owner); result.set(keyCopy, this.deepCopy(val, map, keyCopy, result, dupStubs, owner)); } @@ -1287,10 +1285,7 @@ export class RpcPayload { if (this.value instanceof RpcPromise) { RpcPayload.deliverRpcPromiseTo(this.value, parent, property, promises); } else { - if (parent instanceof Map) { - // A `Map` has no property naming one of its values, so a promise stored as a value is - // located by its key instead. The key is already in the map, so `set()` replaces the value - // in place, keeping the entry's position. + if (parent instanceof Map) { parent.set(property, this.value); } else { (parent)[property] = this.value; From 25c3a7bf24b024d732b046f0703de6dedd812945 Mon Sep 17 00:00:00 2001 From: Sri Krishna <7254698+srikrsna@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:20:23 +0530 Subject: [PATCH 3/4] fmt --- src/core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core.ts b/src/core.ts index 7e53872..1b7dc27 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1285,7 +1285,7 @@ export class RpcPayload { if (this.value instanceof RpcPromise) { RpcPayload.deliverRpcPromiseTo(this.value, parent, property, promises); } else { - if (parent instanceof Map) { + if (parent instanceof Map) { parent.set(property, this.value); } else { (parent)[property] = this.value; From 97f8cc170ca8916cef903472ba7258170f9260cb Mon Sep 17 00:00:00 2001 From: Sri Krishna <7254698+srikrsna@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:13:24 +0530 Subject: [PATCH 4/4] Box non-string/number Map keys --- src/core.ts | 24 ++++++++++++++++-------- src/serialize.ts | 8 ++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/core.ts b/src/core.ts index 1b7dc27..5ff2dd4 100644 --- a/src/core.ts +++ b/src/core.ts @@ -738,7 +738,13 @@ async function pullPromise(promise: RpcPromise): Promise { // ======================================================================================= // RpcPayload -export type LocatedPromise = {parent: object, property: unknown, promise: RpcPromise}; +export type PropertyName = string | number | {mapKey: unknown}; + +export function mapKeyToPropertyName(key: unknown): PropertyName { + return typeof key === "string" || typeof key === "number" ? key : {mapKey: key}; +} + +export type LocatedPromise = {parent: object, property: PropertyName, promise: RpcPromise}; // Represents the params to an RPC call, or the resolution of an RPC promise, as it passes // through the system. @@ -1044,7 +1050,7 @@ export class RpcPayload { } private deepCopy( - value: unknown, oldParent: object | undefined, property: unknown, parent: object, + value: unknown, oldParent: object | undefined, property: PropertyName, parent: object, dupStubs: boolean, owner: RpcPayload | null): unknown { let kind = typeForRpc(value); switch (kind) { @@ -1116,8 +1122,10 @@ export class RpcPayload { let result = new Map(); for (let [key, val] of entries) { - let keyCopy = this.deepCopy(key, map, undefined, result, dupStubs, owner); - result.set(keyCopy, this.deepCopy(val, map, keyCopy, result, dupStubs, owner)); + // As with Set elements, the key's location is never used since keys can't be promises. + let keyCopy = this.deepCopy(key, map, -1, result, dupStubs, owner); + result.set(keyCopy, this.deepCopy( + val, map, mapKeyToPropertyName(keyCopy), result, dupStubs, owner)); } return result; } @@ -1278,15 +1286,15 @@ export class RpcPayload { } // Resolve all promises in this payload and then assign the final value into `parent[property]`, - // or `parent.set(property, value)` if `parent` is a `Map`. - private deliverTo(parent: object, property: unknown, promises: Promise[]): void { + // or `parent.set(key, value)` if `parent` is a `Map`. + private deliverTo(parent: object, property: PropertyName, promises: Promise[]): void { this.ensureDeepCopied(); if (this.value instanceof RpcPromise) { RpcPayload.deliverRpcPromiseTo(this.value, parent, property, promises); } else { if (parent instanceof Map) { - parent.set(property, this.value); + parent.set(typeof property === "object" ? property.mapKey : property, this.value); } else { (parent)[property] = this.value; } @@ -1302,7 +1310,7 @@ export class RpcPayload { } private static deliverRpcPromiseTo( - promise: RpcPromise, parent: object, property: unknown, + promise: RpcPromise, parent: object, property: PropertyName, promises: Promise[]) { // deepCopy() should have replaced any property stubs with normal promise stubs. let hook = unwrapStubNoProperties(promise); diff --git a/src/serialize.ts b/src/serialize.ts index 2c0c298..61bdd16 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit -import { StubHook, RpcPayload, typeForRpc, RpcStub, RpcPromise, LocatedPromise, RpcTarget, unwrapStubAndPath, streamImpl, PromiseStubHook, PayloadStubHook } from "./core.js"; +import { StubHook, RpcPayload, typeForRpc, RpcStub, RpcPromise, LocatedPromise, PropertyName, mapKeyToPropertyName, RpcTarget, unwrapStubAndPath, streamImpl, PromiseStubHook, PayloadStubHook } from "./core.js"; export type ImportId = number; export type ExportId = number; @@ -866,7 +866,7 @@ export class Evaluator { } private evaluateImpl( - value: unknown, parent: object, property: unknown, depth: number): unknown { + value: unknown, parent: object, property: PropertyName, depth: number): unknown { let maxDepth = this.limits.maxDepth; if (depth >= maxDepth) { throw new TypeError( @@ -942,11 +942,11 @@ export class Evaluator { if (!(entry instanceof Array) || entry.length !== 2) { throw new TypeError("Map entries must be serialized as key/value pairs."); } - let key = this.evaluateImpl(entry[0], map, undefined, depth + 1); + let key = this.evaluateImpl(entry[0], map, i, depth + 1); if (key instanceof RpcPromise) { throw new TypeError("Cannot deserialize a promise as a key of a Map."); } - map.set(key, this.evaluateImpl(entry[1], map, key, depth + 1)); + map.set(key, this.evaluateImpl(entry[1], map, mapKeyToPropertyName(key), depth + 1)); } return map; }