Important
PREVIEW ONLY This package is provided as a preview for feedback only. APIs are unstable and the design is subject to change.
Suitable for experiments, exploration and prototypes. It is NOT suitable for production use at this time.
A Cloudflare Worker + Durable Object that boots a Container running
the wsd daemon and exposes a minimal write / read / exec
HTTP surface, modelled on the
cloudflare/sandbox-sdk
bridge.
client ─► Worker /c/<name>/{file,exec}
│ (DO RPC calls)
▼
DO (ContainerExample) ──► Container ──► wsd (:8080)
▲ │
│ ws://workspace.internal/ws │
└────────── capnweb session ◄──────┘
- The DO constructs a
CloudflareContainerBackendfrom@cloudflare/workspace/backends/containerand hands it to aWorkspaceinstance. That backend owns the entire wsd lifecycle: container start, outbound egress interception, port-readiness polling, POST/connectto wsd,/wsupgrade routing, and capnweb session attach. - wsd reaches the Worker through the container's outbound
interception (
ctx.container.interceptOutboundHttp("workspace.internal", …), set up by the backend). The DO passesctx.exports.WorkspaceProxy({ props: { binding, id } })as the egress fetcher; thatWorkerEntrypoint(re-exported from@cloudflare/workspace) routes/wsupgrades back to the owning DO. - When
Workspace.ready()is called for the first time, the backend posts/connectinto wsd with{ url: "http://workspace.internal" }. wsd pollsworkspace.internal/health, then dialsws://workspace.internal/ws. WorkspaceProxy.fetchforwards the upgrade to the DO'sfetch()via the DO binding looked up from its props. The DO'sfetch()delegates tobackend.handleFetch(req), which performs the WebSocket upgrade, resolves the in-flightconnect(), and attaches a capnweb client session to the server-side socket.- The DO exposes a single
getWorkspace()RPC method that returns aWorkspaceStub(anRpcTargetwrapping the innerWorkspace). The Worker's fetch handler callsawait stub.getWorkspace()once per request and then drivesws.fs.writeFile(...)/ws.fs.readFile(...)/ws.shell.exec(...)directly. Promise pipelining keeps the nested-property pattern (ws.fs.writeFile) at one round trip.
The DO extends the plain DurableObject class from
cloudflare:workers. The container lifecycle plumbing all lives
in CloudflareContainerBackend — the DO is a thin host.
The container mounts wsd's VFS at MOUNT_POINT (/workspace) so
exec'd commands see the same tree the RPC surface reads and
writes. On Cloudflare Containers /dev/fuse is exposed and the
real FUSE backend mounts; under wrangler dev the same image
transparently falls back to the userspace shim (FUSE_MOUNT=auto
in the Dockerfile).
The file handler takes the URL path verbatim as an absolute VFS
path and rejects anything outside /workspace with 400. So
PUT /c/<name>/file/workspace/hello.txt writes
/workspace/hello.txt, and GET /c/<name>/file/workspace/r2/x
reads /workspace/r2/x — the URL and the on-disk path always
match. PUT /c/<name>/file/etc/passwd would be rejected; the
example only exposes the mounted tree.
exec is not path-restricted. The command runs as PID 1 of
the container with no cwd default; it can cat /etc/os-release,
touch /tmp/x, or anything else the container's userland allows.
Only writes that land under /workspace make their way back to
the DO via the FUSE mount.
The DO mounts the Bucket R2 binding at /workspace/r2 via
R2Bucket(env.Bucket). On the first call into the workspace the
mount indexer pages through the bucket, streams each object into
vfs_nodes, and from then on /workspace/r2/<key> reads like any
other file. The mount is read-only; writes under /workspace/r2
reject with EROFS.
Seed the bucket once with the bundled fixture (./seed/data/hello.txt,
which contains the bytes hello world):
# Local miniflare bucket — use this with `wrangler dev`.
npm run seed:r2:local --workspace @example/workspace-container
# Real Cloudflare R2 bucket — use this after `wrangler deploy`.
npm run seed:r2 --workspace @example/workspace-containerThen:
curl http://127.0.0.1:8787/c/demo/file/workspace/r2/hello.txt
# => hello worldPUT /c/<name>/file/workspace/<path> raw body → writeFile at /workspace/<path>
GET /c/<name>/file/workspace/<path> octet-stream of /workspace/<path>
(any path outside /workspace returns 400)
POST /c/<name>/exec { command | argv, cwd?, encoding? }
no cwd default; container PID 1 inherits /
→ JSON { exitCode, stdout, stderr }
<name> selects a DO instance; each gets its own container.
The Worker enables Cloudflare's built-in tracing in
wrangler.jsonc (observability.traces.enabled = true) and wires
the workspace observer to ctx.tracing via the
@cloudflare/workspace/observe/cloudflare adapter. Every workspace
operation (workspace.connect, workspace.sync.push,
workspace.sync.pull, workspace.shell.exec, and the
workspace.fs.* family) opens a span on the runtime, so the
dashboard under Workers → Observability → Traces shows them
nested under the automatic fetch and Durable Object spans.
Exporting the traces to a third-party OpenTelemetry collector (Honeycomb, Grafana Cloud, Axiom, etc.) is configured at the account level in the Cloudflare dashboard; no code change is required in this example.
Requires Docker. The Dockerfile pulls
ghcr.io/cloudflare/workspace-wsd-linux-x64:<version> from the
public GitHub Container Registry on first build, so no local image
prep is needed.
npm run dev --workspace @example/workspace-containerSmoke test:
# Trigger the container (first call also boots wsd + the capnweb session).
curl http://127.0.0.1:8787/
# Write a file at /workspace/hello.txt
echo 'hello' | curl -X PUT --data-binary @- \
http://127.0.0.1:8787/c/demo/file/workspace/hello.txt
# Read it back
curl http://127.0.0.1:8787/c/demo/file/workspace/hello.txt
# Exec sees the same bytes via the absolute path.
curl -X POST http://127.0.0.1:8787/c/demo/exec \
-H 'content-type: application/json' \
-d '{"command":"cat /workspace/hello.txt && uname -a","encoding":"utf8"}'examples/container/
Dockerfile debian + libfuse + wsd binary (ENTRYPOINT)
wrangler.jsonc Worker + DO + Container binding
src/index.ts Worker handler, DO (ContainerExample)
- Exec is run-and-collect, not streamed. The handler awaits
handle.result()and emits one JSON response. Live streaming needs the DO to expose an async-iterable RPC; v1 keeps the surface flat. - No auth. The egress proxy trusts anything the container sends.
Fine for in-DO traffic (only the owning Worker can address it),
but the moment we expose
workspace.internalmore broadly we need a handshake. - One-shot session. If wsd's WebSocket drops mid-session, the
cached
BackendHandlegoes stale and the next call throws.Workspace.ready()will retry on the next call, but in-flight operations are lost. Transparent reconnect is deferred work.