From 01d3d3d7aa3a38ab5c9982272d5d4cb65010df77 Mon Sep 17 00:00:00 2001 From: Aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:16:26 +0000 Subject: [PATCH 1/3] computer: add a container backend for durable-object scheduling A container the durable object schedules has no image or size of its own: the wrangler containers block rejects instance_type under that policy, and the platform prepares images for the object to name rather than booting one itself. Every start() therefore has to carry an image, and carry a size if the workload needs one. This backend does that. The launch spec widens to the platform's own startup options, so entrypoint, labels and the snapshot fields reach start() too, and the caller names an image through `name`, which the host resolves against ctx.container.images. Consumers were previously reduced to patching ctx.container.start to inject any of it. Both launch paths build their spec through one helper. They reach the platform through different host methods, and a spec assembled separately in each place drifts silently: the restart drops what the initial start was given, and the replacement container comes up on a different image or at a different size than the one it replaced. The adoption digest grows the fields that cannot change on a running container, so a container launched at one size is no longer adopted by a caller asking for another. Snapshot fields stay out of it: they describe how a container was created rather than a property of the running one, and digesting them would relaunch a healthy container every time the stored handle moved on. An unresolvable image fails loudly rather than falling back. An empty images map means the container is platform-scheduled, which is a valid deployment this backend does not serve, so the error names the one that does. --- packages/computer/package.json | 4 + packages/computer/rolldown.config.ts | 1 + .../container-backend-launch.test.ts | 108 +++ .../backends/container/container-backend.ts | 749 ++++++++++++++++++ .../container/container-client-secret.test.ts | 73 ++ .../container/container-client-secret.ts | 48 ++ .../container/container-host-launch.test.ts | 165 ++++ .../src/backends/container/container-host.ts | 421 ++++++++++ .../container/container-launch-record.test.ts | 131 +++ .../container/container-launch-record.ts | 108 +++ .../container/container-lifecycle.test.ts | 430 ++++++++++ .../backends/container/container-lifecycle.ts | 205 +++++ .../container-runtime-identity.test.ts | 61 ++ .../container/container-runtime-identity.ts | 42 + .../backends/container/health-probe.test.ts | 79 ++ .../src/backends/container/health-probe.ts | 52 ++ .../computer/src/backends/container/index.ts | 42 + 17 files changed, 2719 insertions(+) create mode 100644 packages/computer/src/backends/container/container-backend-launch.test.ts create mode 100644 packages/computer/src/backends/container/container-backend.ts create mode 100644 packages/computer/src/backends/container/container-client-secret.test.ts create mode 100644 packages/computer/src/backends/container/container-client-secret.ts create mode 100644 packages/computer/src/backends/container/container-host-launch.test.ts create mode 100644 packages/computer/src/backends/container/container-host.ts create mode 100644 packages/computer/src/backends/container/container-launch-record.test.ts create mode 100644 packages/computer/src/backends/container/container-launch-record.ts create mode 100644 packages/computer/src/backends/container/container-lifecycle.test.ts create mode 100644 packages/computer/src/backends/container/container-lifecycle.ts create mode 100644 packages/computer/src/backends/container/container-runtime-identity.test.ts create mode 100644 packages/computer/src/backends/container/container-runtime-identity.ts create mode 100644 packages/computer/src/backends/container/health-probe.test.ts create mode 100644 packages/computer/src/backends/container/health-probe.ts create mode 100644 packages/computer/src/backends/container/index.ts diff --git a/packages/computer/package.json b/packages/computer/package.json index 5ffda4b9..5a85963e 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -39,6 +39,10 @@ "types": "./dist/backends/container-legacy/index.d.ts", "import": "./dist/backends/container-legacy/index.js" }, + "./backends/container": { + "types": "./dist/backends/container/index.d.ts", + "import": "./dist/backends/container/index.js" + }, "./backends/worker-javascript": { "types": "./dist/backends/worker-javascript/index.d.ts", "import": "./dist/backends/worker-javascript/index.js" diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 9e2680ad..0a78e602 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ "assets/index": "src/assets/index.ts", "tools/index": "src/tools/index.ts", "backends/container-legacy/index": "src/backends/container-legacy/index.ts", + "backends/container/index": "src/backends/container/index.ts", "backends/worker-javascript/index": "src/backends/worker-javascript/index.ts", "backends/worker-shell/index": "src/backends/worker-shell/index.ts", // The shell-module groups build-bundle.mjs emits. Each is its diff --git a/packages/computer/src/backends/container/container-backend-launch.test.ts b/packages/computer/src/backends/container/container-backend-launch.test.ts new file mode 100644 index 00000000..8d198b87 --- /dev/null +++ b/packages/computer/src/backends/container/container-backend-launch.test.ts @@ -0,0 +1,108 @@ +// The image and the size have to survive a restart. +// +// connect() and the readiness loop reach the platform through +// different host methods, so a spec assembled separately in each place +// drifts without anything failing: the restart drops what the initial +// start was given, and the replacement container comes up smaller, or +// on a different image, than the one it replaced. Nothing observes +// that until a build runs out of memory. +// +// These drive the backend far enough to capture both calls and compare +// them. The connect() attempt is expected to fail — there is no +// container to dial — which is fine, because the assertion is about +// what the host was asked for, not about reaching a session. +import { describe, expect, test } from "vitest"; + +import { ContainerBackend } from "./container-backend.js"; +import type { ContainerRuntimeInfo, IWorkspaceContainerAPI } from "./container-host.js"; +import type { ContainerLaunchSpec } from "./container-launch-record.js"; + +function recordingHost() { + const specs: { method: "start" | "restart"; spec: ContainerLaunchSpec }[] = []; + const info: ContainerRuntimeInfo = { + runtimeId: "runtime-1", + clientSecret: "00112233445566778899aabbccddeeff", + outcome: "launched", + }; + const host: IWorkspaceContainerAPI = { + async start(spec) { + specs.push({ method: "start", spec }); + return info; + }, + async restart(spec) { + specs.push({ method: "restart", spec }); + return info; + }, + async interceptOutboundHttp() {}, + async interceptAllOutboundHttp() {}, + // Never healthy, so the readiness loop exhausts its attempts and + // takes the restart path. That is the path under test. + async fetchPort() { + return new Response(null, { status: 503 }); + }, + port() { + throw new Error("not used"); + }, + async setInactivityTimeout() {}, + async status() { + return { running: true, exit: null }; + }, + async exitInfo() { + return null; + }, + }; + return { host, specs }; +} + +function backendWith(options: { name?: string; instance?: ContainerInstanceSize } = {}) { + const { host, specs } = recordingHost(); + const backend = new ContainerBackend({ + container: () => ({ getWorkspaceContainer: () => host }), + workspace: { binding: "SESSIONS", id: "session-1" }, + // One restart, then give up. Enough to capture both launch paths. + restartAttempts: 1, + connectTimeoutMs: 1_000, + healthProbeTimeoutMs: 50, + healthRetryInitialDelayMs: 10, + healthRetryMaxDelayMs: 20, + heartbeatIntervalMs: 0, + ...options, + }); + return { backend, specs }; +} + +describe("both launch paths request the same container", () => { + test("the restart carries the image the initial start carried", async () => { + const { backend, specs } = backendWith({ name: "toolchain" }); + + await backend.connect().catch(() => undefined); + + const start = specs.find((call) => call.method === "start"); + const restart = specs.find((call) => call.method === "restart"); + expect(start?.spec.name).toBe("toolchain"); + expect(restart?.spec.name).toBe("toolchain"); + }); + + test("the restart carries the instance size the initial start carried", async () => { + const instance = { vcpu: 16, memoryMib: 32768, diskMb: 64000 }; + const { backend, specs } = backendWith({ instance }); + + await backend.connect().catch(() => undefined); + + const start = specs.find((call) => call.method === "start"); + const restart = specs.find((call) => call.method === "restart"); + expect(start?.spec.instance).toEqual(instance); + expect(restart?.spec.instance).toEqual(instance); + }); + + test("every launch names an image even when the caller does not", async () => { + const { backend, specs } = backendWith(); + + await backend.connect().catch(() => undefined); + + expect(specs.length).toBeGreaterThan(1); + for (const call of specs) { + expect(call.spec.name).toBe("app"); + } + }); +}); diff --git a/packages/computer/src/backends/container/container-backend.ts b/packages/computer/src/backends/container/container-backend.ts new file mode 100644 index 00000000..2994f79b --- /dev/null +++ b/packages/computer/src/backends/container/container-backend.ts @@ -0,0 +1,749 @@ +// ContainerBackend — backs Workspace with a computerd instance +// running inside a Cloudflare Container. +// +// The backend drives container lifecycle through an IWorkspaceContainerAPI +// abstraction. Same-DO and cross-DO callers look identical from +// here; whether ctx.container is reached directly or through an +// RPC stub is a concern of the IWorkspaceContainerAPI implementation. +// +// Same-DO shape (one DO owns both the container and the Workspace): +// +// class ComputerdContainer extends DurableObject { +// #backend = new ContainerBackend({ +// container: () => this.ws, +// workspace: { binding: "ComputerdContainer", id: this.ctx.id.toString() }, +// }); +// #workspace = new Workspace({ backends: [this.#backend] }); +// +// override fetch(req: Request): Promise { +// return this.#backend.handleFetch(req); +// } +// } +// +// Cross-DO shape (Agent DO holds the Workspace, a pool member DO +// owns the container): +// +// class AgentDO extends DurableObject { +// #backend = new ContainerBackend({ +// container: async () => { +// const memberId = await pickPoolMember(this.env, this.ctx.id); +// return this.env.ComputerdHost.get(this.env.ComputerdHost.idFromString(memberId)); +// }, +// workspace: { binding: "AgentDO", id: this.ctx.id.toString() }, +// }); +// } +// +// The factory runs once per connect(), so a redial after a session +// drop re-picks the pool member; mid-session container churn is the +// pool's problem, not the backend's. +// +// Failure model: connect() does the bootstrap once and throws on +// any failure. The Workspace's ready() retries by re-entering +// connect() on the next call. On a mid-session WebSocket drop the +// backend resolves `BackendHandle.closed`, which the Workspace +// listens for and uses to drop its cached handle so the next call +// rebuilds against a fresh session. + +import type { WorkspaceRPC } from "@cloudflare/computer-rpc"; +import { newWebSocketRpcSession, type RpcStub } from "capnweb"; + +import type { BackendHandle, WorkspaceBackend } from "../../backend.js"; +import { startHeartbeat } from "../../heartbeat.js"; +import { + WORKSPACE_EGRESS_TOKEN_HEADER, + WORKSPACE_EGRESS_URL_HEADER, + type WorkspaceEgressPolicy, +} from "../../runtime/egress.js"; +import { WorkspaceTransportError } from "../../transport-failure.js"; +import type { IWorkspaceContainerAPI, WorkspaceRef } from "./container-host.js"; +import type { ContainerInstanceSize, ContainerLaunchSpec } from "./container-launch-record.js"; +import { probeComputerdHealth } from "./health-probe.js"; + +// What the backend's `container` factory returns: anything with +// a getWorkspaceContainer() method — the shape withWorkspaceContainer +// installs. Same-DO callers pass `this`; cross-DO callers pass a +// DO stub whose target was extended with withWorkspaceContainer +// (Workers RPC exposes the method as a pipelined callable). +export interface ContainerHostHolder { + getWorkspaceContainer(): IWorkspaceContainerAPI | Promise; +} + +export interface ContainerBackendOptions { + // Resolves the container host to drive on each connect(). Called + // anew per dial so a pool-backed factory can re-pick. Returning + // a Promise is supported for pickers that consult external state + // (KV, a coordinator DO, etc.). + // + // The returned value exposes getWorkspaceContainer() — the same + // shape withWorkspaceContainer installs. Pass `this` (same-DO) + // or a DO stub (cross-DO); the backend calls the method itself. + container: () => ContainerHostHolder | Promise; + + // Identifies the Workspace-owning DO. Fixed for the lifetime of + // the backend: the backend lives inside this DO and the /api + // upgrade always lands here. Plain {binding, id} data so it + // survives the Workers RPC hop to a cross-DO container host. + workspace: WorkspaceRef; + + // Hostname computerd will dial back. Defaults to "computer.internal". + // Override for tests or to avoid collisions with other backends + // sharing the same container host. + egressHost?: string; + + egress?: WorkspaceEgressPolicy; + + // TCP port computerd listens on inside the container. Default 8080, + // matching the Dockerfile shipped with examples/container. + containerPort?: number; + + // Environment variables passed to container.start(). Merged onto + // the defaults (PORT, MOUNT_POINT). Caller-supplied values win. + containerEnv?: Record; + + // Total time the backend waits for: container port to open, + // /connect POST to return, /api upgrade to arrive. Default 30s. + connectTimeoutMs?: number; + + // Period for the application-level heartbeat — a watermarks() + // RPC on a timer. Two jobs: detect a silently-dead peer faster + // than waiting for the next real RPC, and keep middlebox idle + // timers warm. Default 20_000ms. Set 0 to disable. + heartbeatIntervalMs?: number; + + // Number of forced restart attempts after startup readiness + // fails. The first attempt runs host.start() then probes computerd; + // each restart attempt runs host.restart() then probes computerd + // again. Defaults to 1 (one restart after the initial start). + // Set 0 to disable restart on failed readiness. + restartAttempts?: number; + + // Per-probe timeout for the startup health probe. Defaults to + // 2 seconds. The shared probeComputerdHealth helper aborts the request + // when it elapses; the next probe in the loop carries the + // remaining readiness budget. + healthProbeTimeoutMs?: number; + + // First retry delay after a failed startup probe. Defaults to + // 250ms. Subsequent failures double the prior delay, capped at + // healthRetryMaxDelayMs. + healthRetryInitialDelayMs?: number; + + // Maximum delay between failed startup probes. Defaults to 2s. + healthRetryMaxDelayMs?: number; + + // Selector this backend is registered under in Workspace. + // Defaults to "container-shell"; override when the + // workspace hosts more than one instance of the same backend + // kind (e.g. two containers pinned to different pool members). + id?: string; + + // Which prepared image to boot, as a key into + // `ctx.container.images` — the names declared in the `images` block + // of the wrangler containers config. Not the container application + // name, which is a different field in that same block. Defaults to + // "app", matching wrangler's convention. + name?: string; + + // Instance size requested at launch. Under this scheduling policy + // the wrangler containers block rejects `instance_type`, because + // sizing belongs to the object once it owns the lifecycle, so this + // is the only place to ask for one. Omitted lets the platform + // choose, which is rarely what a workload that builds or compiles + // wants. + instance?: ContainerInstanceSize; + + // Remaining startup options, forwarded verbatim to + // `ctx.container.start()`. `entrypoint`, `labels`, `hardTimeout`, + // and the snapshot restore fields all live here. The fields this + // backend owns are excluded: `env` is assembled from containerEnv, + // `enableInternet` follows the egress policy, and `name` and + // `instance` are named options above. + launch?: Omit; +} + +const DEFAULT_EGRESS_HOST = "computer.internal"; +// Image key assumed when a caller names none. Kept in step with the +// same default in container-host.ts, which resolves it. +const DEFAULT_IMAGE_NAME = "app"; +// Paths the egress proxy serves. The container assembles no paths of +// its own, so these travel in the /connect request and both ends stay +// in step from one place. +const EGRESS_HEALTH_PATH = "/health"; +const EGRESS_API_PATH = "/api"; +const DEFAULT_CONTAINER_PORT = 8080; +const DEFAULT_CONNECT_TIMEOUT_MS = 30_000; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 20_000; +const DEFAULT_RESTART_ATTEMPTS = 1; +const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 2_000; +const DEFAULT_HEALTH_RETRY_INITIAL_DELAY_MS = 250; +const DEFAULT_HEALTH_RETRY_MAX_DELAY_MS = 2_000; + +// Bearer check for the dial-back. The scheme token is case-insensitive +// and may be followed by more than one space, matching what the daemon +// accepts on its own surface. +// +// The comparison walks every byte rather than stopping at the first +// difference, so it does not leak how much of the secret was correct. +// crypto.subtle.timingSafeEqual would be the primitive to reach for, but +// the SubtleCrypto this package compiles against does not declare it. +// +// An absent expected secret refuses everything rather than allowing it. +// This only runs once connect() has recorded the secret it launched the +// container with, so an absent one means the upgrade arrived without that +// having happened. +function bearerMatches(header: string | null, expected: string | undefined): boolean { + if (expected === undefined || header === null) return false; + const separator = header.indexOf(" "); + if (separator === -1) return false; + if (header.slice(0, separator).toLowerCase() !== "bearer") return false; + const presented = header.slice(separator + 1).trim(); + if (presented.length !== expected.length) return false; + let differences = 0; + for (let i = 0; i < expected.length; i += 1) { + differences |= presented.charCodeAt(i) ^ expected.charCodeAt(i); + } + return differences === 0; +} + +export class ContainerBackend implements WorkspaceBackend { + readonly type = "cloudflare-container"; + readonly id: string; + + readonly #options: Required< + Omit< + ContainerBackendOptions, + "container" | "workspace" | "containerEnv" | "egress" | "id" | "name" | "instance" | "launch" + > + > & + Pick< + ContainerBackendOptions, + "container" | "workspace" | "containerEnv" | "name" | "instance" | "launch" + >; + readonly #egress: WorkspaceEgressPolicy; + readonly #egressToken: string | undefined; + // Set once start() reports it, before the upgrade slot is armed, so + // handleFetch can check the dial-back against it. + #clientSecret: string | undefined; + + // State for the in-flight /api upgrade. handleFetch() resolves + // #pendingUpgrade; connect() awaits it. + #pendingUpgrade: Promise | undefined; + #resolveUpgrade: ((ws: WebSocket) => void) | undefined; + #rejectUpgrade: ((err: unknown) => void) | undefined; + + // Cached after the first successful connect(). Cleared on close() + // or when the underlying WebSocket reports `close` / `error`. + #handle: BackendHandle | undefined; + + constructor(options: ContainerBackendOptions) { + this.id = options.id ?? "container-shell"; + this.#egress = options.egress ?? { mode: "none" }; + this.#egressToken = this.#egress.mode === "http-gateway" ? crypto.randomUUID() : undefined; + this.#options = { + container: options.container, + workspace: options.workspace, + containerEnv: options.containerEnv, + egressHost: options.egressHost ?? DEFAULT_EGRESS_HOST, + containerPort: options.containerPort ?? DEFAULT_CONTAINER_PORT, + connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, + heartbeatIntervalMs: options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS, + restartAttempts: options.restartAttempts ?? DEFAULT_RESTART_ATTEMPTS, + healthProbeTimeoutMs: options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS, + healthRetryInitialDelayMs: + options.healthRetryInitialDelayMs ?? DEFAULT_HEALTH_RETRY_INITIAL_DELAY_MS, + healthRetryMaxDelayMs: options.healthRetryMaxDelayMs ?? DEFAULT_HEALTH_RETRY_MAX_DELAY_MS, + name: options.name, + instance: options.instance, + launch: options.launch, + }; + } + + async connect(): Promise { + if (this.#handle) return this.#handle; + + const deadline = Date.now() + this.#options.connectTimeoutMs; + const holder = await this.#options.container(); + const host = await holder.getWorkspaceContainer(); + + // Pre-flight: surface any prior container exit so a readiness + // failure can attribute it back to the crash. host.start() + // clears this once the new generation is up, so a successful + // dial against a previously-dead container loses the + // attribution — which is the right semantics; the prior exit + // is only interesting if the new attempt fails too. + const priorExit = await host.exitInfo().catch(() => null); + + const env = { + PORT: String(this.#options.containerPort), + MOUNT_POINT: "/workspace", + ...this.#options.containerEnv, + }; + let runtimeId: string; + // The container requires this on its HTTP surface. It is durable, so + // the value is the same across incarnations and across a replaced + // container. + let clientSecret: string; + try { + ({ runtimeId, clientSecret } = await host.start(this.#launchSpec(env))); + } catch (error) { + throw new WorkspaceTransportError( + this.#formatStageError("start", { + attempt: 1, + maxAttempts: this.#options.restartAttempts + 1, + restarts: 0, + lastError: error, + priorExit, + }), + { cause: error }, + ); + } + try { + await host.interceptOutboundHttp(this.#options.egressHost, this.#options.workspace); + if (this.#egress.mode === "http-gateway" && this.#egressToken !== undefined) { + await host.interceptAllOutboundHttp(this.#options.workspace, this.#egressToken); + } + } catch (error) { + throw new WorkspaceTransportError( + this.#formatStageError("egress", { + attempt: 1, + maxAttempts: this.#options.restartAttempts + 1, + restarts: 0, + lastError: error, + priorExit, + }), + { cause: error }, + ); + } + + this.#clientSecret = clientSecret; + + // Arm the upgrade promise before posting /connect — computerd + // dials back as soon as /health on the egress answers, so + // the upgrade can arrive before the POST resolves. + this.#armUpgrade(); + + runtimeId = await this.#readyWithRestarts(host, env, deadline, priorExit, runtimeId); + await this.#requireAuthEnforced(host, deadline); + await this.#postConnect(host, deadline, clientSecret); + const ws = await this.#waitForUpgrade(deadline); + + const stub = newWebSocketRpcSession( + ws as unknown as globalThis.WebSocket, + ) as RpcStub; + + // `closed` resolves on the first 'close' event from the underlying + // WebSocket. The Workspace listens for it and drops its cached + // handle so the next ready() call rebuilds against a fresh + // session. + let stopHeartbeat: (() => void) | undefined; + const closed = new Promise((resolve) => { + let fired = false; + const onClose = () => { + if (fired) return; + fired = true; + stopHeartbeat?.(); + resolve(); + this.#handle = undefined; + }; + ws.addEventListener("close", onClose, { once: true }); + // Some runtimes fire 'error' without a follow-up 'close' on + // abrupt teardown; treat error as close too. + ws.addEventListener("error", onClose, { once: true }); + // capnweb's RPC layer can notice the session is broken (an + // abort frame, a malformed message) before the underlying + // WebSocket fires close. onRpcBroken closes that gap so the + // next ready() rebuilds against a fresh transport instead of + // waiting on a heartbeat or the next real RPC to discover + // the wedged session. + (stub as unknown as { onRpcBroken: (cb: (err: unknown) => void) => void }).onRpcBroken( + onClose, + ); + }); + + if (this.#options.heartbeatIntervalMs > 0) { + stopHeartbeat = startHeartbeat({ + intervalMs: this.#options.heartbeatIntervalMs, + ping: () => (stub as unknown as WorkspaceRPC).sync.watermarks(), + onFailure: () => { + try { + ws.close(); + } catch { + // already closed; idempotent + } + }, + }); + } + + const handle: BackendHandle = { + rpc: stub as unknown as WorkspaceRPC, + runtimeId, + closed, + close: async () => { + stopHeartbeat?.(); + // Dispose the root stub first. Per capnweb's docs, this is + // the documented way to shut a session down — it lets the + // RPC layer send a clean abort frame to the peer before + // the socket dies. Falling through to ws.close() is + // belt-and-braces for runtimes where the dispose path + // doesn't (yet) close the transport. + try { + (stub as unknown as Disposable)[Symbol.dispose]?.(); + } catch { + // already disposed; idempotent + } + try { + ws.close(); + } catch { + // already closed; idempotent + } + this.#handle = undefined; + }, + }; + this.#handle = handle; + return handle; + } + + // Routes an /api upgrade Request into the in-flight connect(). + // Returns the 101 response that the WorkspaceProxy fetch handler + // forwards back to the container. + async handleFetch(req: Request): Promise { + if ( + this.#egress.mode === "http-gateway" && + this.#egressToken !== undefined && + req.headers.get(WORKSPACE_EGRESS_TOKEN_HEADER) === this.#egressToken + ) { + const headers = new Headers(req.headers); + const originalUrl = headers.get(WORKSPACE_EGRESS_URL_HEADER); + let parsedUrl: URL; + try { + parsedUrl = new URL(originalUrl ?? ""); + } catch { + return new Response("invalid egress URL", { status: 400 }); + } + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + return new Response("invalid egress URL", { status: 400 }); + } + headers.delete(WORKSPACE_EGRESS_TOKEN_HEADER); + headers.delete(WORKSPACE_EGRESS_URL_HEADER); + const sanitized = new Request(req, { headers }); + return this.#egress.gateway.fetch(new Request(parsedUrl, sanitized)); + } + const url = new URL(req.url); + if (url.pathname !== EGRESS_API_PATH) { + return new Response("not found", { status: 404 }); + } + // A request with no Upgrade header is a malformed handshake, which + // is a 400. 426 belongs to the narrower case of a version this end + // does not speak, and is what the daemon answers for it. + if (req.headers.get("upgrade") !== "websocket") { + return new Response(`${EGRESS_API_PATH} requires a websocket upgrade`, { status: 400 }); + } + // The slot armed by connect() hands its session to whoever arrives + // first, and this endpoint is reachable from inside the container + // through the egress interceptor. Without a token, any command the + // workspace runs could take the daemon's place and become the peer + // this durable object pushes its files to and takes its exec output + // from. The daemon presents the secret it was launched with. + if (!bearerMatches(req.headers.get("authorization"), this.#clientSecret)) { + return new Response("unauthorized", { + status: 401, + headers: { "www-authenticate": "Bearer" }, + }); + } + + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + server.accept(); + + if (this.#resolveUpgrade) { + this.#resolveUpgrade(server); + } else { + // No connect() in flight — close the socket immediately. + // The remote will redial on its next attempt; we don't + // hold orphaned sockets that nothing will reap. + server.close(1011, "no pending connect"); + return new Response("no pending connect", { status: 409 }); + } + + return new Response(null, { status: 101, webSocket: client }); + } + + // --- internals -------------------------------------------------- + + /** + * Assemble the launch spec both launch paths use. + * + * connect() and #readyWithRestarts each reach the platform through a + * different host method, and a spec built separately in each place + * drifts: the restart silently drops whatever the initial start was + * given, and the replacement container comes up with a different + * image or size than the one it replaced. One construction site is + * what keeps them honest. + */ + #launchSpec(env: Record): ContainerLaunchSpec { + return { + ...this.#options.launch, + env, + enableInternet: this.#egress.mode === "direct", + name: this.#options.name ?? DEFAULT_IMAGE_NAME, + ...(this.#options.instance === undefined ? {} : { instance: this.#options.instance }), + }; + } + + #armUpgrade(): void { + this.#pendingUpgrade = new Promise((resolve, reject) => { + this.#resolveUpgrade = resolve; + this.#rejectUpgrade = reject; + }); + // Swallow unhandled-rejection noise if connect() throws + // before anyone awaits the promise. + this.#pendingUpgrade.catch(() => {}); + } + + #clearUpgrade(): void { + this.#pendingUpgrade = undefined; + this.#resolveUpgrade = undefined; + this.#rejectUpgrade = undefined; + } + + // Drive startup readiness with bounded restart attempts. Each + // attempt runs the shared probe in a backoff loop until either + // computerd answers, the per-attempt budget elapses, or the overall + // connect deadline elapses. On a failed attempt with restarts + // remaining, run host.restart(env) and try again. + async #readyWithRestarts( + host: IWorkspaceContainerAPI, + env: Record, + deadline: number, + priorExit: { exitedAt: number; reason: string } | null, + initialRuntimeId: string, + ): Promise { + const maxAttempts = this.#options.restartAttempts + 1; + // Split the remaining time across attempts so a failing + // first attempt doesn't starve the restart-retry. Floor at + // 250ms so a near-deadline last attempt still has room to + // dispatch a probe rather than collapsing to ~1ms. + const totalBudget = Math.max(0, deadline - Date.now()); + const perAttemptBudget = Math.max(250, Math.floor(totalBudget / maxAttempts)); + let attempt = 0; + let restarts = 0; + let lastError: unknown; + let runtimeId = initialRuntimeId; + + while (attempt < maxAttempts) { + attempt++; + const attemptDeadline = Math.min(deadline, Date.now() + perAttemptBudget); + const ok = await this.#probeUntilHealthy(host, attemptDeadline).then( + () => true, + (error) => { + lastError = error; + return false; + }, + ); + if (ok) return runtimeId; + + if (attempt < maxAttempts) { + try { + ({ runtimeId } = await host.restart(this.#launchSpec(env))); + restarts++; + } catch (error) { + this.#rejectUpgrade?.(error); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + this.#formatStageError("restart", { + attempt, + maxAttempts, + restarts, + lastError: error, + }), + { cause: error }, + ); + } + } + } + + this.#rejectUpgrade?.(new Error("computerd never became healthy")); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + this.#formatStageError("health", { + attempt, + maxAttempts, + restarts, + lastError, + priorExit, + }), + lastError instanceof Error ? { cause: lastError } : undefined, + ); + } + + async #probeUntilHealthy(host: IWorkspaceContainerAPI, deadline: number): Promise { + let delay = this.#options.healthRetryInitialDelayMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await probeComputerdHealth(host, { + port: this.#options.containerPort, + path: "/health", + timeoutMs: Math.min( + this.#options.healthProbeTimeoutMs, + Math.max(50, deadline - Date.now()), + ), + }); + return; + } catch (error) { + lastError = error; + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await sleep(Math.min(delay, remaining)); + delay = Math.min(delay * 2, this.#options.healthRetryMaxDelayMs); + } + } + throw lastError ?? new Error("computerd health probe timed out"); + } + + #formatStageError( + stage: "start" | "egress" | "health" | "restart" | "connect" | "ws", + info: { + attempt: number; + maxAttempts: number; + restarts: number; + lastError: unknown; + priorExit?: { exitedAt: number; reason: string } | null; + }, + ): string { + const priorExit = info.priorExit ? ` priorExit=${JSON.stringify(info.priorExit.reason)}` : ""; + return ( + `ContainerBackend(${this.id}): connect failed at ` + + `stage=${stage} port=${this.#options.containerPort} ` + + `attempt=${info.attempt}/${info.maxAttempts} restarts=${info.restarts} ` + + `timeoutMs=${this.#options.connectTimeoutMs}${priorExit} ` + + `lastError=${describeError(info.lastError)}` + ); + } + + // Confirm the container refuses an unauthorized request before handing + // it a session. Launching with the secret is arranged elsewhere, but an + // image built before the daemon understood RPC_CLIENT_SECRET ignores it + // and serves everything, and that is invisible from the host: the + // bearer token goes out and the container is content either way. + // + // /api is the probe target because it exists on every daemon that has a + // capnweb endpoint at all, so this check does not depend on which + // diagnostic routes a given build happens to expose. An enforcing + // daemon answers 401 before it looks at the route; one that is not + // enforcing answers whatever the route says for an unauthenticated + // GET. + // + // A definite answer other than 401 fails the connect. Recycling a + // container that predates the secret is the cost of the upgrade, and it + // is preferable to a workspace that believes it is authorized and is + // not. A probe that cannot complete is not evidence either way and is + // allowed through: the readiness loop above is what decides whether the + // container is alive. + async #requireAuthEnforced(host: IWorkspaceContainerAPI, deadline: number): Promise { + let status: number; + try { + const res = await host.fetchPort(this.#options.containerPort, "http://container/api", { + // Bounded like every other request this file makes to the + // container. Unbounded, a container that accepts the connection + // and then stops serving would hang the connect, and a slow one + // would eat the budget #waitForUpgrade needs, surfacing as an + // upgrade that never arrived. + signal: AbortSignal.timeout( + Math.min(this.#options.healthProbeTimeoutMs, Math.max(50, deadline - Date.now())), + ), + }); + status = res.status; + // Release the body; nothing here reads it. + await res.text().catch(() => ""); + } catch { + return; + } + if (status === 401) return; + this.#rejectUpgrade?.(new Error("container is not enforcing RPC_CLIENT_SECRET")); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + `ContainerBackend(${this.id}) [stage=auth]: container served an unauthenticated ` + + `request to /api with ${status}, so this workspace would run without authorization. ` + + `A container or image predating RPC_CLIENT_SECRET has to be recycled.`, + ); + } + + async #postConnect( + host: IWorkspaceContainerAPI, + deadline: number, + clientSecret: string, + ): Promise { + const remaining = Math.max(0, deadline - Date.now()); + let res: Response; + try { + res = await host.fetchPort(this.#options.containerPort, "http://container/connect", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${clientSecret}`, + }, + body: JSON.stringify({ + base: `http://${this.#options.egressHost}`, + health: EGRESS_HEALTH_PATH, + api: EGRESS_API_PATH, + healthTimeoutMs: remaining, + }), + }); + } catch (error) { + this.#rejectUpgrade?.(error); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + `ContainerBackend(${this.id}) [stage=connect]: POST /connect failed: ${describeError(error)}`, + { cause: error }, + ); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + const cause = new Error(`/connect ${res.status}`); + this.#rejectUpgrade?.(cause); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + `ContainerBackend(${this.id}) [stage=connect]: POST /connect returned ${res.status}: ${body}`, + { cause }, + ); + } + } + + async #waitForUpgrade(deadline: number): Promise { + const upgrade = this.#pendingUpgrade; + if (!upgrade) throw new Error("ContainerBackend: upgrade promise missing"); + + const remaining = Math.max(0, deadline - Date.now()); + let timer: ReturnType | undefined; + try { + const ws = await Promise.race([ + upgrade, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new WorkspaceTransportError( + `ContainerBackend(${this.id}) [stage=ws]: /api upgrade did not arrive within ${this.#options.connectTimeoutMs}ms`, + ), + ), + remaining, + ); + }), + ]); + return ws; + } finally { + if (timer) clearTimeout(timer); + this.#clearUpgrade(); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function describeError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/packages/computer/src/backends/container/container-client-secret.test.ts b/packages/computer/src/backends/container/container-client-secret.test.ts new file mode 100644 index 00000000..f04c832a --- /dev/null +++ b/packages/computer/src/backends/container/container-client-secret.test.ts @@ -0,0 +1,73 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +import { describe, expect, test } from "vitest"; + +import { ContainerClientSecret } from "./container-client-secret.js"; + +function fakeStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + let writes = 0; + return { + writes: () => writes, + values, + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: unknown): Promise { + writes++; + values.set(key, value); + }, + }; +} + +describe("ContainerClientSecret", () => { + test("generates 32 hex characters on first use", async () => { + const storage = fakeStorage(); + const secret = await new ContainerClientSecret(storage).ensure(); + + expect(secret).toMatch(/^[0-9a-f]{32}$/); + expect(storage.writes()).toBe(1); + }); + + test("returns the stored value on later calls without rewriting it", async () => { + const storage = fakeStorage(); + const store = new ContainerClientSecret(storage); + + const first = await store.ensure(); + const second = await store.ensure(); + + expect(second).toBe(first); + expect(storage.writes()).toBe(1); + }); + + test("a reconstructed instance reuses the persisted value", async () => { + // This is the case that matters: a durable object rebuilt against a + // container that is still running has to present the secret that + // container was launched with. + const storage = fakeStorage(); + const before = await new ContainerClientSecret(storage).ensure(); + + const after = await new ContainerClientSecret(storage).ensure(); + + expect(after).toBe(before); + }); + + test("two workspaces do not share a secret", async () => { + const one = await new ContainerClientSecret(fakeStorage()).ensure(); + const two = await new ContainerClientSecret(fakeStorage()).ensure(); + + expect(one).not.toBe(two); + }); + + test("replaces a stored value that is empty", async () => { + const storage = fakeStorage({ "computer:container-client-secret": "" }); + + const secret = await new ContainerClientSecret(storage).ensure(); + + expect(secret).toMatch(/^[0-9a-f]{32}$/); + }); +}); diff --git a/packages/computer/src/backends/container/container-client-secret.ts b/packages/computer/src/backends/container/container-client-secret.ts new file mode 100644 index 00000000..e663bb50 --- /dev/null +++ b/packages/computer/src/backends/container/container-client-secret.ts @@ -0,0 +1,48 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +// Shared secret authorizing the host's requests to the container's HTTP +// surface. +// +// It has to be durable rather than generated per call. A durable object +// can be reconstructed while its container keeps running, and in that +// case start() does not relaunch, so the environment the container holds +// is the one from the original launch. A fresh secret each incarnation +// would not match it and every request would be refused, breaking the +// reconnect that POST /connect exists to serve. +// +// Persisting it also covers the other direction: when a replacement +// container is launched, it receives the value already stored, so both +// ends stay in step without the host having to read anything back. + +interface ClientSecretStorage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; +} + +const STORAGE_KEY = "computer:container-client-secret"; + +// 16 random bytes, hex encoded: 32 characters carrying 128 bits. +const SECRET_BYTES = 16; + +function generate(): string { + const bytes = crypto.getRandomValues(new Uint8Array(SECRET_BYTES)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export class ContainerClientSecret { + constructor(private readonly storage: ClientSecretStorage) {} + + // Returns the stored secret, generating and persisting one the first + // time. Callers may invoke this on every start; only the first writes. + async ensure(): Promise { + const existing = await this.storage.get(STORAGE_KEY); + if (typeof existing === "string" && existing.length > 0) return existing; + const secret = generate(); + await this.storage.put(STORAGE_KEY, secret); + return secret; + } +} diff --git a/packages/computer/src/backends/container/container-host-launch.test.ts b/packages/computer/src/backends/container/container-host-launch.test.ts new file mode 100644 index 00000000..e84473e8 --- /dev/null +++ b/packages/computer/src/backends/container/container-host-launch.test.ts @@ -0,0 +1,165 @@ +// What reaches ctx.container.start(), and what happens when the image +// cannot be resolved. +// +// Under this scheduling policy the durable object owns the container +// lifecycle, so start() has to name the image and may name a size. +// Both come from the launch spec, and both have to survive the trip +// through the host: a dropped field is not an error anywhere, it is a +// container that boots with the wrong filesystem or the wrong +// resources. +import { describe, expect, test } from "vitest"; + +import { WorkspaceContainerAPI } from "./container-host.js"; +import type { ContainerLaunchSpec } from "./container-launch-record.js"; + +type StartOptions = ContainerStartupOptions; + +function fakeCtx(options: { images?: Record; running?: boolean } = {}) { + const values = new Map(); + const starts: StartOptions[] = []; + let exited: (() => void) | undefined; + const container = { + running: options.running ?? false, + images: options.images ?? { app: "registry.example/app@sha256:abc" }, + start(spec: StartOptions) { + starts.push(spec); + container.running = true; + }, + async destroy() { + container.running = false; + exited?.(); + exited = undefined; + }, + async setInactivityTimeout() {}, + monitor: () => + new Promise((resolve) => { + exited = resolve; + }), + getTcpPort: () => ({}) as Fetcher, + }; + const ctx = { + container, + storage: { + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: unknown): Promise { + values.set(key, value); + }, + async delete(key: string): Promise { + return values.delete(key); + }, + }, + blockConcurrencyWhile: async (fn: () => Promise) => fn(), + } as unknown as DurableObjectState; + return { ctx, container, starts }; +} + +function spec(overrides: Partial = {}): ContainerLaunchSpec { + return { + env: { PORT: "8080", MOUNT_POINT: "/workspace" }, + enableInternet: false, + ...overrides, + }; +} + +describe("launch options reach the platform", () => { + test("resolves the default image when the spec names none", async () => { + const { ctx, starts } = fakeCtx(); + + await new WorkspaceContainerAPI(ctx).start(spec()); + + expect(starts[0]).toMatchObject({ image: "registry.example/app@sha256:abc" }); + }); + + test("resolves the named image", async () => { + const { ctx, starts } = fakeCtx({ + images: { app: "img-app", toolchain: "img-toolchain" }, + }); + + await new WorkspaceContainerAPI(ctx).start(spec({ name: "toolchain" })); + + expect(starts[0]).toMatchObject({ image: "img-toolchain" }); + }); + + test("forwards the instance size", async () => { + const { ctx, starts } = fakeCtx(); + const instance = { vcpu: 16, memoryMib: 32768, diskMb: 64000 }; + + await new WorkspaceContainerAPI(ctx).start(spec({ instance })); + + expect(starts[0]?.instance).toEqual(instance); + }); + + test("forwards options the host does not itself interpret", async () => { + const { ctx, starts } = fakeCtx(); + + await new WorkspaceContainerAPI(ctx).start( + spec({ entrypoint: ["/bin/sh", "-c", "computerd"], labels: { tier: "gold" } }), + ); + + expect(starts[0]?.entrypoint).toEqual(["/bin/sh", "-c", "computerd"]); + expect(starts[0]?.labels).toEqual({ tier: "gold" }); + }); + + test("injects the client secret over a caller-supplied value", async () => { + const { ctx, starts } = fakeCtx(); + + await new WorkspaceContainerAPI(ctx).start( + spec({ env: { PORT: "8080", RPC_CLIENT_SECRET: "attacker-chosen" } }), + ); + + expect(starts[0]?.env?.RPC_CLIENT_SECRET).toMatch(/^[0-9a-f]{32}$/); + expect(starts[0]?.env?.RPC_CLIENT_SECRET).not.toBe("attacker-chosen"); + }); + + test("boots from a snapshot instead of an image when one is given", async () => { + const { ctx, starts } = fakeCtx(); + + await new WorkspaceContainerAPI(ctx).start(spec({ containerSnapshot: { id: "snap-1" } })); + + expect(starts[0]).toMatchObject({ containerSnapshot: { id: "snap-1" } }); + expect(starts[0]).not.toHaveProperty("image"); + }); +}); + +describe("image resolution failures", () => { + // The likeliest first-run mistake: pointing this backend at a + // container the platform schedules. No image name could resolve, so + // the message has to name the backend that serves that deployment + // rather than just reporting an empty map. + test("an empty images map names the backend that serves that deployment", async () => { + const { ctx } = fakeCtx({ images: {} }); + + await expect(new WorkspaceContainerAPI(ctx).start(spec())).rejects.toThrow( + /LegacyContainerBackend/, + ); + }); + + test("an empty images map explains the wrangler configuration it expects", async () => { + const { ctx } = fakeCtx({ images: {} }); + + await expect(new WorkspaceContainerAPI(ctx).start(spec())).rejects.toThrow(/scheduling_policy/); + }); + + test("an unknown image name lists the prepared images", async () => { + const { ctx } = fakeCtx({ images: { app: "img-app", toolchain: "img-toolchain" } }); + + await expect(new WorkspaceContainerAPI(ctx).start(spec({ name: "missing" }))).rejects.toThrow( + /prepared images: app, toolchain/, + ); + }); + + // A failed resolve must not leave a runtime identity claiming a + // process that was never started, or a later call addresses a + // container that does not exist. + test("a failed launch clears the runtime identity", async () => { + const { ctx, starts } = fakeCtx({ images: {} }); + const api = new WorkspaceContainerAPI(ctx); + + await expect(api.start(spec())).rejects.toThrow(); + + expect(starts).toHaveLength(0); + expect(await api.status()).toMatchObject({ running: false }); + }); +}); diff --git a/packages/computer/src/backends/container/container-host.ts b/packages/computer/src/backends/container/container-host.ts new file mode 100644 index 00000000..b13dcc85 --- /dev/null +++ b/packages/computer/src/backends/container/container-host.ts @@ -0,0 +1,421 @@ +// IWorkspaceContainerAPI — the seam ContainerBackend +// drives instead of talking to a Container binding directly. Two +// reasons it exists: +// +// 1. Same-DO vs cross-DO. A container-pool deployment wants the +// DO that owns the Workspace (e.g. an Agent DO) to be separate +// from the DO that owns the Container binding (a pool member +// that can be re-leased between sessions). The pool member's +// ctx.container isn't reachable from the Agent's isolate, but +// an RpcTarget stub satisfying IWorkspaceContainerAPI is. +// +// 2. Testability. The interface is narrower than `Container` — +// three methods — and fakes don't need to mimic the full +// runtime surface. +// +// Consumers don't implement this interface directly. They mix +// `withWorkspaceContainer(Base)` into their DO class, which adds a +// single `ws` accessor returning a `WorkspaceContainerAPI` — +// an RpcTarget so it works the same in-isolate and across RPC. + +import { RpcTarget } from "cloudflare:workers"; +import { WorkspaceTransportError } from "../../transport-failure.js"; +import { ContainerClientSecret } from "./container-client-secret.js"; +import { + type ContainerLaunchRecord, + type ContainerLaunchSpec, + CurrentContainerLaunchRecord, + launchRecordFor, + sameLaunch, +} from "./container-launch-record.js"; +import { + type ContainerExitInfo, + containerExitInfo, + destroyContainerExpectingExit, + installContainerMonitor, +} from "./container-lifecycle.js"; +import { CurrentContainerRuntimeIdentity } from "./container-runtime-identity.js"; + +export type { ContainerExitInfo } from "./container-lifecycle.js"; + +// Image key assumed when a caller names none. Matches the convention +// wrangler documents for the `images` block. +const DEFAULT_IMAGE_NAME = "app"; + +// Identifies the Durable Object that owns the Workspace and answers +// the /api upgrade. Plain data so it can travel over Workers RPC. +export interface WorkspaceRef { + // Binding name in the host Worker's env that resolves to the + // DurableObjectNamespace for the Workspace-owning DO class. + binding: string; + // Stringified DurableObjectId of the specific Workspace owner. + id: string; +} + +// Driver surface ContainerBackend talks to. Implemented +// by WorkspaceContainerAPI below; exposed on consumer DOs through +// the `ws` accessor that withWorkspaceContainer installs. +export interface ContainerRuntimeInfo { + runtimeId: string; + // Shared secret the container requires on its HTTP surface. Durable, + // so a reconstructed durable object presents the value the running + // container was launched with. + clientSecret: string; + // What the call actually did. `adopted` means a container was already + // running with the requested launch spec and was reused. `relaunched` + // means one was running with a different spec, or with none recorded, + // and had to be replaced: the environment and the internet flag can + // only be set at launch, so adopting it would have quietly ignored + // what the caller asked for. + outcome: "launched" | "adopted" | "relaunched"; +} + +export interface IWorkspaceContainerAPI { + // Idempotent start. Returns the durable identity of the running + // container process once the runtime has accepted the start command; + // readiness is verified by the backend through probeComputerdHealth. + start(spec: ContainerLaunchSpec): Promise; + + // Wire `host` → workspace inside the container's egress table. + // Called once per backend connect(). The implementation + // constructs the loopback Fetcher locally from {binding, id}, + // because Fetchers can't survive a Workers RPC hop. + interceptOutboundHttp(host: string, workspace: WorkspaceRef): Promise; + interceptAllOutboundHttp(workspace: WorkspaceRef, token: string): Promise; + + // Fetch against a named TCP port inside the container. The fetch + // runs in the container-owning Durable Object, so callers across + // Workers RPC do not need to receive and reuse a Fetcher stub. + fetchPort(port: number, input: RequestInfo | URL, init?: RequestInit): Promise; + + // Return a Fetcher bound to the named TCP port inside the + // container for same-isolate callers and advanced integrations. + port(port: number): Fetcher; + + // Force a fresh container generation when startup readiness + // never opens or a lease-time health check has declared the + // current generation dead. Implementation: destroy() the + // container, then start({ env }). Callers bound the number of + // restart attempts — this method does no looping of its own. + restart(spec: ContainerLaunchSpec): Promise; + + // Set the platform's idle timeout for the attached container. Exposed + // so a caller that pre-starts containers, such as a warm pool, never + // needs to reach past this API to ctx.container. + setInactivityTimeout(durationMs: number): Promise; + + // Coarse diagnostic state. The `running` flag reports whether + // the platform still has a container instance attached; it does + // not prove that computerd is listening or responsive. Use + // probeComputerdHealth for readiness; use status() only for logs and + // tracing. `exit` is populated from the in-memory monitor() + // signal when the most recent container generation exited; it + // resets on the next successful start(). + status(): Promise<{ running: boolean; exit: ContainerExitInfo | null }>; + + // Snapshot of the last container exit reason observed through + // monitor(). Null while the current generation is alive (or + // before any container has been started). Used by the backend + // pre-flight check in connect() to attribute readiness failures + // to the prior generation's exit when one is available. + exitInfo(): Promise; +} + +// Concrete implementation. Extends RpcTarget so it travels intact +// across a Workers RPC boundary; in-isolate callers see plain +// method calls. Constructed by withWorkspaceContainer's `ws` +// getter — consumers don't instantiate this directly. +export class WorkspaceContainerAPI extends RpcTarget implements IWorkspaceContainerAPI { + readonly #container: NonNullable; + readonly #ctx: DurableObjectState; + readonly #runtimeIdentity: CurrentContainerRuntimeIdentity; + readonly #clientSecret: ContainerClientSecret; + readonly #launchRecord: CurrentContainerLaunchRecord; + + constructor(ctx: DurableObjectState) { + super(); + if (!ctx.container) { + throw new Error("WorkspaceContainerAPI: DO is not container-enabled (check wrangler.jsonc)"); + } + this.#container = ctx.container; + this.#ctx = ctx; + this.#runtimeIdentity = new CurrentContainerRuntimeIdentity(ctx.storage); + this.#clientSecret = new ContainerClientSecret(ctx.storage); + this.#launchRecord = new CurrentContainerLaunchRecord(ctx.storage); + } + + async start(spec: ContainerLaunchSpec): Promise { + // If a prior generation has died, commit to a fresh one: the + // destroy clears any platform-side carcass, and the start that + // follows is unconditional. We cannot rely on + // this.#container.running to flip to false synchronously after + // destroy resolves, so guarding the start against it would let + // a stale-running flag skip the re-launch entirely. + const priorExit = containerExitInfo(this.#ctx); + // Resolved before any launch so the value written into the + // container's environment is the same one later incarnations read + // back and present on their requests. + const clientSecret = await this.#clientSecret.ensure(); + const requested = await launchRecordFor(spec); + + if (priorExit !== null) { + try { + await destroyContainerExpectingExit(this.#ctx, this.#container); + } catch { + // best-effort — the next start() will surface any real + // platform-side failure. + } + return this.#launchAs(spec, clientSecret, requested, "launched"); + } + if (!this.#container.running) { + return this.#launchAs(spec, clientSecret, requested, "launched"); + } + + // A container is already running. Adopting it is only correct if it + // was launched with the spec being asked for now: the environment + // and the internet flag cannot be changed on a live container, so + // adopting a mismatched one would silently drop what this caller + // wants. An absent record means something started the container + // without going through here, which is the same problem. + const actual = await this.#launchRecord.get(); + if (actual === null || !sameLaunch(actual, requested)) { + console.warn({ + message: + actual === null + ? "container was started outside WorkspaceContainerAPI; relaunching so the requested environment applies" + : "running container was launched with a different spec; relaunching", + component: "workspace-container", + requested, + actual, + }); + try { + await destroyContainerExpectingExit(this.#ctx, this.#container); + } catch { + // best-effort, as above. + } + return this.#launchAs(spec, clientSecret, requested, "relaunched"); + } + + // A Durable Object incarnation can be reconstructed while its + // container stays alive. Reuse the durable runtime id rather + // than treating the new WebSocket as a new process. + const runtime = + (await this.#runtimeIdentity.get()) ?? (await this.#runtimeIdentity.markStarted()); + installContainerMonitor(this.#ctx, this.#container, () => this.#runtimeIdentity.clear(runtime)); + return { runtimeId: runtime.id, clientSecret, outcome: "adopted" }; + } + + async restart(spec: ContainerLaunchSpec): Promise { + // destroy() resolves once the platform has torn down the + // attached container. A subsequent start() launches a fresh + // generation — ports re-bind, the computerd daemon comes up clean. + // destroyContainerExpectingExit flips the lifecycle flag so + // the monitor handler logs the exit as intentional. + try { + await destroyContainerExpectingExit(this.#ctx, this.#container); + } catch { + // tolerate a flaky destroy — start() below will either + // succeed against a fresh generation or surface its own + // failure. + } + const clientSecret = await this.#clientSecret.ensure(); + return this.#launchAs(spec, clientSecret, await launchRecordFor(spec), "launched"); + } + + async setInactivityTimeout(durationMs: number): Promise { + await this.#container.setInactivityTimeout(durationMs); + } + + async #launchAs( + spec: ContainerLaunchSpec, + clientSecret: string, + record: ContainerLaunchRecord, + outcome: "launched" | "relaunched", + ): Promise { + const runtime = await this.#runtimeIdentity.markStarted(); + const { env, name, containerSnapshot, ...passthrough } = spec; + // The platform types image and containerSnapshot as mutually + // exclusive arms of a union, so each is built as its own literal. + // Spreading an optional `image` onto a shared object does not + // typecheck against either arm: the snapshot arm requires the key + // to be absent, and `string | undefined` is not `undefined`. + const common = { + ...passthrough, + enableInternet: spec.enableInternet, + // Assigned after the spread so the secret wins over a + // caller-supplied value. The container refuses requests that do + // not present it, so a caller overriding it would lock the host + // out of its own container. + env: { ...env, RPC_CLIENT_SECRET: clientSecret }, + }; + try { + this.#container.start( + containerSnapshot === undefined + ? { ...common, image: this.#resolveImage(name) } + : { ...common, containerSnapshot }, + ); + } catch (error) { + await this.#runtimeIdentity.clear(runtime); + throw error; + } + // Written after the start is accepted, so a failed launch does not + // leave a record claiming the container holds this spec. + await this.#launchRecord.set(record); + installContainerMonitor(this.#ctx, this.#container, () => this.#runtimeIdentity.clear(runtime)); + return { runtimeId: runtime.id, clientSecret, outcome }; + } + + /** + * Resolve the image the container boots from. + * + * Under this scheduling policy the durable object owns the container + * lifecycle, so the platform supplies no image of its own and + * `start()` has to name one. Wrangler prepares the images declared + * in the `images` block and exposes them as `ctx.container.images`, + * keyed by the name used there. + * + * Only reached when no snapshot was supplied. A snapshot already + * carries the filesystem an image would provide, and the platform + * types the two as mutually exclusive, so that case never needs an + * image resolved. + */ + #resolveImage(name: string | undefined): string { + const images = this.#container.images; + const available = Object.keys(images ?? {}); + if (available.length === 0) { + // Empty `images` means the platform schedules this container and + // picks the image from the wrangler containers block. That is a + // valid deployment, just not one this backend serves, and it + // cannot be rescued here: no image name would resolve. Naming + // the other backend is the whole value of this message, because + // the symptom otherwise is a container that never becomes + // healthy for no visible reason. + throw new Error( + "container has no prepared images. This backend drives containers the " + + 'durable object schedules, which requires `scheduling_policy: "durable_object"` ' + + "and an `images` map in the wrangler containers block. A " + + "platform-scheduled container is served by LegacyContainerBackend " + + "from @cloudflare/computer/backends/container-legacy.", + ); + } + + const wanted = name ?? DEFAULT_IMAGE_NAME; + const image = images?.[wanted]; + if (image === undefined) { + // Almost always an images key that does not match what the code + // asks for, so list what the deployment actually prepared. + throw new Error( + `container image "${wanted}" is not available; prepared images: ${available.join(", ")}`, + ); + } + return image; + } + + async status() { + return { running: this.#container.running, exit: containerExitInfo(this.#ctx) }; + } + + async exitInfo(): Promise { + return containerExitInfo(this.#ctx); + } + + async interceptAllOutboundHttp(ref: WorkspaceRef, token: string) { + const exports = (this.#ctx as unknown as { exports: Record }).exports as { + WorkspaceProxy: (opts: { props: WorkspaceRef & { egressToken: string } }) => Fetcher; + }; + const proxy = exports.WorkspaceProxy({ props: { ...ref, egressToken: token } }); + await Promise.all([ + this.#container.interceptAllOutboundHttp(proxy), + this.#container.interceptOutboundHttps("*", proxy), + ]); + } + + async interceptOutboundHttp(host: string, ref: WorkspaceRef) { + // ctx.exports.WorkspaceProxy is bound by name in the + // consumer's Worker (they re-export WorkspaceProxy from this + // package). The cast keeps us independent of the consumer's + // worker-configuration.d.ts. + // ctx.exports is present at runtime but not on the public + // DurableObjectState type; cast through unknown to reach it. + const exports = (this.#ctx as unknown as { exports: Record }).exports as { + WorkspaceProxy: (opts: { props: WorkspaceRef }) => Fetcher; + }; + await this.#container.interceptOutboundHttp(host, exports.WorkspaceProxy({ props: ref })); + } + + fetchPort(port: number, input: RequestInfo | URL, init?: RequestInit): Promise { + // Short-circuit if the container is known to have exited. + // WorkspaceTransportError is classified by + // isWorkspaceTransportFailure, so the Workspace cache drops + // the stale handle and the next operation reconnects against + // a fresh generation. + const exit = containerExitInfo(this.#ctx); + if (exit !== null) { + throw new WorkspaceTransportError(`container exited: ${exit.reason}`); + } + return this.#container.getTcpPort(port).fetch(input, init); + } + + port(port: number) { + return this.#container.getTcpPort(port); + } +} + +// TS requires a mixin class's constructor to take a single rest +// parameter, so we widen here. DurableObject's runtime signature +// is still (ctx, env); the rest tuple just makes TS happy. We +// don't constrain the instance shape because DurableObject's +// `ctx` is protected — visible inside the mixin's class body via +// `extends Base`, but not as a public structural property. +// biome-ignore lint/suspicious/noExplicitAny: mixin constructor shape requires any[] +type DOCtor = new (...args: any[]) => object; + +// Mixin: add a single `getWorkspaceContainer()` method to a DO +// class. Returns a fresh WorkspaceContainerAPI bound to this DO's +// ctx. One name added to the consumer's class — nothing to +// forward to super, nothing else to override. A method (not a +// getter) so it crosses Workers RPC as a callable, and the +// long-form name keeps it from colliding with anything the +// consumer's base class might already expose. +// +// Same-DO usage (Agent owns the container): +// +// export class Agent extends withWorkspaceContainer( +// class extends DurableObject {}, +// ) { +// #backend = new ContainerBackend({ +// container: () => this, +// workspace: { binding: "Agent", id: this.ctx.id.toString() }, +// }); +// } +// +// Cross-DO usage (pool member owns the container): +// +// export class ComputerdHost extends withWorkspaceContainer( +// class extends DurableObject {}, +// ) {} +// +// #backend = new ContainerBackend({ +// container: () => this.env.ComputerdHost.get(memberId), +// workspace: { binding: "Agent", id: this.ctx.id.toString() }, +// }); +// +// Constructor type the mixin returns. Written explicitly so +// rolldown-plugin-dts can emit a stable .d.ts (anonymous returned +// classes with method declarations trip its TS transformer). +export type WithWorkspaceContainerCtor = TBase & + (new ( + // biome-ignore lint/suspicious/noExplicitAny: mirror mixin constructor shape + ...args: any[] + ) => InstanceType & { getWorkspaceContainer(): WorkspaceContainerAPI }); + +export function withWorkspaceContainer( + Base: TBase, +): WithWorkspaceContainerCtor { + class WithWorkspaceContainer extends Base { + getWorkspaceContainer(): WorkspaceContainerAPI { + return new WorkspaceContainerAPI((this as unknown as { ctx: DurableObjectState }).ctx); + } + } + return WithWorkspaceContainer as WithWorkspaceContainerCtor; +} diff --git a/packages/computer/src/backends/container/container-launch-record.test.ts b/packages/computer/src/backends/container/container-launch-record.test.ts new file mode 100644 index 00000000..7f72426d --- /dev/null +++ b/packages/computer/src/backends/container/container-launch-record.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "vitest"; + +import { + type ContainerLaunchSpec, + CurrentContainerLaunchRecord, + launchRecordFor, + sameLaunch, +} from "./container-launch-record.js"; + +function spec(overrides: Partial = {}): ContainerLaunchSpec { + return { + env: { PORT: "8080", MOUNT_POINT: "/workspace" }, + enableInternet: false, + ...overrides, + }; +} + +function fakeStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: unknown): Promise { + values.set(key, value); + }, + }; +} + +describe("launchRecordFor", () => { + test("is stable regardless of the order keys were written in", async () => { + const one = await launchRecordFor(spec({ env: { A: "1", B: "2" } })); + const two = await launchRecordFor(spec({ env: { B: "2", A: "1" } })); + + expect(one.envDigest).toBe(two.envDigest); + expect(sameLaunch(one, two)).toBe(true); + }); + + test("changes when an environment value changes", async () => { + const one = await launchRecordFor(spec({ env: { A: "1" } })); + const two = await launchRecordFor(spec({ env: { A: "2" } })); + + expect(sameLaunch(one, two)).toBe(false); + }); + + test("changes when the internet flag changes", async () => { + const one = await launchRecordFor(spec({ enableInternet: false })); + const two = await launchRecordFor(spec({ enableInternet: true })); + + expect(sameLaunch(one, two)).toBe(false); + }); +}); + +// The fields below cannot be changed on a running container, so a +// container launched with one value cannot be adopted by a caller that +// asks for another. Each one has to reach the digest, or the adoption +// check silently hands back a container configured for someone else. +describe("launch-time-only options reach the digest", () => { + test("a different instance size is not the same launch", async () => { + const one = await launchRecordFor(spec({ instance: "standard-2" })); + const two = await launchRecordFor(spec({ instance: "standard-4" })); + + expect(sameLaunch(one, two)).toBe(false); + }); + + test("a named tier and a custom size are not the same launch", async () => { + const named = await launchRecordFor(spec({ instance: "standard-2" })); + const custom = await launchRecordFor( + spec({ instance: { vcpu: 1, memoryMib: 6144, diskMb: 12000 } }), + ); + + expect(sameLaunch(named, custom)).toBe(false); + }); + + test("a custom size digests by value, not by key order", async () => { + const one = await launchRecordFor( + spec({ instance: { vcpu: 16, memoryMib: 32768, diskMb: 64000 } }), + ); + const two = await launchRecordFor( + spec({ instance: { diskMb: 64000, vcpu: 16, memoryMib: 32768 } }), + ); + + expect(sameLaunch(one, two)).toBe(true); + }); + + test("a different image name is not the same launch", async () => { + const one = await launchRecordFor(spec({ name: "app" })); + const two = await launchRecordFor(spec({ name: "toolchain" })); + + expect(sameLaunch(one, two)).toBe(false); + }); +}); + +// A snapshot describes how a container was created, not a property of +// the running container: one restored from a snapshot is +// indistinguishable from one that was not. Digesting it would destroy +// and relaunch a healthy container every time the stored handle moved +// on, which is a relaunch nobody asked for. +describe("creation-time options stay out of the digest", () => { + test("a different container snapshot is still the same launch", async () => { + const one = await launchRecordFor(spec({ containerSnapshot: { id: "snap-1" } })); + const two = await launchRecordFor(spec({ containerSnapshot: { id: "snap-2" } })); + + expect(sameLaunch(one, two)).toBe(true); + }); + + test("a different directory snapshot is still the same launch", async () => { + const one = await launchRecordFor(spec({ directorySnapshots: [{ mountPoint: "/cache" }] })); + const two = await launchRecordFor(spec({ directorySnapshots: [{ mountPoint: "/other" }] })); + + expect(sameLaunch(one, two)).toBe(true); + }); +}); + +describe("CurrentContainerLaunchRecord", () => { + test("returns null before anything is stored", async () => { + const record = new CurrentContainerLaunchRecord(fakeStorage()); + + expect(await record.get()).toBeNull(); + }); + + test("round-trips a stored record", async () => { + const record = new CurrentContainerLaunchRecord(fakeStorage()); + const written = await launchRecordFor(spec({ instance: "standard-2", name: "app" })); + + await record.set(written); + + expect(await record.get()).toEqual(written); + }); +}); diff --git a/packages/computer/src/backends/container/container-launch-record.ts b/packages/computer/src/backends/container/container-launch-record.ts new file mode 100644 index 00000000..f283335c --- /dev/null +++ b/packages/computer/src/backends/container/container-launch-record.ts @@ -0,0 +1,108 @@ +// What the running container process was launched with. +// +// Some startup options cannot be changed once a container is running. +// A durable object that finds one already running therefore cannot +// apply them, and until it can tell what the container was launched +// with it has no way to know whether adopting it is safe. A warm pool +// that pre-starts containers is the case in point: the workspace that +// later adopts one may want a different environment, a different size, +// or no internet access at all. +// +// So each launch records what it used, and adoption compares. A +// container launched outside this API leaves no record at all, which +// reads as a mismatch and gets it relaunched rather than trusted. + +/** Instance size accepted by `start()`, as the platform declares it. */ +export type ContainerInstanceSize = NonNullable; + +// Everything the platform accepts at startup, minus the three fields +// this package owns. `env` and `enableInternet` are required rather +// than optional because every launch sets them. `image` is excluded +// deliberately: the platform makes it mutually exclusive with +// `containerSnapshot`, and the caller names an image through `name` +// instead, which the host resolves against ctx.container.images. +export interface ContainerLaunchSpec + extends Omit { + env: Record; + enableInternet: boolean; + /** Key into `ctx.container.images`, resolved by the container host. */ + name?: string; +} + +export interface ContainerLaunchRecord { + enableInternet: boolean; + // A digest rather than the environment itself: containerEnv is + // consumer-supplied and may carry their own secrets, and this record + // only ever needs to answer "the same or not". + envDigest: string; + // Canonicalised rather than stored raw, because a named tier and an + // equivalent custom size are different shapes, and two custom sizes + // written in a different key order are the same launch. + instanceDigest?: string; + name?: string; + // A startup option that cannot change on a running container belongs + // here too. hardTimeout is the next candidate, but the workers-types + // this package compiles against does not declare it, so there is + // nothing to digest yet; add it with the field rather than ahead of + // it. +} + +interface LaunchRecordStorage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; +} + +const STORAGE_KEY = "computer:container-launch-record"; + +export async function launchRecordFor(spec: ContainerLaunchSpec): Promise { + // The field list is explicit rather than "digest the whole spec" so + // that a startup option added upstream is a deliberate decision here + // rather than an accidental relaunch trigger. The test file records + // which side of the line each field falls on and why. + return { + enableInternet: spec.enableInternet, + envDigest: await digestEnv(spec.env), + ...(spec.instance === undefined ? {} : { instanceDigest: canonicalInstance(spec.instance) }), + ...(spec.name === undefined ? {} : { name: spec.name }), + }; +} + +export function sameLaunch(a: ContainerLaunchRecord, b: ContainerLaunchRecord): boolean { + return ( + a.enableInternet === b.enableInternet && + a.envDigest === b.envDigest && + a.instanceDigest === b.instanceDigest && + a.name === b.name + ); +} + +// A named tier is its own name; a custom size is its three numbers in a +// fixed order, tagged so no tier name could ever collide with one. +function canonicalInstance(instance: ContainerInstanceSize): string { + if (typeof instance === "string") return `tier:${instance}`; + return `size:${instance.vcpu}/${instance.memoryMib}/${instance.diskMb}`; +} + +// Sorted so two callers building the same environment in a different +// order agree, and length-prefixed so no combination of names and +// values can be rearranged into the same input. +async function digestEnv(env: Record): Promise { + const canonical = Object.keys(env) + .sort() + .map((name) => `${name.length}:${name}=${env[name]?.length ?? 0}:${env[name] ?? ""}`) + .join(";"); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export class CurrentContainerLaunchRecord { + constructor(private readonly storage: LaunchRecordStorage) {} + + async get(): Promise { + return (await this.storage.get(STORAGE_KEY)) ?? null; + } + + async set(record: ContainerLaunchRecord): Promise { + await this.storage.put(STORAGE_KEY, record); + } +} diff --git a/packages/computer/src/backends/container/container-lifecycle.test.ts b/packages/computer/src/backends/container/container-lifecycle.test.ts new file mode 100644 index 00000000..5e184055 --- /dev/null +++ b/packages/computer/src/backends/container/container-lifecycle.test.ts @@ -0,0 +1,430 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { + containerExitInfo, + destroyContainerExpectingExit, + formatExitReason, + getContainerLifecycle, + installContainerMonitor, + resetContainerLifecycleForTests, +} from "./container-lifecycle.js"; + +// Minimal Container stand-in. The lifecycle module only touches +// .destroy(), .start(), .running, and .monitor(). A controllable +// per-generation monitor() promise lets the tests drive the exit +// signal deterministically and aim it at a specific generation. +// +// `current` exposes the live generation's controls; `generations` +// preserves the per-generation tuples so a test can fire the +// first generation's reject AFTER the second generation has been +// armed, simulating the platform's behavior when an old monitor's +// settle frame arrives late. +interface MonitorControls { + resolve: () => void; + reject: (error: unknown) => void; + promise: Promise; +} + +function makeContainer(): { + container: NonNullable; + starts: number; + destroys: number; + monitorCalls: number; + current: MonitorControls; + generations: MonitorControls[]; +} { + let starts = 0; + let destroys = 0; + let monitorCalls = 0; + let running = false; + const generations: MonitorControls[] = []; + + function armPromise(): MonitorControls { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Swallow unhandled rejection noise when the test path leaves + // the promise pending or rejects without awaiting it. + promise.catch(() => {}); + const controls: MonitorControls = { resolve, reject, promise }; + generations.push(controls); + return controls; + } + let current = armPromise(); + + const container = { + get running() { + return running; + }, + start(_options?: unknown) { + starts++; + running = true; + // Each start() arms a fresh monitor() that the next monitor() + // call returns. + current = armPromise(); + }, + async destroy() { + destroys++; + running = false; + // The real container.monitor() rejects on destroy() (SIGKILL + // surfaces as a non-zero exit). The fake mirrors that + // contract so the lifecycle's expected-exit handler is + // tested against the platform's actual settle direction. + current.reject(new Error("container destroyed")); + }, + monitor() { + monitorCalls++; + return current.promise; + }, + } as unknown as NonNullable; + + return { + container, + get starts() { + return starts; + }, + get destroys() { + return destroys; + }, + get monitorCalls() { + return monitorCalls; + }, + get current() { + return current; + }, + generations, + } as ReturnType; +} + +// Lifecycle state is keyed by ctx — a tiny opaque object suffices. +function makeContext(container: NonNullable): DurableObjectState { + return { container } as unknown as DurableObjectState; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("formatExitReason", () => { + test("returns 'exited normally' for resolve case (no error)", () => { + expect(formatExitReason(undefined)).toBe("exited normally"); + }); + + test("returns the Error message for an Error", () => { + expect(formatExitReason(new Error("OOM killed"))).toBe("OOM killed"); + }); + + test("falls back to String() for non-Error rejections", () => { + expect(formatExitReason(42)).toBe("42"); + }); +}); + +describe("installContainerMonitor", () => { + test("records exit info when the monitor resolves (clean exit)", async () => { + // container.monitor() resolves only on a clean code-0 exit on + // the real platform. The lifecycle treats that as 'exited + // normally' — useful when the workload exits on its own + // rather than being SIGKILL'd by the runtime. + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + expect(containerExitInfo(ctx)).toBeNull(); + fake.current.resolve(); + // Microtask drain. + await Promise.resolve(); + await Promise.resolve(); + + const exit = containerExitInfo(ctx); + expect(exit).not.toBeNull(); + expect(exit?.reason).toBe("exited normally"); + expect(exit?.exitedAt).toBe(Date.now()); + }); + + test("records the rejection reason and runs current-generation cleanup", async () => { + const fake = makeContainer(); + const ctx = makeContext(fake.container); + const cleanup = vi.fn(async () => {}); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container, cleanup); + + fake.current.reject(new Error("container crashed")); + await Promise.resolve(); + await Promise.resolve(); + + const exit = containerExitInfo(ctx); + expect(exit?.reason).toBe("container crashed"); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + test("logs at warn level on an unexpected exit", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + fake.current.reject(new Error("OOM killed")); + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).toHaveBeenCalledTimes(1); + const [arg] = warn.mock.calls[0] ?? []; + expect(arg).toMatchObject({ + message: "workspace.container.exited", + reason: "OOM killed", + expected: false, + }); + }); + + test("logs at info level when the exit was expected (after destroyContainerExpectingExit)", async () => { + // The platform monitor() rejects on destroy. The lifecycle + // snapshots expectingExit at arm time, so even though the + // destroy's finally clears the flag synchronously, the + // monitor handler that fires later still sees expected:true. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + await destroyContainerExpectingExit(ctx, fake.container); + // Drain the monitor's then-chain. + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledTimes(1); + const [arg] = info.mock.calls[0] ?? []; + expect(arg).toMatchObject({ + message: "workspace.container.exited", + expected: true, + reason: "container destroyed", + }); + }); + + test("a late-rejecting stale monitor does not poison a new generation", async () => { + // First generation arms its monitor; we leave it pending. + // Second generation arms a new monitor (incrementing the + // generation counter). The stale handler firing after the + // new generation has armed must NOT overwrite the new + // generation's clean exit state. + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + + const staleCleanup = vi.fn(async () => {}); + fake.container.start(); + const firstGeneration = fake.current; + installContainerMonitor(ctx, fake.container, staleCleanup); + + // Second generation — a new monitor promise is armed in the + // fake's start(); installContainerMonitor bumps the lifecycle's + // generation counter and attaches a fresh handler against the + // new promise. + fake.container.start(); + installContainerMonitor(ctx, fake.container); + const secondGeneration = fake.current; + + expect(containerExitInfo(ctx)).toBeNull(); + // Settle the stale monitor with an error — it must be + // ignored because its generation is no longer current. + firstGeneration.reject(new Error("old generation died long ago")); + await Promise.resolve(); + await Promise.resolve(); + expect(containerExitInfo(ctx)).toBeNull(); + expect(staleCleanup).not.toHaveBeenCalled(); + + // The current generation's monitor still records normally. + secondGeneration.reject(new Error("current generation died")); + await Promise.resolve(); + await Promise.resolve(); + expect(containerExitInfo(ctx)?.reason).toBe("current generation died"); + }); +}); + +describe("destroyContainerExpectingExit", () => { + test("resets expectingExit so the next generation's crash logs as a crash", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + await destroyContainerExpectingExit(ctx, fake.container); + await Promise.resolve(); + await Promise.resolve(); + // Arm a fresh monitor for a new container generation. + fake.container.start(); + installContainerMonitor(ctx, fake.container); + fake.current.reject(new Error("real crash")); + await Promise.resolve(); + await Promise.resolve(); + + // The second exit was unexpected; it must log as a crash. + expect(warn).toHaveBeenCalledTimes(1); + }); + + test("expected-exit log fires for restart even when monitor settles after destroy resolves", async () => { + // Production timing: the platform settles container.monitor() + // asynchronously relative to container.destroy(). If the + // expected-exit log is gated on the monitor handler running + // *before* the next generation is installed, the log is + // silently dropped. destroyContainerExpectingExit must await + // the destroyed generation's monitor handler before + // returning so the caller can install the next generation + // without superseding the pending log. + // + // Real timers here — we need setTimeout to actually fire so + // the deferred reject straddles a task boundary the way + // production does. Restore fake timers at the end so the + // surrounding beforeEach/afterEach contract holds. + vi.useRealTimers(); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Custom container where destroy() does NOT settle the + // monitor synchronously; instead it schedules the rejection + // for a later microtask, mirroring the platform's behavior. + let monitorReject: ((error: unknown) => void) | null = null; + let monitorPromise = new Promise((_, reject) => { + monitorReject = reject; + }); + monitorPromise.catch(() => {}); + const container = { + get running() { + return true; + }, + start() { + // New generation arms a fresh monitor promise. + monitorPromise = new Promise((_, reject) => { + monitorReject = reject; + }); + monitorPromise.catch(() => {}); + }, + async destroy() { + // Capture the current rejector; settle it on a macrotask + // so the destroy() resolution and the monitor rejection + // straddle a task boundary. This mirrors production + // timing — the platform's destroy can return before its + // monitor() promise settles, and microtask-only ordering + // (queueMicrotask, Promise.resolve) would mask the race. + const reject = monitorReject; + setTimeout(() => { + reject?.(new Error("deferred destroy reject")); + }, 0); + }, + monitor() { + return monitorPromise; + }, + } as unknown as NonNullable; + const ctx = makeContext(container); + resetContainerLifecycleForTests(ctx); + + container.start(); + installContainerMonitor(ctx, container); + + await destroyContainerExpectingExit(ctx, container); + // Immediately install the next generation, as restart() does. + container.start(); + installContainerMonitor(ctx, container); + + // Drain any pending tasks (including the macrotask the fake + // queued from destroy). + await new Promise((r) => setTimeout(r, 0)); + await Promise.resolve(); + + // The destroyed generation's exit must have logged as + // expected (info), not as a crash (warn). + expect(info).toHaveBeenCalledTimes(1); + expect(info.mock.calls[0]?.[0]).toMatchObject({ + message: "workspace.container.exited", + expected: true, + }); + expect(warn).not.toHaveBeenCalled(); + + vi.useFakeTimers(); + }); + + test("a later real crash on a fresh generation logs as unexpected after a failed destroy", async () => { + // destroy() rejects against generation 1. The expected-exit + // mark sticks against generation 1. A subsequent start() + // arms generation 2; a crash on generation 2 must be logged + // as unexpected because the mark targets a generation that + // no longer matches the live one. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + const broken = { + destroy: async () => { + throw new Error("destroy rejected"); + }, + } as unknown as NonNullable; + await expect(destroyContainerExpectingExit(ctx, broken)).rejects.toThrow(/destroy rejected/); + + // Fresh generation, fresh monitor. + fake.container.start(); + installContainerMonitor(ctx, fake.container); + fake.current.reject(new Error("real crash")); + await Promise.resolve(); + await Promise.resolve(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toMatchObject({ expected: false }); + }); +}); + +describe("getContainerLifecycle", () => { + test("returns null exit info before any monitor has fired", () => { + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + expect(containerExitInfo(ctx)).toBeNull(); + expect(getContainerLifecycle(ctx).exit).toBeNull(); + }); + + test("isolates state per ctx via the WeakMap", async () => { + const a = makeContainer(); + const b = makeContainer(); + const ctxA = makeContext(a.container); + const ctxB = makeContext(b.container); + resetContainerLifecycleForTests(ctxA); + resetContainerLifecycleForTests(ctxB); + a.container.start(); + installContainerMonitor(ctxA, a.container); + b.container.start(); + installContainerMonitor(ctxB, b.container); + + a.current.reject(new Error("a crashed")); + await Promise.resolve(); + await Promise.resolve(); + + expect(containerExitInfo(ctxA)?.reason).toBe("a crashed"); + expect(containerExitInfo(ctxB)).toBeNull(); + }); +}); diff --git a/packages/computer/src/backends/container/container-lifecycle.ts b/packages/computer/src/backends/container/container-lifecycle.ts new file mode 100644 index 00000000..39139a30 --- /dev/null +++ b/packages/computer/src/backends/container/container-lifecycle.ts @@ -0,0 +1,205 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +// Container lifecycle helpers. +// +// `WorkspaceContainerAPI` is constructed fresh on every +// getWorkspaceContainer() call, so instance fields can't track +// monitor state across calls. The state lives in a module-level +// WeakMap keyed by the owning DO's ctx; each DO gets one slot, +// garbage-collected when the DO is reclaimed. +// +// The lifecycle helpers here are independent of any +// cloudflare:workers imports so they can be unit-tested under the +// node-based vitest runner. + +type ContainerHandle = NonNullable; + +export interface ContainerExitInfo { + exitedAt: number; + reason: string; +} + +interface ContainerLifecycleState { + // Populated when the in-flight monitor() resolves or rejects. + // Only the monitor whose generation matches `currentGeneration` + // is allowed to write here — a stale handler from a previous + // generation must not poison the fresh one's exit state. + exit: ContainerExitInfo | null; + // Monotonically incremented every time a new monitor is armed. + // The installContainerMonitor closure captures the value at arm + // time so a late-resolving old monitor can detect that it has + // been superseded. + currentGeneration: number; + // The generation whose monitor is expected to terminate — set + // by destroyContainerExpectingExit just before the destroy call. + // The monitor handler reads this and, if it matches its own + // generation, logs the exit as intentional. Storing the + // generation (not a boolean) means a later spurious clear can + // never affect the current generation, and a flag set against + // generation N cannot leak into generation N+1. + expectedExitGeneration: number | null; + // Resolves once the *current* generation's monitor handler has + // run to completion. destroyContainerExpectingExit awaits this + // so the expected-exit log is emitted before any subsequent + // installContainerMonitor bumps the generation out from under + // the in-flight handler. Null between generations and before + // the first install. + currentMonitorSettled: Promise | null; +} + +const LIFECYCLE = new WeakMap(); + +export function getContainerLifecycle(ctx: DurableObjectState): ContainerLifecycleState { + let state = LIFECYCLE.get(ctx); + if (state === undefined) { + state = { + exit: null, + currentGeneration: 0, + expectedExitGeneration: null, + currentMonitorSettled: null, + }; + LIFECYCLE.set(ctx, state); + } + return state; +} + +export function containerExitInfo(ctx: DurableObjectState): ContainerExitInfo | null { + return getContainerLifecycle(ctx).exit; +} + +export function formatExitReason(error: unknown): string { + if (error === undefined) return "exited normally"; + if (error instanceof Error) return error.message; + return String(error); +} + +// Arm a monitor() for the currently-attached container generation. +// Every call bumps the generation counter and attaches a fresh +// .then handler. The handler captures its generation at arm time +// so a stale monitor that resolves late cannot overwrite a newer +// generation's exit state. +// +// The expected-or-not classification is taken from the live +// `expectedExitGeneration` slot when the handler runs, which is +// what destroyContainerExpectingExit writes — reading the slot +// (not a closure snapshot) lets a destroy() called *after* arm +// still mark its own generation's exit as intentional. +// +// Callers (start, restart) sequence arming after a successful +// container.start(); the platform contract is "one monitor per +// generation". If a caller arms twice for the same generation, +// the worst that happens is two handlers race to write the same +// exit state — same value, same generation. +export function installContainerMonitor( + ctx: DurableObjectState, + container: ContainerHandle, + onExit?: () => void | Promise, +): void { + const state = getContainerLifecycle(ctx); + state.currentGeneration += 1; + const generation = state.currentGeneration; + // Clear any prior exit info — a fresh generation has started. + state.exit = null; + + const monitorPromise = container.monitor(); + // currentMonitorSettled tracks the wrapper that runs recordExit, + // not the raw monitor promise, so awaiting it guarantees the + // exit has been recorded (and logged) before the awaiter + // continues. Both branches feed into recordExit which never + // throws, so the wrapper itself resolves rather than rejecting. + state.currentMonitorSettled = monitorPromise.then( + () => recordExit(state, generation, undefined, onExit), + (error) => recordExit(state, generation, error, onExit), + ); +} + +// Tear down the current container generation. Marks the current +// generation as the one expected to terminate so the monitor +// handler logs the resulting exit as intentional. +// +// The mark is keyed by generation, not by a global boolean, so a +// destroy() that fires while a different generation is in flight +// (e.g. a stale destroy attempt against a generation that has +// already been replaced) cannot mark the wrong generation's exit +// as intentional. The mark sticks until the next arm; if destroy +// throws and a fresh generation never gets armed, the mark sits +// against a generation that no longer matches `currentGeneration` +// and any later real crash on the new generation logs as a crash. +export async function destroyContainerExpectingExit( + ctx: DurableObjectState, + container: ContainerHandle, +): Promise { + const state = getContainerLifecycle(ctx); + state.expectedExitGeneration = state.currentGeneration; + // Capture the current generation's monitor wrapper BEFORE the + // destroy. A subsequent installContainerMonitor reassigns + // currentMonitorSettled, but the capture here pins us to the + // one we're waiting on. + const settled = state.currentMonitorSettled; + await container.destroy(); + // Wait for the destroyed generation's monitor handler to run so + // its expected-exit log fires before the caller (e.g. + // WorkspaceContainerAPI.restart) installs the next generation's + // monitor and bumps currentGeneration out from under it. The + // platform settles monitor() asynchronously relative to + // destroy(); without this await the next install supersedes the + // pending handler and recordExit drops the write as stale. + if (settled) await settled; +} + +// Reset state for a ctx. Test-only escape hatch; the production +// path relies on WeakMap GC. +export function resetContainerLifecycleForTests(ctx: DurableObjectState): void { + LIFECYCLE.delete(ctx); +} + +async function recordExit( + state: ContainerLifecycleState, + generation: number, + error: unknown, + onExit?: () => void | Promise, +): Promise { + // Drop late writes from superseded monitors. The state object is + // shared across generations; only the current one is allowed to + // mutate `exit`. Log nothing on a stale exit — the operator + // already saw the live generation's exit when it happened. + if (generation !== state.currentGeneration) return; + const expected = state.expectedExitGeneration === generation; + // Consume the expected mark so a later spurious monitor + // resolution (e.g. an idempotent arm against the same + // generation) does not double-claim the intentional log. + if (expected) state.expectedExitGeneration = null; + const reason = formatExitReason(error); + const exitedAt = Date.now(); + state.exit = { reason, exitedAt }; + // Log a single line per exit. Single-object form so Cloudflare + // Logs picks up the structured fields alongside the message. + if (expected) { + console.info({ + message: "workspace.container.exited", + reason, + exitedAt, + expected: true, + }); + } else { + console.warn({ + message: "workspace.container.exited", + reason, + exitedAt, + expected: false, + }); + } + try { + await onExit?.(); + } catch (cleanupError) { + console.error({ + message: "workspace.container.exit_cleanup_failed", + reason: formatExitReason(cleanupError), + exitedAt: Date.now(), + }); + } +} diff --git a/packages/computer/src/backends/container/container-runtime-identity.test.ts b/packages/computer/src/backends/container/container-runtime-identity.test.ts new file mode 100644 index 00000000..f9c2ddeb --- /dev/null +++ b/packages/computer/src/backends/container/container-runtime-identity.test.ts @@ -0,0 +1,61 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +import { describe, expect, it, vi } from "vitest"; + +import { CurrentContainerRuntimeIdentity } from "./container-runtime-identity.js"; + +function storage(initial = new Map()) { + return { + get: vi.fn(async (key: string) => initial.get(key)), + put: vi.fn(async (key: string, value: unknown) => { + initial.set(key, value); + }), + delete: vi.fn(async (key: string) => initial.delete(key)), + }; +} + +describe("CurrentContainerRuntimeIdentity", () => { + it("persists a UUID for a newly started container runtime", async () => { + const backing = new Map(); + const current = new CurrentContainerRuntimeIdentity(storage(backing)); + + const runtime = await current.markStarted(); + + expect(runtime.id).toMatch(/^[0-9a-f-]{36}$/); + await expect(current.get()).resolves.toEqual(runtime); + }); + + it("returns the same stored identity across helper instances", async () => { + const backing = new Map(); + const first = new CurrentContainerRuntimeIdentity(storage(backing)); + const started = await first.markStarted(); + const recreated = new CurrentContainerRuntimeIdentity(storage(backing)); + + await expect(recreated.get()).resolves.toEqual(started); + }); + + it("creates a new UUID for each container runtime", async () => { + const current = new CurrentContainerRuntimeIdentity(storage()); + + const first = await current.markStarted(); + const second = await current.markStarted(); + + expect(second.id).not.toBe(first.id); + }); + + it("clears only the runtime identity that stopped", async () => { + const current = new CurrentContainerRuntimeIdentity(storage()); + const stale = await current.markStarted(); + const active = await current.markStarted(); + + await current.clear(stale); + await expect(current.get()).resolves.toEqual(active); + + await current.clear(active); + await expect(current.get()).resolves.toBeNull(); + }); +}); diff --git a/packages/computer/src/backends/container/container-runtime-identity.ts b/packages/computer/src/backends/container/container-runtime-identity.ts new file mode 100644 index 00000000..5bf2e0c1 --- /dev/null +++ b/packages/computer/src/backends/container/container-runtime-identity.ts @@ -0,0 +1,42 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +// Durable identity for the currently-running container process. +// +// A WebSocket reconnect keeps this id. Starting a replacement process +// writes a new UUID. Execution-scoped operations use it to distinguish +// reconnecting to the same computerd from reaching an empty replacement. + +export interface ContainerRuntimeIdentity { + id: string; +} + +interface RuntimeIdentityStorage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; + delete(key: string): Promise; +} + +const STORAGE_KEY = "computer:container-runtime-identity"; + +export class CurrentContainerRuntimeIdentity { + constructor(private readonly storage: RuntimeIdentityStorage) {} + + async get(): Promise { + return (await this.storage.get(STORAGE_KEY)) ?? null; + } + + async markStarted(): Promise { + const runtime = { id: crypto.randomUUID() }; + await this.storage.put(STORAGE_KEY, runtime); + return runtime; + } + + async clear(runtime: ContainerRuntimeIdentity): Promise { + const current = await this.get(); + if (current?.id === runtime.id) await this.storage.delete(STORAGE_KEY); + } +} diff --git a/packages/computer/src/backends/container/health-probe.test.ts b/packages/computer/src/backends/container/health-probe.test.ts new file mode 100644 index 00000000..7f6a552e --- /dev/null +++ b/packages/computer/src/backends/container/health-probe.test.ts @@ -0,0 +1,79 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +import { describe, expect, test, vi } from "vitest"; + +import type { IWorkspaceContainerAPI } from "./container-host.js"; +import { probeComputerdHealth } from "./health-probe.js"; + +// probeComputerdHealth only consumes fetchPort. The helper is typed +// against the wider IWorkspaceContainerAPI to make ergonomic +// same-isolate calls cheap, but the test scope is narrower — use +// the minimal structural type so the fake doesn't have to stub +// methods it never reaches. +type HealthProbeHost = Pick; + +function fakeHost( + handler: (port: number, input: RequestInfo | URL, init?: RequestInit) => Promise, +): IWorkspaceContainerAPI { + const host: HealthProbeHost = { + fetchPort: vi.fn(handler), + }; + return host as IWorkspaceContainerAPI; +} + +describe("probeComputerdHealth", () => { + test("resolves on a 2xx response", async () => { + const host = fakeHost(async () => new Response(null, { status: 200 })); + await expect( + probeComputerdHealth(host, { port: 8080, path: "/health", timeoutMs: 1_000 }), + ).resolves.toBeUndefined(); + }); + + test("issues a HEAD request to the configured path", async () => { + const calls: { port: number; url: string; method?: string }[] = []; + const host = fakeHost(async (port, input, init) => { + const req = input instanceof Request ? input : new Request(input, init); + calls.push({ port, url: req.url, method: req.method }); + return new Response(null, { status: 200 }); + }); + await probeComputerdHealth(host, { port: 9090, path: "/__computerd/info", timeoutMs: 1_000 }); + expect(calls).toEqual([ + { port: 9090, url: "http://container/__computerd/info", method: "HEAD" }, + ]); + }); + + test("rejects on a non-2xx response with the status in the message", async () => { + const host = fakeHost(async () => new Response("bad", { status: 503 })); + await expect( + probeComputerdHealth(host, { port: 8080, path: "/health", timeoutMs: 1_000 }), + ).rejects.toThrow(/503/); + }); + + test("propagates host.fetchPort rejections", async () => { + const host = fakeHost(async () => { + throw new Error("connection refused"); + }); + await expect( + probeComputerdHealth(host, { port: 8080, path: "/health", timeoutMs: 1_000 }), + ).rejects.toThrow(/connection refused/); + }); + + test("aborts the request after timeoutMs", async () => { + const host = fakeHost(async (_port, _input, init) => { + // Simulate a never-responding computerd: wait until the AbortSignal + // fires, then reject with an AbortError so the helper sees the + // timeout surface as a rejection. + await new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }); + return new Response(null, { status: 200 }); + }); + await expect( + probeComputerdHealth(host, { port: 8080, path: "/health", timeoutMs: 20 }), + ).rejects.toThrow(/aborted|timeout/i); + }); +}); diff --git a/packages/computer/src/backends/container/health-probe.ts b/packages/computer/src/backends/container/health-probe.ts new file mode 100644 index 00000000..af53ad67 --- /dev/null +++ b/packages/computer/src/backends/container/health-probe.ts @@ -0,0 +1,52 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +// Shared computerd health probe. +// +// Used in two places that must agree on what "healthy" means: +// +// - LegacyContainerBackend.connect() startup readiness, in +// place of the previous private #waitForPort loop; +// - the keep-alive alarm's lease-time check. +// +// A single HEAD against the configured healthPath on the container +// port. ctx.container.running tells the runtime the container is +// attached, not that computerd is listening; this probe closes the gap. +// +// Probe failures bubble out as rejections; callers apply their own +// backoff / budget. No retries here — keeping the helper a single +// shot lets startup and lease alarms compose it differently. + +import type { IWorkspaceContainerAPI } from "./container-host.js"; + +export interface ComputerdHealthProbeOptions { + // TCP port computerd listens on inside the container. + port: number; + // Path to probe. Defaults to /health at the call site; required + // here so both callers thread the same value. + path: string; + // Per-probe timeout. The helper aborts the request when it + // elapses; the host's fetchPort surfaces that as a rejection. + timeoutMs: number; +} + +export async function probeComputerdHealth( + host: IWorkspaceContainerAPI, + options: ComputerdHealthProbeOptions, +): Promise { + const signal = AbortSignal.timeout(options.timeoutMs); + const res = await host.fetchPort(options.port, `http://container${options.path}`, { + method: "HEAD", + signal, + }); + // Drain the body so the underlying connection (if any) can be + // released. Some runtimes return a non-null body for HEAD even + // though it should be empty; cancel() is a no-op for null. + void res.body?.cancel(); + if (!res.ok) { + throw new Error(`computerd health returned ${res.status}`); + } +} diff --git a/packages/computer/src/backends/container/index.ts b/packages/computer/src/backends/container/index.ts new file mode 100644 index 00000000..6f242db3 --- /dev/null +++ b/packages/computer/src/backends/container/index.ts @@ -0,0 +1,42 @@ +// Public surface of @cloudflare/computer/backends/container. +// +// The container backend pairs a Workspace with a computerd daemon +// running inside a Cloudflare Container. computerd owns its own +// SQLite-backed VFS; the package syncs the two stores across a +// capnweb WebSocket. +// +// This backend serves containers the durable object schedules, which +// the wrangler containers block selects with +// `scheduling_policy: "durable_object"` and an `images` map. Under +// that policy the object owns the container lifecycle, so every +// start() has to name the image and may name an instance size; the +// block itself rejects `instance_type` and `max_instances`. +// +// A container the platform schedules is a different deployment shape +// and is served by LegacyContainerBackend from +// @cloudflare/computer/backends/container-legacy. +// +// Imported via: +// +// import { +// ContainerBackend, +// withWorkspaceContainer, +// } from "@cloudflare/computer/backends/container"; + +export type { WorkspaceEgressPolicy } from "../../runtime/egress.js"; +export { + ContainerBackend, + type ContainerBackendOptions, + type ContainerHostHolder, +} from "./container-backend.js"; +export { + type ContainerRuntimeInfo, + type IWorkspaceContainerAPI, + WorkspaceContainerAPI, + type WorkspaceRef, + withWorkspaceContainer, +} from "./container-host.js"; +export type { + ContainerInstanceSize, + ContainerLaunchSpec, +} from "./container-launch-record.js"; From 5156b3af52dc5254a964bd4041010b99750cd7e1 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:33:44 +0000 Subject: [PATCH 2/3] computer, docs, examples/container: Document both backends Add a runnable example for the durable-object-scheduled backend and use it in the primary setup documentation. The example shows how image and instance selection move from the Wrangler configuration into each container launch. Keep the platform-scheduled example under `container-legacy` for callers that still use that policy and for documentation tied to its exact setup. --- .changeset/container-instance-backend.md | 5 + README.md | 11 +- docs/01_vfs.md | 36 +- docs/07_injected_service.md | 81 +- docs/11_lifecycle.md | 26 +- docs/12_worker_backend.md | 10 +- docs/README.md | 35 +- examples/container/.gitignore | 3 + examples/container/Dockerfile | 43 + examples/container/README.md | 103 ++ examples/container/package.json | 20 + examples/container/script/run | 53 + examples/container/src/index.ts | 265 +++++ examples/container/tsconfig.json | 17 + examples/container/wrangler.jsonc | 62 ++ package-lock.json | 932 +++++++++++++++++- packages/computer/README.md | 3 +- .../src/backends/container/health-probe.ts | 3 +- 18 files changed, 1577 insertions(+), 131 deletions(-) create mode 100644 .changeset/container-instance-backend.md create mode 100644 examples/container/.gitignore create mode 100644 examples/container/Dockerfile create mode 100644 examples/container/README.md create mode 100644 examples/container/package.json create mode 100755 examples/container/script/run create mode 100644 examples/container/src/index.ts create mode 100644 examples/container/tsconfig.json create mode 100644 examples/container/wrangler.jsonc diff --git a/.changeset/container-instance-backend.md b/.changeset/container-instance-backend.md new file mode 100644 index 00000000..e952abb8 --- /dev/null +++ b/.changeset/container-instance-backend.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add a container backend for durable-object-scheduled containers diff --git a/README.md b/README.md index 859709c7..8e1ea38e 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,14 @@ for setup, build, and test instructions. The [`examples/`](examples) directory holds runnable consumers of the public surface. Each is a Worker workspace with its own README. -- [`examples/container-legacy`](examples/container-legacy) — runs `computerd` inside a - container, mounts a workspace, and talks to a Durable Object over - capnweb. A `write` / `read` / `exec` HTTP surface. +- [`examples/container`](examples/container) — runs `computerd` inside a + container the Durable Object schedules itself, mounts a workspace, and + talks to the object over capnweb. A `write` / `read` / `exec` HTTP + surface. The launch names the image and the instance size, because + `scheduling_policy: "durable_object"` moves both out of the config. +- [`examples/container-legacy`](examples/container-legacy) — the same + surface against a container the platform schedules and sizes from the + `containers` block. - [`examples/worker-shell`](examples/worker-shell) — same HTTP surface as the container example, but the shell runs [just-bash](https://github.com/vercel-labs/just-bash) in a Dynamic Worker loaded through `env.LOADER`. No container. diff --git a/docs/01_vfs.md b/docs/01_vfs.md index b75059ab..7ad56989 100644 --- a/docs/01_vfs.md +++ b/docs/01_vfs.md @@ -16,17 +16,29 @@ container, not on `WorkspaceOptions`). ```ts import { Workspace } from "@cloudflare/computer"; -import { LegacyContainerBackend } from "@cloudflare/computer/backends/container-legacy"; - -new Workspace({ - storage: ctx.storage, - backends: [ - new LegacyContainerBackend({ - container: () => this, - workspace: { binding: "ContainerExample", id: ctx.id.toString() }, - }), - ], -}); +import { + ContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/computer/backends/container"; +import { DurableObject } from "cloudflare:workers"; + +class WorkspaceHost extends withWorkspaceContainer(class extends DurableObject {}) { + readonly backend = new ContainerBackend({ + container: () => this, + workspace: { binding: "WorkspaceHost", id: this.ctx.id.toString() }, + name: "app", + instance: "standard-2", + }); + + readonly workspace = new Workspace({ + storage: this.ctx.storage, + backends: [this.backend], + }); + + override fetch(request: Request): Promise { + return this.backend.handleFetch(request); + } +} ``` `backends` is optional. Omit it to construct a filesystem-only @@ -126,7 +138,7 @@ by the in-image `FUSE_MOUNT` env var (`auto` by default; see doc 07). On Cloudflare Containers `/dev/fuse` is exposed and the real kernel FUSE backend mounts; under `wrangler dev` it isn't, and `auto` falls back to the userspace shim. Either way the in-container view is a -live mirror of the DO-side VFS. Earlier revisions of `LegacyContainerBackend` +live mirror of the DO-side VFS. Earlier revisions of the container backend pinned `DISABLE_FUSE=1`, which produced a degraded mode where: - The in-container filesystem at `/workspace` is the container's own diff --git a/docs/07_injected_service.md b/docs/07_injected_service.md index cb021f26..a863262e 100644 --- a/docs/07_injected_service.md +++ b/docs/07_injected_service.md @@ -21,8 +21,8 @@ npm run build:bin --workspace @cloudflare/computerd # → artifacts/computerd/computerd-macos-x64 ``` -`examples/container-legacy/Dockerfile` is the canonical recipe for -staging the binary into a container image. +`examples/container/Dockerfile` is the canonical recipe for staging the +binary into a container image. ## Responsibilities @@ -65,7 +65,7 @@ The capnweb bootstrap interface is **`WorkspaceRPC`** (defined in ## Installing into your sandbox image -The canonical recipe is `examples/container-legacy/Dockerfile`: +The canonical recipe is `examples/container/Dockerfile`: ```dockerfile FROM --platform=linux/amd64 debian:stable-slim @@ -118,46 +118,43 @@ Provider-agnostic shape — three steps, in order: ### Cloudflare Containers specifics -`LegacyContainerBackend` (`packages/computer/src/backends/container-legacy/cloudflare-container.ts`) -wires it like this: - -1. **Start.** `LegacyWorkspaceContainerAPI.start({ env, enableInternet })`, - which reaches the Cloudflare Containers API — not the - `@cloudflare/sandbox` SDK. There is no process-name registry, no - `startProcess`/`getProcess`, and no `node /app/...` command (the - container's `ENTRYPOINT` runs `computerd` directly). `containerEnv` - pins `PORT=8080` and lets the image's own `FUSE_MOUNT` value - (typically `auto`) win, and the API adds `RPC_CLIENT_SECRET`. - - Neither the environment nor the internet flag can be changed on a - running container, so the launch records both and a container found - already running is only adopted when it matches. Otherwise it is - relaunched, which is what keeps a warm pool from handing a workspace - a container configured for something else. A container started - outside this API has no record and is relaunched too. +Cloudflare Computer has one backend for each container scheduling policy. + +`ContainerBackend` is the default. It works with containers that the durable +object schedules, configured with `scheduling_policy: "durable_object"` and +an `images` map. Each launch selects an image from +`ctx.container.images`. The backend can also request an `instance` size and +pass options such as `entrypoint`, `labels`, and snapshot settings to +`container.start()`. + +`LegacyContainerBackend` works with containers that the platform schedules. +The containers block chooses the image and instance size, so this backend +passes only the environment and internet setting when it starts the +container. + +Both backends use the same connection flow: + +1. **Start the container.** The image's `ENTRYPOINT` runs `computerd` + directly. The backend sets `PORT=8080`, preserves the image's + `FUSE_MOUNT` setting, and adds `RPC_CLIENT_SECRET`. It records the launch + settings so it can reject or replace a running container with the wrong + configuration. 2. **Wire egress.** `container.interceptOutboundHttp(egressHost, egress)` - routes outbound HTTP from the container at `egressHost` back to a - Worker `Fetcher` the DO controls. -3. **Probe.** `container.getTcpPort(containerPort).fetch("/health", { method: "HEAD" })`, - repeated until it returns `200`. -4. **Invert the WebSocket.** The DO arms an upgrade slot - (`#armUpgrade`) and then `POST`s to `/connect` on the container - (`#postConnect`). The request names the egress base and both paths, - so `computerd` polls `base + health` and then dials `base + api`; - the daemon assembles no paths of its own. Because the egress is - intercepted, - that outbound dial loops back to the DO's `handleFetch()`, which - accepts the upgrade and resolves the in-flight `#pendingUpgrade`. - The capnweb session then runs over that socket. **The WebSocket - carrier is inverted** versus a naive "host dials into container" - model. - -Sharp edges actually present in `cloudflare-container.ts`: - -- `#armUpgrade` must be set up *before* `#postConnect`, because `computerd` - can dial back before the `POST /connect` response returns. -- The container host records each monitored generation's exit reason. The dead container closes its WebSocket, and `fetchPort()` also short-circuits later requests with a transport error; either path invalidates the matching Workspace handle. -- **Reconnect replaces the whole session.** If the WebSocket dies, `Workspace` invalidates and closes the matching backend handle, then calls `LegacyContainerBackend.connect()` again. The replacement runs the complete start, egress-interception, health, `/connect`, and reverse-WebSocket sequence; the backend never splices a new carrier into the dead capnweb session. Replay-safe sync and process lifecycle operations get one retry. Command spawn is retried only when no request was dispatched. + routes outbound HTTP from the container back to a Worker `Fetcher` owned + by the durable object. +3. **Probe health.** The backend sends `HEAD /health` through the + container's TCP port until `computerd` responds. +4. **Open the WebSocket.** The backend prepares an upgrade slot before it + posts to `/connect`. `computerd` then dials the intercepted egress URL, + which routes the upgrade back to `handleFetch()`. The capnweb session + runs over that WebSocket. + +A reconnect creates a complete new session. If the WebSocket closes, +`Workspace` drops the old backend handle and calls the selected backend's +`connect()` method again. The replacement starts or adopts the container, +checks health, and repeats the `/connect` handshake. Sync and process +lifecycle operations get one retry when replay is safe. A command is only +retried when the request was not dispatched. ## Environment variables diff --git a/docs/11_lifecycle.md b/docs/11_lifecycle.md index c33b01d1..a0fc3f62 100644 --- a/docs/11_lifecycle.md +++ b/docs/11_lifecycle.md @@ -125,7 +125,10 @@ lifetime policy. From the DO's perspective: `computerd` is a long-lived process. It outlives DO restarts — the `Container.monitor()` promise resolves only when the container itself exits, and the backend's `#monitoring` flag drops the cached handle at -that point so the next call rebuilds from scratch (see the container host and backend implementations under `packages/computer/src/backends/container-legacy/`). +that point so the next call rebuilds from scratch. The primary implementation +lives under `packages/computer/src/backends/container/`; the +platform-scheduled variant lives under +`packages/computer/src/backends/container-legacy/`. When `computerd` runs with its default in-memory store, the two sides differ: the **container's VFS lasts only as long as the process**, @@ -168,11 +171,13 @@ the `close` callback, the session is gone. ### Where capnweb attaches in our code -On the DO side: `newWebSocketRpcSession(ws)` in -`LegacyContainerBackend.connect()` in `packages/computer/src/backends/container-legacy/cloudflare-container.ts`. -This installs `addEventListener("message", ...)` on the accepted -WebSocket, which means **the DO must be alive in memory to receive -frames**. There is no hibernation-aware variant today. +On the durable object side, `ContainerBackend.connect()` calls +`newWebSocketRpcSession(ws)` in +`packages/computer/src/backends/container/container-backend.ts`. +`LegacyContainerBackend` uses the same session setup. This installs +`addEventListener("message", ...)` on the accepted WebSocket, which means +**the durable object must be alive in memory to receive frames**. There is +no hibernation-aware variant today. On the container side, `acceptWebSocketSession(ws, rpc)` is attached by the inbound upgrade and outbound `/connect` paths in `packages/computerd/src/cli/computerd.ts`. Both attach to a `ws` package WebSocket and require the `computerd` process to be live. @@ -293,11 +298,10 @@ prove no unbounded growth under sustained workloads. > [!NOTE] > This section describes a target architecture, not shipped code. -> Today's `LegacyContainerBackend` uses `server.accept()`, which -> is **not** the hibernation API. The DO stays in memory for the -> lifetime of the WebSocket. Enabling hibernation requires changes -> across capnweb and the backend; the work is sketched here so the -> direction is clear. +> Today's container backends use `server.accept()`, which is **not** the +> hibernation API. The durable object stays in memory for the lifetime of +> the WebSocket. Enabling hibernation requires changes across capnweb and +> both backends; the work is sketched here so the direction is clear. ### What hibernation gives us diff --git a/docs/12_worker_backend.md b/docs/12_worker_backend.md index f55cb21d..1bba49b5 100644 --- a/docs/12_worker_backend.md +++ b/docs/12_worker_backend.md @@ -22,10 +22,12 @@ import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; ## When to reach for it -The container backend (`@cloudflare/computer/backends/container-legacy`) -gives you a real Linux environment with arbitrary binaries on -`$PATH`, optional network access, and a full POSIX filesystem. It costs a -container per session and a real roundtrip on every filesystem op. +The primary container backend (`@cloudflare/computer/backends/container`) +gives you a real Linux environment with arbitrary binaries on `$PATH`, +optional network access, and a full POSIX filesystem. It costs a container +per session and a real roundtrip on every filesystem operation. Use +`@cloudflare/computer/backends/container-legacy` instead when the platform +schedules and sizes the container. The worker backend trades the real environment for a Workers isolate that boots instantly, scales out cheaply, and has no diff --git a/docs/README.md b/docs/README.md index 14817581..5561b393 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,7 +43,8 @@ The package ships several entrypoints: | Entrypoint | Purpose | | --- | --- | | `@cloudflare/computer` | The Workspace wrapper, first-class `workspace.runtime`, stub types, the R2 mount, and proxy classes. | -| `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | +| `@cloudflare/computer/backends/container` | `ContainerBackend` and `withWorkspaceContainer`, for a container the durable object schedules (`scheduling_policy: "durable_object"`). Same sync plumbing; the launch names the image and the instance size. | +| `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`, for a container the platform schedules and sizes from the containers block. | | `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash command runtime. | | `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | | `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. | @@ -58,7 +59,7 @@ Wire types shared with the in-container service live in the sibling package `@cl ### Sandbox container image The container needs the `computerd` daemon alongside a FUSE runtime. The -simplest pattern, used by [`examples/container-legacy/Dockerfile`](../examples/container-legacy/Dockerfile), +simplest pattern, used by [`examples/container/Dockerfile`](../examples/container/Dockerfile), copies the prebuilt binary out of the public GHCR image and into a thin Debian base: @@ -87,33 +88,39 @@ To build the binary from source instead, run `npm run build:bin `artifacts/computerd/computerd-linux-x64`, then `COPY` that into the image. -`computerd`'s own default port is `45678`; the Cloudflare container backend pins the in-image listener to `8080`, which is what `examples/container-legacy/` uses. See [07. Injected Service](./07_injected_service.md) for the env vars (`PORT`, `MOUNT_POINT`, `FUSE_MOUNT`, `EXEC_LOG_MAX_BYTES`) and the reverse-dial boot sequence. +`computerd`'s own default port is `45678`; the Cloudflare container backend pins the in-image listener to `8080`, which is what [`examples/container`](../examples/container) uses. See [07. Injected Service](./07_injected_service.md) for the env vars (`PORT`, `MOUNT_POINT`, `FUSE_MOUNT`, `EXEC_LOG_MAX_BYTES`) and the reverse-dial boot sequence. ## Example ```ts import { Workspace } from "@cloudflare/computer"; import { - LegacyContainerBackend, - withLegacyWorkspaceContainer, -} from "@cloudflare/computer/backends/container-legacy"; + ContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/computer/backends/container"; import { DurableObject } from "cloudflare:workers"; -export class Agent extends withLegacyWorkspaceContainer(class extends DurableObject {}) { +export class Agent extends withWorkspaceContainer(class extends DurableObject {}) { + readonly backend = new ContainerBackend({ + container: () => this, + workspace: { binding: "Agent", id: this.ctx.id.toString() }, + name: "app", + instance: "standard-2", + }); + readonly workspace = new Workspace({ - storage: this.ctx.storage, // DO storage → VFS lives here - backends: [ - new LegacyContainerBackend({ - container: () => this, - workspace: { binding: "Agent", id: this.ctx.id.toString() }, - }), - ], + storage: this.ctx.storage, // Durable Object storage → VFS lives here + backends: [this.backend], }); async initialize() { await this.workspace.ready(); await this.workspace.fs.mkdir("/workspace", { recursive: true }); } + + override fetch(request: Request): Promise { + return this.backend.handleFetch(request); + } } ``` diff --git a/examples/container/.gitignore b/examples/container/.gitignore new file mode 100644 index 00000000..55845e82 --- /dev/null +++ b/examples/container/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +build/ +node_modules/ diff --git a/examples/container/Dockerfile b/examples/container/Dockerfile new file mode 100644 index 00000000..51fde903 --- /dev/null +++ b/examples/container/Dockerfile @@ -0,0 +1,43 @@ +# Container image for the container example. +# +# Pulls the computerd binary out of the public GHCR image. That image is +# a single layer over `scratch` whose only contents are the SEA +# binary at /usr/local/bin/computerd; we COPY it into a slim debian +# runtime below. The :VERSION tag is rewritten by the changesets +# Version Packages PR through .github/changeset-version.mjs. +# +# computerd mounts a FUSE filesystem at MOUNT_POINT so exec'd commands +# see the same VFS the RPC surface reads and writes. With +# FUSE_MOUNT=auto (below) the same image works in both directions: +# Cloudflare Containers expose /dev/fuse to the workload, so the +# real FUSE backend mounts; `wrangler dev` doesn't, so computerd falls +# back to the userspace shim transparently. + + +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.3.1 AS computerd + +FROM debian:stable-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fuse3 libfuse2t64 ca-certificates curl gnupg git \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd + +# computerd's defaults: HTTP+WS on :8080, FUSE mount on MOUNT_POINT. +# FUSE_MOUNT=auto picks real FUSE on Cloudflare Containers (where +# /dev/fuse is exposed) and the userspace shim under wrangler dev. +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +ENV FUSE_MOUNT=auto +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/computerd"] diff --git a/examples/container/README.md b/examples/container/README.md new file mode 100644 index 00000000..ed4b3952 --- /dev/null +++ b/examples/container/README.md @@ -0,0 +1,103 @@ +# container example + +> [!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 +`computerd` daemon and exposes a minimal `write` / `read` / `exec` HTTP +surface. + +## Container configuration + +Declare the available images in `wrangler.jsonc`. Wrangler prepares each +image and exposes it through `ctx.container.images`: + +```jsonc +"containers": [ + { + "class_name": "ContainerExample", + "scheduling_policy": "durable_object", + "images": { "app": { "dockerfile": "./Dockerfile" } } + } +] +``` + +Select the image when you create the backend: + +```ts +new ContainerBackend({ + container: () => this, + workspace: { binding: "ContainerExample", id: this.ctx.id.toString() }, + name: "app", // the images key above; "app" is the default +}); +``` + +Set the instance size on the backend: + +```ts +new ContainerBackend({ + // ... + instance: "standard-2", +}); +``` + +The `containers` entry accepts these fields: + +| Field | Purpose | +| --- | --- | +| `name` | Names the container application. | +| `class_name` | Names the Durable Object class that owns the container. | +| `scheduling_policy` | Set to `"durable_object"` so each Durable Object manages its container. | +| `images` | Declares the named images available through `ctx.container.images`. | +| `observability` | Configures logging for the container application. | +| `unsafe` | Holds restricted experimental settings. | + +## Architecture + +``` +client ─► Worker /c//{file,exec} + │ (DO RPC calls) + ▼ + DO (ContainerExample) ──► Container ──► computerd (:8080) + ▲ │ + │ ws://computer.internal/api │ + └────────── capnweb session ◄──────┘ +``` + +1. The DO constructs a `ContainerBackend` and hands it to a `Workspace`. + The backend owns the computerd lifecycle: it starts the container with + the image and size above, wires egress, probes `/health`, and asks + computerd to dial back. +2. computerd's outbound `/api` upgrade is intercepted by the egress and + lands on the DO's `fetch`, which forwards it to `backend.handleFetch`. +3. The resulting capnweb session carries filesystem sync and `exec`. + +If a container fails its startup health check, the replacement uses the +same image and instance size. + +## Running it + +```bash +npm run dev --workspace @example/computer-container +``` + +```bash +# write a file into the workspace +curl -X PUT --data 'hello' \ + http://localhost:8787/c/demo/file/workspace/hello.txt + +# read it back +curl http://localhost:8787/c/demo/file/workspace/hello.txt + +# run a command against it +curl -X POST -H 'content-type: application/json' \ + -d '{"command":"cat /workspace/hello.txt"}' \ + http://localhost:8787/c/demo/exec +``` + +Local development needs Docker. `wrangler dev` builds the image and runs it +against the local daemon. diff --git a/examples/container/package.json b/examples/container/package.json new file mode 100644 index 00000000..8ce18ffa --- /dev/null +++ b/examples/container/package.json @@ -0,0 +1,20 @@ +{ + "name": "@example/computer-container", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object that uses @cloudflare/computer inside a container the Durable Object schedules itself.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "build:types": "wrangler types" + }, + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } +} diff --git a/examples/container/script/run b/examples/container/script/run new file mode 100755 index 00000000..2e8242e5 --- /dev/null +++ b/examples/container/script/run @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Four-step smoke test against a running `wrangler dev` of this +# example. Demonstrates the round-trip between the file HTTP +# surface (which writes through the DO into the workspace VFS) and +# the exec HTTP surface (which runs commands inside the container, +# where computerd's FUSE / shim mount exposes the same tree at +# /workspace). +# +# Run `npm run dev` in another terminal first, then: +# ./script/run # against http://127.0.0.1:8787 +# ./script/run https://my-deploy.example.workers.dev +# NAME=foo ./script/run # use DO instance 'foo' instead of 'demo' + +set -euo pipefail + +BASE_URL="${1:-http://127.0.0.1:8787}" +BASE_URL="${BASE_URL%/}" +NAME="${NAME:-demo}" +PATH_VIA_API="api-wrote.txt" +PATH_VIA_EXEC="exec-wrote.txt" + +step() { printf '\n=== %s ===\n' "$*"; } +fail() { printf '\nFAIL: %s\n' "$*" >&2; exit 1; } + +step "1. write via API: PUT /c/${NAME}/file/workspace/${PATH_VIA_API}" +printf 'hello-from-api' | curl -fsS -X PUT --data-binary @- \ + "${BASE_URL}/c/${NAME}/file/workspace/${PATH_VIA_API}" \ + -w 'HTTP %{http_code}\n' + +step "2. read via exec: cat /workspace/${PATH_VIA_API} inside the container" +read_via_exec=$(curl -fsS -X POST "${BASE_URL}/c/${NAME}/exec" \ + -H 'content-type: application/json' \ + -d "{\"command\":\"cat /workspace/${PATH_VIA_API}\",\"encoding\":\"utf8\"}") +printf '%s\n' "$read_via_exec" +echo "$read_via_exec" | grep -q 'hello-from-api' \ + || fail "exec did not see the file the API just wrote" + +step "3. write via exec: echo … > /workspace/${PATH_VIA_EXEC}" +curl -fsS -X POST "${BASE_URL}/c/${NAME}/exec" \ + -H 'content-type: application/json' \ + -d "{\"command\":\"echo hello-from-exec > /workspace/${PATH_VIA_EXEC} && cat /workspace/${PATH_VIA_EXEC}\",\"encoding\":\"utf8\"}" +printf '\n' +# No settle wait needed: computerd's `beforeFetch` hook runs the shim's +# disk→VFS reconcile synchronously inside `fetchChanges`, so the +# GET below is guaranteed to see whatever the exec just wrote. + +step "4. read via API: GET /c/${NAME}/file/workspace/${PATH_VIA_EXEC}" +read_via_api=$(curl -fsS "${BASE_URL}/c/${NAME}/file/workspace/${PATH_VIA_EXEC}") +printf '%s\n' "$read_via_api" +[[ "$read_via_api" == "hello-from-exec" ]] \ + || fail "API did not see the file exec just wrote (got: $read_via_api)" + +printf '\nOK — both directions round-tripped through the VFS.\n' diff --git a/examples/container/src/index.ts b/examples/container/src/index.ts new file mode 100644 index 00000000..02a025dc --- /dev/null +++ b/examples/container/src/index.ts @@ -0,0 +1,265 @@ +// Example Worker + container-enabled Durable Object that runs a +// Workspace inside a container the Durable Object schedules itself. +// +// The DO is a thin shell over ContainerBackend: it picks the container +// (this.ctx.container) and the egress fetcher +// (ctx.exports.WorkspaceProxy bound to this DO instance), forwards +// container-bound /api upgrades back through the backend, and +// otherwise just calls into a single Workspace instance. +// +// What distinguishes this example from examples/container-legacy is +// the scheduling policy. The wrangler containers block here sets +// `scheduling_policy: "durable_object"`, which hands the container +// lifecycle to this object and takes image selection and sizing with +// it: the block rejects `instance_type`, and it prepares an `images` +// map for the object to choose from. So the backend below names both, +// and every start() it issues carries them. +// +// Wire shape: +// +// client ─► Worker /c//{file,exec} +// │ (DO RPC) +// ▼ +// ContainerExample DO ──► Container ──► computerd (:8080) +// ▲ │ +// │ ws://computer.internal/api │ +// └─── capnweb session ◀─────────────┘ + +import { DurableObject, tracing } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + getWorkspace, + type WorkspaceOptions, + WorkspaceProxy, + withWorkspace, +} from "@cloudflare/computer"; +import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; +import { createCloudflareObserver } from "@cloudflare/computer/observe/cloudflare"; + +// Re-export so the runtime can build a loopback binding for the +// container egress (ctx.exports.WorkspaceProxy below). The class +// itself lives in @cloudflare/computer; the re-export is what +// puts it in the worker's top-level module graph. +export { WorkspaceProxy }; + +// Env is generated by `wrangler types` into worker-configuration.d.ts. + +// --------------------------------------------------------------- +// Durable Object: owns one Workspace backed by one container. +// --------------------------------------------------------------- +// The container half of the DO. The backend lives here rather than on +// ContainerExample because withWorkspace's options callback needs it +// while constructing the Workspace: base-class fields are initialized +// by the time the callback runs, subclass fields are not. +class ContainerBase extends withWorkspaceContainer(class extends DurableObject {}) { + readonly backend = new ContainerBackend({ + container: () => this, + workspace: { binding: "ContainerExample", id: this.ctx.id.toString() }, + egress: { mode: "direct" }, + // Key into `ctx.container.images`, which wrangler populates from + // the `images` map in wrangler.jsonc. "app" is the default, so + // this line is redundant here and is kept to show where the name + // comes from: it is the images key, not the container application + // name that sits beside it in the same block. + name: "app", + // Requested at launch because the containers block cannot declare + // it under this policy. Omitting it lets the platform choose, + // which is rarely right for a workload that compiles or installs + // packages -- the named tiers top out at 4 vCPU / 12 GiB, and a + // custom size is the only way past that. + instance: "standard-2", + }); +} + +// Named, with an explicit return type: an inline callback would make +// the class's own base expression part of its inferred type. +// DurableObject keeps ctx and env protected, so read them through a +// cast the way the mixin's own docs do. +function workspaceOptions(self: InstanceType): WorkspaceOptions { + const { ctx } = self as unknown as { ctx: DurableObjectState; env: Env }; + return { + // ctx.storage.sql.exec returns a narrower row type than + // DurableObjectStorageLike declares; the runtime shape + // matches. Cast through unknown to bypass invariance. + storage: ctx.storage as unknown as DurableObjectStorageLike, + backends: [self.backend], + // Route every workspace operation through the Cloudflare + // runtime's user-tracing surface. The runtime owns the span + // lifecycle; the observer is a thin wrapper that forwards seed + // attributes and a `setAttribute` callback. With + // `observability.traces.enabled = true` in wrangler.jsonc, the + // spans show up in the Workers Observability dashboard + // alongside the runtime's automatic fetch and binding spans. + observer: createCloudflareObserver({ tracing }), + }; +} + +// withWorkspace owns the Workspace and installs the prototype +// accessor `getWorkspace` dispatches to. Methods on the client it +// hands back round-trip into this DO; the actual SyncRPC + ShellRPC +// traffic stays on the computerd ↔ DO capnweb wire. +export class ContainerExample extends withWorkspace(ContainerBase, workspaceOptions) { + // ---- WebSocket: computerd's outbound /api upgrade -------------------- + + override async fetch(request: Request): Promise { + return this.backend.handleFetch(request); + } +} + +// --------------------------------------------------------------- +// Worker HTTP surface (unchanged) +// --------------------------------------------------------------- + +interface ExecRequest { + command?: string; + argv?: string[]; + cwd?: string; + encoding?: "utf8"; +} + +// computerd mounts the VFS at /workspace inside the container. The file +// handler enforces that every path it touches sits under that +// root; callers pass the absolute VFS path verbatim in the URL +// (e.g. PUT /c//file/workspace/hello.txt writes +// /workspace/hello.txt, exactly as computerd sees it). Anything outside +// /workspace is rejected up front — the example only wants to +// expose the mounted tree, not the container's full filesystem. +const MOUNT_ROOT = "/workspace"; + +function resolveMountPath(rest: string): string | null { + // URL was matched as /c//file/; treat as an + // absolute path with the leading `/` stripped. + const candidate = `/${rest}`; + if (candidate !== MOUNT_ROOT && !candidate.startsWith(`${MOUNT_ROOT}/`)) { + return null; + } + // Reject .. segments so a caller can't escape the mount through + // path traversal once we've validated the prefix. + if (candidate.split("/").includes("..")) { + return null; + } + return candidate; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + const fileMatch = url.pathname.match(/^\/c\/([^/]+)\/file\/(.+)$/); + if (fileMatch) { + const resolved = resolveMountPath(fileMatch[2]); + if (resolved === null) { + return errorJSON(new Error(`path must sit under ${MOUNT_ROOT}; got /${fileMatch[2]}`), 400); + } + return handleFile(request, env, fileMatch[1], resolved); + } + + const execMatch = url.pathname.match(/^\/c\/([^/]+)\/exec\/?$/); + if (execMatch) return handleExec(request, env, execMatch[1]); + + if (url.pathname === "/" || url.pathname === "") { + return new Response( + [ + "container example", + "", + ` PUT /c//file/workspace/ write file at ${MOUNT_ROOT}/`, + ` GET /c//file/workspace/ read file at ${MOUNT_ROOT}/`, + " POST /c//exec run a command (JSON result)", + + "", + ].join("\n"), + { headers: { "content-type": "text/plain" } }, + ); + } + + return new Response("not found", { status: 404 }); + }, +} satisfies ExportedHandler; + +async function handleFile( + request: Request, + env: Env, + name: string, + path: string, +): Promise { + const stub = env.ContainerExample.get(env.ContainerExample.idFromName(name)); + // `wrangler types` doesn't surface the accessor the withWorkspace + // mixin installs, so cast at the boundary. + const ws = await getWorkspace(stub as unknown as Parameters[0]); + + if (request.method === "PUT") { + const body = new Uint8Array(await request.arrayBuffer()); + try { + await ws.fs.mkdir(MOUNT_ROOT, { recursive: true }); + await ws.fs.writeFile(path, body); + return new Response(null, { status: 204 }); + } catch (error) { + return errorJSON(error, 500); + } + } + + if (request.method === "GET") { + try { + const stream = await ws.fs.readFile(path, {}); + return new Response(stream, { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }); + } catch (error) { + const code = (error as { code?: string }).code; + if (code === "ENOENT") return errorJSON(error, 404); + return errorJSON(error, 500); + } + } + + return new Response("method not allowed", { status: 405, headers: { allow: "GET, PUT" } }); +} + +async function handleExec(request: Request, env: Env, name: string): Promise { + if (request.method !== "POST") { + return new Response("method not allowed", { status: 405, headers: { allow: "POST" } }); + } + let body: ExecRequest; + try { + body = (await request.json()) as ExecRequest; + } catch { + return errorJSON(new Error("invalid JSON body"), 400); + } + + let command: string; + if (typeof body.command === "string" && body.command.length > 0) { + command = body.command; + } else if (Array.isArray(body.argv) && body.argv.length > 0) { + command = body.argv.map(shellQuote).join(" "); + } else { + return errorJSON(new Error("must provide command or argv"), 400); + } + + const stub = env.ContainerExample.get(env.ContainerExample.idFromName(name)); + const ws = await getWorkspace(stub as unknown as Parameters[0]); + try { + const handle = await ws.runtime.exec(command, { cwd: body.cwd, encoding: "utf8" }); + const result = await handle.result(); + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + return errorJSON(error, 500); + } +} + +function errorJSON(error: unknown, status: number): Response { + const message = error instanceof Error ? error.message : String(error); + const code = (error as { code?: string }).code; + return new Response(JSON.stringify({ error: message, code }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function shellQuote(arg: string): string { + if (/^[A-Za-z0-9_\-+=:,./@%]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/examples/container/tsconfig.json b/examples/container/tsconfig.json new file mode 100644 index 00000000..5a6253fb --- /dev/null +++ b/examples/container/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/container/wrangler.jsonc b/examples/container/wrangler.jsonc new file mode 100644 index 00000000..5129654d --- /dev/null +++ b/examples/container/wrangler.jsonc @@ -0,0 +1,62 @@ +{ + // Example: Worker + Durable Object hosting a Cloudflare Container that + // the Durable Object schedules itself, rather than one the platform + // places and sizes. + // + // The containers block below is the whole difference from + // examples/container-legacy. Under `scheduling_policy: "durable_object"` + // the object owns the container lifecycle, so: + // + // - `images` replaces `image`. Wrangler prepares each entry and + // exposes it as `ctx.container.images.`; the backend's `name` + // option names the key to boot. + // - `instance_type` and `max_instances` are rejected. Sizing belongs + // to the object now, so it is requested at launch through the + // backend's `instance` option (see src/index.ts). + // + // Wrangler accepts only `name`, `class_name`, `scheduling_policy`, + // `images`, `observability`, and `unsafe.configuration.experimental_flags` + // under this policy. Anything else fails the deploy. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-container-instance-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat"], + + "containers": [ + { + "class_name": "ContainerExample", + "scheduling_policy": "durable_object", + "images": { + // Built from the local Dockerfile, which COPYs the computerd + // binary staged into ./build/ by the predev / predeploy hook. + "app": { "dockerfile": "./Dockerfile" } + }, + "observability": { + "enabled": true + } + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "ContainerExample", + "class_name": "ContainerExample" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ContainerExample"] + } + ], + + "observability": { + "traces": { + "enabled": true + } + } +} diff --git a/package-lock.json b/package-lock.json index 0b455503..a68ec3f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2561,18 +2561,860 @@ } } }, + "examples/container": { + "name": "@example/computer-container", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } + }, "examples/container-legacy": { "name": "@example/computer-container-legacy", "version": "0.0.0", "dependencies": { - "@cloudflare/computer": "*" + "@cloudflare/computer": "*" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } + }, + "examples/container-legacy/node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260921.1.tgz", + "integrity": "sha512-3iB2WnYOlZ29T+1zhCwbHFExCBp6E9bgmDUMryATYwrIGEQ1YbvR78m4ydm56XKN/d/yF3803ivMGfZMYDtiMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260921.1.tgz", + "integrity": "sha512-FpqVR7IQXVBmGtajyonEmhmb5UAsmV7dTaIkpemmHZXHEw7uYpkhkzKPjc4BOPhNQy8iwt2p+RZBPMY3Y7/bvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260921.1.tgz", + "integrity": "sha512-riAJIohaVp5A8Sqy4yKlzHOaLPOICMf5oey+jC2rm45RVT+wK8+7UU0d31Dy/02Nc8YUkobAFwNVjX06P8WQ5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260921.1.tgz", + "integrity": "sha512-tnJu08tT7s0XWDqp3O0H/vCp0voy9OqVAzspb89biMo1dh8IiEpnyXnoPmdJ7H4qBnXCmXgy0kuEphuvpDPj9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260921.1.tgz", + "integrity": "sha512-VgNcRPstoZMb1G94JTrx+jU24GtkkazNfox0gnF/2fkuXpcfW/M0e0xvdMovYfwt8ZxG5AB2ZNvanD6ufBwiuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "examples/container-legacy/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/miniflare": { + "version": "5.20260921.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260921.0-alpha.tgz", + "integrity": "sha512-vHH/unOYvV2jA1Q9SdkmzrQhhMoksdwg5jegu6ZeKaaRzgxZhVbt1NdTpQjHF2VTgiBjgP8SiUlUMfruB3N3SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260921.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" }, - "devDependencies": { - "typescript": "^6.0.3", - "wrangler": "^4.137.0" + "engines": { + "node": ">=22.0.0" } }, - "examples/container-legacy/node_modules/@cloudflare/unenv-preset": { + "examples/container-legacy/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "examples/container-legacy/node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "examples/container-legacy/node_modules/workerd": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260921.1.tgz", + "integrity": "sha512-4HyG7G1W4ksa6tUZ8bV2jxDRWuL5PXnHm9+Z1sjFPb9OZNoYtXz4y7QQRh4ibi0BF/lOmlAVjbhkUqsAVZuUKA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260921.1", + "@cloudflare/workerd-darwin-arm64": "1.20260921.1", + "@cloudflare/workerd-linux-64": "1.20260921.1", + "@cloudflare/workerd-linux-arm64": "1.20260921.1", + "@cloudflare/workerd-windows-64": "1.20260921.1" + } + }, + "examples/container-legacy/node_modules/wrangler": { + "version": "4.137.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.137.0.tgz", + "integrity": "sha512-vq2JmxkvwOjnsMUejQwd89/EK6u1d20OMlGUVVmb55gVi2zSBKp2rvmxUiPqrSEntK8NwzpTF3DQ/S2I9/TLsg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260921.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260921.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260921.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "examples/container-legacy/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "examples/container/node_modules/@cloudflare/unenv-preset": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", @@ -2588,7 +3430,7 @@ } } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-darwin-64": { + "examples/container/node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260921.1.tgz", "integrity": "sha512-3iB2WnYOlZ29T+1zhCwbHFExCBp6E9bgmDUMryATYwrIGEQ1YbvR78m4ydm56XKN/d/yF3803ivMGfZMYDtiMg==", @@ -2605,7 +3447,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-darwin-arm64": { + "examples/container/node_modules/@cloudflare/workerd-darwin-arm64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260921.1.tgz", "integrity": "sha512-FpqVR7IQXVBmGtajyonEmhmb5UAsmV7dTaIkpemmHZXHEw7uYpkhkzKPjc4BOPhNQy8iwt2p+RZBPMY3Y7/bvQ==", @@ -2622,7 +3464,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-linux-64": { + "examples/container/node_modules/@cloudflare/workerd-linux-64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260921.1.tgz", "integrity": "sha512-riAJIohaVp5A8Sqy4yKlzHOaLPOICMf5oey+jC2rm45RVT+wK8+7UU0d31Dy/02Nc8YUkobAFwNVjX06P8WQ5g==", @@ -2639,7 +3481,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-linux-arm64": { + "examples/container/node_modules/@cloudflare/workerd-linux-arm64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260921.1.tgz", "integrity": "sha512-tnJu08tT7s0XWDqp3O0H/vCp0voy9OqVAzspb89biMo1dh8IiEpnyXnoPmdJ7H4qBnXCmXgy0kuEphuvpDPj9w==", @@ -2656,7 +3498,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-windows-64": { + "examples/container/node_modules/@cloudflare/workerd-windows-64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260921.1.tgz", "integrity": "sha512-VgNcRPstoZMb1G94JTrx+jU24GtkkazNfox0gnF/2fkuXpcfW/M0e0xvdMovYfwt8ZxG5AB2ZNvanD6ufBwiuQ==", @@ -2673,7 +3515,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@emnapi/runtime": { + "examples/container/node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", @@ -2684,7 +3526,7 @@ "tslib": "^2.4.0" } }, - "examples/container-legacy/node_modules/@img/sharp-darwin-arm64": { + "examples/container/node_modules/@img/sharp-darwin-arm64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", @@ -2707,7 +3549,7 @@ "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-darwin-x64": { + "examples/container/node_modules/@img/sharp-darwin-x64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", @@ -2730,7 +3572,7 @@ "@img/sharp-libvips-darwin-x64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-freebsd-wasm32": { + "examples/container/node_modules/@img/sharp-freebsd-wasm32": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", @@ -2750,7 +3592,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-darwin-arm64": { + "examples/container/node_modules/@img/sharp-libvips-darwin-arm64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", @@ -2767,7 +3609,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-darwin-x64": { + "examples/container/node_modules/@img/sharp-libvips-darwin-x64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", @@ -2784,7 +3626,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-arm": { + "examples/container/node_modules/@img/sharp-libvips-linux-arm": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", @@ -2804,7 +3646,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-arm64": { + "examples/container/node_modules/@img/sharp-libvips-linux-arm64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", @@ -2824,7 +3666,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-ppc64": { + "examples/container/node_modules/@img/sharp-libvips-linux-ppc64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", @@ -2844,7 +3686,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-riscv64": { + "examples/container/node_modules/@img/sharp-libvips-linux-riscv64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", @@ -2864,7 +3706,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-s390x": { + "examples/container/node_modules/@img/sharp-libvips-linux-s390x": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", @@ -2884,7 +3726,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-x64": { + "examples/container/node_modules/@img/sharp-libvips-linux-x64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", @@ -2904,7 +3746,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "examples/container/node_modules/@img/sharp-libvips-linuxmusl-arm64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", @@ -2924,7 +3766,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "examples/container/node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", @@ -2944,7 +3786,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-arm": { + "examples/container/node_modules/@img/sharp-linux-arm": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", @@ -2970,7 +3812,7 @@ "@img/sharp-libvips-linux-arm": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-arm64": { + "examples/container/node_modules/@img/sharp-linux-arm64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", @@ -2996,7 +3838,7 @@ "@img/sharp-libvips-linux-arm64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-ppc64": { + "examples/container/node_modules/@img/sharp-linux-ppc64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", @@ -3022,7 +3864,7 @@ "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-riscv64": { + "examples/container/node_modules/@img/sharp-linux-riscv64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", @@ -3048,7 +3890,7 @@ "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-s390x": { + "examples/container/node_modules/@img/sharp-linux-s390x": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", @@ -3074,7 +3916,7 @@ "@img/sharp-libvips-linux-s390x": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-x64": { + "examples/container/node_modules/@img/sharp-linux-x64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", @@ -3100,7 +3942,7 @@ "@img/sharp-libvips-linux-x64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linuxmusl-arm64": { + "examples/container/node_modules/@img/sharp-linuxmusl-arm64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", @@ -3126,7 +3968,7 @@ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linuxmusl-x64": { + "examples/container/node_modules/@img/sharp-linuxmusl-x64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", @@ -3152,7 +3994,7 @@ "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-wasm32": { + "examples/container/node_modules/@img/sharp-wasm32": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", @@ -3169,7 +4011,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-webcontainers-wasm32": { + "examples/container/node_modules/@img/sharp-webcontainers-wasm32": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", @@ -3189,7 +4031,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-win32-arm64": { + "examples/container/node_modules/@img/sharp-win32-arm64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", @@ -3209,7 +4051,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-win32-ia32": { + "examples/container/node_modules/@img/sharp-win32-ia32": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", @@ -3229,7 +4071,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-win32-x64": { + "examples/container/node_modules/@img/sharp-win32-x64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", @@ -3249,7 +4091,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/miniflare": { + "examples/container/node_modules/miniflare": { "version": "5.20260921.0-alpha", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260921.0-alpha.tgz", "integrity": "sha512-vHH/unOYvV2jA1Q9SdkmzrQhhMoksdwg5jegu6ZeKaaRzgxZhVbt1NdTpQjHF2VTgiBjgP8SiUlUMfruB3N3SQ==", @@ -3267,14 +4109,14 @@ "node": ">=22.0.0" } }, - "examples/container-legacy/node_modules/path-to-regexp": { + "examples/container/node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, "license": "MIT" }, - "examples/container-legacy/node_modules/sharp": { + "examples/container/node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", @@ -3324,7 +4166,7 @@ } } }, - "examples/container-legacy/node_modules/workerd": { + "examples/container/node_modules/workerd": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260921.1.tgz", "integrity": "sha512-4HyG7G1W4ksa6tUZ8bV2jxDRWuL5PXnHm9+Z1sjFPb9OZNoYtXz4y7QQRh4ibi0BF/lOmlAVjbhkUqsAVZuUKA==", @@ -3345,7 +4187,7 @@ "@cloudflare/workerd-windows-64": "1.20260921.1" } }, - "examples/container-legacy/node_modules/wrangler": { + "examples/container/node_modules/wrangler": { "version": "4.137.0", "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.137.0.tgz", "integrity": "sha512-vq2JmxkvwOjnsMUejQwd89/EK6u1d20OMlGUVVmb55gVi2zSBKp2rvmxUiPqrSEntK8NwzpTF3DQ/S2I9/TLsg==", @@ -3381,7 +4223,7 @@ } } }, - "examples/container-legacy/node_modules/ws": { + "examples/container/node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", @@ -12502,6 +13344,10 @@ "resolved": "examples/celld", "link": true }, + "node_modules/@example/computer-container": { + "resolved": "examples/container", + "link": true + }, "node_modules/@example/computer-container-legacy": { "resolved": "examples/container-legacy", "link": true diff --git a/packages/computer/README.md b/packages/computer/README.md index 473fd2eb..dc9b81b4 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -238,7 +238,8 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and | Backend | Import | Runs | Needs | | --- | --- | --- | --- | -| **Container** | `@cloudflare/computer/backends/container-legacy` | Shell commands in full Linux userland (real binaries, `npm`, `node`, network) | A Cloudflare Container running `computerd` | +| **Container** | `@cloudflare/computer/backends/container` | Shell commands in full Linux userland (real binaries, `npm`, `node`, network) | A Cloudflare Container running `computerd`, scheduled by the durable object (`scheduling_policy: "durable_object"`) | +| **Container (legacy)** | `@cloudflare/computer/backends/container-legacy` | The same | A Cloudflare Container the platform schedules and sizes from the `containers` block | | **Worker shell** | `@cloudflare/computer/backends/worker-shell` | Shell commands via [just-bash](https://github.com/vercel-labs/just-bash) in a Dynamic Worker | A Worker Loader binding; `experimental` flag | | **Worker JavaScript** | `@cloudflare/computer/backends/worker-javascript` | ECMAScript modules in a fresh Dynamic Worker | A Worker Loader binding; `experimental` flag | diff --git a/packages/computer/src/backends/container/health-probe.ts b/packages/computer/src/backends/container/health-probe.ts index af53ad67..7ede4877 100644 --- a/packages/computer/src/backends/container/health-probe.ts +++ b/packages/computer/src/backends/container/health-probe.ts @@ -1,4 +1,5 @@ -// Duplicated verbatim from ../container-legacy/. The two backends serve +// Duplicated from ../container-legacy/, differing only in the name of +// the host interface it probes through. The two backends serve // different container scheduling policies, but this file touches // neither the launch spec nor the policy, so there is no divergence // pressure on it and the copy is deliberate rather than overlooked. From bed8c85bd6decbf178db1702892d586838aba96f Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 1 Oct 2026 11:18:55 +0100 Subject: [PATCH 3/3] ci: Test the new container example alongside the legacy one This branch adds examples/container back next to examples/container-legacy, so the example matrix tests both. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87115310..25b57935 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,9 @@ jobs: - name: assets workspace: "@example/computer-assets" path: examples/assets + - name: container + workspace: "@example/computer-container" + path: examples/container - name: container-legacy workspace: "@example/computer-container-legacy" path: examples/container-legacy