Capnweb RPC Wire Types: Differences Between SyncRPC and ShellRPC and How They Share Transport

Capnweb uses two distinct RPC wire types—SyncRPC and ShellRPC—that share a single WebSocket session through a unified WorkspaceRPC stub, eliminating the need for separate version negotiation between filesystem synchronization and process execution channels.

Capnweb is the RPC framing layer that connects a Cloudflare Durable Object (DO) to the in-container computerd process. Its wire protocol transmits single JSON-text frames over a persistent WebSocket, with an HTTP-batch fallback for reliability. Understanding how the two wire types differ—and how they coexist on the same transport—is essential for anyone building or debugging on the Cloudflare Computer platform.

The Two Capnweb RPC Wire Types

Capnweb splits its wire surface into two logical halves, each serving fundamentally different purposes.

SyncRPC: Filesystem and Blob Synchronization

SyncRPC handles bidirectional data flow between the DO and container, focusing on state reconciliation.

  • Direction: DO → container (push) and container → DO (fetch)
  • Primary purpose: Synchronizes filesystem changes, object blobs, and watermarks
  • Key operations:
    • push() — streams ChangeEntry records to the remote
    • fetchChanges() — pulls changes after a specified cursor
    • hasObjects() — probes for missing blobs by hash
    • pushObjects() / fetchObjects() — transfers actual byte content

This interface is defined in packages/rpc/src/interface.ts as the SyncRPC interface and exposed via WorkspaceRPC.sync.

ShellRPC: Process Execution and Supervision

ShellRPC manages containerized process lifecycle and event streaming.

  • Direction: Bidirectional (DO ↔ container)
  • Primary purpose: Spawns commands, manages execution state, and streams output
  • Key operations:
    • exec() — spawns a new command with source and returns execution ID plus ExecEvent stream
    • getExec() — re-attaches to running or completed executions
    • killExec() — sends signals to running processes
    • disposeExec() — cleans up execution logs and resources

All exec events stream as ExecEvent records. This interface is declared alongside SyncRPC in packages/rpc/src/interface.ts and reached via WorkspaceRPC.shell.

How the Wire Types Share Transport

Both halves share the same WebSocket session and the same Capnweb stub. This design decision has important consequences for versioning and contract evolution.

Unified Stub Architecture

The DO creates a single stub for WorkspaceRPC. That stub contains two sub-stubs:

// Conceptual structure of WorkspaceRPC
interface WorkspaceRPC {
  sync: SyncRPC;   // filesystem synchronization
  shell: ShellRPC; // process execution
}

Because the contract is defined in one TypeScript interface, changes to either half do not require separate version negotiation. The entire contract evolves lock-step. This keeps the wire surface stable while allowing independent testing of each logical group.

Wire Serialization Format

The actual JSON messages are generated by the Capnweb library. Each RPC call serializes to:

  • Method name
  • Arguments
  • Unique request ID

Responses carry either the result or a structured WireError. Error codes are defined in the same interface (WireErrorCode) and propagate unchanged across the wire. Callers can branch on err.code directly without parsing message text.

Working with Both Wire Types in Practice

Here is how to create a client that exercises both SyncRPC and ShellRPC through the shared transport:

import { createWorkspaceClient } from '@cloudflare/computer-rpc';
import { WorkspaceRPC } from '@cloudflare/computer-rpc';

// Connect to the DO-side WebSocket (URL provided by host)
const client: WorkspaceRPC = await createWorkspaceClient('ws://my-do.example.com/ws');

// ----- SyncRPC: filesystem synchronization -----
await client.sync.push({
  senderRev: 42,
  changes: changeStream,  // ReadableStream<ChangeEntry>
});

const { currentCursor, stream } = await client.sync.fetchChanges({
  after: { rev: 0, path: null },
});

// ----- ShellRPC: process execution -----
const { id, events } = await client.shell.exec({
  source: 'node -e "console.log(\'hello\')"',
});

for await (const ev of events) {
  if (ev.name === 'stdout') {
    console.log(new TextDecoder().decode(ev.value));
  }
}

Handling Wire-Level Errors

The shared error schema enables portable error handling across both RPC types:

try {
  await client.sync.hasObjects([hash1, hash2]);
} catch (e) {
  if (e.code === 'EUNKNOWN_HASH') {
    // Remote does not recognize one or more hashes — probe or retry
  }
}

Key Source Files

File Role
docs/08_capnweb_interface.md Transport, framing, and overall RPC contract description
packages/rpc/src/interface.ts Concrete SyncRPC, ShellRPC, and WorkspaceRPC TypeScript interfaces, including WireErrorCode definitions
packages/dofs/src/sync/changes.ts ChangeEntry schema referenced by sync RPC
packages/computerd/src/exec/log.ts Exec event storage that Shell RPC streams
packages/rpc/README.md High-level RPC package overview and usage examples

Summary

  • SyncRPC and ShellRPC are the two Capnweb RPC wire types, handling filesystem synchronization and process execution respectively
  • Both share a single WebSocket session through the unified WorkspaceRPC stub
  • The lock-step contract evolution eliminates version negotiation complexity
  • Structured wire errors with stable codes enable reliable cross-network error handling
  • All definitions live in packages/rpc/src/interface.ts with architectural documentation in docs/08_capnweb_interface.md

Frequently Asked Questions

What distinguishes SyncRPC from ShellRPC in Capnweb?

SyncRPC synchronizes filesystem state, object blobs, and watermarks between the DO and container, using operations like push(), fetchChanges(), and hasObjects(). ShellRPC manages process lifecycle—spawning commands with exec(), re-attaching with getExec(), and streaming output via ExecEvent records. They operate on entirely different domains but share the same transport mechanism.

Why do both wire types use the same stub instead of separate connections?

Sharing a single WorkspaceRPC stub over one WebSocket simplifies connection management and enforces lock-step contract evolution. Because both halves are declared in one TypeScript interface, the system avoids version negotiation overhead. The trade-off is that changes to either half require coordinated deployment, but this is acceptable for the tightly coupled DO-container relationship.

How are errors handled consistently across SyncRPC and ShellRPC?

Both wire types use the same WireError structure with codes defined in WireErrorCode. When the Capnweb library serializes a response, errors propagate with their code intact. This lets callers switch on err.code—such as 'EUNKNOWN_HASH' for missing blobs—without parsing opaque message strings, enabling portable error handling logic.

Where is the Capnweb RPC contract formally defined?

The concrete TypeScript interfaces are in packages/rpc/src/interface.ts, which exports SyncRPC, ShellRPC, WorkspaceRPC, and WireErrorCode. The architectural design, transport semantics, and framing details are documented in docs/08_capnweb_interface.md at the repository root.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →