Skip to content

Commit 32e362f

Browse files
authored
If an RpcTarget passed in params has a dup() method, use it. (#121)
This matches behavior introduced in workerd in: cloudflare/workerd#5733 This plus the workerd change together should allow full end-to-end proxying between Cap'n Web and native workerd RPC to work correctly (provided workerd has enabled the `rpc_params_dup_stubs` compat flag, at least).
1 parent 76fdff1 commit 32e362f

File tree

4 files changed

+87
-0
lines changed

4 files changed

+87
-0
lines changed

.changeset/slimy-falcons-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"capnweb": minor
3+
---
4+
5+
Improved compatibility with Cloudflare Workers' built-in RPC, particularly when proxying from one to the other.

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,14 @@ export default {
528528
}
529529
```
530530

531+
#### Compatibility with Workers' built-in RPC
532+
533+
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/).
534+
535+
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.
536+
537+
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.)
538+
531539
### HTTP server on Node.js
532540

533541
A server on Node.js is a bit more involved, due to the awkward handling of WebSockets in Node's HTTP library.

__tests__/index.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,56 @@ describe("stub disposal over RPC", () => {
10541054
expect(targetDisposedCount).toBe(1);
10551055
});
10561056

1057+
it("dupes RpcTarget that was passed in params if it has a dup() method", async () => {
1058+
let dupCount = 0;
1059+
let disposeCount = 0;
1060+
class DisposableTarget extends RpcTarget {
1061+
getValue() { return 42; }
1062+
1063+
dup() {
1064+
++dupCount;
1065+
return new DisposableTarget();
1066+
}
1067+
1068+
disposed = false;
1069+
[Symbol.dispose]() {
1070+
if (this.disposed) throw new Error("double disposed");
1071+
this.disposed = true;
1072+
++disposeCount;
1073+
}
1074+
}
1075+
1076+
class MainTarget extends RpcTarget {
1077+
useDisposableTarget(stub: RpcStub<DisposableTarget>) {
1078+
return stub.getValue();
1079+
}
1080+
}
1081+
1082+
await using harness = new TestHarness(new MainTarget());
1083+
let mainStub = harness.stub as any;
1084+
1085+
let disposableTarget = new DisposableTarget();
1086+
1087+
{
1088+
let result = await mainStub.useDisposableTarget(disposableTarget);
1089+
expect(dupCount).toBe(1);
1090+
expect(result).toBe(42);
1091+
}
1092+
1093+
{
1094+
let result = await mainStub.useDisposableTarget(disposableTarget);
1095+
expect(dupCount).toBe(2);
1096+
expect(result).toBe(42);
1097+
}
1098+
1099+
// Wait a bit for the disposal message to be processed
1100+
await pumpMicrotasks();
1101+
1102+
expect(dupCount).toBe(2);
1103+
expect(disposeCount).toBe(2);
1104+
expect(disposableTarget.disposed).toBe(false);
1105+
});
1106+
10571107
it("only disposes remote target when all RPC dups are disposed", async () => {
10581108
let targetDisposed = false;
10591109
class DisposableTarget extends RpcTarget {

src/core.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,30 @@ export class RpcPayload {
740740
public getHookForRpcTarget(target: RpcTarget | Function, parent: object | undefined,
741741
dupStubs: boolean = true): StubHook {
742742
if (this.source === "params") {
743+
if (dupStubs) {
744+
// We aren't supposed to take ownership of stubs appearing in params -- we're supposed to
745+
// dupe them. But an RpcTarget isn't a stub. If we create a stub around it, the stub takes
746+
// ownership.
747+
//
748+
// Usually, people passing raw RpcTargets into functions actually want the call to take
749+
// ownership -- that is, they want to have the disposer called later.
750+
//
751+
// But, if the RpcTarget happens to implement a `dup()` method, we will go ahead and call
752+
// that method, and wrap whatever it returns instead. This method wouldn't actually be
753+
// available over RPC anyway (since calling `dup()` on the client-side stub just dupes the
754+
// stub), so if an `RpcTarget` implements this, it must intend for us to use it.
755+
//
756+
// This is particularly important for the case of workerd-native RpcStubs, that is, stubs
757+
// from the built-in RPC system, rather than the pure-JS implementation of Cap'n Web.
758+
// We treat those stubs as RpcTargets. But, we do need to dup() them, just like we would
759+
// our own stubs.
760+
761+
let dupable = target as any;
762+
if (typeof dupable.dup === "function") {
763+
target = dupable.dup();
764+
}
765+
}
766+
743767
return TargetStubHook.create(target, parent);
744768
} else if (this.source === "return") {
745769
// If dupStubs is true, we want to both make sure the map contains the stub, and also return

0 commit comments

Comments
 (0)