Skip to content

computer: Allow richer JavaScriptBackendModules - #301

Open
aron-cf wants to merge 8 commits into
browser/exec-optionsfrom
host-objects
Open

aron-cf wants to merge 8 commits into
browser/exec-optionsfrom
host-objects

Conversation

@aron-cf

@aron-cf aron-cf commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Replaces #293. Closes the main part of #297. Based on #293's branch, so it includes Ben's stack until that lands.

Today a host module can only return JSON. To give code an object, like a job or a page, you write two modules: flat host functions over IDs, and source in the isolate that builds the objects back up. createCodemodeModule() makes that one module. Its objects keep working after a pause or a deploy.

Returning objects

import { createCodemodeModule } from "@cloudflare/computer";

const jobs = createCodemodeModule({ description: "Build jobs: `createJob(name)` returns a job." });

const Run = jobs.define("Run", ({ id }) => ({
  wait: async (_args, ctx) => api.wait(id, { signal: ctx.signal }),
}));

const Job = jobs.define("Job", ({ id }) => ({
  start: async ([ref], ctx) => Run({ id: await api.start(id, ref, { key: ctx.callId }) }),
}));

modules: {
  "codemode:jobs": jobs.module({
    createJob: async ([name], ctx) => Job({ id: await api.create(name, { key: ctx.callId }) }),
  }),
}
import { createJob } from "codemode:jobs";

const job = await createJob("build");
const run = await job.start("main");
await approval();                    // the run can pause here, across a deploy
const { status } = await run.wait(); // still works, and nothing ran twice

Run({ id }) builds the object and remembers { id }. After a restart, the backend calls the same builder with { id } to get it back. The one rule: an object is whatever its state rebuilds.

Shared context

context(name, create) adds ctx[name] to every function and method in the module. It's made once, when the backend connects.

jobs.context("api", (host) => createJobsClient(host));

const Job = jobs.define("Job", ({ id }) => ({
  start: async ([ref], { api, callId }) => Run({ id: await api.start(id, ref, { key: callId }) }),
}));

State for one run

Add { scoped: true } to get a fresh value for each scope of a run. It starts the first time a method reads it, so ctx.session is a promise. It's disposed when the run pauses or ends.

const browser = createCodemodeModule();

browser.context(
  "session",
  (scope) => browsers.connect({ conversation: scope.metadata.conversation }),
  {
    scoped: true,
    async dispose(session, end) {
      await session.close();
      if (end.reason !== "paused") end.log("Closed the browser.\n");
    },
  },
);

const Page = browser.define("Page", ({ pageId }) => ({
  text: async (_args, ctx) => (await ctx.session).page(pageId).text(),
}));

modules: {
  "codemode:browser": browser.module({
    open: async ([url], ctx) => Page({ pageId: await (await ctx.session).open(String(url)) }),
  }),
}

Objects read context when they're called, not when they're built. So after a resume, Page just works against the new session.

Richer values and errors

A codemode module can use Date, bigint, byte arrays, Headers and errors in any run, including replayable ones. Errors keep their name and their own properties.

schedule: async ([when]) => {
  if (when < new Date()) {
    throw Object.assign(new RangeError("That's in the past"), { code: "EPAST", earliest: new Date() });
  }
  return { at: when, id: 2n ** 64n };
},
try {
  await schedule(new Date(0));
} catch (error) {
  error instanceof RangeError; // true, live and on replay
  error.earliest;              // a Date
}

Callbacks, streams and live stubs

In runs that don't replay, Workers RPC carries these as they are:

watch: async ([onEvent], ctx) => {
  for await (const event of deploys.events({ signal: ctx.signal })) await onEvent(event);
},
logs: async ([id]) => deploys.logStream(id),          // a ReadableStream
room: async ([name]) => env.ROOMS.get(env.ROOMS.idFromName(String(name))), // a live stub

A replayable run refuses them, since the journal can't keep them yet.

Releasing early

const run = await job.start("main");
await run.wait();
await run[Symbol.asyncDispose](); // the host object's disposer runs with reason "disposed"

Rules

Methods The object's own functions, called as (args, ctx).
Kinds Named with a string, stored as codemode:jobs#Run. Don't use variable names, since minifiers change them.
State Anything a replayable run can carry. Must rebuild the object.
Replayable runs JSON plus Date, bigint, byte arrays, typed arrays, Headers and errors. No URL, Map, Set or RegExp.
Runs that don't replay Anything Workers RPC carries, including callbacks, streams and live stubs.
One module per builder Calling module() twice throws, unless the builder has a name.

Plain host modules and factories don't change. Codemode modules are experimental.


Implementation

No new transport. A method on an object is just a host call named host/<ref>.<method>, like host/codemode:jobs#3:0.start. It goes through the existing bridge.call(), with the same limits, journal and replay ordering as any other call.

Refs and proxies. When a result holds a defined object, the bridge swaps it for a marker and lists it as { ref, kind, state }. A ref is the module, the journal seq of the call that made it, and its place in the result. The journal keeps the state. The isolate only gets the ref, and turns it into a Proxy whose methods are host calls. A scope that only replayed an object rebuilds it from its state the first time it's called live. Refs the bridge didn't issue or replay are refused, and so are objects from another module.

Journal values. Plain JSON keeps the journal's old format and call keys, so existing journals read and match as before. Anything richer is stored with Cap'n Web's serialize(), behind a NUL-prefixed marker. We only use its encoding, not a session. Cap'n Web refuses functions, stubs and streams, which is the rule a replayable run needs. Workers RPC also has to carry the value to the isolate, which rules out URL.

Lifecycle. This takes #293's scope lifecycle and drops its API. Started scoped contexts and live objects with a disposer go into the record's instances, and close together when the scope ends, with #293's reasons, deadline and log(). createScopedModule() never shipped, so it's removed, along with its export list and its "functions don't match exports" check.

Also in this PR

  • Bumps capnweb to ^0.12.0 in packages/computer and packages/rpc.
  • Fixes a byte array in a codemode result being copied as a plain object.
  • Updates the replay digest test. REPLAY_VERSION stays, since the new isolate code only acts on responses that list objects or carry error names and properties, which no older journal has.

Devin Review

@changeset-bot

changeset-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 79ed36e

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/computer-rpc Minor
@cloudflare/dofs 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

devin-ai-integration[bot]

This comment was marked as resolved.

@pkg-pr-new

pkg-pr-new Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 79ed36e

@ben-reitz
ben-reitz added this pull request to stack #303 November 4, 2026 10:26
@ben-reitz
ben-reitz removed this pull request from stack #303 November 4, 2026 10:46
@ben-reitz
ben-reitz changed the base branch from browser/module-hooks to browser/exec-options November 4, 2026 10:47
@ben-reitz
ben-reitz added this pull request to stack #304 November 4, 2026 10:50
ben-reitz and others added 7 commits November 4, 2026 10:55
A host module that holds state for a run, such as a browser session
it opens on the first call, had no way to learn when to let go of
it. A run waiting in `ctx.interrupt()` releases its isolate and later
starts again from the top, and a run that ends gives no signal to the
modules it called. Host modules are built once per connection, so
each author would have to keep per-run state keyed by id themselves.

`createScopedModule({ exports, start })` makes a host module with one
instance for each scope of a run. A scope lasts from when the run's
code starts in an isolate until it waits in `ctx.interrupt()` or
ends, so a run that resumes gets a new scope and a new instance. The
backend calls `start` with the run's id and metadata on the scope's
first call to the module, and calls made meanwhile share that start,
so it gets one signal for the scope, which aborts when the scope is
cancelled or released. A call answered from the run's journal
doesn't start an instance. The export names are listed up front
because the backend builds the module graph before any run, and a
call fails if the functions `start` returns don't match them. The
module is marked by `createScopedModule()` rather than recognized by
shape, since an object module's keys are its export names.

An instance closes if and only if it started. It closes with reason
"paused" when a waiting run has released its isolate and the calls it
was making have settled, before the run can resume, so an answer that
arrives meanwhile resumes it only after the close. It closes with the
run's terminal reason after its host calls settle and before its exit
is published. A run killed while paused has nothing open, so nothing
closes. Every instance of a scope closes at once under one
`maxHostCallMs` deadline, and a close that throws or runs late is
reported with `console.error` without changing how the run ends.

Only a terminal close gets `log()`, which appends to the run's
stderr. Logged text is saved in the same transaction as the run's
terminal state rather than appended as it arrives, so a run
interrupted between the two never resumes and replays against output
it didn't write. It counts toward `maxStdioBytes`, and text past the
limit is cut and followed by a line saying so.

Releasing a paused run now waits for its isolate to stop rather than
for its completion callbacks, which may be settling the run and wait
for the release in turn. A run that ends while it is being released
waits for that release, including the calls it drains, before its
exit is published.
Version 0.11 adds URL and exact typed array serialization, 0.11.1
keeps the published bundles ASCII-only, and 0.12 fixes argument leaks
on failed calls. The one type change in 0.12, for methods declared to
return Promise<RpcStub<T>>, isn't used anywhere in the repository.
A host module could only return JSON, so a module that gives code
objects had to be two modules: flat host functions over IDs, and source
in the isolate that built the objects back up. createCodemodeModule()
makes that one module. It defines kinds of object with define(), adds
shared context with context(), and makes the module with module().

An object's methods are host calls like any other. When a result holds
a defined object, the bridge swaps it for a marker and lists it with a
ref, its kind and its state. The ref names the module, the journal
sequence of the call that made it, and its place in the result. The
journal keeps the state and the isolate gets the ref, which the
capabilities module turns into a proxy. Calling a method on the proxy
calls host/<ref>.<method> through the same limits, journal and replay
ordering as any other call, so the journal format doesn't change. A
scope that only replayed an object builds it again from its state the
first time it calls it live. A ref the bridge didn't issue or replay is
refused, and so is an object from another module.

context() adds a value to ctx, made once when the backend connects, or
with scoped set, once per scope the first time a method reads it. That
replaces createScopedModule(), which never shipped. Its lifecycle stays:
started scoped contexts and live objects with a disposer go into the
record's instances and close together when the scope ends, with the
same reasons, deadline and log(). An object code releases early is
disposed with the reason "disposed". Without an export list to keep in
step with start(), the check that an instance's functions match its
exports goes away.

In a run that doesn't replay, a codemode module can also take callbacks
and return streams and live stubs, which Workers RPC carries as they
are. A replayable run refuses them, since the journal can't keep them.

The runtime code the isolate loads changed, so the replay digest test
is updated. REPLAY_VERSION stays: the new code only acts on responses
that list objects, which no existing journal holds.
A replayable run's host calls had to take and return JSON, because the
journal stored JSON. A codemode module couldn't return a Date or throw
an error with a code and details, and have it look the same on replay.

The journal now stores a value that plain JSON can't hold with Cap'n
Web's serialize(), behind a prefix that starts with a NUL, which JSON
text never does. Plain values keep the old format, and call keys for
plain arguments don't change, so existing journals read and match as
before. Cap'n Web refuses functions, stubs and streams, which is the
rule a replayable run needs anyway. Only the encoding is used, not a
Cap'n Web session.

In a replayable run, codemode arguments, results and object state can
now hold anything Cap'n Web 0.12 encodes and Workers RPC carries to the
isolate: JSON plus Date, bigint, byte arrays, ArrayBuffer and the other
typed arrays, Headers, and errors. URL is refused, since Workers RPC
can't carry it. A codemode error keeps its standard name, such as
RangeError, and its own properties that fit, live and on replay, and
the isolate rebuilds it with the same constructor.

Writing the tests turned up a bug from the change that added codemode
modules: a byte array in a codemode result was copied as a plain
object. It now crosses as it is.

The isolate's error handling changed, so the replay digest is updated.
REPLAY_VERSION stays, since a journal without the new fields replays as
it did.
In a replayable run, every codemode result was measured with Cap'n
Web's serialize(), even plain JSON, which the journal stores as JSON
anyway. Two results that a plain host module takes then failed: JSON
nested past 256 levels ("Serialization exceeded maximum allowed
depth"), such as a 128-level DOM.getDocument result, and text whose
JSON escaping pushed it over the byte limit.

Results are now measured the way replayable requests already are:
plain data by its raw size, and only richer values by their encoding.
A value with anything richer in it is still stored whole with Cap'n
Web's encoding, so measuring also checks that the whole value
encodes. A request or result too deep for the journal is then the
call's error, rather than a failure when the journal stores it.
Only objects can be released early, so a scoped context's dispose is
never told "disposed". Its end type still allowed it, so checking
`end.reason !== "paused"` didn't narrow to the reasons that have
log(), and the docs' example didn't compile.

dispose now takes CodemodeContextScopeEnd, which leaves "disposed"
out, and the test module drops its extra check.
A method that awaits a scoped context carried on after its call had
timed out or been cancelled, if the context was slow to start. The
caller had already been told the call failed, but the method could
still act, such as writing through a session it never should have used.

The scoped context's getter now checks the call's signal once the
context has started, so the method's await throws instead.
devin-ai-integration[bot]

This comment was marked as resolved.

…demode modules (#307)

* computer: Count a replayed codemode result as its first attempt did

A replayable run charges each response against its total response
budget, live and again on replay. Live, a codemode result counts plain
data as text and only richer values, such as a Date, by Cap'n Web's
encoding. On replay, a result with any richer value counted wholly by
that encoding, which grows escaped text, and its object descriptors
didn't count. After a restart, a later call could exceed the budget
where the first attempt hadn't.

Both now use the same measurement.

* computer: Label journaled calls that take a bigint

The journal labels each call with its name and arguments, using
JSON.stringify, which throws on a bigint. A replayable run allows
bigint arguments, but a codemode call that took one failed before the
journal recorded it. The label now shows a bigint as its digits and n.

* computer: Say how codemode results treat undefined

The docs listed undefined among the values a replayable run keeps. In
results, it follows JSON's rules in every run: an undefined field is
left out, and an undefined array item or result becomes null.
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.

2 participants