Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/10_project_layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
3 changes: 3 additions & 0 deletions examples/worker-javascript/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
node_modules/
.wrangler/
142 changes: 142 additions & 0 deletions examples/worker-javascript/README.md
Original file line number Diff line number Diff line change
@@ -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/<name>/{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/<name>/file/workspace/hello.txt` writes
`/workspace/hello.txt`, and
`GET /c/<name>/file/workspace/r2/x` reads `/workspace/r2/x` —
the URL and the on-disk path always match. 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/<name>/file/workspace/<path> raw body → writeFile at /workspace/<path>
GET /c/<name>/file/workspace/<path> octet-stream of /workspace/<path>
(any path outside /workspace returns 400)
POST /c/<name>/exec { 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.
22 changes: 22 additions & 0 deletions examples/worker-javascript/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions examples/worker-javascript/seed/data/hello.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello world
152 changes: 152 additions & 0 deletions examples/worker-javascript/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<Env> {}, (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<Response> {
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/<name>/file/workspace/<path> write file at ${MOUNT_ROOT}/<path>`,
` GET /c/<name>/file/workspace/<path> read file at ${MOUNT_ROOT}/<path>`,
" POST /c/<name>/exec run an ECMAScript module (JSON result)",
"",
].join("\n"),
{ headers: { "content-type": "text/plain" } },
);
}

return new Response("not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;

async function handleFile(
request: Request,
env: Env,
name: string,
path: string,
): Promise<Response> {
const stub = env.ContainerExample.get(env.ContainerExample.idFromName(name));
const ws = await getWorkspace(stub as unknown as Parameters<typeof getWorkspace>[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<Response> {
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<typeof getWorkspace>[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 }), {

Check warning

Code scanning / CodeQL

Information exposure through a stack trace Medium

This information exposed to the user depends on
stack trace information
.
This information exposed to the user depends on
stack trace information
.
This information exposed to the user depends on
stack trace information
.
This information exposed to the user depends on
stack trace information
.
This information exposed to the user depends on
stack trace information
.
status,
headers: { "content-type": "application/json" },
});
}
17 changes: 17 additions & 0 deletions examples/worker-javascript/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
9 changes: 9 additions & 0 deletions examples/worker-javascript/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -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<import("./src/index.js").ContainerExample>;
LOADER: WorkerLoader;
Bucket: R2Bucket;
}
Loading
Loading