diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index 0ca1546..5667978 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -200,6 +200,7 @@ Runnable examples live at the repo root, not inside any package: examples/ ├── container/ # Reference container image for computerd ├── worker-shell/ # WorkerShellBackend example +├── worker-javascript/ # WorkerJavaScriptBackend example ├── code/ # workspace.runtime with Worker and Container shells └── think/ # @cloudflare/think integration ``` diff --git a/examples/worker-javascript/.gitignore b/examples/worker-javascript/.gitignore new file mode 100644 index 0000000..d27f2ec --- /dev/null +++ b/examples/worker-javascript/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +.wrangler/ diff --git a/examples/worker-javascript/README.md b/examples/worker-javascript/README.md new file mode 100644 index 0000000..05cd2d1 --- /dev/null +++ b/examples/worker-javascript/README.md @@ -0,0 +1,142 @@ +# worker-javascript example + +> [!IMPORTANT] +> **PREVIEW ONLY** This package is provided as a preview for feedback only. +> APIs are unstable and the design is subject to change. + +A Cloudflare Worker + Durable Object that runs a Workspace whose +runtime evaluates **ECMAScript modules** in a **Dynamic Worker** +loaded through `env.LOADER`. It mirrors +[`examples/worker-shell`](../worker-shell) beat for beat — same DO +class, same routes, same R2 mount — but `exec` runs a JavaScript +module instead of a just-bash command. + +## Architecture + +``` +client ─► Worker /c//{file,exec} + │ (DO RPC calls) + ▼ + DO (ContainerExample) ──► Workspace ──► WorkerJavaScriptBackend + │ + │ env.LOADER.load(...) + ▼ + Dynamic Worker + (module runner) + │ + │ evaluate(input, bridge) + ▼ + host capability bridge + (fs, git, artifacts) +``` + +1. The DO constructs a `WorkerJavaScriptBackend` from + `@cloudflare/computer/backends/worker-javascript`, passing only + the Loader binding. Unlike the shell backend there is no + `WorkspaceServiceProxy` loopback: the backend is self-contained + and reaches the host through the `WorkspaceRuntimeBridge` it + builds internally. +2. Each `exec(source, { backend: "worker-javascript", input })` + builds a module graph from the source, mints a fresh Dynamic + Worker through `env.LOADER.load(...)`, and calls the runner's + `evaluate(input, bridge)`. The module's default export receives + the structured `input` and its return value becomes the + result's `value`. +3. Filesystem, Git, and artifacts operations from inside the module + round-trip through the bridge to the host DO's own store, so + storage handles stay valid and one workspace per DO is the + natural boundary. +4. `BackendHandle.sync` is `"none"`. There's a single authoritative + store (the DO's SQLite); push and pull short-circuit. + `pushed` / `pulled` are always zero. + +The backend requires `waitUntil`, so the DO passes +`ctx.waitUntil.bind(ctx)` into the Workspace options. The DO is a +thin host; the Dynamic Worker lifecycle is the loader's problem. + +## Paths + +`PUT /c//file/workspace/hello.txt` writes +`/workspace/hello.txt`, and +`GET /c//file/workspace/r2/x` reads `/workspace/r2/x` — +the URL and the on-disk path always match. Any URL outside +`/workspace` returns 400. + +`exec` runs with `cwd` defaulting to `/workspace`. The source is +an ECMAScript module; its default export may be a function that +receives the request's `input` value and returns a +JSON-compatible result. The Dynamic Worker has +`globalOutbound: null`, so the module can't reach the public +internet on its own. + +## R2 mount + +Identical to the worker-shell example. Seed once with: + +```sh +npm run seed:r2:local --workspace @example/computer-worker-javascript + +# or after deploy +npm run seed:r2 --workspace @example/computer-worker-javascript +``` + +## HTTP surface + +``` +PUT /c//file/workspace/ raw body → writeFile at /workspace/ +GET /c//file/workspace/ octet-stream of /workspace/ + (any path outside /workspace returns 400) +POST /c//exec { source, input?, cwd? } + cwd defaults to /workspace + → JSON { status, exitCode, stdout, stderr, value } +``` + +## Run it locally + +No Docker, no extra build step. The module runner ships inside +`@cloudflare/computer/backends/worker-javascript`; +`WorkerJavaScriptBackend` mints the Dynamic Worker through the +Loader binding internally so the DO constructor stays a one-line +backend invocation. + +```sh +npm run dev --workspace @example/computer-worker-javascript +``` + +Smoke test: + +```sh +curl http://127.0.0.1:8787/ + +echo 'hello' | curl -X PUT --data-binary @- \ + http://127.0.0.1:8787/c/demo/file/workspace/hello.txt + +curl http://127.0.0.1:8787/c/demo/file/workspace/hello.txt + +curl -X POST http://127.0.0.1:8787/c/demo/exec \ + -H 'content-type: application/json' \ + -d '{"source":"import { readFile } from \"node:fs/promises\";\nexport default async () => (await readFile(\"/workspace/hello.txt\", \"utf8\")).trim();"}' + +curl -X POST http://127.0.0.1:8787/c/demo/exec \ + -H 'content-type: application/json' \ + -d '{"source":"export default (input) => input.n * 2;","input":{"n":21}}' +``` + +## Layout + +``` +examples/worker-javascript/ + wrangler.jsonc Worker + DO + worker_loaders binding + src/index.ts Worker handler + DO (ContainerExample) +``` + +Nothing else. The Dynamic Worker module runner ships from +`@cloudflare/computer/backends/worker-javascript`. + +## Known limitations + +- **Exec is run-and-collect.** The handler awaits + `handle.result()` and emits one JSON response. +- **One execution at a time by default.** The backend admits a + single execution per workspace unless configured otherwise, and + bounds completed-execution retention by time and count. diff --git a/examples/worker-javascript/package.json b/examples/worker-javascript/package.json new file mode 100644 index 0000000..6188206 --- /dev/null +++ b/examples/worker-javascript/package.json @@ -0,0 +1,22 @@ +{ + "name": "@example/computer-worker-javascript", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object that evaluates ECMAScript modules inside a Dynamic Worker via @cloudflare/computer's WorkerJavaScriptBackend.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "seed:r2": "wrangler r2 object put computer-worker-javascript-hello/hello.txt --file ./seed/data/hello.txt --remote", + "seed:r2:local": "wrangler r2 object put computer-worker-javascript-hello/hello.txt --file ./seed/data/hello.txt --local" + }, + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "wrangler": "^4.107.1" + } +} diff --git a/examples/worker-javascript/seed/data/hello.txt b/examples/worker-javascript/seed/data/hello.txt new file mode 100644 index 0000000..3b18e51 --- /dev/null +++ b/examples/worker-javascript/seed/data/hello.txt @@ -0,0 +1 @@ +hello world diff --git a/examples/worker-javascript/src/index.ts b/examples/worker-javascript/src/index.ts new file mode 100644 index 0000000..4f0fc3e --- /dev/null +++ b/examples/worker-javascript/src/index.ts @@ -0,0 +1,152 @@ +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + getWorkspace, + R2Bucket, + type WorkspaceRuntimeValue, + withWorkspace, +} from "@cloudflare/computer"; +import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript"; + +export class ContainerExample extends withWorkspace(class extends DurableObject {}, (self) => { + const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env }; + return { + storage: ctx.storage as unknown as DurableObjectStorageLike, + waitUntil: ctx.waitUntil.bind(ctx), + backends: [new WorkerJavaScriptBackend({ loader: env.LOADER })], + mounts: { + "/workspace/r2": R2Bucket(env.Bucket), + }, + }; +}) {} + +interface ExecRequest { + source?: string; + input?: WorkspaceRuntimeValue; + cwd?: string; +} + +const MOUNT_ROOT = "/workspace"; + +function resolveMountPath(rest: string): string | null { + const candidate = `/${rest}`; + if (candidate !== MOUNT_ROOT && !candidate.startsWith(`${MOUNT_ROOT}/`)) { + return null; + } + 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( + [ + "worker-javascript example", + "", + ` PUT /c//file/workspace/ write file at ${MOUNT_ROOT}/`, + ` GET /c//file/workspace/ read file at ${MOUNT_ROOT}/`, + " POST /c//exec run an ECMAScript module (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)); + 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.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); + } + + if (typeof body.source !== "string" || body.source.length === 0) { + return errorJSON(new Error("must provide source"), 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(body.source, { + backend: "worker-javascript", + cwd: body.cwd, + input: body.input, + 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" }, + }); +} diff --git a/examples/worker-javascript/tsconfig.json b/examples/worker-javascript/tsconfig.json new file mode 100644 index 0000000..4a2585d --- /dev/null +++ b/examples/worker-javascript/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"], + "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/worker-javascript/worker-configuration.d.ts b/examples/worker-javascript/worker-configuration.d.ts new file mode 100644 index 0000000..632c892 --- /dev/null +++ b/examples/worker-javascript/worker-configuration.d.ts @@ -0,0 +1,9 @@ +// Hand-written env shape for the platform Worker. Run +// `wrangler types` to regenerate from wrangler.jsonc when the +// bindings change. + +interface Env { + ContainerExample: DurableObjectNamespace; + LOADER: WorkerLoader; + Bucket: R2Bucket; +} diff --git a/examples/worker-javascript/wrangler.jsonc b/examples/worker-javascript/wrangler.jsonc new file mode 100644 index 0000000..83c9b55 --- /dev/null +++ b/examples/worker-javascript/wrangler.jsonc @@ -0,0 +1,51 @@ +{ + // Example: Worker + Durable Object that runs a Workspace whose + // runtime is an ECMAScript module evaluated in a Dynamic Worker + // loaded through env.LOADER. + // + // Mirrors examples/worker-shell beat for beat — same DO class + // name, same routes, same R2 mount. The only difference is the + // backend: WorkerJavaScriptBackend instead of WorkerShellBackend, + // so exec evaluates JavaScript modules rather than shell commands. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-worker-javascript-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + // Worker Loader binding. The DO's WorkerJavaScriptBackend calls + // env.LOADER.load(...) to mint a Dynamic Worker that evaluates + // each module. + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "ContainerExample", + "class_name": "ContainerExample" + } + ] + }, + + // R2 bucket mounted into every Workspace at /workspace/r2 via + // R2Bucket(...). Seed it once with: + // npm run seed:r2 --workspace @example/computer-worker-javascript + // which uploads ./seed/data/hello.txt to the bucket. + "r2_buckets": [ + { + "binding": "Bucket", + "bucket_name": "computer-worker-javascript-hello" + } + ], + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ContainerExample"] + } + ] +} diff --git a/package-lock.json b/package-lock.json index acd4bcd..82f6263 100644 --- a/package-lock.json +++ b/package-lock.json @@ -220,8 +220,8 @@ "dev": true, "license": "MIT OR Apache-2.0" }, - "examples/worker": { - "name": "@example/computer-worker", + "examples/worker-javascript": { + "name": "@example/computer-worker-javascript", "version": "0.0.0", "dependencies": { "@cloudflare/computer": "*" @@ -232,10 +232,29 @@ "wrangler": "^4.107.1" } }, - "examples/worker/node_modules/@cloudflare/workers-types": { - "version": "4.20260702.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", - "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "examples/worker-javascript/node_modules/@cloudflare/workers-types": { + "version": "4.20260626.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260626.1.tgz", + "integrity": "sha512-fBnpQyFRS3Ce1l2IUd3k+aUxgy/7VMlVXF4F672/eSrpXFeezCy3Ha6Z2uTyGgqu9sGvQPOj8nqKBv2yeI+ciw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "examples/worker-shell": { + "name": "@example/computer-worker-shell", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "wrangler": "^4.107.1" + } + }, + "examples/worker-shell/node_modules/@cloudflare/workers-types": { + "version": "4.20260626.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260626.1.tgz", + "integrity": "sha512-fBnpQyFRS3Ce1l2IUd3k+aUxgy/7VMlVXF4F672/eSrpXFeezCy3Ha6Z2uTyGgqu9sGvQPOj8nqKBv2yeI+ciw==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -2822,8 +2841,12 @@ "resolved": "examples/tutorial", "link": true }, - "node_modules/@example/computer-worker": { - "resolved": "examples/worker", + "node_modules/@example/computer-worker-javascript": { + "resolved": "examples/worker-javascript", + "link": true + }, + "node_modules/@example/computer-worker-shell": { + "resolved": "examples/worker-shell", "link": true }, "node_modules/@exodus/bytes": { diff --git a/packages/computer/src/backend.ts b/packages/computer/src/backend.ts index ac3237c..0156fd2 100644 --- a/packages/computer/src/backend.ts +++ b/packages/computer/src/backend.ts @@ -38,6 +38,13 @@ export interface WorkspaceBackend { // Workspace selection logic. readonly type: string; + // Whether the backend accepts a structured `input` value and + // returns a structured `result` value. Independent of the + // implementation kind: a shell backend that coerces JSON to + // argv/stdin and parses stdout back to a value can be callable + // too. Defaults to false when omitted. + readonly callable?: boolean; + // Materialise a connection. Called lazily on first use, once // per backend per workspace lifetime. The Workspace caches the // resulting handle by `id`; subsequent exec / push / pull diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index ce4c5f5..daba801 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -122,6 +122,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { readonly protocol = "module" as const; readonly requiresWaitUntil = true; readonly type = "worker-javascript"; + readonly callable = true; readonly id: string; readonly #options: ResolvedWorkerJavaScriptBackendOptions; diff --git a/packages/computer/src/runtime/runtime.test.ts b/packages/computer/src/runtime/runtime.test.ts new file mode 100644 index 0000000..cc27ba2 --- /dev/null +++ b/packages/computer/src/runtime/runtime.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WorkspaceShell } from "../shell.js"; +import { WorkspaceRuntime } from "./runtime.js"; +import type { ModuleExecutionEnvelope, WorkspaceModuleBackendHandle } from "./types.js"; + +function emptyEnvelope(id: string): ModuleExecutionEnvelope { + return { + id, + events: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }; +} + +function moduleHandleStub(): WorkspaceModuleBackendHandle { + return { + exec: vi.fn(async (input) => emptyEnvelope(input.id ?? "exec")), + getExec: vi.fn(async (input) => emptyEnvelope(input.id)), + killExec: vi.fn(async () => {}), + disposeExec: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; +} + +describe("WorkspaceRuntime callable gate", () => { + it("rejects structured input for a non-callable backend", async () => { + const runtime = new WorkspaceRuntime({ + commandBackendIds: new Set(["worker-shell"]), + callableBackendIds: new Set(), + shell: () => ({}) as unknown as WorkspaceShell, + moduleHandle: async () => moduleHandleStub(), + resolveBackendId: () => "worker-shell", + }); + + await expect( + runtime.exec("echo hi", { backend: "worker-shell", input: { n: 1 } }), + ).rejects.toThrow(/not callable/); + }); + + it("accepts structured input for a callable module backend", async () => { + const handle = moduleHandleStub(); + const runtime = new WorkspaceRuntime({ + commandBackendIds: new Set(), + callableBackendIds: new Set(["worker-javascript"]), + shell: () => ({}) as unknown as WorkspaceShell, + moduleHandle: async () => handle, + resolveBackendId: () => "worker-javascript", + }); + + await runtime.exec("export default (i) => i", { + backend: "worker-javascript", + input: { n: 1 }, + }); + + expect(handle.exec).toHaveBeenCalledWith(expect.objectContaining({ input: { n: 1 } })); + }); +}); diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index e7a9895..05cc219 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -14,6 +14,7 @@ import type { interface WorkspaceRuntimeRouterOptions { commandBackendIds: ReadonlySet; + callableBackendIds: ReadonlySet; shell: () => WorkspaceShell; moduleHandle: (id: string) => Promise; resolveBackendId: (id: string | undefined) => string; @@ -41,10 +42,12 @@ export class WorkspaceRuntime { ): Promise> { if (options.id !== undefined) assertExecutionId(options.id); const backend = this.#backend(options.backend); + if (options.input !== undefined && !this.#options.callableBackendIds.has(backend)) { + throw new Error( + `Backend ${JSON.stringify(backend)} is not callable; it does not accept structured input.`, + ); + } if (this.#options.commandBackendIds.has(backend)) { - if (options.input !== undefined) { - throw new Error(`Backend ${JSON.stringify(backend)} does not accept structured input.`); - } const shell = this.#options.shell(); const handle = await ( shell.exec as unknown as ( diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 7964de2..a7aaa78 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -170,6 +170,7 @@ export interface WorkspaceModuleBackend { readonly id: string; readonly requiresWaitUntil?: boolean; readonly type: string; + readonly callable?: boolean; connect(host: WorkspaceModuleBackendHost): Promise; } diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index 7d6b0b3..115504f 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -192,6 +192,7 @@ export class Workspace { readonly #backendsById: Map; readonly #moduleBackendsById: Map; readonly #registeredBackendIds: Set; + readonly #callableBackendIds: Set; readonly #defaultBackendId: string | undefined; readonly #defaultCommandBackendId: string | undefined; readonly #observer: WorkspaceObserver; @@ -291,6 +292,9 @@ export class Workspace { registered.filter(isModuleBackend).map((backend) => [backend.id, backend]), ); this.#registeredBackendIds = new Set(); + this.#callableBackendIds = new Set( + registered.filter((backend) => backend.callable === true).map((backend) => backend.id), + ); for (const backend of registered) { if (this.#registeredBackendIds.has(backend.id)) { throw new Error( @@ -408,6 +412,7 @@ export class Workspace { if (!this.#runtime) { this.#runtime = new WorkspaceRuntime({ commandBackendIds: new Set(this.#backendsById.keys()), + callableBackendIds: this.#callableBackendIds, shell: () => this.#routedShell(), moduleHandle: (id) => this.#moduleHandleFor(id), resolveBackendId: (id) => this.#resolveBackendId(id) ?? "",