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

Improved compatibility with Cloudflare Workers' built-in RPC, particularly when proxying from one to the other.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DisposableTarget>) {
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 {
Expand Down
24 changes: 24 additions & 0 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading