Skip to content

computer: Add an experimental dynamic JavaScript backend - #250

Draft
mattzcarey wants to merge 3 commits into
mainfrom
computer/dynamic-javascript-backend
Draft

mattzcarey wants to merge 3 commits into
mainfrom
computer/dynamic-javascript-backend

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Adds DynamicJavaScriptBackend at @cloudflare/computer/backends/dynamic-javascript, marked experimental. It runs code like WorkerJavaScriptBackend, and it also runs a Workspace file whose default export extends DurableObject as a long-lived app: code imports the file and calls methods on an instance, and each instance runs as a facet of the host Durable Object with its own SQLite storage.

// /workspace/counter.js
import { DurableObject } from "cloudflare:workers";
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;
  }
}

// exec code: the import is ctx.facets for the app (a DurableObjectFacets)
import Counter from "./counter.js";
export default async () => {
  await Counter.get("main").increment(2);
  await Counter.clone("main", "backup"); // also Counter.abort(name, reason), Counter.delete(name)
  return Counter.get("backup").increment(0);
};

Code run by the JavaScript backend starts from nothing on every run. Cloudflare OS has a shape for state that lasts: the agent writes a Durable Object class, the host runs it as a facet, and one-off code calls it. This copies that shape. It is separate from durable replay in #238 and changes no existing backend.

  • importing an app gives ctx.facets for it, one facet per instance name. The runtime doesn't let code construct a DurableObjectFacets, so it copies the shape: get, abort, delete and clone on a prototype named DurableObjectFacets. get takes no startup options, and each method call on an instance crosses the existing bridge as plain data
  • the app's file and imports load through the existing module graph, so node:fs, source modules and ws:* host modules work inside its methods, under the backend's limits
  • editing the app's file, or anything it imports, restarts its instances on their next call and keeps their storage
  • an instance runs one call at a time, a caller that passes its deadline stops waiting while the instance keeps going, and a read-only backend refuses app calls

New workerd tests in vitest.config.dynamic-javascript.ts, plus unit tests for detecting an app. npm run check, the package build, and npm test --workspace @cloudflare/computer pass. Docs are in docs/20_dynamic_javascript.md.

Follow-up, designed but not built (the "Planned: app interfaces" section of the docs page): app interfaces over RPC, as Cloudflare OS does it.

  • Computer adds a host-side backend.facets(path) whose instances are RpcTargets using the same dispatcher, bridge and queue as calls from code.
  • An agents SDK host, such as the pi durable harness, serves them through an openApp callable on its Cap'n Web transport, behind a show_app tool.
  • The chat client runs the app's interface in a sandboxed iframe with no network, connected to the instance over a MessagePort, so the user and the agent share one live instance.

@changeset-bot

changeset-bot Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: 2552a45

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@cloudflare/computer Minor
@cloudflare/dofs Minor
@cloudflare/computer-rpc Minor
@cloudflare/computerd Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@250

commit: 2552a45

@mattzcarey
mattzcarey force-pushed the computer/dynamic-javascript-backend branch from feddf43 to 2c7956d Compare October 19, 2026 13:54
WorkerJavaScriptBackend checked its options and built its description
in its constructor, and its handle was private to the module. A second
backend that runs code the same way had to copy all of it.

The option checks and defaults move into
resolveJavaScriptBackendOptions, which names the calling backend in its
errors, and the description into describeJavaScriptBackend, which takes
extra paragraphs. The handle is exported inside the package and takes
an optional extension: host functions the bridge serves without caller
source being able to import them by specifier, generated modules every
execution's bundle holds, and a hook that replaces a Workspace file in
the module graph with generated source.

The module graph gains those as internalModules and replaceModule, and
runtimeModules for runtime modules code may import as written, such as
cloudflare:workers. WorkerJavaScriptBackend uses none of them and
behaves as before.
Code run by the JavaScript backend starts from nothing each time. To
keep state between runs, a model had to write it to files and read it
back. Cloudflare OS has a better shape for this: the agent writes a
Durable Object class, the host runs it as a facet of itself, and
one-off code calls methods on it.

DynamicJavaScriptBackend runs code exactly as WorkerJavaScriptBackend
does, through the same handle, and adds apps. An app is a Workspace
file whose default export extends DurableObject from
cloudflare:workers. When code imports one, the module graph swaps in
ctx.facets for that app, with one facet per instance name. The runtime
doesn't let code construct a DurableObjectFacets, so the import copies
its shape: get(name) returns an instance whose method calls cross the
bridge to the host, and abort(name, reason), delete(name) and
clone(source, destination) act on instances.

The host runs each instance as a facet named after the backend, the
app's path and the instance's name. It loads the app's module graph in
a Dynamic Worker whose main module subclasses the app with one
dispatcher method. The dispatcher installs a bridge for that call, so
node:fs and host modules work inside the app's methods under the
backend's limits, refuses private and inherited methods, and checks the
result is plain data. Every call hashes the app's graph and aborts the
facet when the code has changed, so the next call runs the new code
against the same storage. An instance runs one call at a time, and a
clone waits for both instances. A caller whose call passes its deadline
stops waiting, and the instance keeps running. A read-only backend
refuses app calls, since they write storage.

The workerd tests cover state across runs, separate instances, code
changes, abort, delete and clone, Workspace access from an app, the
method guard, plain-data checks, the facets object, read-only
backends, deadlines, and two backends sharing one host.
@mattzcarey
mattzcarey force-pushed the computer/dynamic-javascript-backend branch from 2c7956d to dc57eb4 Compare October 19, 2026 14:04
Add a page for DynamicJavaScriptBackend: how to set it up, what makes
a Workspace file an app, how code gets an instance and calls it, when
an instance restarts and what survives, and the limits on calls. Link
it from the execution and isolate JavaScript pages, and list the
backend as experimental in the package README.

The page also records the planned design for app interfaces, so later
changes can follow it: a host-side ctx.facets whose instances are
RpcTargets, served to an agents SDK client over its Cap'n Web
transport and on into a sandboxed iframe through a MessagePort.
@mattzcarey
mattzcarey force-pushed the computer/dynamic-javascript-backend branch from dc57eb4 to 2552a45 Compare October 19, 2026 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant