diff --git a/.changeset/slimy-falcons-hammer.md b/.changeset/slimy-falcons-hammer.md new file mode 100644 index 0000000..a50fdac --- /dev/null +++ b/.changeset/slimy-falcons-hammer.md @@ -0,0 +1,5 @@ +--- +"capnweb": minor +--- + +Improved compatibility with Cloudflare Workers' built-in RPC, particularly when proxying from one to the other. diff --git a/README.md b/README.md index d5c2b0b..e53bd31 100644 --- a/README.md +++ b/README.md @@ -528,6 +528,14 @@ export default { } ``` +#### Compatibility with Workers' built-in RPC + +Cloudflare Workers has long featured [a built-in RPC system with semantics similar to Cap'n Web](https://developers.cloudflare.com/workers/runtime-apis/rpc/). + +Cap'n Web is designed to be compatible with Workers RPC, meaning you can pass Cap'n Web RPC stubs over Workers RPC and vice versa. The system will automatically wrap one stub type in the other and arrange to proxy calls. + +For best compatibility, make sure to set your [Workers compatibilty date](https://developers.cloudflare.com/workers/configuration/compatibility-dates/) to at least `2026-01-20`, or enable the [compatibility flag](https://developers.cloudflare.com/workers/configuration/compatibility-flags/) `rpc_params_dup_stubs`. (As of this writing, `2026-01-20` is in the future, so you will need to use the flag for now.) + ### HTTP server on Node.js A server on Node.js is a bit more involved, due to the awkward handling of WebSockets in Node's HTTP library. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 27c6ca3..98e4eb0 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1054,6 +1054,56 @@ describe("stub disposal over RPC", () => { expect(targetDisposedCount).toBe(1); }); + it("dupes RpcTarget that was passed in params if it has a dup() method", async () => { + let dupCount = 0; + let disposeCount = 0; + class DisposableTarget extends RpcTarget { + getValue() { return 42; } + + dup() { + ++dupCount; + return new DisposableTarget(); + } + + disposed = false; + [Symbol.dispose]() { + if (this.disposed) throw new Error("double disposed"); + this.disposed = true; + ++disposeCount; + } + } + + class MainTarget extends RpcTarget { + useDisposableTarget(stub: RpcStub) { + return stub.getValue(); + } + } + + await using harness = new TestHarness(new MainTarget()); + let mainStub = harness.stub as any; + + let disposableTarget = new DisposableTarget(); + + { + let result = await mainStub.useDisposableTarget(disposableTarget); + expect(dupCount).toBe(1); + expect(result).toBe(42); + } + + { + let result = await mainStub.useDisposableTarget(disposableTarget); + expect(dupCount).toBe(2); + expect(result).toBe(42); + } + + // Wait a bit for the disposal message to be processed + await pumpMicrotasks(); + + expect(dupCount).toBe(2); + expect(disposeCount).toBe(2); + expect(disposableTarget.disposed).toBe(false); + }); + it("only disposes remote target when all RPC dups are disposed", async () => { let targetDisposed = false; class DisposableTarget extends RpcTarget { diff --git a/src/core.ts b/src/core.ts index 45802e8..e86ad35 100644 --- a/src/core.ts +++ b/src/core.ts @@ -740,6 +740,30 @@ export class RpcPayload { public getHookForRpcTarget(target: RpcTarget | Function, parent: object | undefined, dupStubs: boolean = true): StubHook { if (this.source === "params") { + if (dupStubs) { + // We aren't supposed to take ownership of stubs appearing in params -- we're supposed to + // dupe them. But an RpcTarget isn't a stub. If we create a stub around it, the stub takes + // ownership. + // + // Usually, people passing raw RpcTargets into functions actually want the call to take + // ownership -- that is, they want to have the disposer called later. + // + // But, if the RpcTarget happens to implement a `dup()` method, we will go ahead and call + // that method, and wrap whatever it returns instead. This method wouldn't actually be + // available over RPC anyway (since calling `dup()` on the client-side stub just dupes the + // stub), so if an `RpcTarget` implements this, it must intend for us to use it. + // + // This is particularly important for the case of workerd-native RpcStubs, that is, stubs + // from the built-in RPC system, rather than the pure-JS implementation of Cap'n Web. + // We treat those stubs as RpcTargets. But, we do need to dup() them, just like we would + // our own stubs. + + let dupable = target as any; + if (typeof dupable.dup === "function") { + target = dupable.dup(); + } + } + return TargetStubHook.create(target, parent); } else if (this.source === "return") { // If dupStubs is true, we want to both make sure the map contains the stub, and also return