cloudflare/capnweb
| capnweb RPC patterns for this repo, with a heavy focus on stub lifecycle and disposal. Load when touching anything that crosses the Durable Object Durable Object server. Triggers include "capnweb", "RpcTarget", "stub", "RPC wire", "promise pipelining", "stub disposal", "RpcPromise".
npx skills add https://github.com/cloudflare/computer --skill capnweb
capnweb is the RPC framing
between the Durable Object and computerd. It's an object-capability RPC
system with promise pipelining, structured-clone-style transfer of
stubs, and bidirectional calls. The wire format used here is text
JSON over a long-lived WebSocket, with an HTTP batch alternative.
packages/rpc/src/interface.ts— typed wire surface. WorkspaceRPC is the root stub and composes
SyncRPC and ShellRPC. Add new methods here first.
packages/rpc/src/server.ts— Database-backed implementation. Imported by the Durable Object
and the in-container computerd.
packages/rpc/src/client.ts— typed stubs over a WebSocket carrier. The Durable Object uses a
deferred transport so the stub can be created before the upgrade
completes.
packages/rpc/src/sync-driver.ts— pullOnce, pushOnce, tick. These wrap streaming methods and
handle disposal internally.
packages/rpc/src/debug.ts— enableStubTracking, stubSnapshot for leak hunting.
docs/08_capnweb_interface.md— design intent for the wire.
docs/11_lifecycle.md— the repo's stub disposal contract.
A stub is a capability: holding it is the right to call the
remote object. Stubs are not garbage-collected across the network —
the local GC has no visibility into the remote object graph, and the
remote runtime has no idea whether you're under memory pressure. If
you don't explicitly dispose stubs, you leak resources on the other
side of the connection.
This matters more here than in many capnweb deployments because the
connection is long-lived. HTTP batch sessions auto-dispose
everything when the batch ends, but our WebSocket between the
Durable Object and computerd stays up for the lifetime of the workspace.
Every undisposed stub stays alive until that connection drops.
The fundamental principle, from the capnweb spec:
> The caller is responsible for disposing all stubs.
Concretely:
callee receives duplicates that the RPC system auto-disposes when
the call completes. You can dispose your originals immediately
after the call — they were duplicated at send time.
The caller must dispose them. The RPC system disposes the callee's
duplicates once it knows no more pipelined calls will land.
think they contain stubs. Dispose them anyway — a future API
change may add stubs and your caller will silently leak.
Three patterns, in order of preference:
// 1. `using` declaration — preferred when the stub is scope-local.
using result = await client.sync.fetchChanges({ sinceRev });
for await (const entry of result.stream) {
// ...
}
// result is disposed when the block exits.
// 2. try/finally — when `using` isn't available or the scope is awkward.
const result = await client.sync.fetchChanges({ sinceRev });
try {
for await (const entry of result.stream) {
// ...
}
} finally {
result[Symbol.dispose]();
}
// 3. Explicit dispose — when ownership crosses a boundary.
const stub = api.getThing();
// ... pass `stub` somewhere ...
stub[Symbol.dispose]();
pullOnce and pushOnce disposeevery envelope they touch. Code that goes through the driver
doesn't need to think about it.
client.sync / client.shell directly to call fetchChanges,
fetchObjects, shell.exec, or shell.getExec, you own the
envelope. Bind it with using or dispose in finally. The stream
on the envelope is the body; draining it does not release the
envelope itself.
createSyncClient /createWorkspaceClient dispose the root stub when you call
client.close(). Don't tear down the underlying WebSocket
yourself; let close() cascade.
RpcPromise resolves it; if it resolves to a stub, you now own
that stub and must dispose it.
.dup()If you need to pass a stub somewhere that will dispose it but also
want to keep using it locally, call stub.dup(). The underlying
target stays alive until every duplicate is disposed.
.dup() also works on a property of a stub or promise. This is the
idiomatic way to grab a stub-shaped property without an extra round
trip:
// Grab `authedApi` as a stub immediately, without awaiting.
using authedApi = api.authenticate(token).dup();
// Use it for pipelined calls right away.
const userId = await authedApi.getUserId();
An RpcTarget may declare a Symbol.dispose method. capnweb calls
it once every stub pointing at the target has been disposed.
class SessionTarget extends RpcTarget {
// ...
[Symbol.dispose]() {
// Release any per-session resources.
}
}
If you pass the same target to RPC multiple times, you get one
dispose call per stub. To collapse them into one, wrap the target in
new RpcStub(target) once and pass that stub around instead.
stub.onRpcBroken(cb) fires when the stub becomes unusable —
typically because the underlying connection dropped or, for a
promise, because the promise rejected. After the callback runs every
method call on that stub will throw. packages/computer already
folds onRpcBroken into the workspace's closed promise; reuse that
plumbing rather than wiring up a parallel listener.
An RpcPromise is also a stub for its eventual result. Don't await
unless you actually need the value locally:
// Three calls, one round trip.
using authed = api.authenticate(token).dup();
const profile = await api.getUserProfile(authed.getUserId());
You can pass an RpcPromise as an argument to another RPC. capnweb
substitutes the resolved value on the receiver side before
delivering the call.
Property access on a stub or promise returns an RpcPromise **that
does not have its own disposer** — you must dispose the stub or
promise it came from. You can pass a property in params or returns,
but doing so never causes anything to be implicitly disposed.
.map().map() runs a sync callback on the remote resolution of a promise,
in one round trip:
const idsPromise = api.listUserIds();
const names = await idsPromise.map(id => [id, api.getUserName(id)]);
Restrictions:
await).side effects beyond RPC calls.
the recording. Treat captured stubs as exposed to the peer; only
use stubs that originated from the same peer.
ReadableStream<T> is a regular capnweb value. The wire never
inlines blob bytes — change streams carry content-addressed
(hash, size) records and the receiver calls back via hasObjects
/ pushObjects for the missing subset. Prefer streaming over single
large payloads when adding new RPCs.
When you receive a streaming result, the envelope owns the stream.
Draining the stream does not dispose the envelope; dispose the
envelope to release every stub it contains.
WorkspaceRPC in interface.tsbefore implementing them on either side.
boundaries unless you mean to grant access.
pullOnce / pushOnce when youcan.
using on every awaited result envelope from adirect streaming call.
contain stubs — futureproofing is cheap.
.dup() rather than awaiting twice when you need astub copy to outlive a callee's auto-dispose.
script/computerd-stub-soak) when youchange anything around RPC lifecycle, and check
session.getStats() for drift.
through client.close() so the root stub disposes first.
WorkspaceRPC interface without updatingboth sides — the wire contract is shared.
private in TypeScript and assume it'shidden from RPC. Use a #-prefixed name to actually make it
private at runtime.
enableStubTracking() + stubSnapshot() in a test to assertno stub survives a round trip you expected to clean up.
script/computerd-stub-soak fordisposal-sensitive changes; it reads session.getStats() to
detect drift.
Databaseand real driver helpers in packages/rpc and packages/computer.
upstream README is the authoritative reference for stub
ownership, .dup(), .map(), and transport semantics.
docs/08_capnweb_interface.mddocs/11_lifecycle.mdpackages/rpc/README.mdTake cloudflare/capnweb from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.