diff --git a/.changeset/dynamic-javascript-backend.md b/.changeset/dynamic-javascript-backend.md new file mode 100644 index 000000000..03b00a221 --- /dev/null +++ b/.changeset/dynamic-javascript-backend.md @@ -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. diff --git a/docs/16_code_execution.md b/docs/16_code_execution.md index 34c316a22..17d34610f 100644 --- a/docs/16_code_execution.md +++ b/docs/16_code_execution.md @@ -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. @@ -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. diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index 0bf86598b..79eaca787 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -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 diff --git a/docs/20_dynamic_javascript.md b/docs/20_dynamic_javascript.md new file mode 100644 index 000000000..776c633f8 --- /dev/null +++ b/docs/20_dynamic_javascript.md @@ -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 { + 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. diff --git a/docs/README.md b/docs/README.md index 76d456d50..e343d2f01 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/packages/computer/README.md b/packages/computer/README.md index d5539d43d..676470b04 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -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 | @@ -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 @@ -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). @@ -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. | diff --git a/packages/computer/package.json b/packages/computer/package.json index 43e4b509f..7e0cd2f45 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -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" @@ -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" diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 5c8818b4e..cfc256aed 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -42,6 +42,7 @@ export default defineConfig({ "modules/assets": "src/modules/assets.ts", "backends/container-legacy/index": "src/backends/container-legacy/index.ts", "backends/container/index": "src/backends/container/index.ts", + "backends/dynamic-javascript/index": "src/backends/dynamic-javascript/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/dynamic-javascript/app-modules.test.ts b/packages/computer/src/backends/dynamic-javascript/app-modules.test.ts new file mode 100644 index 000000000..943f26cf9 --- /dev/null +++ b/packages/computer/src/backends/dynamic-javascript/app-modules.test.ts @@ -0,0 +1,64 @@ +import { durableObjectClassName } from "./app-modules.js"; + +describe("durableObjectClassName", () => { + it.each([ + [ + "a default-exported class", + `import { DurableObject } from "cloudflare:workers"; export default class Counter extends DurableObject {}`, + "Counter", + ], + [ + "an anonymous class", + `import { DurableObject } from "cloudflare:workers"; export default class extends DurableObject {}`, + "default", + ], + [ + "a class declared first", + `import { DurableObject } from "cloudflare:workers"; class Store extends DurableObject {} export default Store;`, + "Store", + ], + [ + "a class exported as default by name", + `import { DurableObject } from "cloudflare:workers"; class Store extends DurableObject {} export { Store as default };`, + "Store", + ], + [ + "a class expression in a const", + `import { DurableObject } from "cloudflare:workers"; const Store = class extends DurableObject {}; export default Store;`, + "Store", + ], + [ + "a renamed import", + `import { DurableObject as Base } from "cloudflare:workers"; export default class App extends Base {}`, + "App", + ], + [ + "a namespace import", + `import * as cf from "cloudflare:workers"; export default class App extends cf.DurableObject {}`, + "App", + ], + ])("recognizes %s", (_label, source, name) => { + expect(durableObjectClassName(source)).toBe(name); + }); + + it.each([ + ["a plain class", `export default class Plain {}`], + ["a function", `export default function run() {}`], + [ + "no default export", + `import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject {}`, + ], + [ + "a DurableObject from somewhere else", + `import { DurableObject } from "./base.js"; export default class App extends DurableObject {}`, + ], + [ + "another cloudflare:workers class", + `import { WorkerEntrypoint } from "cloudflare:workers"; export default class App extends WorkerEntrypoint {}`, + ], + ["a re-export", `export { default } from "./counter.js";`], + ["source that doesn't parse", `export default class {`], + ])("ignores %s", (_label, source) => { + expect(durableObjectClassName(source)).toBeUndefined(); + }); +}); diff --git a/packages/computer/src/backends/dynamic-javascript/app-modules.ts b/packages/computer/src/backends/dynamic-javascript/app-modules.ts new file mode 100644 index 000000000..d39b2ab98 --- /dev/null +++ b/packages/computer/src/backends/dynamic-javascript/app-modules.ts @@ -0,0 +1,299 @@ +import { parse } from "acorn"; + +import { + APP_MODULE, + CAPABILITIES_MODULE, + FACETS_MODULE, +} from "../worker-javascript/module-graph.js"; + +/** The class the app's Dynamic Worker exports for its facets. */ +export const APP_CLASS = "WorkspaceApp"; + +/** The method the host calls on a facet to run one of the app's methods. */ +export const DISPATCH_METHOD = "__workspaceCall"; + +/** + * The internal specifier the bridge routes app calls by. It isn't a + * `ws:*` name, so caller source can't import it; only the stand-in + * modules the backend generates call it. + */ +export const APPS_SPECIFIER = "dynamic-javascript:apps"; + +/** + * Whether a module's default export is a class that extends + * `DurableObject` from `cloudflare:workers` directly. That is what makes + * a Workspace file an app rather than an ordinary module. + * + * Reads the source without running it, so it recognizes the common + * forms: `export default class extends DurableObject`, a class declared + * first and exported as the default, and `DurableObject` imported under + * another name or through a namespace import. + * + * @param source - The module's source. + * @returns The class's name (`"default"` when it has none), or + * undefined when the module is not an app or doesn't parse. + */ +export function durableObjectClassName(source: string): string | undefined { + let ast: ReturnType; + try { + ast = parse(source, { ecmaVersion: "latest", sourceType: "module" }); + } catch { + return undefined; + } + const direct = new Set(); + const namespaces = new Set(); + const classes = new Map(); + let defaultExport: AstNode | undefined; + let defaultName: string | undefined; + for (const node of ast.body as unknown as AstNode[]) { + if (node.type === "ImportDeclaration" && node.source?.value === "cloudflare:workers") { + for (const specifier of node.specifiers ?? []) { + if (specifier.type === "ImportNamespaceSpecifier") + namespaces.add(identifier(specifier.local)); + else if ( + specifier.type === "ImportSpecifier" && + importedName(specifier.imported) === "DurableObject" + ) { + direct.add(identifier(specifier.local)); + } + } + } else if (node.type === "ClassDeclaration" && node.id) { + classes.set(identifier(node.id), node); + } else if (node.type === "VariableDeclaration") { + for (const declaration of node.declarations ?? []) { + if (declaration.id?.type === "Identifier" && declaration.init?.type === "ClassExpression") { + classes.set(identifier(declaration.id), declaration.init); + } + } + } else if (node.type === "ExportDefaultDeclaration") { + defaultExport = node.declaration; + } else if (node.type === "ExportNamedDeclaration") { + if (node.declaration?.type === "ClassDeclaration" && node.declaration.id) { + classes.set(identifier(node.declaration.id), node.declaration); + } + if (node.source) continue; + for (const specifier of node.specifiers ?? []) { + if (importedName(specifier.exported) === "default") { + defaultName = identifier(specifier.local); + } + } + } + } + let candidate: AstNode | undefined = defaultExport; + // A class expression bound to a name takes that name, as at run time. + let boundName: string | undefined; + if (candidate?.type === "Identifier") { + boundName = identifier(candidate); + candidate = classes.get(boundName); + } + if (candidate === undefined && defaultName !== undefined) { + boundName = defaultName; + candidate = classes.get(defaultName); + } + if (candidate?.type !== "ClassDeclaration" && candidate?.type !== "ClassExpression") { + return undefined; + } + const parent = candidate.superClass; + const extendsDurableObject = + (parent?.type === "Identifier" && direct.has(identifier(parent))) || + (parent?.type === "MemberExpression" && + parent.object?.type === "Identifier" && + namespaces.has(identifier(parent.object)) && + !parent.computed && + parent.property?.type === "Identifier" && + identifier(parent.property) === "DurableObject"); + if (!extendsDurableObject) return undefined; + return candidate.id ? identifier(candidate.id) : (boundName ?? "default"); +} + +/** + * The module that defines what code gets when it imports an app: an + * object shaped like `ctx.facets`, whose class is named + * `DurableObjectFacets` as the runtime's is. `get(name)` returns an + * instance whose method calls run in that app's facet, and `abort`, + * `delete`, and `clone` act on its instances. The runtime doesn't expose + * its own class to code, so this one copies its shape. + * + * @returns The module's source. + */ +export function facetsModule(): string { + return ` + import { call } from ${JSON.stringify(`./${CAPABILITIES_MODULE}`)}; + + const invoke = (operation, args) => call(${JSON.stringify(`host/${APPS_SPECIFIER}`)}, operation, args); + const constructing = Symbol("constructing"); + + // An app's class runs in its own Durable Object, not here. What code + // gets when it imports the app is one of these, ctx.facets for that + // app, with one facet per instance name. + class DurableObjectFacets { + #path; + #label; + + constructor(token, path, label) { + if (token !== constructing) throw new TypeError("Illegal constructor"); + this.#path = path; + this.#label = label; + } + + get(name) { + DurableObjectFacets.#check(this, "get", name); + const path = this.#path; + const label = this.#label + " instance " + JSON.stringify(name); + return new Proxy(Object.create(null), { + get(_target, method) { + // Not a thenable, so awaiting an instance doesn't call it, and + // printing one doesn't call into the app. + if (typeof method !== "string" || method === "then" || method === "valueOf") return undefined; + if (method === "toString" || method === "toJSON") return () => label; + return (...args) => invoke("call", [path, name, method, args]); + }, + }); + } + + abort(name, reason) { + DurableObjectFacets.#check(this, "abort", name); + return invoke("abort", [this.#path, name, reason === undefined ? null : String(reason)]); + } + + delete(name) { + DurableObjectFacets.#check(this, "delete", name); + return invoke("delete", [this.#path, name]); + } + + clone(source, destination) { + DurableObjectFacets.#check(this, "clone", source); + DurableObjectFacets.#check(this, "clone", destination); + return invoke("clone", [this.#path, source, destination]); + } + + get [Symbol.toStringTag]() { + return "DurableObjectFacets"; + } + + // A method called without its object, such as a destructured get, + // says what to do rather than failing on a private field. + static #check(facets, operation, name) { + if (typeof facets !== "object" || facets === null || !(#path in facets)) { + throw new TypeError("Call " + operation + "() on an app's import, as in Counter." + operation + "(...)."); + } + if (typeof name !== "string" || name === "") { + throw new TypeError(facets.#label + "." + operation + "() takes an instance's name, a non-empty string."); + } + } + } + + export function facetsFor(path, label) { + return new DurableObjectFacets(constructing, path, label); + } + `; +} + +/** + * The module exec code gets in place of an app's file: `ctx.facets` for + * that app. + * + * @param path - The app's absolute Workspace path. + * @param className - The app class's name, or `"default"` for an + * anonymous class, used in messages and printed instances. + * @param facetsImport - The specifier of the facets module. + * @returns The stand-in's source. + */ +export function appStandInModule(path: string, className: string, facetsImport: string): string { + const label = className === "default" ? "App" : className; + return ` + import { facetsFor } from ${JSON.stringify(facetsImport)}; + export default facetsFor(${JSON.stringify(path)}, ${JSON.stringify(label)}); + `; +} + +/** + * The main module of an app's Dynamic Worker. It exports a subclass of + * the app's class with one extra method, the dispatcher the host calls. + * The dispatcher installs the call's bridge, so `node:fs` and host + * modules work inside the app's methods, checks the method, calls it, + * and returns its result or error as plain data. + * + * @param entryName - The module name of the entry that re-exports the app. + * @returns The main module's source. + */ +export function appMainModule(entryName: string): string { + return ` + import { DurableObject } from "cloudflare:workers"; + import { install } from ${JSON.stringify(`./${CAPABILITIES_MODULE}`)}; + import App from ${JSON.stringify(`./${entryName}`)}; + + if (typeof App !== "function" || !(App.prototype instanceof DurableObject)) { + throw new TypeError("The app's default export must be a class that extends DurableObject."); + } + + // A method the app defines on its class, or a class between it and + // DurableObject. Fields that hold functions count too. Names that + // start with an underscore are private by convention, and nothing + // from DurableObject or Object is callable. + function findMethod(instance, method) { + if (typeof method !== "string" || method === "" || method === "constructor" || method.startsWith("_")) { + return undefined; + } + if (Object.hasOwn(instance, method)) { + return typeof instance[method] === "function" ? instance[method] : undefined; + } + for (let prototype = App.prototype; prototype && prototype !== DurableObject.prototype; prototype = Object.getPrototypeOf(prototype)) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, method); + if (descriptor === undefined) continue; + return typeof descriptor.value === "function" ? descriptor.value : undefined; + } + return undefined; + } + + export class ${APP_CLASS} extends App { + async ${DISPATCH_METHOD}(method, args, host) { + install(host); + try { + const fn = findMethod(this, method); + if (fn === undefined) { + throw new TypeError(App.name + " has no method " + JSON.stringify(method) + " to call."); + } + const result = await fn.apply(this, Array.isArray(args) ? args : []); + const value = result === undefined ? null : result; + await host.assertResult(value); + return { result: value }; + } catch (error) { + return { error: { message: error instanceof Error ? error.message : String(error) } }; + } + } + } + `; +} + +/** The module names of an app's main module and of the facets module. */ +export { APP_MODULE, FACETS_MODULE }; + +interface AstNode { + type: string; + id?: AstNode | null; + name?: string; + value?: unknown; + local?: AstNode; + imported?: AstNode; + exported?: AstNode; + source?: AstNode | null; + specifiers?: AstNode[]; + declaration?: AstNode; + declarations?: AstNode[]; + init?: AstNode | null; + superClass?: AstNode | null; + object?: AstNode; + property?: AstNode; + computed?: boolean; +} + +function identifier(node: AstNode | null | undefined): string { + return node?.name ?? ""; +} + +// `import { "DurableObject" as X }` names the import with a string. +function importedName(node: AstNode | undefined): string { + if (node === undefined) return ""; + return node.type === "Literal" ? String(node.value) : identifier(node); +} diff --git a/packages/computer/src/backends/dynamic-javascript/dynamic-javascript.test.ts b/packages/computer/src/backends/dynamic-javascript/dynamic-javascript.test.ts new file mode 100644 index 000000000..d8f89b9c5 --- /dev/null +++ b/packages/computer/src/backends/dynamic-javascript/dynamic-javascript.test.ts @@ -0,0 +1,50 @@ +import { DynamicJavaScriptBackend, type DynamicJavaScriptFacets } from "./dynamic-javascript.js"; + +// DurableObjectClass is an empty interface, so a plain object fills it. +const loader = { + load: () => ({ getEntrypoint: () => ({}), getDurableObjectClass: () => ({}) }), +}; +const facets: DynamicJavaScriptFacets = { + get: () => ({}), + abort: () => undefined, + delete: () => undefined, + clone: () => undefined, +}; + +describe("DynamicJavaScriptBackend", () => { + it("defaults its id and type to dynamic-javascript", () => { + const backend = new DynamicJavaScriptBackend({ loader, facets }); + expect(backend.id).toBe("dynamic-javascript"); + expect(backend.type).toBe("dynamic-javascript"); + expect(backend.callable).toBe(true); + }); + + it("needs the host Durable Object's facets", () => { + expect( + () => + // SAFETY: The test leaves out a required option on purpose. + new DynamicJavaScriptBackend({ loader } as never), + ).toThrow(/needs facets/); + }); + + it("names itself in option errors", () => { + expect( + () => new DynamicJavaScriptBackend({ loader, facets, defaultTimeoutMs: 2, maxTimeoutMs: 1 }), + ).toThrow("DynamicJavaScriptBackend defaultTimeoutMs cannot exceed maxTimeoutMs."); + }); + + it("describes apps beside its modules", () => { + const backend = new DynamicJavaScriptBackend({ + loader, + facets, + modules: { "ws:weather": { forecast: () => "sun" } }, + }); + expect(backend.description).toContain("extends `DurableObject` from `cloudflare:workers`"); + expect(backend.description).toContain( + "gives `ctx.facets` for that app, a `DurableObjectFacets`", + ); + expect(backend.description).toContain("`Counter.clone(source, destination)`"); + expect(backend.description).toContain("- `ws:weather`: exports `forecast`."); + expect(backend.description).toContain("Code has no direct network access."); + }); +}); diff --git a/packages/computer/src/backends/dynamic-javascript/dynamic-javascript.ts b/packages/computer/src/backends/dynamic-javascript/dynamic-javascript.ts new file mode 100644 index 000000000..3a450dc83 --- /dev/null +++ b/packages/computer/src/backends/dynamic-javascript/dynamic-javascript.ts @@ -0,0 +1,428 @@ +import { WorkspaceRuntimeBridge } from "../../runtime/bridge.js"; +import { assertRuntimeValue, WorkspaceRuntimeCapability } from "../../runtime/capability.js"; +import { dynamicWorkerEgress } from "../../runtime/egress.js"; +import type { + WorkspaceModuleBackend, + WorkspaceModuleBackendHandle, + WorkspaceModuleBackendHost, + WorkspaceModuleCallContext, + WorkspaceModuleFunctions, + WorkspaceRuntimeValue, +} from "../../runtime/types.js"; +import { buildModuleGraph, hasNodeModules } from "../worker-javascript/module-graph.js"; +import { + assertLoaderGraph, + describeJavaScriptBackend, + JavaScriptBackendHandle, + type JavaScriptBackendConnection, + type ResolvedWorkerJavaScriptBackendOptions, + resolveJavaScriptBackendOptions, + runtimeError, + type WorkerJavaScriptBackendOptions, +} from "../worker-javascript/worker-javascript.js"; +import { + APP_CLASS, + APP_MODULE, + APPS_SPECIFIER, + appMainModule, + appStandInModule, + DISPATCH_METHOD, + durableObjectClassName, + facetsModule, + FACETS_MODULE, +} from "./app-modules.js"; + +/** + * The Worker Loader binding, as the backend uses it. It loads each app + * as a Dynamic Worker and runs its Durable Object class as a facet. + */ +export interface DynamicJavaScriptLoader { + load(code: { + compatibilityDate: string; + compatibilityFlags?: string[]; + limits?: { cpuMs?: number }; + mainModule: string; + modules: Record; + env?: Record; + globalOutbound?: Fetcher | null; + }): { + getEntrypoint(name?: string, options?: { limits?: { cpuMs?: number } }): unknown; + getDurableObjectClass( + name?: string, + options?: { limits?: { cpuMs?: number } }, + ): DurableObjectClass; + }; +} + +/** + * The host Durable Object's `ctx.facets`. Each app instance runs as one + * facet of it, with its own SQLite storage. + */ +export interface DynamicJavaScriptFacets { + get(name: string, getStartupOptions: () => { class: DurableObjectClass }): unknown; + abort(name: string, reason: unknown): void; + delete(name: string): void; + clone(source: string, destination: string): void; +} + +export interface DynamicJavaScriptBackendOptions extends Omit< + WorkerJavaScriptBackendOptions, + "loader" +> { + /** The Worker Loader binding, such as `env.LOADER`. */ + loader: DynamicJavaScriptLoader; + /** The host Durable Object's `ctx.facets`. */ + facets: DynamicJavaScriptFacets; +} + +// Facet names are capped at 256 characters, and an instance name is part +// of one, hashed, so this only bounds what crosses the bridge. +const MAX_INSTANCE_NAME_BYTES = 1024; + +const APPS_DESCRIPTION = [ + "A workspace file whose default export is a class that extends `DurableObject` from `cloudflare:workers` is an app: it runs in its own long-lived Durable Object, with its own storage in `this.ctx.storage` (`sql` and `kv`). `node:fs` and the modules below work inside its methods.", + "Importing an app gives `ctx.facets` for that app, a `DurableObjectFacets` with one facet per instance name. `const counter = Counter.get(\"main\")` returns the instance with that name, and `await counter.increment(1)` runs the method in it. `Counter.abort(name, reason)` restarts an instance and keeps its storage, `Counter.delete(name)` deletes it with its storage, and `Counter.clone(source, destination)` replaces one instance's storage with a copy of another's. Editing the file restarts its instances on their next call.", + "Arguments and results must be JSON-compatible. Keep anything that must last in storage, not in fields: an instance can restart at any time.", +].join("\n"); + +/** + * Runs ECMAScript modules like `WorkerJavaScriptBackend`, and runs + * Workspace files that export a Durable Object class as long-lived apps. + * + * Code imports an app's file and calls methods on an instance of it: + * + * ```js + * import Counter from "./counter.js"; + * + * export default async function () { + * const counter = Counter.get("main"); + * return counter.increment(1); + * } + * ``` + * + * Each instance runs as a facet of the host Durable Object, with its own + * SQLite storage, and keeps running between executions. + * + * Experimental. + */ +export class DynamicJavaScriptBackend implements WorkspaceModuleBackend { + readonly protocol = "module" as const; + readonly type = "dynamic-javascript"; + readonly callable = true; + readonly id: string; + readonly #options: ResolvedWorkerJavaScriptBackendOptions; + readonly #loader: DynamicJavaScriptLoader; + readonly #facets: DynamicJavaScriptFacets; + + constructor(options: DynamicJavaScriptBackendOptions) { + const { facets, ...rest } = options; + if (facets === undefined || facets === null) { + throw new Error( + "DynamicJavaScriptBackend needs facets, the host Durable Object's ctx.facets.", + ); + } + this.#options = resolveJavaScriptBackendOptions( + rest, + "DynamicJavaScriptBackend", + "dynamic-javascript", + ); + this.id = this.#options.id ?? "dynamic-javascript"; + this.#loader = options.loader; + this.#facets = facets; + } + + /** What this backend tells a model: the source language, apps, and every importable module. */ + get description(): string { + return describeJavaScriptBackend(this.#options, [APPS_DESCRIPTION]); + } + + async connect(host: WorkspaceModuleBackendHost): Promise { + return new JavaScriptBackendHandle(this.#options, host, (connection) => { + const apps = new AppInstances({ + options: this.#options, + loader: this.#loader, + facets: this.#facets, + host, + connection, + }); + return { + bridgeModules: new Map([[APPS_SPECIFIER, apps.functions]]), + internalModules: { [FACETS_MODULE]: facetsModule() }, + replaceModule(path, source, internalImport) { + const className = durableObjectClassName(source); + if (className === undefined) return undefined; + return appStandInModule(path, className, internalImport(FACETS_MODULE)); + }, + }; + }); + } +} + +interface AppFacet { + [DISPATCH_METHOD]( + method: string, + args: WorkspaceRuntimeValue[], + host: WorkspaceRuntimeBridge, + ): Promise<{ result: WorkspaceRuntimeValue } | { error: { message: string } }>; +} + +// Runs app instances as facets: loads each app's code, restarts an +// instance when its code changes, and runs one call at a time per +// instance. +class AppInstances { + readonly functions: WorkspaceModuleFunctions; + readonly #options: ResolvedWorkerJavaScriptBackendOptions; + readonly #loader: DynamicJavaScriptLoader; + readonly #facets: DynamicJavaScriptFacets; + readonly #host: WorkspaceModuleBackendHost; + readonly #connection: JavaScriptBackendConnection; + // The hash of the code each running facet loaded. A facet missing here + // may still be running code from before this object existed, such as + // after the host restarted, so its first call restarts it. + readonly #loaded = new Map(); + // The last call queued for each facet. Each call waits for the one + // before it to settle, so an instance runs one call at a time. + readonly #queues = new Map>(); + + constructor(input: { + options: ResolvedWorkerJavaScriptBackendOptions; + loader: DynamicJavaScriptLoader; + facets: DynamicJavaScriptFacets; + host: WorkspaceModuleBackendHost; + connection: JavaScriptBackendConnection; + }) { + this.#options = input.options; + this.#loader = input.loader; + this.#facets = input.facets; + this.#host = input.host; + this.#connection = input.connection; + this.functions = { + call: (args, context) => this.#call(args, context), + abort: (args, context) => this.#abort(args, context), + delete: (args, context) => this.#delete(args, context), + clone: (args, context) => this.#clone(args, context), + }; + } + + async #call(args: readonly WorkspaceRuntimeValue[], context: WorkspaceModuleCallContext) { + this.#assertWritable(context); + const [path, name, method, methodArgs] = args; + if (typeof method !== "string" || !Array.isArray(methodArgs)) { + throw new TypeError("An app call takes a method name and an argument list."); + } + const { resolved, facet } = await this.#target(path, name, context, false); + const run = this.#queued(facet).then(() => + this.#run(resolved, facet, method, methodArgs, context), + ); + this.#enqueue(facet, run); + return untilAborted(run, context.signal); + } + + // Settles once every call queued on the facet so far has settled. + #queued(facet: string): Promise { + return this.#queues.get(facet) ?? Promise.resolve(); + } + + // Make later calls to the facet wait for `run`. The queue moves on when + // the call settles, not when its caller stops waiting: a method that + // never returns holds the instance until `abort` restarts it. + #enqueue(facet: string, run: Promise) { + const settled = run.then( + () => undefined, + () => undefined, + ); + this.#queues.set(facet, settled); + void settled.then(() => { + if (this.#queues.get(facet) === settled) this.#queues.delete(facet); + }); + } + + async #abort(args: readonly WorkspaceRuntimeValue[], context: WorkspaceModuleCallContext) { + this.#assertWritable(context); + const [path, name, reason] = args; + const { facet } = await this.#target(path, name, context, true); + this.#facets.abort( + facet, + new Error(typeof reason === "string" ? reason : "The app instance was aborted."), + ); + this.#loaded.delete(facet); + return null; + } + + async #delete(args: readonly WorkspaceRuntimeValue[], context: WorkspaceModuleCallContext) { + this.#assertWritable(context); + const [path, name] = args; + const { facet } = await this.#target(path, name, context, true); + this.#facets.delete(facet); + this.#loaded.delete(facet); + return null; + } + + // Copy one instance's storage over another's, as ctx.facets.clone does. + // It waits for calls already queued on both instances, and later calls + // to either wait for it. + async #clone(args: readonly WorkspaceRuntimeValue[], context: WorkspaceModuleCallContext) { + this.#assertWritable(context); + const [path, source, destination] = args; + const from = await this.#target(path, source, context, false); + const to = await this.#target(path, destination, context, false); + if (from.facet === to.facet) { + throw new TypeError("An app instance can't be cloned onto itself."); + } + const run = Promise.all([this.#queued(from.facet), this.#queued(to.facet)]).then(() => { + if (context.signal.aborted) throw context.signal.reason; + this.#facets.clone(from.facet, to.facet); + // The destination now holds another instance's storage, so its + // next call starts it afresh. + this.#loaded.delete(to.facet); + return null; + }); + this.#enqueue(from.facet, run); + this.#enqueue(to.facet, run); + return untilAborted(run, context.signal); + } + + // Calling an app can write its storage, so a read-only backend refuses + // every app call. + #assertWritable(context: WorkspaceModuleCallContext) { + if (context.access !== "read-write") { + throw runtimeError("EACCES", "Apps can't be called on a read-only backend."); + } + } + + async #target( + path: WorkspaceRuntimeValue | undefined, + name: WorkspaceRuntimeValue | undefined, + context: WorkspaceModuleCallContext, + allowMissing: boolean, + ) { + if (typeof path !== "string" || !path.startsWith("/")) { + throw new TypeError("An app call takes the app's absolute path."); + } + if (typeof name !== "string" || name === "") { + throw new TypeError("An app instance's name must be a non-empty string."); + } + if (new TextEncoder().encode(name).byteLength > MAX_INSTANCE_NAME_BYTES) { + throw new TypeError( + `An app instance's name must be at most ${MAX_INSTANCE_NAME_BYTES} bytes.`, + ); + } + const resolved = await context.resolvePath(path, { allowMissing }); + const facet = `${this.#options.id}:${await sha256(JSON.stringify([resolved, name]))}`; + return { resolved, facet }; + } + + async #run( + path: string, + facet: string, + method: string, + args: WorkspaceRuntimeValue[], + context: WorkspaceModuleCallContext, + ): Promise { + if (context.signal.aborted) throw context.signal.reason; + const capability = new WorkspaceRuntimeCapability( + this.#host.fs, + this.#options.root, + this.#options.access, + this.#options.maxCapabilityBytes, + this.#options.maxDirectoryEntries, + ); + const source = await capability.readFile(path); + if (durableObjectClassName(source) === undefined) { + throw new TypeError( + `${path} is not an app: its default export must be a class that extends DurableObject from cloudflare:workers.`, + ); + } + const slash = path.lastIndexOf("/"); + const graph = await buildModuleGraph({ + source: `export { default } from ${JSON.stringify(`./${path.slice(slash + 1)}`)};`, + cwd: path.slice(0, slash) || "/", + capability, + configuredModules: this.#connection.sourceModules(), + hostModules: this.#connection.hostModules, + emptyHostModules: this.#connection.emptyHostModules, + nodeModules: hasNodeModules(this.#options.compatibilityFlags), + runtimeModules: ["cloudflare:workers"], + maxSourceBytes: this.#options.maxSourceBytes, + maxCapabilityBytes: this.#options.maxCapabilityBytes, + }); + const modules = { ...graph.modules, [APP_MODULE]: appMainModule(graph.entryName) }; + assertLoaderGraph(modules, this.#options.maxSourceBytes); + const hash = await sha256(JSON.stringify(Object.entries(modules).sort(byKey))); + if (this.#loaded.get(facet) !== hash) { + // Aborting a facet that isn't running does nothing, so this is safe + // when the instance has never started. + this.#facets.abort(facet, new Error("The app's code changed, so its instance restarted.")); + this.#loaded.set(facet, hash); + } + // SAFETY: A facet stub forwards method calls to the class the startup + // options name, and that class is APP_CLASS from appMainModule, which + // defines the dispatcher. + const stub = this.#facets.get(facet, () => ({ + class: this.#loader + .load({ + compatibilityDate: this.#options.compatibilityDate, + compatibilityFlags: this.#options.compatibilityFlags, + mainModule: APP_MODULE, + modules, + env: {}, + ...dynamicWorkerEgress(this.#options.egress), + }) + .getDurableObjectClass(APP_CLASS, { limits: { cpuMs: this.#options.maxCpuMs } }), + })) as AppFacet; + const bridge = new WorkspaceRuntimeBridge(capability, { + hostModules: this.#connection.hostModules, + maxPayloadBytes: this.#options.maxCapabilityBytes, + maxCallDurationMs: this.#options.maxHostCallMs, + maxConcurrentCalls: this.#options.maxConcurrentCapabilityCalls, + maxCalls: this.#options.maxCapabilityCalls, + maxTotalRequestBytes: this.#options.maxCapabilityRequestBytes, + maxTotalResponseBytes: this.#options.maxCapabilityResponseBytes, + maxResultBytes: this.#options.maxResultBytes, + }); + // When the caller stops waiting, the app can't reach the Workspace + // through this call any more. + const cancel = () => void bridge.cancelAndDrain().catch(() => undefined); + context.signal.addEventListener("abort", cancel, { once: true }); + try { + const response = await stub[DISPATCH_METHOD](method, args, bridge); + if ("error" in response) throw new Error(response.error.message); + assertRuntimeValue(response.result); + return response.result; + } finally { + context.signal.removeEventListener("abort", cancel); + // Wait for Workspace calls the method started and didn't await, so + // none lands after the call returns. Later ones are refused. + await bridge.cancelAndDrain().catch(() => undefined); + } + } +} + +// Stop waiting when the caller's call passes its deadline or its +// execution is cancelled. The call itself keeps going. +function untilAborted(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +function byKey([a]: [string, unknown], [b]: [string, unknown]) { + return a < b ? -1 : a > b ? 1 : 0; +} + +async function sha256(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/packages/computer/src/backends/dynamic-javascript/index.ts b/packages/computer/src/backends/dynamic-javascript/index.ts new file mode 100644 index 000000000..f7438057a --- /dev/null +++ b/packages/computer/src/backends/dynamic-javascript/index.ts @@ -0,0 +1,16 @@ +// Public surface of @cloudflare/computer/backends/dynamic-javascript. +// +// DynamicJavaScriptBackend runs ECMAScript modules like the +// worker-javascript backend, and runs Workspace files that export a +// Durable Object class as long-lived apps, each instance a facet of the +// host Durable Object. +// +// Status: experimental. + +export type { WorkspaceEgressPolicy } from "../../runtime/egress.js"; +export { + DynamicJavaScriptBackend, + type DynamicJavaScriptBackendOptions, + type DynamicJavaScriptFacets, + type DynamicJavaScriptLoader, +} from "./dynamic-javascript.js"; diff --git a/packages/computer/src/backends/worker-javascript/module-graph.ts b/packages/computer/src/backends/worker-javascript/module-graph.ts index e6522e95d..0fddd7575 100644 --- a/packages/computer/src/backends/worker-javascript/module-graph.ts +++ b/packages/computer/src/backends/worker-javascript/module-graph.ts @@ -16,7 +16,12 @@ export type JavaScriptModuleMap = WorkspaceRuntimeLoader extends { const ENTRY_BASENAME = "__workspace_entry__.js"; const RUNNER_MODULE = "workspace-runtime-runner.js"; -const CAPABILITIES_MODULE = "workspace-capabilities.js"; +/** The module that installs a call's bridge and forwards capability calls to it. */ +export const CAPABILITIES_MODULE = "workspace-capabilities.js"; +/** The main module of a Durable Object app's Dynamic Worker. */ +export const APP_MODULE = "workspace-app.js"; +/** The module that defines what code gets when it imports a Durable Object app. */ +export const FACETS_MODULE = "workspace-facets.js"; // Configured and host modules are stored once, here. The Loader // resolves a bare import next to the importing file and has no // node_modules lookup, so every import of one of them is rewritten to a @@ -193,6 +198,35 @@ export interface BuildModuleGraphOptions { emptyHostModules?: ReadonlySet; /** Whether the Dynamic Worker has the Node.js built-ins. */ nodeModules: boolean; + /** + * Modules the Workers runtime provides that code may import as + * written, such as `cloudflare:workers` for a Durable Object app. + */ + runtimeModules?: readonly string[]; + /** + * Generated modules to store at the root of the Worker's bundle, by + * module name. Each name must be one of the reserved internal names, + * so no Workspace file can take it. They can import the capabilities + * module as `./workspace-capabilities.js`. + */ + internalModules?: Readonly>; + /** + * Source to load in place of a Workspace file, or undefined to load + * the file itself. A replacement is stored as returned and its imports + * are not followed, so it may only import the capabilities module and + * `internalModules`, through the specifiers `internalImport` gives. + * + * @param path - The file's absolute Workspace path. + * @param source - The file's source. + * @param internalImport - Gives the specifier that imports an internal + * module, such as the capabilities module, from where the file is + * stored. + */ + replaceModule?: ( + path: string, + source: string, + internalImport: (name: string) => string, + ) => string | undefined; maxSourceBytes: number; maxCapabilityBytes: number; maxModules?: number; @@ -283,6 +317,7 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { for (const site of importSites(source, { allowComputed: false })) { const specifier = site.specifier; if (BUILT_IN_MODULES.some((builtIn) => builtIn === specifier)) continue; + if (options.runtimeModules?.includes(specifier)) continue; if ( options.hostModules.has(specifier) || Object.hasOwn(options.configuredModules, specifier) @@ -336,6 +371,17 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { `Workspace JavaScript module graph exceeds ${options.maxSourceBytes} source bytes.`, ); } + const replacement = options.replaceModule?.(resolved, child, (internal) => + relativeSpecifier(childName, internal), + ); + if (replacement !== undefined) { + seen.add(childName); + if (seen.size > maxModules) { + throw new Error(`Workspace JavaScript module graph exceeds ${maxModules} modules.`); + } + modules[childName] = replacement; + continue; + } await visit(resolved, child, depth + 1); continue; } @@ -351,6 +397,12 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { // node:* specifiers are resolved by name in both registries, so they // keep their exact keys. + for (const [name, source] of Object.entries(options.internalModules ?? {})) { + if (!isInternalModuleName(name) || name.includes("/")) { + throw new Error(`Module ${JSON.stringify(name)} is not a reserved internal module name.`); + } + modules[name] = { js: source }; + } modules["node:fs/promises"] = { js: nodeFsPromisesModule() }; modules["node:fs"] = { js: nodeFsModule() }; @@ -511,6 +563,8 @@ function isInternalModuleName(name: string) { return ( name === CAPABILITIES_MODULE || name === RUNNER_MODULE || + name === APP_MODULE || + name === FACETS_MODULE || name === ENTRY_BASENAME || name === MODULES_DIRECTORY || name.startsWith(`${MODULES_DIRECTORY}/`) || diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 3a8ee6e30..c4ece4914 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -18,6 +18,7 @@ import type { import { decodeRuntimeFrames, type RuntimeFrame } from "./frames.js"; import { assertHostModuleExports, + type BuildModuleGraphOptions, buildModuleGraph, hasNodeModules, NODE_MODULES_DESCRIPTION, @@ -125,7 +126,7 @@ export interface WorkerJavaScriptBackendOptions { globalOutbound?: Fetcher | null; } -type ResolvedWorkerJavaScriptBackendOptions = Required< +export type ResolvedWorkerJavaScriptBackendOptions = Required< Pick< WorkerJavaScriptBackendOptions, | "root" @@ -199,126 +200,155 @@ interface ExecutionRecord { exitCode?: number; } +/** + * Check a JavaScript backend's options and fill in the defaults. + * + * @param options - The options the backend was constructed with. + * @param backend - The backend's class name, for error messages. + * @param defaultId - The id to use when `options.id` is omitted. + * @returns The options with every default filled in. + * @throws When an option is out of range or two options conflict. + */ +export function resolveJavaScriptBackendOptions( + options: WorkerJavaScriptBackendOptions, + backend: string, + defaultId: string, +): ResolvedWorkerJavaScriptBackendOptions { + if (options.egress !== undefined && options.globalOutbound !== undefined) { + throw new Error(`${backend} cannot use globalOutbound together with egress.`); + } + const maxTimeoutMs = options.maxTimeoutMs ?? 180_000; + const defaultTimeoutMs = options.defaultTimeoutMs ?? Math.min(60_000, maxTimeoutMs); + assertPositiveFinite(maxTimeoutMs, "maxTimeoutMs"); + assertPositiveFinite(defaultTimeoutMs, "defaultTimeoutMs"); + const maxCpuMs = options.maxCpuMs ?? maxTimeoutMs; + assertPositiveFinite(maxCpuMs, "maxCpuMs"); + const keepAliveIntervalMs = options.keepAliveIntervalMs ?? 60_000; + // Pending work protects a Durable Object for at most 15 minutes, so a + // longer interval would leave gaps. + if ( + !Number.isFinite(keepAliveIntervalMs) || + keepAliveIntervalMs < 0 || + keepAliveIntervalMs >= 900_000 + ) { + throw new Error(`${backend} keepAliveIntervalMs must be zero or less than 15 minutes.`); + } + assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); + assertPositiveFinite(options.maxInputBytes ?? 1024 * 1024, "maxInputBytes"); + assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); + assertPositiveFinite(options.maxEnvBytes ?? 1024 * 1024, "maxEnvBytes"); + assertPositiveFinite(options.maxResultBytes ?? 1024 * 1024, "maxResultBytes"); + assertPositiveFinite(options.maxStdioBytes ?? 1024 * 1024, "maxStdioBytes"); + assertPositiveFinite(options.maxCapabilityBytes ?? 1024 * 1024, "maxCapabilityBytes"); + assertPositiveFinite(options.maxHostCallMs ?? maxTimeoutMs, "maxHostCallMs"); + assertPositiveInteger(options.maxConcurrentCapabilityCalls ?? 32, "maxConcurrentCapabilityCalls"); + assertPositiveInteger(options.maxCapabilityCalls ?? 256, "maxCapabilityCalls"); + assertPositiveFinite( + options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, + "maxCapabilityRequestBytes", + ); + assertPositiveFinite( + options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, + "maxCapabilityResponseBytes", + ); + assertPositiveInteger(options.maxDirectoryEntries ?? 1024, "maxDirectoryEntries"); + assertPositiveInteger(options.maxExecutionSubscribers ?? 8, "maxExecutionSubscribers"); + assertPositiveFinite(options.retentionMs ?? 60 * 60_000, "retentionMs"); + assertPositiveInteger(options.maxRetainedExecutions ?? 100, "maxRetainedExecutions"); + if ((options.maxCapabilityBytes ?? 1024 * 1024) < 256) { + throw new Error(`${backend} maxCapabilityBytes must be at least 256 bytes.`); + } + const compatibilityDate = options.compatibilityDate ?? "2026-05-23"; + if (!/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate)) { + throw new Error(`${backend} compatibilityDate must use YYYY-MM-DD.`); + } + if (defaultTimeoutMs > maxTimeoutMs) { + throw new Error(`${backend} defaultTimeoutMs cannot exceed maxTimeoutMs.`); + } + const { globalOutbound, egress, ...backendOptions } = options; + const resolvedEgress = + egress ?? + (globalOutbound === undefined + ? { mode: "none" as const } + : globalOutbound === null + ? { mode: "none" as const } + : { mode: "http-gateway" as const, gateway: globalOutbound }); + return { + ...backendOptions, + id: options.id ?? defaultId, + egress: resolvedEgress, + modules: parseModules(options.modules ?? {}), + root: options.root ?? "/workspace", + access: options.access ?? "read-write", + defaultTimeoutMs, + maxTimeoutMs, + maxCpuMs, + keepAliveIntervalMs, + maxSourceBytes: options.maxSourceBytes ?? 1024 * 1024, + maxInputBytes: options.maxInputBytes ?? 1024 * 1024, + maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, + maxEnvBytes: options.maxEnvBytes ?? 1024 * 1024, + maxResultBytes: options.maxResultBytes ?? 1024 * 1024, + maxStdioBytes: options.maxStdioBytes ?? 1024 * 1024, + maxCapabilityBytes: options.maxCapabilityBytes ?? 1024 * 1024, + maxHostCallMs: options.maxHostCallMs ?? maxTimeoutMs, + maxConcurrentCapabilityCalls: options.maxConcurrentCapabilityCalls ?? 32, + maxCapabilityCalls: options.maxCapabilityCalls ?? 256, + maxCapabilityRequestBytes: options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, + maxCapabilityResponseBytes: options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, + maxDirectoryEntries: options.maxDirectoryEntries ?? 1024, + maxExecutionSubscribers: options.maxExecutionSubscribers ?? 8, + retentionMs: options.retentionMs ?? 60 * 60_000, + maxRetainedExecutions: options.maxRetainedExecutions ?? 100, + compatibilityDate, + compatibilityFlags: options.compatibilityFlags ?? ["nodejs_compat"], + }; +} + +/** + * What a JavaScript backend tells a model: the source language, any + * backend-specific text, and every importable module. + * + * @param options - The backend's resolved options. + * @param extra - Paragraphs to add after the source language. + * @returns The description the exec tool shows. + */ +export function describeJavaScriptBackend( + options: ResolvedWorkerJavaScriptBackendOptions, + extra: readonly string[] = [], +): string { + return [ + "`command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace.", + "Put the work in `export default async function (input) { ... }` and call `node:fs` and the other modules below inside it, since the module's top level can't do I/O. To run a file you've already written, re-export it: `export { default } from \"./main.js\"`.", + ...extra, + ...(options.egress.mode === "none" ? ["Code has no direct network access."] : []), + ...(options.access === "read" ? ["The workspace is read-only here."] : []), + "", + "Modules code can import:", + options.modules.description, + ...(hasNodeModules(options.compatibilityFlags) ? [NODE_MODULES_DESCRIPTION] : []), + ].join("\n"); +} + export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { readonly protocol = "module" as const; readonly type = "worker-javascript"; readonly callable = true; readonly id: string; readonly #options: ResolvedWorkerJavaScriptBackendOptions; - readonly #header: string; constructor(options: WorkerJavaScriptBackendOptions) { - this.id = options.id ?? "worker-javascript"; - if (options.egress !== undefined && options.globalOutbound !== undefined) { - throw new Error("WorkerJavaScriptBackend cannot use globalOutbound together with egress."); - } - const maxTimeoutMs = options.maxTimeoutMs ?? 180_000; - const defaultTimeoutMs = options.defaultTimeoutMs ?? Math.min(60_000, maxTimeoutMs); - assertPositiveFinite(maxTimeoutMs, "maxTimeoutMs"); - assertPositiveFinite(defaultTimeoutMs, "defaultTimeoutMs"); - const maxCpuMs = options.maxCpuMs ?? maxTimeoutMs; - assertPositiveFinite(maxCpuMs, "maxCpuMs"); - const keepAliveIntervalMs = options.keepAliveIntervalMs ?? 60_000; - // Pending work protects a Durable Object for at most 15 minutes, so a - // longer interval would leave gaps. - if ( - !Number.isFinite(keepAliveIntervalMs) || - keepAliveIntervalMs < 0 || - keepAliveIntervalMs >= 900_000 - ) { - throw new Error( - "WorkerJavaScriptBackend keepAliveIntervalMs must be zero or less than 15 minutes.", - ); - } - assertPositiveFinite(options.maxSourceBytes ?? 1024 * 1024, "maxSourceBytes"); - assertPositiveFinite(options.maxInputBytes ?? 1024 * 1024, "maxInputBytes"); - assertPositiveFinite(options.maxStdinBytes ?? 256 * 1024, "maxStdinBytes"); - assertPositiveFinite(options.maxEnvBytes ?? 1024 * 1024, "maxEnvBytes"); - assertPositiveFinite(options.maxResultBytes ?? 1024 * 1024, "maxResultBytes"); - assertPositiveFinite(options.maxStdioBytes ?? 1024 * 1024, "maxStdioBytes"); - assertPositiveFinite(options.maxCapabilityBytes ?? 1024 * 1024, "maxCapabilityBytes"); - assertPositiveFinite(options.maxHostCallMs ?? maxTimeoutMs, "maxHostCallMs"); - assertPositiveInteger( - options.maxConcurrentCapabilityCalls ?? 32, - "maxConcurrentCapabilityCalls", - ); - assertPositiveInteger(options.maxCapabilityCalls ?? 256, "maxCapabilityCalls"); - assertPositiveFinite( - options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, - "maxCapabilityRequestBytes", + this.#options = resolveJavaScriptBackendOptions( + options, + "WorkerJavaScriptBackend", + "worker-javascript", ); - assertPositiveFinite( - options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, - "maxCapabilityResponseBytes", - ); - assertPositiveInteger(options.maxDirectoryEntries ?? 1024, "maxDirectoryEntries"); - assertPositiveInteger(options.maxExecutionSubscribers ?? 8, "maxExecutionSubscribers"); - assertPositiveFinite(options.retentionMs ?? 60 * 60_000, "retentionMs"); - assertPositiveInteger(options.maxRetainedExecutions ?? 100, "maxRetainedExecutions"); - if ((options.maxCapabilityBytes ?? 1024 * 1024) < 256) { - throw new Error("WorkerJavaScriptBackend maxCapabilityBytes must be at least 256 bytes."); - } - const compatibilityDate = options.compatibilityDate ?? "2026-05-23"; - if (!/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate)) { - throw new Error("WorkerJavaScriptBackend compatibilityDate must use YYYY-MM-DD."); - } - if (defaultTimeoutMs > maxTimeoutMs) { - throw new Error("WorkerJavaScriptBackend defaultTimeoutMs cannot exceed maxTimeoutMs."); - } - const { globalOutbound, egress, ...backendOptions } = options; - const resolvedEgress = - egress ?? - (globalOutbound === undefined - ? { mode: "none" as const } - : globalOutbound === null - ? { mode: "none" as const } - : { mode: "http-gateway" as const, gateway: globalOutbound }); - this.#options = { - ...backendOptions, - egress: resolvedEgress, - modules: parseModules(options.modules ?? {}), - root: options.root ?? "/workspace", - access: options.access ?? "read-write", - defaultTimeoutMs, - maxTimeoutMs, - maxCpuMs, - keepAliveIntervalMs, - maxSourceBytes: options.maxSourceBytes ?? 1024 * 1024, - maxInputBytes: options.maxInputBytes ?? 1024 * 1024, - maxStdinBytes: options.maxStdinBytes ?? 256 * 1024, - maxEnvBytes: options.maxEnvBytes ?? 1024 * 1024, - maxResultBytes: options.maxResultBytes ?? 1024 * 1024, - maxStdioBytes: options.maxStdioBytes ?? 1024 * 1024, - maxCapabilityBytes: options.maxCapabilityBytes ?? 1024 * 1024, - maxHostCallMs: options.maxHostCallMs ?? maxTimeoutMs, - maxConcurrentCapabilityCalls: options.maxConcurrentCapabilityCalls ?? 32, - maxCapabilityCalls: options.maxCapabilityCalls ?? 256, - maxCapabilityRequestBytes: options.maxCapabilityRequestBytes ?? 8 * 1024 * 1024, - maxCapabilityResponseBytes: options.maxCapabilityResponseBytes ?? 8 * 1024 * 1024, - maxDirectoryEntries: options.maxDirectoryEntries ?? 1024, - maxExecutionSubscribers: options.maxExecutionSubscribers ?? 8, - retentionMs: options.retentionMs ?? 60 * 60_000, - maxRetainedExecutions: options.maxRetainedExecutions ?? 100, - compatibilityDate, - compatibilityFlags: options.compatibilityFlags ?? ["nodejs_compat"], - }; - this.#header = [ - "`command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace.", - "Put the work in `export default async function (input) { ... }` and call `node:fs` and the other modules below inside it, since the module's top level can't do I/O. To run a file you've already written, re-export it: `export { default } from \"./main.js\"`.", - ...(resolvedEgress.mode === "none" ? ["Code has no direct network access."] : []), - ...(this.#options.access === "read" ? ["The workspace is read-only here."] : []), - "", - "Modules code can import:", - ].join("\n"); + this.id = this.#options.id ?? "worker-javascript"; } /** What this backend tells a model: the source language and every importable module. */ get description(): string { - return [ - this.#header, - this.#options.modules.description, - ...(hasNodeModules(this.#options.compatibilityFlags) ? [NODE_MODULES_DESCRIPTION] : []), - ].join("\n"); + return describeJavaScriptBackend(this.#options); } async connect(host: WorkspaceModuleBackendHost): Promise { @@ -326,11 +356,40 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { } } -class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { +/** What a backend built on the JavaScript handle sees when it connects. */ +export interface JavaScriptBackendConnection { + /** Host modules isolate code can import, built for this connection. */ + readonly hostModules: ReadonlyMap; + /** Host modules whose factory built nothing on this connection. */ + readonly emptyHostModules: ReadonlySet; + /** The backend's source modules, prepared for the module directory. */ + sourceModules(): Readonly>; +} + +/** + * What a backend built on the JavaScript handle adds to every + * execution. Not part of the public API. + */ +export interface JavaScriptBackendExtension { + /** + * Host functions the bridge serves but caller source can't import by + * specifier. Only modules that `replaceModule` generates reach them. + */ + readonly bridgeModules: ReadonlyMap; + /** Generated modules every execution's bundle holds. See `buildModuleGraph`. */ + readonly internalModules?: BuildModuleGraphOptions["internalModules"]; + /** Source to load in place of a Workspace file. See `buildModuleGraph`. */ + readonly replaceModule?: BuildModuleGraphOptions["replaceModule"]; +} + +export class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #options: ResolvedWorkerJavaScriptBackendOptions; readonly #host: WorkspaceModuleBackendHost; readonly #hostModuleFunctions: ReadonlyMap; readonly #emptyHostModules: ReadonlySet; + readonly #bridgeModules: ReadonlyMap; + readonly #replaceModule: BuildModuleGraphOptions["replaceModule"]; + readonly #internalModules: BuildModuleGraphOptions["internalModules"]; #sourceModules: Readonly> | undefined; readonly #records = new Map(); readonly #pendingIds = new Set(); @@ -345,7 +404,11 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { // Zero when the timer is off or can't keep this object in memory. readonly #keepAliveIntervalMs: number; - constructor(options: ResolvedWorkerJavaScriptBackendOptions, host: WorkspaceModuleBackendHost) { + constructor( + options: ResolvedWorkerJavaScriptBackendOptions, + host: WorkspaceModuleBackendHost, + extend?: (connection: JavaScriptBackendConnection) => JavaScriptBackendExtension, + ) { this.#options = options; this.#keepAliveIntervalMs = hostKeepsPendingWork() ? options.keepAliveIntervalMs : 0; this.#host = host; @@ -372,6 +435,15 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { } this.#hostModuleFunctions = functions; this.#emptyHostModules = empty; + const extension = extend?.({ + hostModules: functions, + emptyHostModules: empty, + sourceModules: () => this.#preparedSourceModules(), + }); + this.#bridgeModules = + extension === undefined ? functions : new Map([...functions, ...extension.bridgeModules]); + this.#replaceModule = extension?.replaceModule; + this.#internalModules = extension?.internalModules; host.db.run(` CREATE TABLE IF NOT EXISTS workspace_runtime_executions ( backend TEXT NOT NULL, @@ -492,6 +564,8 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { hostModules: this.#hostModuleFunctions, emptyHostModules: this.#emptyHostModules, nodeModules: hasNodeModules(this.#options.compatibilityFlags), + replaceModule: this.#replaceModule, + internalModules: this.#internalModules, maxSourceBytes: this.#options.maxSourceBytes, maxCapabilityBytes: this.#options.maxCapabilityBytes, }); @@ -525,7 +599,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { this.#markRunning(id, timeoutMs); try { const bridge = new WorkspaceRuntimeBridge(capability, { - hostModules: this.#hostModuleFunctions, + hostModules: this.#bridgeModules, maxPayloadBytes: this.#options.maxCapabilityBytes, maxCallDurationMs: this.#options.maxHostCallMs, maxConcurrentCalls: this.#options.maxConcurrentCapabilityCalls, @@ -1408,7 +1482,7 @@ async function ensureDirectory(fs: WorkspaceBackendHost["fs"], path: string): Pr } } -function assertLoaderGraph( +export function assertLoaderGraph( modules: Record, maxSourceBytes: number, ) { @@ -1506,7 +1580,7 @@ function addColumnIfMissing( } } -function runtimeError(code: string, message: string): Error & { code: string } { +export function runtimeError(code: string, message: string): Error & { code: string } { const error = new Error(message) as Error & { code: string }; error.code = code; return error; diff --git a/packages/computer/tests/dynamic-javascript-worker.ts b/packages/computer/tests/dynamic-javascript-worker.ts new file mode 100644 index 000000000..e03bde21c --- /dev/null +++ b/packages/computer/tests/dynamic-javascript-worker.ts @@ -0,0 +1,87 @@ +import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; + +import { + DynamicJavaScriptBackend, + type DynamicJavaScriptBackendOptions, +} from "../src/backends/dynamic-javascript/index.js"; +import type { WorkspaceRuntimeValue } from "../src/index.js"; +import { Workspace } from "../src/index.js"; + +export interface Env { + HOST: DurableObjectNamespace; + LOADER: WorkerLoader; +} + +function backend( + ctx: DurableObjectState, + env: Env, + overrides: Partial = {}, +) { + return new DynamicJavaScriptBackend({ + loader: env.LOADER, + facets: ctx.facets, + modules: { + "math-kit": "export const double = (value) => value * 2;", + "ws:test-host": { + async echo(args) { + return { echoed: [...args] }; + }, + }, + }, + ...overrides, + }); +} + +export class HostDO extends DurableObject { + readonly #workspace: Workspace; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.#workspace = new Workspace({ + storage: ctx.storage, + backends: [ + backend(ctx, env), + backend(ctx, env, { id: "second" }), + backend(ctx, env, { id: "read-only", access: "read" }), + backend(ctx, env, { id: "impatient", maxHostCallMs: 300 }), + ], + }); + } + + async writeFile(path: string, source: string) { + await this.#workspace.fs.mkdir(path.slice(0, path.lastIndexOf("/")) || "/", { + recursive: true, + }); + await this.#workspace.fs.writeFile(path, source); + } + + readFile(path: string) { + return this.#workspace.fs.readFile(path, "utf8"); + } + + description(backendId = "dynamic-javascript") { + return this.#workspace.runtime.backends().find((entry) => entry.id === backendId)?.description; + } + + async run(source: string, options: { backend?: string; input?: WorkspaceRuntimeValue } = {}) { + const handle = await this.#workspace.runtime.exec(source, { + backend: options.backend ?? "dynamic-javascript", + cwd: "/workspace", + input: options.input, + encoding: "utf8", + }); + const result = await handle.result(); + return { + status: result.status, + exitCode: result.exitCode, + stderr: result.stderr, + value: result.value ?? null, + }; + } +} + +export default class extends WorkerEntrypoint { + override fetch() { + return new Response("ok"); + } +} diff --git a/packages/computer/tests/dynamic-javascript.test.ts b/packages/computer/tests/dynamic-javascript.test.ts new file mode 100644 index 000000000..c21ad37ac --- /dev/null +++ b/packages/computer/tests/dynamic-javascript.test.ts @@ -0,0 +1,441 @@ +import { env } from "cloudflare:workers"; + +import type { Env, HostDO } from "./dynamic-javascript-worker.js"; + +// The host's methods as the tests call them. The stub's own RPC types +// are too deep for the type checker to follow here. +interface Host { + writeFile: HostDO["writeFile"]; + readFile: HostDO["readFile"]; + description(backendId?: string): Promise; + run: HostDO["run"]; +} + +// Each test gets its own host Durable Object, so apps and their storage +// never leak between tests. +function host(): Host { + const namespace = (env as unknown as Env).HOST; + // SAFETY: A Durable Object stub forwards these methods to HostDO over RPC. + return namespace.get(namespace.idFromName(crypto.randomUUID())) as unknown as Host; +} + +const COUNTER = ` + import { DurableObject } from "cloudflare:workers"; + + export default class Counter extends DurableObject { + calls = 0; + increment(by = 1) { + this.calls += 1; + const total = (this.ctx.storage.kv.get("total") ?? 0) + by; + this.ctx.storage.kv.put("total", total); + return { total, calls: this.calls }; + } + } +`; + +const call = (expression: string) => ` + import Counter from "./counter.js"; + export default async function () { + return ${expression}; + } +`; + +describe("DynamicJavaScriptBackend", () => { + it("keeps an instance's fields and storage between executions", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + expect((await stub.run(call(`Counter.get("main").increment(2)`))).value).toEqual({ + total: 2, + calls: 1, + }); + expect((await stub.run(call(`Counter.get("main").increment(3)`))).value).toEqual({ + total: 5, + calls: 2, + }); + }); + + it("gives each instance name its own storage", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + const result = await stub.run( + call(`[ + await Counter.get("a").increment(1), + await Counter.get("a").increment(1), + await Counter.get("b").increment(10), + ].map((entry) => entry.total)`), + ); + expect(result.value).toEqual([1, 2, 10]); + }); + + it("restarts an instance on new code when its file changes, keeping storage", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + await stub.run(call(`Counter.get("main").increment(4)`)); + await stub.writeFile( + "/workspace/counter.js", + COUNTER.replace("return { total, calls: this.calls };", "return { total, version: 2 };"), + ); + expect((await stub.run(call(`Counter.get("main").increment(1)`))).value).toEqual({ + total: 5, + version: 2, + }); + }); + + it("restarts on a change to a module the app imports", async () => { + const stub = host(); + await stub.writeFile("/workspace/lib/label.js", `export const label = "one";`); + await stub.writeFile( + "/workspace/labelled.js", + ` + import { DurableObject } from "cloudflare:workers"; + import { label } from "./lib/label.js"; + export default class extends DurableObject { + read() { return label; } + } + `, + ); + const read = `import Labelled from "./labelled.js"; export default () => Labelled.get("x").read();`; + expect((await stub.run(read)).value).toBe("one"); + await stub.writeFile("/workspace/lib/label.js", `export const label = "two";`); + expect((await stub.run(read)).value).toBe("two"); + }); + + it("aborts an instance, dropping its fields and keeping its storage", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + const result = await stub.run( + call(`[ + await Counter.get("main").increment(1), + await Counter.get("main").increment(1), + await Counter.abort("main", "start over"), + await Counter.get("main").increment(1), + ]`), + ); + expect(result.value).toEqual([ + { total: 1, calls: 1 }, + { total: 2, calls: 2 }, + null, + { total: 3, calls: 1 }, + ]); + }); + + it("deletes an instance with its storage, and leaves the file", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + const result = await stub.run( + call(`[ + (await Counter.get("main").increment(7)).total, + await Counter.delete("main"), + (await Counter.get("main").increment(1)).total, + ]`), + ); + expect(result.value).toEqual([7, null, 1]); + expect(await stub.readFile("/workspace/counter.js")).toBe(COUNTER); + }); + + it("clones an instance's storage over another's", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + const result = await stub.run( + call(`[ + (await Counter.get("a").increment(5)).total, + (await Counter.get("b").increment(100)).total, + await Counter.clone("a", "b"), + (await Counter.get("b").increment(1)).total, + (await Counter.get("a").increment(1)).total, + await Counter.clone("missing", "c"), + (await Counter.get("c").increment(1)).total, + ]`), + ); + expect(result.value).toEqual([5, 100, null, 6, 6, null, 1]); + }); + + it("labels an anonymous app class as App", async () => { + const stub = host(); + await stub.writeFile( + "/workspace/anonymous.js", + `import { DurableObject } from "cloudflare:workers"; export default class extends DurableObject { hi() { return "anonymous"; } }`, + ); + const result = await stub.run(` + import Anonymous from "./anonymous.js"; + export default async () => { + let unnamed; + try { + Anonymous.get(""); + } catch (error) { + unnamed = error.message; + } + return [String(Anonymous.get("x")), await Anonymous.get("x").hi(), unnamed]; + }; + `); + expect(result.value).toEqual([ + 'App instance "x"', + "anonymous", + "App.get() takes an instance's name, a non-empty string.", + ]); + }); + + it("gives an app node:fs, source modules, and host modules inside its methods", async () => { + const stub = host(); + await stub.writeFile( + "/workspace/notes.js", + ` + import { DurableObject } from "cloudflare:workers"; + import fs from "node:fs/promises"; + import { double } from "math-kit"; + import { echo } from "ws:test-host"; + + export default class Notes extends DurableObject { + async save(text) { + await fs.writeFile("/workspace/note.txt", text); + return { + read: await fs.readFile("/workspace/note.txt", "utf8"), + doubled: double(text.length), + host: await echo(text), + }; + } + } + `, + ); + const result = await stub.run( + `import Notes from "./notes.js"; export default () => Notes.get("n").save("hello");`, + ); + expect(result.value).toEqual({ read: "hello", doubled: 10, host: { echoed: ["hello"] } }); + expect(await stub.readFile("/workspace/note.txt")).toBe("hello"); + }); + + it("calls the app's own abort and delete methods on an instance", async () => { + const stub = host(); + await stub.writeFile( + "/workspace/job.js", + ` + import { DurableObject } from "cloudflare:workers"; + export default class Job extends DurableObject { + abort() { return "own abort"; } + delete() { return "own delete"; } + } + `, + ); + const result = await stub.run( + `import Job from "./job.js"; + export default async () => [await Job.get("j").abort(), await Job.get("j").delete()];`, + ); + expect(result.value).toEqual(["own abort", "own delete"]); + }); + + it("refuses methods that aren't the app's public methods", async () => { + const stub = host(); + await stub.writeFile( + "/workspace/guarded.js", + ` + import { DurableObject } from "cloudflare:workers"; + export default class Guarded extends DurableObject { + _secret() { return "secret"; } + open() { return "open"; } + } + `, + ); + const result = await stub.run(` + import Guarded from "./guarded.js"; + export default async function () { + const instance = Guarded.get("g"); + const outcomes = {}; + for (const method of ["_secret", "constructor", "missing", "hasOwnProperty", "fetch", "__workspaceCall", "open"]) { + try { + outcomes[method] = await instance[method](); + } catch (error) { + outcomes[method] = error.message; + } + } + return outcomes; + } + `); + expect(result.value).toEqual({ + _secret: 'Guarded has no method "_secret" to call.', + constructor: 'Guarded has no method "constructor" to call.', + missing: 'Guarded has no method "missing" to call.', + hasOwnProperty: 'Guarded has no method "hasOwnProperty" to call.', + fetch: 'Guarded has no method "fetch" to call.', + __workspaceCall: 'Guarded has no method "__workspaceCall" to call.', + open: "open", + }); + }); + + it("refuses arguments and results that aren't plain data", async () => { + const stub = host(); + await stub.writeFile( + "/workspace/dates.js", + ` + import { DurableObject } from "cloudflare:workers"; + export default class extends DurableObject { + now() { return new Date(0); } + echo(value) { return value; } + } + `, + ); + const result = await stub.run(` + import Dates from "./dates.js"; + export default async function () { + const instance = Dates.get("d"); + const messages = []; + for (const attempt of [() => instance.now(), () => instance.echo(() => 1)]) { + try { + await attempt(); + messages.push("ok"); + } catch (error) { + messages.push(error.message); + } + } + return messages; + } + `); + expect(result.value).toEqual([ + expect.stringMatching(/plain|JSON/i), + expect.stringMatching(/plain data/), + ]); + }); + + it("gives exec code ctx.facets for the app", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + const result = await stub.run(` + import Counter from "./counter.js"; + export default async function () { + let constructed; + try { + new Counter.constructor(); + constructed = "constructed"; + } catch (error) { + constructed = error.message; + } + let unnamed; + try { + Counter.get(""); + } catch (error) { + unnamed = error.message; + } + let detached; + try { + const { get } = Counter; + get("main"); + } catch (error) { + detached = error.message; + } + const instance = await Counter.get("main"); + return { + kind: Counter.constructor.name, + tag: Object.prototype.toString.call(Counter), + methods: Object.getOwnPropertyNames(Object.getPrototypeOf(Counter)), + constructed, + unnamed, + detached, + awaited: typeof instance.increment, + printed: String(instance), + json: JSON.stringify({ instance }), + }; + } + `); + expect(result.value).toEqual({ + kind: "DurableObjectFacets", + tag: "[object DurableObjectFacets]", + methods: ["constructor", "get", "abort", "delete", "clone"], + constructed: "Illegal constructor", + unnamed: "Counter.get() takes an instance's name, a non-empty string.", + detached: "Call get() on an app's import, as in Counter.get(...).", + awaited: "function", + printed: 'Counter instance "main"', + json: '{"instance":"Counter instance \\"main\\""}', + }); + }); + + it("imports a Workspace file that isn't an app as an ordinary module", async () => { + const stub = host(); + await stub.writeFile( + "/workspace/plain.js", + `export default class Plain { greet() { return "local"; } }`, + ); + const result = await stub.run( + `import Plain from "./plain.js"; export default () => new Plain().greet();`, + ); + expect(result.value).toBe("local"); + }); + + it("fails a named import from an app", async () => { + const stub = host(); + await stub.writeFile( + "/workspace/helpers.js", + ` + import { DurableObject } from "cloudflare:workers"; + export const helper = 1; + export default class extends DurableObject {} + `, + ); + const result = await stub.run( + `import { helper } from "./helpers.js"; export default () => helper;`, + ); + expect(result.status).toBe("failed"); + expect(result.stderr).toContain("helper"); + }); + + it("refuses app calls on a read-only backend", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + const result = await stub.run(call(`Counter.get("main").increment(1)`), { + backend: "read-only", + }); + expect(result.status).toBe("failed"); + expect(result.stderr).toContain("read-only"); + }); + + it("keeps two backends' instances apart", async () => { + const stub = host(); + await stub.writeFile("/workspace/counter.js", COUNTER); + await stub.run(call(`Counter.get("main").increment(5)`)); + const second = await stub.run(call(`Counter.get("main").increment(1)`), { + backend: "second", + }); + expect(second.value).toEqual({ total: 1, calls: 1 }); + }); + + it("fails a call that passes its deadline and leaves the instance usable", async () => { + const stub = host(); + await stub.writeFile( + "/workspace/slow.js", + ` + import { DurableObject } from "cloudflare:workers"; + export default class extends DurableObject { + async wait(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); + this.ctx.storage.kv.put("waited", ms); + return ms; + } + waited() { return this.ctx.storage.kv.get("waited") ?? null; } + } + `, + ); + const timedOut = await stub.run( + `import Slow from "./slow.js"; export default () => Slow.get("s").wait(800);`, + { backend: "impatient" }, + ); + expect(timedOut.status).toBe("failed"); + expect(timedOut.stderr).toMatch(/timed out|deadline/); + const after = await stub.run( + ` + import Slow from "./slow.js"; + export default async function () { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return Slow.get("s").waited(); + } + `, + { backend: "impatient" }, + ); + expect(after.value).toBe(800); + }); + + it("tells a model about apps in its description", async () => { + const description = await host().description(); + expect(description).toContain("extends `DurableObject`"); + expect(description).toContain('Counter.get("main")'); + expect(description).toContain("`math-kit`"); + }); +}); diff --git a/packages/computer/tests/wrangler.dynamic-javascript.jsonc b/packages/computer/tests/wrangler.dynamic-javascript.jsonc new file mode 100644 index 000000000..db9a73022 --- /dev/null +++ b/packages/computer/tests/wrangler.dynamic-javascript.jsonc @@ -0,0 +1,15 @@ +{ + // Test-only worker for the DynamicJavaScriptBackend integration tests. + // Not deployed. HOST is a host Durable Object that owns a Workspace + // with dynamic-javascript backends, and LOADER is the Worker Loader + // binding they load apps and executions through. + "name": "computer-dynamic-javascript-tests", + "main": "./dynamic-javascript-worker.ts", + "compatibility_date": "2026-06-23", + "compatibility_flags": ["nodejs_compat"], + "worker_loaders": [{ "binding": "LOADER" }], + "durable_objects": { + "bindings": [{ "name": "HOST", "class_name": "HostDO" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["HostDO"] }] +} diff --git a/packages/computer/vitest.config.dynamic-javascript.ts b/packages/computer/vitest.config.dynamic-javascript.ts new file mode 100644 index 000000000..e74563057 --- /dev/null +++ b/packages/computer/vitest.config.dynamic-javascript.ts @@ -0,0 +1,15 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./tests/wrangler.dynamic-javascript.jsonc" }, + }), + ], + test: { + globals: true, + include: ["tests/dynamic-javascript.test.ts"], + testTimeout: 60_000, + }, +});