Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/dynamic-javascript-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

Add the experimental `DynamicJavaScriptBackend` at `@cloudflare/computer/backends/dynamic-javascript`. It runs code like `WorkerJavaScriptBackend`, and runs a Workspace file whose default export extends `DurableObject` as a long-lived app: importing the file gives `ctx.facets` for that app, with `get`, `abort`, `delete` and `clone`, and each instance's methods run in a Durable Object facet with its own storage.
3 changes: 2 additions & 1 deletion docs/16_code_execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The selected backend defines how it interprets `source`.
| `container-shell` | shell command | Full Linux, native binaries, installed packages, processes |
| `worker-shell` | just-bash command | Fast text tools and Workspace Git without a Container |
| `worker-javascript` | ECMAScript module | Isolated structured JavaScript with Workspace host modules |
| `dynamic-javascript` | ECMAScript module | The same, plus long-lived Durable Object apps that code imports and calls (experimental) |

Applications may register additional command or module backends under their own IDs. Backend IDs are part of the execution contract: changing the backend may change the source language.

Expand Down Expand Up @@ -85,4 +86,4 @@ new WorkerJavaScriptBackend({

The backend argument is never itself authorization.

See [17. Isolate JavaScript](./17_isolate_javascript.md) for module behavior.
See [17. Isolate JavaScript](./17_isolate_javascript.md) for module behavior, and [20. Dynamic JavaScript](./20_dynamic_javascript.md) for apps.
2 changes: 1 addition & 1 deletion docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ A run also needs its Durable Object to stay in memory, and that depends on the h

CPU time is limited separately from wall-clock time. Each run's CPU limit is the smaller of its timeout and `maxCpuMs`, which defaults to `maxTimeoutMs`. CPU time adds up over the whole run, however much of it is spent waiting, and Cloudflare currently stops a Dynamic Worker call at about five minutes of CPU whatever the limit. A run that mostly waits on I/O uses little of that and can last hours. A run that computes heavily fails with `Worker exceeded CPU time limit.` once it reaches the cap. Every execution gets a fresh Dynamic Worker and a fresh CPU budget, so heavy work can be split across executions or moved to a container. The platform's subrequest limit, 10,000 by default on the Workers Paid plan, also counts every `fetch` over the whole run. A long run also holds one of the ten Dynamic Workers a Durable Object can have in flight for as long as it lasts.

Nothing keeps a run going across a deploy or a crash. A deploy shuts the old instance down a few minutes later, and a run that was in flight is marked as failed the next time the backend connects.
Nothing keeps a run going across a deploy or a crash. A deploy shuts the old instance down a few minutes later, and a run that was in flight is marked as failed the next time the backend connects. For state that lasts between runs, `DynamicJavaScriptBackend` runs Durable Object classes from Workspace files as long-lived apps that code can import and call. See [20. Dynamic JavaScript](./20_dynamic_javascript.md).

## Environment, standard input, and the `process` shim

Expand Down
148 changes: 148 additions & 0 deletions docs/20_dynamic_javascript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Dynamic JavaScript runtime

> [!NOTE]
> **Experimental.** `DynamicJavaScriptBackend` and the way it runs apps may change in any release. See the [status table](../packages/computer/README.md#status).

`DynamicJavaScriptBackend` lets a model make an app and then write code against it. The app is a Durable Object class in a Workspace file. Code that runs on the backend imports that file and calls methods on an instance of the class. Each instance keeps running between executions, with its own SQLite storage, as a [Durable Object facet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/) of the Durable Object that owns the Workspace.

Everything else works as on `WorkerJavaScriptBackend` ([17. Isolate JavaScript](./17_isolate_javascript.md)): the code you pass to `runtime.exec` runs in a fresh Dynamic Worker, with the same module rules, host modules, limits, events, and lifecycle. Apps are what this backend adds.

## Setup

The backend needs the Worker Loader binding and the host Durable Object's `ctx.facets`:

```ts
import { DurableObject } from "cloudflare:workers";
import { Workspace } from "@cloudflare/computer";
import { DynamicJavaScriptBackend } from "@cloudflare/computer/backends/dynamic-javascript";

export class Agent extends DurableObject<Env> {
workspace = new Workspace({
storage: this.ctx.storage,
backends: [
new DynamicJavaScriptBackend({
loader: this.env.LOADER,
facets: this.ctx.facets,
// Every WorkerJavaScriptBackend option works here too.
}),
],
});
}
```

```jsonc
// wrangler.jsonc
{
"worker_loaders": [{ "binding": "LOADER" }],
"durable_objects": { "bindings": [{ "name": "AGENT", "class_name": "Agent" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Agent"] }]
}
```

The backend's id and type are both `dynamic-javascript` unless you pass `id`.

## Make an app

An app is a Workspace file whose default export is a class that extends `DurableObject` from `cloudflare:workers`:

```js
// /workspace/counter.js
import { DurableObject } from "cloudflare:workers";
import fs from "node:fs/promises";

export default class Counter extends DurableObject {
increment(by = 1) {
const total = (this.ctx.storage.kv.get("total") ?? 0) + by;
this.ctx.storage.kv.put("total", total);
return total;
}

async report() {
await fs.writeFile("/workspace/total.txt", String(this.ctx.storage.kv.get("total") ?? 0));
}
}
```

It's a real Durable Object. `this.ctx.storage` has `sql` and `kv`, and `blockConcurrencyWhile` works. Inside its methods, an app can use `node:fs` for the Workspace's files, the backend's source modules, and its `ws:*` host modules, the same as code passed to `exec`. Its imports resolve as they do for that code, and it can import `cloudflare:workers`. Its `env` is empty, and it has no network access unless the backend's `globalOutbound` or `egress` gives it some.

The class has to extend `DurableObject` itself. A class that extends your own base class, even one that extends `DurableObject`, isn't recognized as an app.

## Write code against it

```js
import Counter from "./counter.js";

export default async function () {
const counter = Counter.get("main");
await counter.increment(2);
await counter.report();
return counter.increment(0);
}
```

When code imports an app, the class doesn't load into the code's isolate. The import gives [`ctx.facets`](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/) for that app instead: a `DurableObjectFacets`, with one facet per instance name.

| Call | What it does |
| --- | --- |
| `Counter.get(name)` | Returns the instance called `name`. Each name is a separate instance with its own storage. Getting one does no I/O. |
| `instance.method(...args)` | Runs the method in the instance and resolves with its result. The instance starts the first time it's called. |
| `Counter.abort(name, reason?)` | Stops the instance. Its fields are lost and its storage stays. The next call starts it again. |
| `Counter.delete(name)` | Stops the instance and deletes its storage. The file stays. |
| `Counter.clone(source, destination)` | Replaces the `destination` instance's storage with a copy of the `source` instance's, and restarts `destination`. Cloning an instance that has never stored anything leaves `destination` empty. |

It differs from a real `ctx.facets` in two ways. `get` doesn't take startup options, since the class is always the app's. And an instance isn't a full stub: each method call returns a promise of the result, `fetch` and `connect` aren't available, and values must be plain data, as described below. The runtime doesn't let code construct its own `DurableObjectFacets`, so the import copies its shape: `Counter.constructor.name` is `"DurableObjectFacets"`, and `get`, `abort`, `delete`, and `clone` are on its prototype. Call them on the import, as in `Counter.get(...)`, rather than passing `get` around. Methods on an instance belong to the app, so an app can have methods of its own called `abort` or `delete`.

Only the default export comes through, so a named import from an app's file fails before the run starts. Any other module the code imports that imports the app gets the same `ctx.facets`. A Workspace file that isn't an app imports as an ordinary module.

Arguments and results must be JSON-compatible plain data, and are checked the same way as `exec` input and results. A method can be called if the app's class, or a class between it and `DurableObject`, defines it. Its name can't be `constructor` or start with `_`, so `_helper()` stays private.

## What happens over time

An instance is named after the app's path and the instance's name. `/workspace/counter.js` and `/workspace/lib/../counter.js` are the same app. Moving or renaming the file starts a new app with empty storage, and deleting it leaves its instances' storage behind. Code reaches instances only by importing the app's file, so call `delete(name)` before you move or delete it, or put an app back at the old path to reach the old instances again.

When the app's file, or any module it imports, changes, each instance restarts on its next call and runs the new code. Its storage stays. The backend checks this on every call by reading the app's module graph from the Workspace.

An instance runs one call at a time, in order. A method never interleaves with another call to the same instance, even at an `await`. A call to one instance doesn't wait for calls to another. `clone` waits for the calls already queued on both instances, and later calls to either wait for it.

Keep anything that must last in storage, not in fields. An instance restarts when its code changes, when it's aborted, and when the host Durable Object restarts, is evicted, or is redeployed, since a facet lives and dies with its parent. A call that was running then fails and isn't retried.

Facets can't set alarms, so an app does nothing between calls. Something has to call it.

## Limits

Each call into an instance is one host call of the execution that makes it. It counts against that execution's capability limits and has to finish within `maxHostCallMs`. When it doesn't, the call fails, the execution fails as it would for any late host call, and the instance keeps running. The method can't reach the Workspace once its caller has stopped waiting, but it keeps going until it returns, and calls to that instance queue behind it until then. Call `abort` to stop a method that never returns.

Inside the instance, each call gets its own capability limits for its `node:fs` and host module calls, from the same options. A call's CPU time is limited by `maxCpuMs`.

When a call returns, the backend waits for Workspace calls the method started and didn't await, and refuses any that come later.

Calling an app can write to its storage, so a backend with `access: "read"` refuses every app call.

An app's console output goes nowhere for now.

An app can't call another app. An app that imports another app's file gets the plain class, not its `ctx.facets`.

## Planned: app interfaces

Not built yet. This section records the intended design so later changes can follow it.

An app should be able to have an interface that the agent shows and the user works in, while the agent keeps reading and changing the same instance from code. The plan copies how Cloudflare OS shows the apps its agent writes: over RPC, not HTTP.

The host side gets the same `ctx.facets` shape that code gets when it imports an app:

```ts
const todos = backend.facets("/workspace/todo.js");
const app = todos.get("main"); // an RpcTarget
await app.add("milk");
```

`get(name)` returns an `RpcTarget` whose calls go through the same path as calls from code: a bridge for `node:fs` and host modules, the method guard, plain-data checks, and the instance's queue. So a user and the agent never interleave inside a method. The raw facet stub isn't handed out: the runtime can't pass a facet stub over RPC, and it would skip those checks.

With an agent built on the agents SDK, such as the pi durable harness, the pieces fit like this:

1. The agent writes the app, `todo.js`, and its interface, a sibling file such as `todo.client.js`, then calls a `show_app({ path, name })` tool. The tool records that the app was shown in the session and returns details the chat client renders.
2. The agent's `WebSockets` capability serves an `openApp(path, name)` callable on the Cap'n Web transport (`useAgent({ transport: "capnweb" })`). It refuses apps the agent hasn't shown, and returns the instance from `backend.facets(path).get(name)` with the interface's source. On Workers, capnweb uses the runtime's own `RpcTarget`, so the instance reaches the browser as a live stub.
3. The client renders the result in a sandboxed `srcdoc` iframe with only `allow-scripts` and a `connect-src 'none'` content security policy, so the model-written interface has no origin and no network. Cap'n Web is injected into the frame, and the parent connects a `MessageChannel` to the app stub with `newMessagePortRpcSession`. Interface code calls `await app.add("milk")`. Console output and errors go to the parent, and the frame reopens when the file changes or the connection reconnects, since stubs end with their session.
4. The agent sees what the user did by calling the same instance from code, as in `Todo.get("main").list()`.

The open questions are how stubs on the agents Cap'n Web transport behave across reconnects, and what authority its callables run with.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ rather than an API.
| [17. Isolate JavaScript runtime](./17_isolate_javascript.md) | Stable, except `ws:container` | ECMAScript modules, durable imports, configured libraries, durable `node:fs/promises`, host modules, and managed lifecycle. |
| [18. Runtime migration](./18_runtime_migration.md) | Reference | Breaking preview-API mappings from public shell and script-execution surfaces to `workspace.runtime`. |
| [19. Performance](./19_performance.md) | Reference | Filesystem benchmarks: `fs-bench` numbers, an `npm install` comparison, and how to reproduce them. |
| [20. Dynamic JavaScript runtime](./20_dynamic_javascript.md) | Experimental | Durable Object apps in Workspace files, run as facets, and code that imports and calls them. |

## High-level API

Expand Down
8 changes: 8 additions & 0 deletions packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ removed in a future release; each one names its replacement.
| Observability hook and its Cloudflare adapter | `@cloudflare/computer`, `@cloudflare/computer/observe/cloudflare` | Stable |
| `ContainerBackend` and `withWorkspaceContainer` | `@cloudflare/computer/backends/container` | Experimental |
| `LegacyContainerBackend` and `withLegacyWorkspaceContainer` | `@cloudflare/computer/backends/container-legacy` | Deprecated: use `ContainerBackend` |
| `DynamicJavaScriptBackend` | `@cloudflare/computer/backends/dynamic-javascript` | Experimental |
| `WorkerShellBackend` and the shell command groups | `@cloudflare/computer/backends/worker-shell`, `@cloudflare/computer/shell/*` | Experimental |
| The `ws:container` host module | `@cloudflare/computer/modules/container` | Experimental |
| `workspace.push()`, `workspace.pull()`, and the sync protocol between the durable object and `computerd` | `@cloudflare/computer` | Experimental |
Expand Down Expand Up @@ -317,6 +318,7 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and
| **Container (legacy)** | Deprecated | `@cloudflare/computer/backends/container-legacy` | The same | A Cloudflare Container the platform schedules and sizes from the `containers` block |
| **Worker shell** | Experimental | `@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; the `just-bash` package |
| **Worker JavaScript** | Stable | `@cloudflare/computer/backends/worker-javascript` | ECMAScript modules in a fresh Dynamic Worker | A Worker Loader binding |
| **Dynamic JavaScript** | Experimental | `@cloudflare/computer/backends/dynamic-javascript` | The same, plus Durable Object apps from Workspace files that keep running between executions | A Worker Loader binding and the host Durable Object's `ctx.facets` |

- **Container** cold-starts more slowly but gives you a real Linux
environment. The container owns its own SQLite-backed VFS and this
Expand All @@ -339,6 +341,11 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and
run stays alive while its event stream is consumed. See
[`docs/17_isolate_javascript.md`](../../docs/17_isolate_javascript.md)
and [`examples/worker-javascript`](../../examples/worker-javascript).
- **Dynamic JavaScript** runs code the same way, and adds apps: a
Workspace file whose default export extends `DurableObject` runs as a
facet of the host Durable Object, with its own storage, and code
imports the file and calls methods on its instances. See
[`docs/20_dynamic_javascript.md`](../../docs/20_dynamic_javascript.md).

You can register several backends on one Workspace and route each call
to a named one — see [Multiple backends](#multiple-backends).
Expand Down Expand Up @@ -510,6 +517,7 @@ on a computerd instance.
| `@cloudflare/computer/backends/container-legacy` | Deprecated. `LegacyContainerBackend` and `withLegacyWorkspaceContainer`; use `@cloudflare/computer/backends/container` instead. |
| `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash runtime. |
| `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable imports, `node:fs/promises`, and host modules. |
| `@cloudflare/computer/backends/dynamic-javascript` | Experimental. `DynamicJavaScriptBackend`: the worker-javascript backend plus Durable Object apps run as facets. |
| `@cloudflare/computer/modules/container` | `createContainerModule()` for `ws:container`: run container commands from isolate JavaScript. |
| `@cloudflare/computer/modules/git` | `createGitModule()` for `ws:git`: confined Git from isolate JavaScript. |
| `@cloudflare/computer/modules/artifacts` | `createArtifactsModule()` for `ws:artifacts`: Artifacts and git tokens from isolate JavaScript. |
Expand Down
7 changes: 6 additions & 1 deletion packages/computer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@
"types": "./dist/backends/container/index.d.ts",
"import": "./dist/backends/container/index.js"
},
"./backends/dynamic-javascript": {
"types": "./dist/backends/dynamic-javascript/index.d.ts",
"import": "./dist/backends/dynamic-javascript/index.js"
},
"./backends/worker-javascript": {
"types": "./dist/backends/worker-javascript/index.d.ts",
"import": "./dist/backends/worker-javascript/index.js"
Expand Down Expand Up @@ -155,10 +159,11 @@
"prepare": "npm run build:shell-bundle",
"build": "rolldown -c",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"test": "vitest run && vitest run --config vitest.config.proxy.ts && vitest run --config vitest.config.worker-backend.ts && vitest run --config vitest.config.script-runner.ts && vitest run --config vitest.config.stub-soak.ts",
"test": "vitest run && vitest run --config vitest.config.proxy.ts && vitest run --config vitest.config.worker-backend.ts && vitest run --config vitest.config.script-runner.ts && vitest run --config vitest.config.dynamic-javascript.ts && vitest run --config vitest.config.stub-soak.ts",
"test:stub-soak": "vitest run --config vitest.config.stub-soak.ts",
"test:worker-backend": "vitest run --config vitest.config.worker-backend.ts",
"test:code": "vitest run --config vitest.config.script-runner.ts",
"test:dynamic-javascript": "vitest run --config vitest.config.dynamic-javascript.ts",
"test:harness": "./test-harness/run-harness.sh",
"bench:harness": "./test-harness/run-harness.sh bench",
"prepublishOnly": "npm run build && npm test"
Expand Down
Loading
Loading