How Capnweb RPC Handles Promise Pipelining and Object Capabilities in Cloudflare Computer
Capnweb RPC implements promise pipelining and object capabilities in Cloudflare Computer by exposing WebSocket connections as disposable, typed JavaScript Proxies (RpcStub) that queue method calls into pipelined promises, allowing callers to chain remote operations without awaiting intermediate network round-trips.
Cloudflare Computer uses capnweb as the RPC framing layer between a Durable Object (the host) and the in-container computerd process. The architecture treats remote interfaces as object capabilities represented by typed stubs, enabling fine-grained permission control and automatic resource cleanup while maintaining Cap’n Proto–style semantics over WebSocket or HTTP-batch transports.
Object-Capability Stubs and the Proxy Pattern
Capnweb models remote services as capability objects that grant the holder the right to invoke specific methods. When a client establishes a session, capnweb constructs a root stub implementing the WorkspaceRPC interface—a composite of SyncRPC and ShellRPC—and returns it as a fully typed JavaScript Proxy.
Creating Typed Capabilities with RpcStub
When you call newWebSocketRpcSession() (or nodeHttpBatchRpcResponse), the library builds a root stub that intercepts every property access and forwards it to the remote side. Because the stub is a Proxy implementing the exact shape of the remote interface, TypeScript can provide fully typed views through createSyncClient and createWorkspaceClient in packages/rpc/src/client.ts (lines 47–48).
// packages/rpc/src/client.ts
const stub = newWebSocketRpcSession(ws as unknown as globalThis.WebSocket)
as RpcStub<SyncRPC>;
Possession of the stub constitutes the capability; no other code can fabricate one because the stub’s prototype is hidden behind the proxy. This pattern enforces capability-based security: you can only invoke methods if you hold the reference.
Capability Disposal and Resource Cleanup
Stubs are disposable resources. Calling Symbol.dispose (or the close() helper) tears down the underlying session, sends a clean abort frame to the remote side, and releases the capability, preventing further calls. The implementation in packages/rpc/src/client.ts (lines 55–62) invokes dispose on the root stub before closing the WebSocket:
(target as unknown as Disposable)[Symbol.dispose]?.();
This explicit lifecycle management allows the remote computerd process to free associated resources immediately rather than waiting for TCP timeouts.
Promise Pipelining for Low-Latency RPC
Capnweb’s RPC model supports pipelined promises: a property read returns an RpcPromise (or another RpcStub) immediately, without waiting for the remote call to finish. This enables call-chaining where subsequent operations are queued and sent in a single frame once the network is ready.
The RpcPromise Proxy Chain
When you access a property on the stub (e.g., .sync.push), the proxy records the path and returns a thenable RpcPromise. If you chain further property accesses, the proxy accumulates the complete path and sends the serialized request in one frame. The remote side resolves each step in order, preserving call sequence while eliminating round-trip latency for intermediate steps.
// Pipelined: push() and property access queued without intermediate await
const pushResult = await client.sync.push({ senderRev, changes });
const water = await client.sync.watermarks();
Intercepting Calls for Observability
The client wrapper intercepts method calls, records start times, and attaches .then/.catch handlers to emit onRPCEvent telemetry. This logic resides in the get handler of the proxy in packages/rpc/src/client.ts (lines 54–88):
const result = (value as (...a: unknown[]) => unknown)(...args);
if (result && typeof (result as { then?: unknown }).then === "function") {
return (result as Promise<unknown>).then(
v => { onEvent(...); return v; },
err => { onEvent(...); throw err; }
);
}
Because RpcPromise is already a thenable, downstream code can keep chaining calls without awaiting each individual RPC, achieving the classic promise-pipelining pattern used by Cap’n Proto’s RPC system.
Managing the Capability Lifecycle
Acquisition via Session Establishment
A client obtains a capability by establishing a capnweb session through newWebSocketRpcSession or nodeHttpBatchRpcResponse. The returned RpcStub is the sole holder of the capability. The library multiplexes concurrent RPCs over the single WebSocket, with RPC identifiers generated internally by capnweb.
Automatic Re-establishment on Disconnect
If the underlying WebSocket disconnects, the Durable Object discards the old stub and lazily creates a new one on the next RPC. The capability is recreated automatically, preserving the same interface shape for callers while ensuring that stale capabilities cannot be used after transport failure.
Wire Contract and Interface Types
The wire contract is defined in packages/rpc/src/interface.ts. It declares the two halves of the WorkspaceRPC interface:
export interface WorkspaceRPC {
sync: SyncRPC;
shell: ShellRPC;
}
SyncRPCmethods (push,fetchChanges,hasObjects,pushObjects) return Promises orReadableStreams that are pipelined through the stub.ShellRPCmethods (exec,getExec,killExec,disposeExec) return promises resolving toReadableStream<ExecEvent>, treating streams as first-class capnweb values that propagate back-pressure from the consumer to the spawned process.
Code Examples
Below are practical usage patterns demonstrating capability acquisition, promise pipelining, and proper disposal.
// ==== Creating a Sync client (raw sync half) ====
import { createSyncClient } from "@cloudflare/computer/rpc";
const client = createSyncClient({
url: "ws://localhost:45678/ws",
onRPCEvent: ev => console.log(ev), // optional observability
});
// Push a batch of changes (pipelined)
await client.sync.push({
senderRev: 42,
changes: changeStream, // ReadableStream<ChangeEntry>
});
// Fetch changes after a known cursor
const { currentCursor, stream } = await client.sync.fetchChanges({
after: { rev: 100, path: null },
});
for await (const entry of stream) {
console.log(entry);
}
// ==== Using the composite Workspace client ====
import { createWorkspaceClient } from "@cloudflare/computer/rpc";
const wsClient = createWorkspaceClient({ url: "ws://localhost:45678/ws" });
// Pipeline a push and then read a watermark in a single async flow
const pushResult = await wsClient.sync.push({ senderRev: 0, changes: changeStream });
const water = await wsClient.sync.watermarks();
console.log(`rev ${pushResult.rev}, current ${water.currentRev}`);
// Execute a command and stream its output
const { id, events } = await wsClient.shell.exec({ source: "ls -l /data" });
for await (const ev of events) {
if (ev.name === "stdout") console.log(new TextDecoder().decode(ev.value));
}
await wsClient.shell.disposeExec({ id });
Summary
- Object-capability stubs are implemented as JavaScript Proxies in
packages/rpc/src/client.ts, wherenewWebSocketRpcSessionreturns a typedRpcStub<WorkspaceRPC>that grants access to remote methods. - Promise pipelining allows callers to chain property accesses and method calls (e.g.,
client.sync.push().then(...)) without awaiting intermediate network round-trips, with the proxy recording paths and batching serialized requests. - Explicit disposal via
Symbol.dispose(invoked throughclose()) sends clean abort frames to the remote side, enabling immediate resource cleanup in thecomputerdprocess. - Automatic re-establishment ensures that disconnected WebSockets result in fresh capability stubs on the next call, maintaining interface continuity while preventing use of stale references.
Frequently Asked Questions
What is an object capability in Capnweb RPC?
An object capability is a typed JavaScript Proxy (RpcStub) that represents the right to invoke methods on a remote interface. Possession of the stub—returned by newWebSocketRpcSession or nodeHttpBatchRpcResponse—grants the holder permission to call any method defined on WorkspaceRPC, SyncRPC, or ShellRPC. The stub cannot be fabricated by other code, enforcing security through reference availability rather than access control lists.
How does promise pipelining reduce latency?
Promise pipelining reduces latency by allowing callers to queue multiple method calls and property traversals without waiting for each individual network round-trip. When you access client.sync.push, the proxy returns an RpcPromise immediately and records the path; subsequent operations are batched into a single frame. The remote side processes the pipeline in order, returning results for all queued calls at once, effectively collapsing nested round-trips into one.
How do you properly close a Capnweb RPC session?
Invoke Symbol.dispose on the root stub before closing the underlying WebSocket. The createSyncClient and createWorkspaceClient adapters provide a close() helper that performs this cleanup (see packages/rpc/src/client.ts, lines 55–62). This sends a clean abort frame to the remote side, signaling the computerd process to release resources associated with that capability.
What happens when the WebSocket disconnects?
When the WebSocket disconnects, the Durable Object discards the stale RpcStub. On the next RPC attempt, the system lazily establishes a new capnweb session and returns a fresh stub implementing the same interface. This re-establishment is transparent to calling code, which continues to interact with the new capability as if it were the original, ensuring resilience without manual reconnection logic.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →