Skip to content

RpcTarget methods on a client-exported capability are lost across a Durable Object hop #166

Description

@tboser

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions