From 46683685e9ba8b6c7abad2a435229de0b5a2aa30 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/2] Support `Map` over the RPC --- .changeset/serialize-map.md | 5 ++ README.md | 3 +- __tests__/index.test.ts | 153 ++++++++++++++++++++++++++++++++++++ protocol.md | 4 + src/core.ts | 83 ++++++++++++++++++- src/serialize.ts | 33 +++++++- 6 files changed, 278 insertions(+), 3 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..5192811 --- /dev/null +++ b/.changeset/serialize-map.md @@ -0,0 +1,5 @@ +--- +"capnweb": minor +--- + +Support serializing `Map` objects over RPC. diff --git a/README.md b/README.md index 10aa4f4..5127699 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ The following types can be passed over RPC (in arguments or return values), and * Arrays * `bigint` * `Date` +* `Map` * `ArrayBuffer`, `DataView`, and typed arrays * `Error` and its well-known subclasses * `Blob` @@ -206,7 +207,7 @@ The following types can be passed over RPC (in arguments or return values), and * `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` +* `Set` * `RegExp` The following are intentionally NOT supported: diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 722a1ee..619b9dc 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -27,6 +27,10 @@ let SERIALIZE_TEST_CASES: Record = { '{"foo":[[123]]}': {foo: [123]}, '{"foo":[[123]],"bar":[[456,789]]}': {foo: [123], bar: [456, 789]}, + '["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!"), @@ -132,6 +136,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", () => { @@ -1573,6 +1579,153 @@ describe("promise pipelining", () => { }); }); +describe("promises inside a Map", () => { + // Like a Set, a Map has no addressable positions, but delivery resolves a located promise by + // assigning `parent[property] = value`. Both keys and values may be promises, so each entry + // needs two distinct slots. These tests pin the behavior of those temporary setters on both the + // wire path (Evaluator) and the local path (RpcPayload.deepCopy). + 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 key or value it belongs to. + strayProps: Object.getOwnPropertyNames(container), + }; + } + + // Blobs are always delivered through the promise machinery, so this exercises the same path + // in the returning direction without any pipelining on the caller's part. + makeBlobMap() { + return new Map([[new Blob(["key"]), new Blob(["value"])]]); + } + } + + 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 a promise sent as a Map key, preserving insertion order", 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], [promise, 2], ["omega", 3], + ])); + + // Rebuilding the Map must not move the resolved key to the end. + expect(result.entries).toStrictEqual([["alpha", 1], [9, 2], ["omega", 3]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("substitutes a promise key and a promise value in the same entry", async () => { + await using harness = new TestHarness(new MapTarget()); + let stub = harness.stub; + using key = stub.square(3); + using value = stub.square(4); + + // The key and the value each need their own slot, and either may land first. + let result = await stub.inspect(new Map([[key, value]])); + + expect(result.entries).toStrictEqual([[9, 16]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("substitutes multiple promises 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); + using third = stub.square(5); + + let result = await stub.inspect(new Map([ + ["a", first], [second, "b"], ["c", 0], [third, third], + ])); + + expect(result.entries).toStrictEqual([["a", 9], [16, "b"], ["c", 0], [25, 25]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("leaves no residue when a promise is nested inside a Map entry", async () => { + await using harness = new TestHarness(new MapTarget()); + let stub = harness.stub; + using promise = stub.square(4); + + // Here the promise's parent is the inner object, not the Map, so the Map needs no setter. + let result = await stub.inspect(new Map([["k", {value: promise}]])); + + expect(result.entries).toStrictEqual([["k", {value: 16}]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("collapses a key that resolves to an existing key", async () => { + await using harness = new TestHarness(new MapTarget()); + let stub = harness.stub; + using promise = stub.square(3); + + // Rebuilding the Map re-applies Map semantics: the later entry wins on value, but keeps the + // earlier entry's position. + let result = await stub.inspect(new Map([ + [9, "first"], [promise, "second"], ["tail", "third"], + ])); + + expect(result.entries).toStrictEqual([[9, "second"], ["tail", "third"]]); + expect(result.strayProps).toStrictEqual([]); + }); + + it("substitutes Blobs in a Map returned to the caller", async () => { + await using harness = new TestHarness(new MapTarget()); + + let received = await harness.stub.makeBlobMap(); + + expect(received).toBeInstanceOf(Map); + expect(Object.getOwnPropertyNames(received)).toStrictEqual([]); + let [[key, value]] = [...received]; + expect(await key.text()).toBe("key"); + expect(await value.text()).toBe("value"); + }); + + it("substitutes promises in a Map passed to a local stub", async () => { + using stub = new RpcStub(new MapTarget()); + using key = stub.square(3); + using value = stub.square(4); + let source = new Map([["alpha", value], [key, "omega"]]); + + let result = await stub.inspect(source); + + expect(result.entries).toStrictEqual([["alpha", 16], [9, "omega"]]); + expect(result.strayProps).toStrictEqual([]); + + // deepCopy() must fix up its copy, not the caller's Map. + expect([...source]).toStrictEqual([["alpha", value], [key, "omega"]]); + expect(Object.getOwnPropertyNames(source)).toStrictEqual([]); + }); +}); + describe("map() over RPC", () => { it("supports map() on nulls", async () => { let counter = new RpcStub(new Counter(0)); diff --git a/protocol.md b/protocol.md index c6c8abd..d318207 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. +`["map", entries]` + +A JavaScript `Map` value. `entries` is an array of `[key, value]` pairs, in insertion order. + `["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..3d19596 100644 --- a/src/core.ts +++ b/src/core.ts @@ -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" | "map" | "bigint" | "bytes" | "blob" | "stub" | "rpc-promise" | "rpc-target" | "rpc-thenable" | "error" | "undefined" | "writable" | "readable" | "headers" | "request" | "response"; @@ -93,6 +93,9 @@ export function typeForRpc(value: unknown): TypeForRpc { case Date.prototype: return "date"; + case Map.prototype: + return "map"; + case Uint8Array.prototype: case BUFFER_PROTOTYPE: case ArrayBuffer.prototype: @@ -626,6 +629,47 @@ export function unwrapStubAndPath(stub: RpcStub): {hook: StubHook, pathIfPromise return stub[RAW_STUB]; } +// The property names used to address the key and the value of the `Map` entry at `index`. +// +// Delivery writes `parent[property] = resolved`, so every slot in the map needs its own name; +// sharing one would make two writes target the same slot, losing all but the last. +export function mapPromiseSlotProperties(index: number): {key: string, value: string} { + return {key: `${index}:key`, value: `${index}:value`}; +} + +// RpcPromise keys and values are set using property access on the parent. +// +// To make this work for `Map`, this function defines one-time-use setters that write the +// resolution back into `entries` and rebuild the map from it. `entries` -- not the map -- is the +// source of truth, so a key and its value can resolve in either order, and a key that resolves to +// a duplicate collapses under normal `Map` semantics. +// +// `entries[index]` must be the entry that was inserted into `map` at position `index`. +export function defineMapPromiseSlots( + map: Map, entries: [unknown, unknown][], index: number) { + let entry = entries[index]!; + if (!(entry[0] instanceof RpcPromise) && !(entry[1] instanceof RpcPromise)) return; + + let properties = mapPromiseSlotProperties(index); + + let defineSlot = (property: string, slot: 0 | 1) => { + Object.defineProperty(map, property, { + configurable: true, enumerable: false, + set(resolved: unknown) { + delete (map as any)[property]; + entries[index]![slot] = resolved; + map.clear(); + for (let [key, value] of entries) { + map.set(key, value); + } + } + }); + }; + + if (entry[0] instanceof RpcPromise) defineSlot(properties.key, 0); + if (entry[1] instanceof RpcPromise) defineSlot(properties.value, 1); +} + // Given a promise stub (still wrapped in a Proxy), pull the remote promise and deliver the // payload. This is a helper used to implement the then/catch/finally methods of RpcPromise. async function pullPromise(promise: RpcPromise): Promise { @@ -981,6 +1025,24 @@ 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 result = new Map(); + let entries: [unknown, unknown][] = []; + for (let [key, val] of map) { + let index = entries.length; + let properties = mapPromiseSlotProperties(index); + let keyCopy = this.deepCopy(key, map, properties.key, result, dupStubs, owner); + let valCopy = this.deepCopy(val, map, properties.value, result, dupStubs, owner); + entries.push([keyCopy, valCopy]); + defineMapPromiseSlots(result, entries, index); + result.set(keyCopy, valCopy); + } + return result; + } + case "object": { // Plain object. Unfortunately there's no way to pre-allocate the right shape. let result: Record = {}; @@ -1357,6 +1419,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) { @@ -1503,6 +1574,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) { @@ -1641,6 +1721,7 @@ function followPath(value: unknown, parent: object | undefined, case "bytes": case "blob": case "date": + case "map": case "error": case "headers": case "request": diff --git a/src/serialize.ts b/src/serialize.ts index 9cd578c..be0846e 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, RpcTarget, unwrapStubAndPath, streamImpl, PromiseStubHook, PayloadStubHook, defineMapPromiseSlots, mapPromiseSlotProperties } from "./core.js"; export type ImportId = number; export type ExportId = number; @@ -319,6 +319,18 @@ export class Devaluator { return [result]; } + case "map": { + let map = >value; + let entries: unknown[] = []; + for (let [key, val] of map) { + 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") { @@ -841,6 +853,25 @@ export class Evaluator { return new Date(value[1]); } break; + case "map": + if (value.length === 2 && value[1] instanceof Array) { + let map = new Map(); + let entries: [unknown, unknown][] = []; + for (let entry of value[1]) { + if (!(entry instanceof Array) || entry.length !== 2) { + throw new TypeError("Map entries must be serialized as key/value pairs."); + } + let index = entries.length; + let properties = mapPromiseSlotProperties(index); + let key = this.evaluateImpl(entry[0], map, properties.key, depth + 1); + let val = this.evaluateImpl(entry[1], map, properties.value, depth + 1); + entries.push([key, val]); + defineMapPromiseSlots(map, entries, index); + map.set(key, val); + } + return map; + } + break; case "bytes": { let bytes: Uint8Array; // At jsonCompatibleWithBytes/structuredClonable level, bytes may already be raw. From 3c3665858972cca4c9e4c792f0450d34b6a0c234 Mon Sep 17 00:00:00 2001 From: "ask-bonk[bot]" Date: Thu, 6 Aug 2026 15:58:45 +0000 Subject: [PATCH 2/2] Reviewed Map RPC PR #232: LGTM Co-authored-by: teamchong --- package-lock.json | 57 ----------------------------------------------- 1 file changed, 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7fe7e2d..26fe58d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2757,9 +2757,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2777,9 +2774,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2797,9 +2791,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2817,9 +2808,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2837,9 +2825,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2857,9 +2842,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3038,9 +3020,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3055,9 +3034,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3072,9 +3048,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3089,9 +3062,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3106,9 +3076,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3123,9 +3090,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3140,9 +3104,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3157,9 +3118,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3174,9 +3132,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3191,9 +3146,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3208,9 +3160,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3225,9 +3174,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3242,9 +3188,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [