capnweb

작성자: cloudflare

capnweb RPC 패턴을 이 저장소에 맞춰 설명하며, 스텁 수명 주기와 폐기에 중점을 둡니다. Durable Object ↔ computerd…를 건드리는 모든 작업에서 로드하세요.

npx skills add https://github.com/cloudflare/computer --skill capnweb

capnweb in this repo

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. That is the only carrier: capnweb's HTTP batch transport cannot deliver a stream returned from a call, which is what every read on this interface is.

Where things live

The mental model

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. A short-lived session disposes everything when it ends; our WebSocket between the Durable Object and computerd stays up for the lifetime of the workspace, so every undisposed stub stays alive until that connection drops.

The caller-disposes rule

The fundamental principle, from the capnweb spec:

The caller is responsible for disposing all stubs.

Concretely:

  • Stubs passed in parameters remain owned by the caller. The 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.
  • Stubs returned in results transfer ownership to the caller. The caller must dispose them. The RPC system disposes the callee's duplicates once it knows no more pipelined calls will land.
  • Result envelopes always have a disposer, even if you don't think they contain stubs. Dispose them anyway — a future API change may add stubs and your caller will silently leak.

How to dispose

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]();

Repo-specific disposal contract

  • Drivers own their stubs. pullOnce and pushOnce dispose every envelope they touch. Code that goes through the driver doesn't need to think about it.
  • Direct streaming calls own their result. If you reach into 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.
  • Closing disposes the root. createSyncClient / createWorkspaceClient dispose the root stub when you call client.close(). Don't tear down the underlying WebSocket yourself; let close() cascade.
  • Don't await a stub just to inspect it. Awaiting an RpcPromise resolves it; if it resolves to a stub, you now own that stub and must dispose it.

Duplicating stubs with .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();

Listening for disposal on the server

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.

Listening for disconnect

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.

Promise pipelining

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.

The magic .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:

  • The callback must be synchronous (no await).
  • The callback runs in record mode locally first, so it must have no side effects beyond RPC calls.
  • Any stubs captured by the callback are sent to the peer along with the recording. Treat captured stubs as exposed to the peer; only use stubs that originated from the same peer.

Streams are first-class

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.

Do / don't

  • Do define new methods on WorkspaceRPC in interface.ts before implementing them on either side.
  • Do treat stubs as capabilities. Don't pass them across session boundaries unless you mean to grant access.
  • Do drive sync rounds through pullOnce / pushOnce when you can.
  • Do declare using on every awaited result envelope from a direct streaming call.
  • Do dispose result envelopes even when you don't think they contain stubs — futureproofing is cheap.
  • Do call .dup() rather than awaiting twice when you need a stub copy to outlive a callee's auto-dispose.
  • Do run the leak harness (script/computerd-stub-soak) when you change anything around RPC lifecycle, and check session.getStats() for drift.
  • Don't send binary WebSocket frames. The wire is text JSON.
  • Don't tear down the underlying carrier directly. Always go through client.close() so the root stub disposes first.
  • Don't mutate the WorkspaceRPC interface without updating both sides — the wire contract is shared.
  • Don't declare a method private in TypeScript and assume it's hidden from RPC. Use a #-prefixed name to actually make it private at runtime.
  • Don't rely on garbage collection to clean up stubs. It won't.

Testing for leaks

  • Use enableStubTracking() + stubSnapshot() in a test to assert no stub survives a round trip you expected to clean up.
  • Soak the boundary with script/computerd-stub-soak for disposal-sensitive changes; it reads session.getStats() to detect drift.
  • Server-side RPC behavior is tested against the real Database and real driver helpers in packages/rpc and packages/computer.

Further reading

cloudflare의 다른 스킬

dependabot-review
cloudflare
Dependabot PR을 분석하여 각 업데이트된 패키지에서 실제로 변경된 사항과 해당 변경 사항이 이 저장소에 영향을 미치는지 확인합니다. 변경된 API/메서드 등을 보고합니다.
module-registry
cloudflare
workerd에서 모듈 레지스트리를 작업할 때 로드 — 모듈 해석, 컴파일, 평가, 등록을 읽기, 수정, 디버깅, 검토하는 경우…
reproduce
cloudflare
cloudflare/agents GitHub 이슈를 재현하기 위해 최소한의 Agents/Worker 프로젝트를 스캐폴딩하고 임시 Cloudflare 계정에 배포한 후 보고합니다…
local-explorer
cloudflare
로컬 탐색기 또는 로컬 API에 제품/리소스를 추가하는 방법. 새로운 로컬 API나 UI 라우트를 구현할 때 사용합니다.
open-pr
cloudflare
클라우드플레어/에이전트 GitHub 이슈와 재현 결과를 바탕으로 수정 PR을 한 번에 생성합니다 — 브랜치 생성, 변경, 테스트, 푸시, 그리고 이슈에 연결된 PR 열기까지 수행합니다.
write-endpoints
cloudflare
chanfana를 사용한 OpenAPI 엔드포인트 구축을 위한 종합 가이드 - 스키마 정의, 요청 검증, CRUD 작업, D1 데이터베이스 통합 등
agents-sdk
cloudflare
Cloudflare Workers에서 Agents SDK를 사용하여 AI 에이전트를 구축하세요. 상태 저장 에이전트, 지속 가능한 워크플로우, 실시간 WebSocket 앱, 예약된 작업 등을 생성할 때 로드하세요.
changelog
cloudflare
Cloudflare 문서 사이트의 제품 변경 로그 항목을 생성, 업데이트 및 검토합니다. 변경 로그 MDX 파일을 생성하거나 기존 파일을 편집할 때 로드합니다.