Skip to content

Proposal: add a catch-all hook for dynamic RPC paths#2

Open
jonastemplestein wants to merge 4 commits into
mainfrom
catchall-rpc-target
Open

Proposal: add a catch-all hook for dynamic RPC paths#2
jonastemplestein wants to merge 4 commits into
mainfrom
catchall-rpc-target

Conversation

@jonastemplestein

@jonastemplestein jonastemplestein commented Jun 18, 2026

Copy link
Copy Markdown

Question

What are the odds of adding a special catch-all method that RPC-capable objects can opt into across the three Workers RPC shapes: RpcTarget, DurableObject, and WorkerEntrypoint?

Concretely, I am asking whether an object could implement one well-known hook that receives the unmatched RPC path and the call arguments:

import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
import { fallbackCall, RpcTarget } from "capnweb";

type RpcPath = (string | number)[];

class DynamicTarget extends RpcTarget {
  [fallbackCall](path: RpcPath, args: unknown[]) {
    return dispatch(path, args);
  }
}

export class DynamicObject extends DurableObject<Env> {
  [fallbackCall](path: RpcPath, args: unknown[]) {
    return dispatch(path, args);
  }
}

export class DynamicEntrypoint extends WorkerEntrypoint<Env> {
  [fallbackCall](path: RpcPath, args: unknown[]) {
    return dispatch(path, args);
  }
}

The exact spelling is not important. This branch uses an exported symbol, fallbackCall, because a symbol avoids colliding with real application method names. A native Workers implementation could choose a different name or symbol if that fits the runtime better.

This PR implements the userspace RpcTarget version as a concrete sketch. The larger question is whether the same concept makes sense in the native dispatch path for DurableObject and WorkerEntrypoint, where public class methods are already exposed as RPC methods.

Motivation

Workers RPC is very pleasant when the method set is known ahead of time. A class declares public methods, the caller invokes those methods directly, and the RPC system preserves the JavaScript shape of the code.

Some useful RPC surfaces are not known ahead of time, though. They are discovered or registered at runtime:

await clientB.provideCapability("runSwiftCode", async (source: string) => {
  return { language: "swift", stdout: `ran: ${source}` };
});

await clientA.runSwiftCode("print(1)");

Or they are naturally hierarchical because they are adapting another API:

await clientB.provideCapability("slack", new SlackLikeClient());

await clientA.slack.chat.postMessage({
  channel: "C123",
  text: "hi",
});

Without a catch-all RPC hook, the server has to flatten this into something like call(path, args) or route it through fetch(). That works, but it gives up the main thing Workers RPC is good at: the call site stops looking like normal JavaScript, and the dynamic path becomes a second-class protocol inside the RPC protocol.

The behavior I want is narrower: declared RPC methods and getters still win. Private instance properties are still protected. Promise / stub helper names still stay local. But if an RPC path reaches a public surface that opted into dynamic dispatch, the unmatched suffix can be delivered to that hook as one call.

So this:

await stub.slack.chat.postMessage({ text: "hi" });

arrives as:

target[fallbackCall](["slack", "chat", "postMessage"], [{ text: "hi" }]);

with the same pipelining behavior and round-trip cost as a declared method call.

Minimal implementation

The branch adds one public symbol:

export const fallbackCall = Symbol("capnweb.fallbackCall");

Then the core of the implementation is to notice the point where normal path-following fails on an RPC target, and return a callable wrapper that routes the remaining path to the hook:

function makeFallbackResult(target: object, remainingPath: PropertyPath): FollowPathResult {
  let wrapper = (...args: unknown[]) => (<any>target)[fallbackCall](remainingPath, args);

  (<any>wrapper)[fallbackCall] = (sub: PropertyPath, args: unknown[]) => {
    return (<any>target)[fallbackCall]([...remainingPath, ...sub], args);
  };

  return { value: wrapper, parent: target, owner: null };
}

The second assignment is the important get-then-call case. It means this still works:

const slack = await stub.slack;
await slack.chat.postMessage({ text: "hi" });

slack is a callable dynamic handle, and further navigation keeps accumulating into the same fallback path.

The actual dispatch change is small. For normal objects / functions returned over RPC, a value with its own fallback hook can continue to consume deeper path segments:

if (Object.hasOwn(<object>value, part) && !isFallbackFunctionIntrinsic(value, part)) {
  value = (<any>value)[part];
} else if (typeof (<any>value)[fallbackCall] === "function") {
  return makeFallbackResult(<object>value, path.slice(i));
} else {
  value = undefined;
}

For RpcTarget instances, fallback only runs if the path does not name an existing method / getter / property. This preserves the existing privacy rule for instance properties:

if (Object.hasOwn(<object>value, part)) {
  throw new Error(
    `Attempt to access ${part}, which is an instance property of the RpcTarget. ` +
    `To avoid leaking private internals, instance properties cannot be accessed over RPC.`);
} else if (!(part in <object>value) && typeof (<any>value)[fallbackCall] === "function") {
  return makeFallbackResult(<object>value, path.slice(i));
} else {
  value = (<any>value)[part];
}

And client proxy path construction canonicalizes bracket indexes so array-like dynamic paths survive the wire shape:

function propertyPathPart(prop: string): string | number {
  let number = Number(prop);
  return Number.isSafeInteger(number) && number >= 0 && String(number) === prop ? number : prop;
}

So:

await stub.items[0].ping("x");

arrives with 0 as a numeric path segment:

target[fallbackCall](["items", 0, "ping"], ["x"]);

Semantics this tries to preserve

  • Declared methods and getters are resolved normally and do not go through fallbackCall.
  • Targets that do not implement the hook behave as they do today.
  • RpcTarget instance properties remain inaccessible over RPC; fallback does not hide that error.
  • then, catch, finally, map, dup, onRpcBroken, and toString stay local stub / promise helpers.
  • Object.prototype names stay blocked, matching the existing prototype-pollution guardrails.
  • name and length can be dynamic path segments on fallback-capable callable handles, even though JavaScript functions have those as own properties.
  • Fallback may be async.
  • Fallback may return another RpcTarget / capability, and pipelined calls into that returned target still work.
  • Rejected fallback results reject pipelined calls with the fallback reason.
  • Numeric bracket indexes are preserved as numeric path parts.

Example included here

The new examples/dynamic-capabilities Worker uses a Durable Object as a shared runtime capability registry:

class Session extends RpcTarget {
  constructor(private capabilityStore: any) {
    super();
  }

  provideCapability(name: string, cap: unknown) {
    return this.capabilityStore.provideCapability(name, cap);
  }

  revokeCapability(name: string) {
    return this.capabilityStore.revokeCapability(name);
  }

  [fallbackCall](path: (string | number)[], args: unknown[]) {
    return this.capabilityStore.invokeCapability(path, args);
  }
}

The Durable Object stores client-provided capabilities and forwards dynamic calls to them:

export class CapabilityStore extends DurableObject<Env> {
  #caps = new Map<string, Capability>();

  provideCapability(name: string, cap: unknown) {
    this.revokeCapability(name);
    this.#caps.set(name, (<Capability>cap).dup());
  }

  invokeCapability(path: (string | number)[], args: unknown[]) {
    let [name, ...rest] = path;
    let cap = this.#caps.get(String(name));
    if (!cap) throw new Error(`no capability named "${String(name)}"`);

    let target = rest.reduce<any>((value, part) => value[part], cap);
    return target(...args);
  }
}

That example is intentionally close to the native question: it works today by putting the fallback hook on an RpcTarget session object and using a Durable Object behind it. What I would really like to know is whether the same hook is viable directly on native DurableObject and WorkerEntrypoint classes too.

What changed in this branch

  • Exports fallbackCall from capnweb.
  • Adds fallback routing to unresolved RpcTarget paths.
  • Preserves fallback routing through property-get handles, returned targets, async fallback results, and client-provided capabilities.
  • Canonicalizes numeric proxy path parts so stub.list()[0] traverses arrays as numeric path segment 0.
  • Keeps local RPC / Promise helper names reserved.
  • Adds regression tests for result behavior, wire shape, and message counts so fallback does not become a second-class slow path.
  • Adds a Cloudflare Worker + Durable Object example for runtime capability registration.

Validation coverage

The branch includes tests for:

  • direct unknown method fallback;
  • deep unknown paths;
  • get-then-call fallback handles;
  • async fallback results;
  • nested RpcTarget returns and pipelining;
  • cross-connection client-provided capabilities;
  • Slack-shaped nested capability paths;
  • numeric array indexes;
  • reserved helper names;
  • object prototype guardrails;
  • fallback compatibility with the existing session battery.

Useful validation commands for the branch:

npm test
npm run test:types
npm run build
cd examples/dynamic-capabilities && npm test
cd examples/dynamic-capabilities && npm run typecheck

@jonastemplestein jonastemplestein changed the title Add opt-in catchall for RpcTargets Add opt-in fallbackCall for RpcTargets Jun 18, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6578a8e. Configure here.

Comment thread src/core.ts Outdated
Following a path over RPC into an RpcTarget resolves each segment to a
declared method/getter; an unknown segment resolves to undefined (and a
later access fails). This adds an opt-in escape hatch: if the target
defines a method under the exported `fallbackCall` symbol, followPath
forwards the unmatched remainder of the path plus the call's arguments to
it instead. It is invoked through the normal call machinery, so its result
is awaited (it may be async), and because the whole dotted path still
arrives in a single call(), promise pipelining is preserved.

This lets a surface expose a fast, natively-pipelined tree of real
methods/getters while routing a dynamic, runtime-defined set of
capabilities through one handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jonastemplestein and others added 2 commits June 18, 2026 10:58
Adds tests proving:
- a deep fallback path is delivered to [fallbackCall] in a single push
  carrying the full path, costing the same round trips as a declared method
  (pinned by counting transport messages);
- a [fallbackCall] returning a nested RpcTarget pipelines further with no
  extra round trip;
- a [fallbackCall] on a client-PROVIDED RpcTarget fires across the
  connection: provideCapability(name, cap.dup()) on the server, then
  session.myCap.something.somethingElse(x) routes back over the wire to the
  providing client's [fallbackCall] with the full path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Previously a [fallbackCall] miss returned a bare wrapper function. That
works for a call, but a get() deep-copies it into a stub that dead-ends:
`const t = await stub.dynamic; t.foo(x)` threw "'foo' is not a function".

Make the wrapper dual-purpose: it's still a function (so the call path's
deliverCall works) but it also carries the `fallbackCall` symbol, and the
"function"/"object" case of followPath now honors that symbol. So when a
get exports the wrapper as a stub and the caller navigates it further,
followPath re-enters the fallback with the accumulated path. Call and get
unify through one mechanism; no call-vs-get flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jonastemplestein jonastemplestein changed the title Add opt-in fallbackCall for RpcTargets Add opt-in fallbackCall for dynamic RpcTarget paths Jun 18, 2026
@jonastemplestein jonastemplestein changed the title Add opt-in fallbackCall for dynamic RpcTarget paths Proposal: add a catch-all hook for dynamic RPC paths Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant