Summary
A capability the client exports over Cap'n Web survives a Durable Object to Durable Object hop when it is a bare function, but not when it is an RpcTarget with named methods. Calling a named method on the forwarded stub throws 'notify.bind' is not a function. Real workerd handles both shapes.
This blocks any app whose clients subscribe by handing the server an RpcTarget. We hit it porting an app whose every subscribeTo* call takes one, so no subscription ever delivers and the UI never finishes loading.
Version
celld 0.3.0 from ghcr.io/denoland/celld:0.3.0. Compared against wrangler dev (wrangler 4.120.0), same worker.js and same wrangler.json. capnweb 0.12.0 on both ends.
What happened
Four cases over one Cap'n Web WebSocket session. The client passes a stub to the server, the server either calls it directly or forwards it to a second Durable Object and calls it there.
| case |
celld 0.3.0 |
workerd |
RpcTarget method, called in the receiving DO |
ok |
ok |
RpcTarget method, called after one DO hop |
THREW: 'notify.bind' is not a function. |
ok |
| bare function, called in the receiving DO |
ok |
ok |
| bare function, called after one DO hop |
ok |
ok |
celld:
methodDirect server said: ok client callbacks: method:direct
methodViaHop server said: THREW: 'notify.bind' is not a function. client callbacks: (none)
functionDirect server said: ok client callbacks: function:direct
functionViaHop server said: sent client callbacks: function:via-second-DO
workerd, same files:
methodDirect server said: ok client callbacks: method:direct
methodViaHop server said: sent client callbacks: method:via-second-DO
functionDirect server said: ok client callbacks: function:direct
functionViaHop server said: sent client callbacks: function:via-second-DO
The bare-function row is the control. It rules out the hop itself and points at how an RpcTarget stub is encoded when it crosses one.
Expected
methodViaHop calls notify on the client and returns sent, as it does on workerd.
Reproduction
worker.js:
import { DurableObject } from "cloudflare:workers";
import { newWebSocketRpcSession, RpcTarget } from "./capnweb.js";
export class Hop extends DurableObject {
async callMethod(stub) { await stub.notify("via-second-DO"); return "sent"; }
async callFunction(fn) { await fn("via-second-DO"); return "sent"; }
}
class Api extends RpcTarget {
constructor(env) { super(); this.env = env; }
async methodDirect(stub) {
try { await stub.notify("direct"); return "ok"; } catch (e) { return `THREW: ${e.message}`; }
}
async methodViaHop(stub) {
try {
const ns = this.env.HOP;
return await ns.get(ns.idFromName("h")).callMethod(stub.dup());
} catch (e) { return `THREW: ${e.message}`; }
}
async functionDirect(fn) {
try { await fn("direct"); return "ok"; } catch (e) { return `THREW: ${e.message}`; }
}
async functionViaHop(fn) {
try {
const ns = this.env.HOP;
return await ns.get(ns.idFromName("h")).callFunction(fn.dup());
} catch (e) { return `THREW: ${e.message}`; }
}
}
export class SocketHost extends DurableObject {
async fetch(req) {
if (req.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("no", {status: 400});
const pair = new WebSocketPair();
pair[0].accept();
newWebSocketRpcSession(pair[0], new Api(this.env));
return new Response(null, { status: 101, webSocket: pair[1] });
}
}
export default {
async fetch(req, env) {
const ns = env.SOCKET;
return ns.get(ns.idFromName("s")).fetch(req);
},
};
wrangler.json:
{ "name": "push-probe", "main": "worker.js", "compatibility_date": "2026-02-02",
"compatibility_flags": ["allow_irrevocable_stub_storage", "enhanced_error_serialization"],
"durable_objects": { "bindings": [
{ "name": "SOCKET", "class_name": "SocketHost" },
{ "name": "HOP", "class_name": "Hop" }] },
"migrations": [{ "tag": "v0", "new_sqlite_classes": ["SocketHost", "Hop"] }] }
Client, run with node against either server:
const api = newWebSocketRpcSession(new WebSocket(`ws://127.0.0.1:${PORT}/`));
class Sub extends RpcTarget { notify(where) { console.log("method:" + where); return "ack"; } }
const fn = (where) => { console.log("function:" + where); return "ack"; };
console.log(await api.methodDirect(new RpcStub(new Sub())));
console.log(await api.methodViaHop(new RpcStub(new Sub())));
console.log(await api.functionDirect(new RpcStub(fn)));
console.log(await api.functionViaHop(new RpcStub(fn)));
capnweb.js is capnweb 0.12.0's dist/index.js with globalThis[Symbol("workers-module")] = cfw injected, which is what its Workers build does.
Related
#149 fixed the same class of problem for WebSocket targets crossing service bindings. This looks like its equivalent for RPC stubs crossing a Durable Object hop.
Summary
A capability the client exports over Cap'n Web survives a Durable Object to Durable Object hop when it is a bare function, but not when it is an
RpcTargetwith named methods. Calling a named method on the forwarded stub throws'notify.bind' is not a function. Real workerd handles both shapes.This blocks any app whose clients subscribe by handing the server an
RpcTarget. We hit it porting an app whose everysubscribeTo*call takes one, so no subscription ever delivers and the UI never finishes loading.Version
celld 0.3.0fromghcr.io/denoland/celld:0.3.0. Compared againstwrangler dev(wrangler 4.120.0), sameworker.jsand samewrangler.json. capnweb 0.12.0 on both ends.What happened
Four cases over one Cap'n Web WebSocket session. The client passes a stub to the server, the server either calls it directly or forwards it to a second Durable Object and calls it there.
RpcTargetmethod, called in the receiving DORpcTargetmethod, called after one DO hopTHREW: 'notify.bind' is not a function.celld:
workerd, same files:
The bare-function row is the control. It rules out the hop itself and points at how an
RpcTargetstub is encoded when it crosses one.Expected
methodViaHopcallsnotifyon the client and returnssent, as it does on workerd.Reproduction
worker.js:wrangler.json:{ "name": "push-probe", "main": "worker.js", "compatibility_date": "2026-02-02", "compatibility_flags": ["allow_irrevocable_stub_storage", "enhanced_error_serialization"], "durable_objects": { "bindings": [ { "name": "SOCKET", "class_name": "SocketHost" }, { "name": "HOP", "class_name": "Hop" }] }, "migrations": [{ "tag": "v0", "new_sqlite_classes": ["SocketHost", "Hop"] }] }Client, run with node against either server:
capnweb.jsis capnweb 0.12.0'sdist/index.jswithglobalThis[Symbol("workers-module")] = cfwinjected, which is what its Workers build does.Related
#149 fixed the same class of problem for WebSocket targets crossing service bindings. This looks like its equivalent for RPC stubs crossing a Durable Object hop.