Proposal: add a catch-all hook for dynamic RPC paths#2
Open
jonastemplestein wants to merge 4 commits into
Open
Proposal: add a catch-all hook for dynamic RPC paths#2jonastemplestein wants to merge 4 commits into
jonastemplestein wants to merge 4 commits into
Conversation
d70ca39 to
6578a8e
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
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>
6578a8e to
464bb78
Compare
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>
31bef6c to
fbaa330
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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, andWorkerEntrypoint?Concretely, I am asking whether an object could implement one well-known hook that receives the unmatched RPC path and the call arguments:
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
RpcTargetversion as a concrete sketch. The larger question is whether the same concept makes sense in the native dispatch path forDurableObjectandWorkerEntrypoint, 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:
Or they are naturally hierarchical because they are adapting another API:
Without a catch-all RPC hook, the server has to flatten this into something like
call(path, args)or route it throughfetch(). 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:
arrives as:
with the same pipelining behavior and round-trip cost as a declared method call.
Minimal implementation
The branch adds one public symbol:
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:
The second assignment is the important get-then-call case. It means this still works:
slackis 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:
For
RpcTargetinstances, fallback only runs if the path does not name an existing method / getter / property. This preserves the existing privacy rule for instance properties:And client proxy path construction canonicalizes bracket indexes so array-like dynamic paths survive the wire shape:
So:
arrives with
0as a numeric path segment:Semantics this tries to preserve
fallbackCall.RpcTargetinstance properties remain inaccessible over RPC; fallback does not hide that error.then,catch,finally,map,dup,onRpcBroken, andtoStringstay local stub / promise helpers.Object.prototypenames stay blocked, matching the existing prototype-pollution guardrails.nameandlengthcan be dynamic path segments on fallback-capable callable handles, even though JavaScript functions have those as own properties.RpcTarget/ capability, and pipelined calls into that returned target still work.Example included here
The new
examples/dynamic-capabilitiesWorker uses a Durable Object as a shared runtime capability registry:The Durable Object stores client-provided capabilities and forwards dynamic calls to them:
That example is intentionally close to the native question: it works today by putting the fallback hook on an
RpcTargetsession object and using a Durable Object behind it. What I would really like to know is whether the same hook is viable directly on nativeDurableObjectandWorkerEntrypointclasses too.What changed in this branch
fallbackCallfromcapnweb.RpcTargetpaths.stub.list()[0]traverses arrays as numeric path segment0.Validation coverage
The branch includes tests for:
RpcTargetreturns and pipelining;Useful validation commands for the branch: