Add experimental RPC bridge API#664
Draft
aron-cf wants to merge 5 commits intorpc-bridge-cifrom
Draft
Conversation
Currently e2e tests will only run when `run-e2e-bridge` label is applied to the PR (for testing).
Introduces a typed capnweb RPC layer alongside the existing HTTP routes.
Exposed at `${apiPrefix}/rpc` (default `/v1/rpc`), gated behind a new
`enableExperimentalRPC` flag on `BridgeConfig`. Returns 404 by default.
Wire shape:
interface BridgeRPCAPI {
sandbox(id?: string): Promise<SandboxRPCAPI>;
}
`sandbox()` validates the id (or generates a fresh one via
`generateSandboxId`), then returns a `SandboxRPCAPI` stub bound to that
sandbox. `SandboxRPCAPI` mirrors the container's internal `SandboxAPI`
and exposes all ten domains: commands, files, processes, ports, git,
interpreter, utils, backup, desktop, watch — plus an `id` getter so
callers can read back a server-generated id. Each domain shim forwards
to the SDK's `BridgeSandbox` proxy (the runtime `Sandbox` class typed
via `PublicInterface<Sandbox<any>>` so private members can't leak).
Authentication is carried in `Sec-WebSocket-Protocol`
(`cloudflare-sandbox-bridge.bearer.<token>`) — the only auth-bearing
header browser `WebSocket` constructors can set. Factored into
`authenticateRpcUpgrade()` so the check is independently testable.
The endpoint deliberately bypasses the `/sandbox/*` HTTP middleware:
sandbox-id validation lives inside the RPC call, container resolution
is direct via `getBridgeSandbox(ns, id)` (no warm-pool indirection),
and one connection can address many sandboxes via repeated
`rpc.sandbox(id)` calls. The route is registered in the same Hono app
as the rest of the `/v1/*` surface; the `enableExperimentalRPC` gate
lives inside the single handler (404 fast-path before any auth or
upgrade work).
Adds `listSessions()` to `UtilityClient` so the HTTP transport
satisfies `SandboxUtilsAPI` (the capnweb transport already had it);
the bridge `utils.listSessions` shim now delegates instead of throwing
not_implemented.
Tests live in `packages/sandbox/tests/`:
- `bridge-rpc.test.ts` (27 tests) drives `handleRpcUpgrade()` directly
through an in-process WebSocket pair, plus a small `createBridgeApp`
slice for the gating behaviour.
- `bridge-test-helpers.ts` provides the focused `createMockSandbox` /
`createMockEnv` used by both the RPC tests and the bridge-client
tests.
This endpoint is **experimental**: the surface mirrors the still-
evolving sandbox interface and is subject to breaking changes.
🦋 Changeset detectedLatest commit: 3936cc8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
Contributor
🐳 Docker Images Published
Usage: FROM cloudflare/sandbox:0.0.0-pr-664-3936cc8Version: 📦 Standalone BinaryFor arbitrary Dockerfiles: COPY --from=cloudflare/sandbox:0.0.0-pr-664-3936cc8 /container-server/sandbox /sandbox
ENTRYPOINT ["/sandbox"]Download via GitHub CLI: gh run download 25215867899 -n sandbox-binaryExtract from Docker: docker run --rm cloudflare/sandbox:0.0.0-pr-664-3936cc8 cat /container-server/sandbox > sandbox && chmod +x sandbox |
Typed capnweb client for the bridge Worker's `GET /v1/rpc` endpoint.
One `BridgeClient` instance manages many sandboxes over a single
WebSocket; method calls on `client.sandbox(id)` lazily resolve a
per-sandbox stub and forward the call through capnweb.
```ts
import { createBridgeClient } from '@cloudflare/sandbox/bridge-client';
const client = createBridgeClient({
url: 'https://bridge.example.com',
token: process.env.SANDBOX_API_KEY,
});
const sandbox = client.sandbox('my-sandbox');
const result = await sandbox.commands.execute('ls', sessionId);
await client.close();
```
Implementation notes:
- Single WebSocket per `BridgeClient`. Per-sandbox stubs are cached;
`client.sandbox(id)` returns a lazy proxy that resolves the stub on
first method call and reuses it thereafter. `sandbox()` (no id)
asks the server to generate one; the result is readable via the
`id` getter on the handle.
- Auth is carried in `Sec-WebSocket-Protocol`
(`cloudflare-sandbox-bridge.bearer.<token>`), so the same client
works in browsers, Bun, Node 22+, and Cloudflare Workers without
per-runtime adapters.
- Browsers and Bun hide the upgrade response status on a failed WS
upgrade. To keep the `BridgeAuthError` contract honest, the client
probes the endpoint with a follow-up `fetch()` on close-without-open
and uses the real HTTP status — 401 reliably becomes
`BridgeAuthError { status: 401 }`; everything else stays
`BridgeConnectError`.
- `Symbol.asyncDispose` is implemented for `await using` syntax.
Wired into the build via `tsdown.config.ts` and the `./bridge-client`
subpath in `packages/sandbox/package.json`.
Tests in `packages/sandbox/tests/bridge-client.test.ts` (9 tests) run
against the real bridge route via `handleRpcUpgrade()` and an
in-process WebSocket pair, exercising the full subprotocol auth and
capnweb wiring through the public client surface.
Includes a changeset describing the experimental endpoint and the
opt-in flag from the consumer's perspective.
Plumbs the new `enableExperimentalRPC` flag through the bridge worker behind a `SANDBOX_EXPERIMENTAL_RPC=true` deployment var: - `bridge/worker/src/index.ts` reads `env.SANDBOX_EXPERIMENTAL_RPC` and passes the flag to `bridge()`. - `bridge/worker/wrangler.jsonc` declares the new var (empty default, override per deployment). - `bridge/worker/.dev.vars.example` documents the opt-in alongside `SANDBOX_API_KEY` (using the bare `KEY=` style consistently). - `bridge/worker/package.json` updates `cf-typegen` to pass `--env-file .dev.vars.example` so vars get a usable `string` type in the generated `Env` interface, not a literal-narrowed `""`. Regenerated `worker-configuration.d.ts` to match. - `bridge/worker/README.md` documents the route in the API table and adds a full reference section with the experimental warning, the `SANDBOX_EXPERIMENTAL_RPC=true` flag, the subprotocol-only auth scheme, and a `bridge-client` usage snippet. `bridge/script/integration` exercises the new endpoint end-to-end: - Raw HTTP coverage (plain GET → 400, missing/wrong subprotocol → 401, Authorization header rejected, correct subprotocol → 101 with echoed protocol). - `@cloudflare/sandbox/bridge-client` coverage (auth-error path, end-to-end `utils.ping`, pool-error fallthrough when no container backend is available). - Spawns `wrangler dev` with `--var SANDBOX_EXPERIMENTAL_RPC:true` so the local route is reachable; bare process env doesn't reach workerd.
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.
Adds a typed capnweb RPC endpoint to
@cloudflare/sandbox/bridgeand a thin TypeScript client at@cloudflare/sandbox/bridge-client. One WebSocket per client gives consumers a typed handle to every sandbox method, no per-route HTTP plumbing, no warm pool indirection.What's in this PR
@cloudflare/sandbox/bridge—GET /v1/rpcWebSocket endpoint, gated behind a newenableExperimentalRPCflag onBridgeConfig. Subprotocol-only bearer auth (Sec-WebSocket-Protocol: cloudflare-sandbox-bridge.bearer.<token>) so browserWebSocketclients work uniformly with Bun, Node 22+, and Cloudflare Workers. The endpoint deliberately bypasses the/sandbox/*HTTP middleware: sandbox-id validation lives inside the RPC call, container resolution is direct, and one connection can address many sandboxes.@cloudflare/sandbox/bridge-client— typed client subpath:createBridgeClient({ url, token })returns oneBridgeClientinstance that manages many sandboxes over a single socket.client.sandbox(id)returns a lazy proxy structurally typed asSandboxRPCAPI(the 10 domains — commands, files, processes, ports, git, interpreter, utils, backup, desktop, watch — plus anidgetter). Auth failures surface as a typedBridgeAuthError.await usingis supported viaSymbol.asyncDispose.bridge/(the deployable bridge Worker) — readsSANDBOX_EXPERIMENTAL_RPC=truefrom the deployment vars and passesenableExperimentalRPC: truetobridge(). Route returns 404 by default. Integration tests cover the new endpoint end-to-end.Enabling the endpoint
Just set the
SANDBOX_EXPERIMENTAL_RPC=truevar.If you're embedding
bridge()directly, opt in via config:Using the client
Run a command
Read and write files
Stream command output
commands.executeStreamreturns aReadableStream<Uint8Array>of SSE-encodedExecEvents, identical to the wire format produced bySandbox.execStream(). Capnweb forwards the stream natively across the WebSocket:Re-connect to a previous sandbox ID
Address many sandboxes from one client
A single
BridgeClientopens one WebSocket and reuses it for every sandbox you address. Per-sandbox stubs are cached on first access:Handle auth failures
await using(TypeScript 5.2+ / Node 22+)Why experimental
The RPC surface (
SandboxRPCAPI) was sketched to mirror the container's internalSandboxAPIshape — ten neatly-typed domains, each with a small set of methods. Wiring it up against the SDK's actualSandboxclass exposed several places where the two have drifted:Method shapes — Several methods exist on both surfaces with different signatures.
commands.executetakes(command, sessionId, { timeoutMs, env, cwd })on the wire;Sandbox.exectakes(command, { timeout, env, cwd })withsessionIdeither implicit (default session) or passed as a separategetSession(id).exec(...)call. The shim translatestimeoutMs ↔ timeoutand routes throughgetSession()for explicit-session calls — but the types in@repo/shared'sISandboxinterface have drifted from the runtimeSandboxclass. We had to switch the bridge surface fromISandboxtoSandbox<any>(viaPublicInterface<T>) to access what the proxy actually exposes —desktop,exposePort, etc.Return shapes —
processes.startProcessreturns a flatProcessStartResultDTO on the wire ({ processId, pid, command, timestamp });Sandbox.startProcessreturns a richProcessobject with methods (kill,getStatus,waitForLog,waitForPort,waitForExit).Hostname coupling —
ports.exposePortneeds a hostname to synthesise preview URLs. The bridge captures it from the upgrade request'sHostheader (orBRIDGE_PREVIEW_HOSTNAMEenv var) and threads it through every RPC session.Method gaps — The wire shape lists ten domains' worth of methods; some have no public equivalent on
Sandboxand went through the underlyingclient.{utils,backup,interpreter,ports}.X()to be wired.These rough edges all map back to the same root cause: the SDK's public
Sandboxinterface and the container'sSandboxAPIwere designed independently, and the bridge sits between them. These should evolve over time so that the public Sandbox interface is a subset of the DO/Container interface.