@mcp-b/do-runtime 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3caa712: Move the reusable Chrome-host mechanics out of Rook: add a crash-safe browser alarm coordinator with durable transport recovery, package the MessagePort-backed WebSocket transport used between browser supervisors and actor workers, and share offscreen-document creation and stale-slot recovery.
8
+
9
+ ### Patch Changes
10
+
11
+ - a2570d4: Ship a migration guide that separates Cloudflare Durable Object class lifecycle,
12
+ Drizzle application schemas, persisted Agents state, and runtime-owned storage.
13
+ - 41b6ccc: Keep transformed foreign awaits visible to lifecycle checks until their continuations are published, and preserve native synchronous-iterable behavior in transformed `for await` loops.
14
+
15
+ Restore WebSocket auto-response configuration and timestamps when a host recreates an actor from its hibernation mirror.
16
+
17
+ Roll back failed SQLite WASM snapshot replacements and direct storage copies. If rollback also fails, retain the original database images in `SqliteWasmRestoreError.recoverySnapshot` for host recovery. This rollback is in memory; hosts needing replacement to survive process loss should restore into a fresh prefix before switching placement.
18
+
19
+ Correct the minimal-host setup and verify its documented TypeScript configuration and code against the files included in the package.
20
+
21
+ ## 0.6.1
22
+
23
+ ### Patch Changes
24
+
25
+ - 55bae81: Export the shared in-memory hibernation mirror and browser WebSocket-upgrade adapter so embedders can reuse the reference host behavior instead of copying example shims.
26
+
3
27
  ## 0.6.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -1,109 +1,243 @@
1
1
  # do-runtime
2
2
 
3
- Cloudflare's Durable Object runtime, ported from [workerd](https://github.com/cloudflare/workerd) to TypeScript, so the same actors run in a browser tab and in Node.
3
+ ![A local Agent running in an open browser-extension popup](docs/assets/extension-agent-runtime.svg)
4
+
5
+ Cloudflare Durable Objects and Agents SDK code, running locally inside Chrome
6
+ extensions and browser tabs.
7
+
8
+ ```ts
9
+ import { Agent, callable } from "agents";
10
+
11
+ export class Counter extends Agent<Cloudflare.Env, { count: number }> {
12
+ initialState = { count: 0 };
13
+
14
+ @callable()
15
+ increment() {
16
+ this.setState({ count: this.state.count + 1 });
17
+ }
18
+
19
+ @callable()
20
+ async armWake() {
21
+ await this.schedule(5, "scheduledIncrement");
22
+ }
23
+
24
+ scheduledIncrement() {
25
+ this.setState({ count: this.state.count + 1 });
26
+ }
27
+ }
28
+ ```
29
+
30
+ The [Chrome MV3 example](examples/extension/README.md) runs this ordinary Agent
31
+ inside a module Worker. `setState()` persists to SQLite on OPFS; SDK calls and
32
+ state sync cross a `MessagePort`-backed WebSocket; `schedule()` stores the task
33
+ and projects its wake through `chrome.alarms`. Its end-to-end test destroys the
34
+ offscreen host before the alarm fires, then proves Chrome rebuilds the runtime
35
+ and calls `scheduledIncrement()` against the same state. Cloudflare runs the
36
+ same Agent source inside workerd.
4
37
 
5
38
  [![CI](https://img.shields.io/github/actions/workflow/status/WebMCP-org/do-runtime/ci.yml?branch=main)](https://github.com/WebMCP-org/do-runtime/actions)
6
39
  [![License: FSL 1.1 MIT](https://img.shields.io/badge/license-FSL--1.1--MIT-orange.svg)](LICENSE)
7
40
 
8
- A Durable Object is an actor: one identity, one private SQLite database, one event at a time, reachable by name. That model only ran inside Cloudflare's edge. `do-runtime` is the runtime underneath it — input and output gates, implicit transactions, facets, alarms, Worker Loader, the `cloudflare:workers` module — rebuilt over two storage substrates: **sqlite-wasm on OPFS** inside a Web Worker, and **`node:sqlite`** in a Node process. Its behaviour is pinned by one conformance suite that runs against real workerd, against Node, and against headless Chromium, so "the same semantics" is something the tests assert rather than something this README claims.
41
+ ## The problem
42
+
43
+ An `Agent` or `DurableObject` class is TypeScript, but its safety comes from the
44
+ host around it. [Workerd](https://github.com/cloudflare/workerd) gives every named
45
+ object a private SQLite database, gates storage access and event continuations,
46
+ holds replies until their writes commit, delivers alarms, routes object-to-object
47
+ calls, and restores hibernatable WebSockets. Cloudflare's Agents SDK relies on
48
+ those rules.
49
+
50
+ A browser has the raw components needed to build that host: dedicated workers,
51
+ WebAssembly, OPFS, and `MessagePort`. It does not assemble them into a Durable
52
+ Object runtime. A storage adapter alone would leave concurrency, commit ordering,
53
+ wake-up, and lifecycle behavior undefined. Those differences surface when two
54
+ requests overlap, Chrome evicts an extension context, or a reply races the write
55
+ it reports.
56
+
57
+ `do-runtime` supplies that missing host. It ports the relevant Durable Object
58
+ behavior from workerd to TypeScript, runs each root actor in a Web Worker, stores
59
+ its SQLite database in OPFS, and carries capabilities between workers with
60
+ [Cap'n Web](https://github.com/cloudflare/capnweb). Supported `Agent` and
61
+ `DurableObject` classes can execute on-device and deploy to Cloudflare from the
62
+ same source. A `node:sqlite` backend provides a second local host and a fast test
63
+ lane.
64
+
65
+ The repository contains the runtime and the proofs needed to use it as an
66
+ execution target:
67
+
68
+ | Area | What it contains |
69
+ | --- | --- |
70
+ | [`@mcp-b/do-runtime`](src/) | Input and output gates, implicit transactions, Durable Object storage APIs, facets, alarms, hibernatable WebSockets, Worker Loader, and `cloudflare:workers`. |
71
+ | [`vendor/agents/`](vendor/agents/README.md) | Rook's Agents SDK fork. It consumes the runtime; the runtime package does not import the SDK. |
72
+ | [`conformance/`](conformance/) | One behavioral suite run against real workerd, the Node host, and the browser host in Chromium. |
73
+ | [`examples/extension/`](examples/extension/README.md) | A Chrome MV3 host with an offscreen supervisor, local Agents SDK actors and sub-agents, OPFS persistence, durable alarms, hibernatable sockets, and restart recovery. |
74
+ | [`examples/vibe-platform/`](examples/vibe-platform/README.md) | An in-tab editor that runs a user-authored Agent locally and exports the unchanged source as a Wrangler project. |
9
75
 
10
- It was extracted from Rook, SigVelo's AI agent for Chrome, which needed real Durable Object semantics under Cloudflare's Agents SDK inside a Chrome extension. Cloudflare, Workers, Durable Objects, and workerd are Cloudflare's; this is an independent port and is not affiliated with or endorsed by Cloudflare.
76
+ This work was extracted from Rook, SigVelo's AI agent for Chrome. Cloudflare,
77
+ Workers, Durable Objects, and workerd are Cloudflare's; this is an independent
78
+ port and is not affiliated with or endorsed by Cloudflare.
11
79
 
12
80
  ## Contents
13
81
 
14
- - [The model: actors, and what a Durable Object adds](#the-model-actors-and-what-a-durable-object-adds)
15
- - [How it runs in the browser](#how-it-runs-in-the-browser)
16
- - [Quickstart](#quickstart)
82
+ - [The browser runtime](#the-browser-runtime)
83
+ - [Run it in a browser](#run-it-in-a-browser)
84
+ - [Durable Object semantics](#durable-object-semantics)
85
+ - [Minimal host](#minimal-host)
17
86
  - [Hosting an actor](#hosting-an-actor)
18
87
  - [Storage, alarms, facets, I/O](#storage)
88
+ - [Migrations](#migrations)
19
89
  - [What is not supported, and stability](#what-is-not-supported)
20
90
  - [Package layout](#package-layout)
21
91
  - [Tests](#tests)
22
92
  - [Development](#development)
23
93
  - [Acknowledgements and license](#acknowledgements)
24
94
 
25
- ## The model: actors, and what a Durable Object adds
95
+ ## The browser runtime
26
96
 
27
- An **actor** is the oldest answer to concurrency that does not involve locks: a unit of identity plus private state that processes one message at a time and talks to other actors only by sending messages. Nothing outside an actor can touch its state, so there is nothing to race. Erlang processes, Akka actors, Orleans grains, and Durable Objects are all this shape.
28
-
29
- A **Durable Object** is an actor with four things bolted on, and this package ports all four:
30
-
31
- | | What it means | Where it lives here |
32
- | --- | --- | --- |
33
- | **Named identity** | `idFromName("alice")` always means the same actor, and the id names its storage. | `ActorContainerOptions.id` + `uniqueKey`, `src/server/actor-id-impl.ts` |
34
- | **Private transactional storage** | A SQLite database only this actor can open. KV and SQL on the same file; writes coalesce into an implicit transaction that commits at the end of the event. | `src/io/actor-sqlite.ts`, `src/api/sql.ts`, `src/util/` |
35
- | **Input and output gates** | The single-threaded illusion survives `await`. The input gate admits one event at a time (and re-admits a continuation only through a gated primitive); the output gate holds a reply until the write it could reveal is durable. | `src/io/io-gate.ts`, `src/io/io-context.ts` |
36
- | **Alarms and facets** | `setAlarm()` wakes the actor later with retries and backoff. Facets are child actors under a root: own gates, own database, one tree index. | `src/server/alarm-scheduler.ts`, `src/server/facet-*.ts` |
37
-
38
- Two consequences fall out of the gates and are the whole reason the runtime is more than a SQLite wrapper:
39
-
40
- - **No interleaving.** If a method awaits storage, a second call on the same actor waits. Application code reads and writes state without locks and is still correct.
41
- - **No phantom reads.** A reply that could expose a write does not leave until that write is committed. A crash between "returned" and "committed" cannot lie to a caller.
42
-
43
- Everything else in the package serves those two lines.
44
-
45
- ## How it runs in the browser
46
-
47
- Workerd gives every actor its own isolate, so `setTimeout`, `fetch`, and `scheduler.wait` can only ever mean the one actor in that isolate. The browser equivalent is **one root actor per Web Worker**, with the page acting as the supervisor workerd's `Server` is:
97
+ Workerd tracks each actor's I/O context inside a Worker isolate;
98
+ [multiple actors can share that isolate](https://developers.cloudflare.com/durable-objects/reference/in-memory-state/).
99
+ The reference browser host places one root actor in each Web Worker so its
100
+ ambient timers, `fetch`, and `scheduler` resolve to that root.
101
+ The page, or an extension's offscreen document, supervises placement and owns no
102
+ actor storage.
48
103
 
49
104
  ```mermaid
50
105
  flowchart TB
51
- subgraph page["Page the supervisor (owns no storage)"]
52
- S["spawns workers · actor registry · routes actor→actor calls · owns alarm delivery"]
106
+ subgraph page["Page or offscreen document / supervisor"]
107
+ S["spawns workers; actor registry; routes actor calls; owns alarm delivery"]
53
108
  end
54
109
 
55
- subgraph wa["Web Worker actor alice"]
110
+ subgraph wa["Web Worker / actor alice"]
56
111
  direction TB
57
- Ca["ActorContainer<br/>input gate · output gate · state · globals"]
58
- Fa["facet containers<br/>(own gates + db, same realm)"]
59
- Pa[("one OPFS SAH pool<br/>sqlite-wasm")]
112
+ Ca["Agent or DurableObject<br/>inside ActorContainer<br/>input gate; output gate; globals"]
113
+ Fa["facet containers<br/>(own gates and database, same realm)"]
114
+ Pa[("sqlite-wasm<br/>OPFS SAH pool")]
60
115
  Ca --- Fa
61
116
  Ca --> Pa
62
117
  Fa --> Pa
63
118
  end
64
119
 
65
- subgraph wb["Web Worker actor bob"]
120
+ subgraph wb["Web Worker / actor bob"]
66
121
  direction TB
67
- Cb["ActorContainer"]
68
- Pb[("OPFS SAH pool")]
122
+ Cb["Agent or DurableObject<br/>inside ActorContainer"]
123
+ Pb[("sqlite-wasm<br/>OPFS SAH pool")]
69
124
  Cb --> Pb
70
125
  end
71
126
 
72
- subgraph wal["Web Worker alarms"]
127
+ subgraph wal["Web Worker / alarms"]
73
128
  direction TB
74
- A["AlarmScheduler<br/>_cf_ALARM · retry ladder · backoff"]
75
- PA[("OPFS SAH pool")]
129
+ A["AlarmScheduler<br/>_cf_ALARM; retries; backoff"]
130
+ PA[("sqlite-wasm<br/>OPFS SAH pool")]
76
131
  A --> PA
77
132
  end
78
133
 
79
- S <-- "MessagePort · Cap'n Web" --> Ca
80
- S <-- "MessagePort · Cap'n Web" --> Cb
81
- S <-- "MessagePort · Cap'n Web" --> A
134
+ S <-- "MessagePort + Cap'n Web" --> Ca
135
+ S <-- "MessagePort + Cap'n Web" --> Cb
136
+ S <-- "MessagePort + Cap'n Web" --> A
82
137
  ```
83
138
 
84
139
  Why it is shaped this way:
85
140
 
86
- - **The page cannot hold storage.** OPFS synchronous access handles — the only way to run SQLite synchronously in a browser — exist only inside a dedicated worker. So the page is a pure supervisor: it creates workers, keeps the registry, and routes `alice → bob` calls. It is the offscreen document's job in a Chrome extension and `Server`'s job in workerd.
87
- - **One root actor per worker.** The worker entry calls `installActorScope(globalThis, () => container.globals)`, which installs gated `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `fetch`, `crypto`, and `scheduler` as the worker's ambient globals. With one root per realm the ambient is unambiguous, which is exactly why workerd gets this for free and why application code — and any SDK it pulls in — needs no changes.
88
- - **Facets stay in their parent's worker**, as they stay in their parent's isolate upstream. A facet is a separate `ActorContainer` with its own gates and its own database prefix inside the parent's pool; what it shares is the JavaScript realm and the root's synchronous facet-tree index, which is what lets a facet have facets of its own.
89
- - **Alarms get their own worker** because the scheduler needs a database and a database needs a worker. Setting an alarm is one durable row there; delivery comes back through the supervisor, which places the target actor if it is not running.
90
- - **Every hop is `MessagePort` + [Cap'n Web](https://github.com/cloudflare/capnweb).** Each worker is booted with one raw `postMessage` carrying its port; everything after is a capability-based RPC session opened by `newRpcSession()`. A container's `entry(instance)` proxy is what sits behind the session, so every call from outside is one gated event.
141
+ - OPFS synchronous access handles exist only inside a dedicated worker, so the
142
+ supervisor creates workers, keeps the registry, and routes calls between them.
143
+ - Each root gets its own worker. `installActorScope()` can therefore install the
144
+ actor's gated timers, `fetch`, `crypto`, and `scheduler` as unambiguous ambient
145
+ globals for application and SDK code.
146
+ - Facets stay in their parent's worker, matching workerd's isolate layout. Each
147
+ facet still has its own container, gates, and database prefix.
148
+ - The reference host gives its alarm scheduler a separate worker because the
149
+ scheduler has durable SQLite state of its own. A single-root host can colocate
150
+ it with the actor worker, as the MV3 example does. Either layout can place a
151
+ sleeping actor when a stored alarm becomes due.
152
+ - Each cross-worker call is a capability-based RPC session over a transferred
153
+ `MessagePort`. The proxy exposed by `container.entry(instance)` turns each call
154
+ into one gated actor event.
91
155
 
92
- Boot order inside an actor worker is load-bearing; each inversion below is a measured failure, not a style choice:
156
+ `conformance/browser/` is that picture, runnable: [`host.ts`](conformance/browser/host.ts) is the page, [`actor.worker.ts`](conformance/browser/actor.worker.ts) is a worker hosting one actor tree over OPFS, [`alarms.worker.ts`](conformance/browser/alarms.worker.ts) is the scheduler, and [`protocol.ts`](conformance/browser/protocol.ts) is the three RPC surfaces between them.
93
157
 
94
- 1. Capture raw platform timers at module scope and build the `Timer` port on them — a `Timer` that reads the installed globals recurses once the scope is in.
95
- 2. Set `globalThis.sqlite3ApiConfig = { disable: { vfs: { opfs: true, "opfs-wl": true } } }` before touching sqlite — only the SAH pool is wanted, and the other two VFSes spawn workers and arm watchdogs of their own.
96
- 3. `sqlite3InitModule()` and `installOpfsSAHPoolVfs(...)` **before** `installActorScope` — the installer arms watchdogs through the global `setTimeout`, which must not yet be the actor's gate.
97
- 4. `installActorScope(globalThis, resolve)` with a `resolve` that throws when the container is gone, so a torn-down worker refuses instead of falling through to raw timers.
98
- 5. Application pool settings, not the conformance lane's test-only ones: a stable pool name (it becomes an OPFS directory name), `clearOnInit: false`, capacity sized to two databases per root plus journals. The pool takes exclusive sync access handles — one holder per pool; a second context fails to install.
158
+ ## Run it in a browser
99
159
 
100
- `conformance/browser/` is that picture, runnable: [`host.ts`](conformance/browser/host.ts) is the page, [`actor.worker.ts`](conformance/browser/actor.worker.ts) is a worker hosting one actor tree over OPFS, [`alarms.worker.ts`](conformance/browser/alarms.worker.ts) is the scheduler, and [`protocol.ts`](conformance/browser/protocol.ts) is the three RPC surfaces between them.
160
+ The Chrome MV3 extension is the primary reference host. From a fresh checkout:
161
+
162
+ ```bash
163
+ pnpm sdk:setup
164
+ pnpm install
165
+ pnpm exec playwright install chromium
166
+ pnpm --filter do-runtime-example-extension e2e
167
+ ```
168
+
169
+ | Example | Browser shape | What the test proves |
170
+ | --- | --- | --- |
171
+ | [Chrome MV3 extension](examples/extension/README.md) | Service worker -> offscreen document -> actor Worker -> sqlite-wasm/OPFS | An Agents SDK root and its sub-agents retain state across host teardown; alarms wake an evicted host; hibernatable sockets, state sync, callable RPC, queues, MCP, and email routing use the real SDK paths. |
172
+ | [In-tab coding platform](examples/vibe-platform/README.md) | Page -> workspace Worker and authored-Agent Worker -> sqlite-wasm/OPFS | A user-authored Agent runs against durable local state, survives code replacement, and exports from the browser as the same source in a Wrangler project. |
173
+
174
+ Run both browser end-to-end suites with `pnpm test:examples`.
175
+
176
+ The [in-tab coding platform](examples/vibe-platform/README.md) is the second
177
+ composition: an editor, local preview, persisted Agent state, and Wrangler
178
+ export, all inside a normal browser tab.
179
+
180
+ ![The vibe-platform example running an Agents SDK Agent in a browser, with the Agent source on the left and its SQLite-backed application on the right](docs/assets/browser-agent-runtime.png)
181
+
182
+ Start it at `http://localhost:5173` with
183
+ `pnpm --filter do-runtime-example-vibe-platform dev`.
184
+
185
+ ## Durable Object semantics
186
+
187
+ An actor has an identity and private state. A Durable Object adds gates that
188
+ serialize event slices and storage access, plus storage and lifecycle behavior
189
+ that survives process loss:
190
+
191
+ | | What it means | Where it lives here |
192
+ | --- | --- | --- |
193
+ | **Named identity** | `idFromName("alice")` always means the same actor, and the id names its storage. | `ActorContainerOptions.id` + `uniqueKey`, `src/server/actor-id-impl.ts` |
194
+ | **Private transactional storage** | A SQLite database only this actor can open. KV and SQL share the file; writes coalesce into implicit transactions that commit at gate-release boundaries. | `src/io/actor-sqlite.ts`, `src/api/sql.ts`, `src/util/` |
195
+ | **Input and output gates** | The input gate serializes event slices across `await`. The output gate holds a reply until the write it could reveal is durable. | `src/io/io-gate.ts`, `src/io/io-context.ts` |
196
+ | **Alarms and facets** | `setAlarm()` wakes the actor later with retries and backoff. Facets are child actors with their own gates and database under a root. | `src/server/alarm-scheduler.ts`, `src/server/facet-*.ts` |
197
+
198
+ With the default storage options, a method awaiting storage holds other calls
199
+ back, so a read-modify-write that only awaits storage needs no extra lock.
200
+ Awaiting external I/O such as `fetch()` releases the input gate: another event
201
+ can run before the method resumes. Use
202
+ [`blockConcurrencyWhile()`](https://developers.cloudflare.com/durable-objects/api/state/#blockconcurrencywhile)
203
+ when an async operation must exclude other events. A reply that exposes a write
204
+ waits for that write to commit, so a crash cannot leave a caller holding an
205
+ acknowledgement for data that never became durable.
101
206
 
102
- ## Quickstart
207
+ ## Minimal host
103
208
 
104
- Install with `pnpm add @mcp-b/do-runtime`. The package ships ESM JavaScript and declarations and requires Node ≥ 24.11 when using the `node:sqlite` backend.
209
+ Install with `pnpm add @mcp-b/do-runtime`. The package ships ESM JavaScript and
210
+ declarations. The shortest complete host uses the `node:sqlite` backend, which
211
+ requires Node 24.11 or newer. Browser hosts place the same container inside a
212
+ Web Worker and supply the sqlite-wasm backend shown in the runnable examples.
213
+
214
+ For a standalone TypeScript host, install the ambient Node and Workers types:
215
+
216
+ ```bash
217
+ pnpm add -D typescript @types/node @cloudflare/workers-types@5.20260820.1
218
+ ```
219
+
220
+ Use the following `tsconfig.json`. An existing Workers project can keep its
221
+ generated Cloudflare types instead of loading `@cloudflare/workers-types` too.
222
+
223
+ ```json
224
+ {
225
+ "compilerOptions": {
226
+ "target": "ESNext",
227
+ "module": "NodeNext",
228
+ "lib": ["ESNext"],
229
+ "types": ["node", "@cloudflare/workers-types"],
230
+ "strict": true,
231
+ "skipLibCheck": true,
232
+ "noEmit": true
233
+ }
234
+ }
235
+ ```
236
+
237
+ Save this as `host.mts` and run `pnpm exec tsc && node host.mts`:
105
238
 
106
239
  ```ts
240
+ import { mkdir } from "node:fs/promises";
107
241
  import { DurableObject } from "@mcp-b/do-runtime/cloudflare-workers";
108
242
  import { createActorContainer, DEFAULT_ALARM_OUTLET, noFacets, type Timer } from "@mcp-b/do-runtime";
109
243
  import { createNodeSqlProvider } from "@mcp-b/do-runtime/backends/node-sqlite";
@@ -133,6 +267,7 @@ const timer: Timer = {
133
267
  }),
134
268
  };
135
269
 
270
+ await mkdir("./data", { recursive: true });
136
271
  const container = await createActorContainer({
137
272
  id: "counter-1",
138
273
  uniqueKey: "my-app", // keep this stable forever: every DurableObjectId is derived from it
@@ -151,14 +286,12 @@ await counter.increment(); // 1
151
286
  await counter.increment(); // 2
152
287
  ```
153
288
 
154
- Open a second container over the same directory and `increment()` answers `3`: the instance was volatile, the storage was not. In a browser the only line that changes is `sql`, which becomes `createSqliteWasmProvider(pool, { prefix: "/counter-1" })` from `@mcp-b/do-runtime/backends/sqlite-wasm`.
155
-
156
- ## Examples
157
-
158
- Two runnable browser hosts live in [`examples/`](examples/), each with its own README and Playwright e2e (`pnpm test:examples`):
159
-
160
- - [`examples/extension/`](examples/extension/) — a Chrome MV3 compatibility harness: service worker → offscreen document (with corpse recovery) → worker hosting an Agents SDK `Counter` and local sub-agents. Proves persistent state, sibling and nested facet isolation, overlapping async work, abort/delete lifecycle, sub-agent scheduling across host recreation, exclusive host ownership, hibernating `AgentClient` WebSockets, state sync, callable and streaming RPC, SDK queues, stateless MCP, inbound email routing, the MV3 CSP story (`'wasm-unsafe-eval'`), and `chrome.alarms` recreation of an evicted host before durable alarm delivery.
161
- - [`examples/vibe-platform/`](examples/vibe-platform/) — a self-contained vibe-coding page that authors both a front-end and an Agents SDK `Agent`, runs them in-tab with durable SQLite-backed state, and exports the unchanged sources as a Wrangler project that passes `wrangler deploy --dry-run`.
289
+ Open a second container over the same directory and `increment()` answers `3`:
290
+ the instance was volatile, the storage was not. Inside an initialized actor
291
+ worker, the browser host supplies
292
+ `createSqliteWasmProvider({ pool, capi: sqlite3.capi }, { prefix: "/counter-1" })` from
293
+ `@mcp-b/do-runtime/backends/sqlite-wasm`. The supervisor, OPFS pool, and worker
294
+ bootstrapping are shown in the browser examples above.
162
295
 
163
296
  ## Hosting an actor
164
297
 
@@ -190,6 +323,27 @@ The lifecycle:
190
323
  6. Watch `container.onBroken`; dispose the placement; recreate it on the next event over the same storage. A failed `blockConcurrencyWhile()` rejects its caller with `BrokenActorError` and breaks the placement with that same error.
191
324
  7. Before evicting, inspect `container.quiescence()`. Mirror live sockets through `ports.hibernation`, then build the replacement with `webSockets`; do not reconnect or call `acceptWebSocket()` again.
192
325
 
326
+ ### Browser worker boot order
327
+
328
+ The order inside an actor worker is load-bearing. Each inversion below has a
329
+ measured failure in the browser test lane:
330
+
331
+ 1. Capture raw platform timers at module scope and build the `Timer` port on
332
+ them. Reading the installed globals from that port recurses after the actor
333
+ scope replaces them.
334
+ 2. Set
335
+ `globalThis.sqlite3ApiConfig = { disable: { vfs: { opfs: true, "opfs-wl": true } } }`
336
+ before initializing sqlite. The host uses the SAH pool; the other OPFS VFSes
337
+ spawn workers and arm watchdogs of their own.
338
+ 3. Run `sqlite3InitModule()` and `installOpfsSAHPoolVfs(...)` before
339
+ `installActorScope`. The pool installer uses global timers during startup.
340
+ 4. Install the actor scope with a resolver that throws when its container is
341
+ gone. A torn-down worker must refuse new work instead of falling through to
342
+ ungated platform timers.
343
+ 5. Use a stable pool name, preserve files with `clearOnInit: false`, and size the
344
+ pool for two databases per root plus journals. The pool owns exclusive sync
345
+ access handles, so another context cannot open it at the same time.
346
+
193
347
  For a standard Durable Object binding, call
194
348
  `createDurableObjectNamespace(uniqueKey, channel)` and put the result in `env`
195
349
  and `ctx.exports`. The channel maps each routed id to a placed `Fetcher`; that
@@ -207,15 +361,31 @@ re-enters the owning input gate.
207
361
 
208
362
  `SqlDatabaseProvider.open(name)` is the runtime execution seam. The runtime owns database names, tables, transactions, reset behaviour, facet metadata, and streaming `sql.ingest()` statement boundaries; the host chooses the physical provider and prefix. Stored KV values use structured-clone semantics across workerd, Node, and the browser; existing JSON rows remain readable. `_cf_` names are reserved to the runtime.
209
363
 
210
- Schema migrations work exactly as on Cloudflare, at both layers. An application migrates its own tables in its constructor — synchronous DDL under boot semantics, or `ctx.blockConcurrencyWhile()` when the path is async; the Agents SDK's versioned `_ensureSchema` and Drizzle's `durable-sqlite` migrator (`drizzle-kit generate` compiled into the bundle, `migrate()` in the constructor) run unchanged, the latter pinned end-to-end by [`src/drizzle-migrations.test.ts`](src/drizzle-migrations.test.ts). The runtime's own `_cf_` tables are versioned separately, per database file, in `PRAGMA user_version`: [`src/util/sqlite-migrations.ts`](src/util/sqlite-migrations.ts) brings an older file forward at open — before any event can enter — a file or imported snapshot stamped by a newer release refuses with the remedy named, and application SQL cannot reach the stamp, because `sql.exec()` enforces workerd's pragma allowlist (decision 19).
364
+ ### Migrations
365
+
366
+ Wrangler class declarations, application SQL migrations, persisted `Agent.state`,
367
+ Agents SDK tables, and runtime-owned tables have separate owners. Application
368
+ SQL uses the same Drizzle migration bundle and constructor pattern on Cloudflare
369
+ and on this runtime; `do-runtime` adds no application migration registry. See
370
+ [`docs/migrations.md`](docs/migrations.md) for the Rook-facing workflow and the
371
+ upstream Cloudflare and Drizzle references.
372
+
373
+ The runtime's own `_cf_` tables are versioned per database file in
374
+ `PRAGMA user_version`. [`src/util/sqlite-migrations.ts`](src/util/sqlite-migrations.ts)
375
+ brings older files forward before any event enters and refuses storage written by
376
+ a newer package version. Application SQL cannot access that runtime-owned stamp.
211
377
 
212
378
  The browser provider takes an already-installed OPFS SAH pool (`installOpfsSAHPoolVfs`; sync access handles in a dedicated worker — no cross-origin isolation or `SharedArrayBuffer` needed). One pool per worker; the root and each local facet get separate prefixes inside it. `SqliteWasmActorStorage` adds the close, physical delete, and clone operations a local placement host needs around one prefix. The Node provider uses in-memory databases by default and a directory when asked.
213
379
 
214
380
  Both concrete providers also implement `SqlDatabaseSnapshotProvider`. After the host has stopped the actor, `provider.close()` releases every database handle; `exportSnapshot()` then returns the SQLite images for the whole actor storage scope, and `importSnapshot()` replaces an idle scope. The same snapshot can seed a cold local replica because SQLite images are portable between these providers. Node snapshots require a dedicated directory-backed provider. This is backup/restore and replica seeding, not Cloudflare's time-indexed PITR or continuously updated read replication.
215
381
 
382
+ The browser provider attempts to restore the original images when a snapshot import or direct `SqliteWasmActorStorage.copyFrom()` replacement fails; `SqliteWasmRestoreError.recoverySnapshot` retains those images if rollback also fails. This rollback lives in memory and cannot survive Worker or process loss during replacement; a host requiring that guarantee must import into a fresh prefix and durably switch placement after success.
383
+
216
384
  ### Alarms
217
385
 
218
- Construct one `AlarmScheduler` per namespace over a `SqlDatabase` of its own. It owns `_cf_ALARM`, delivery, retry counts (`ALARM_RETRY_MAX_TRIES`), exponential backoff with jitter, and abandonment. Pass `scheduler.hooks(id)` as a root actor's `ports.alarms`, and give the scheduler a `getActor(id)` that places the actor if it is not running — an alarm is a reason to wake a Durable Object, not something that needs one awake already. A browser host may project the scheduler's current one-shot wait onto a physical timer (`chrome.alarms`, say) but must not duplicate delivery policy.
386
+ Construct one `AlarmScheduler` per namespace over a `SqlDatabase` of its own. It owns `_cf_ALARM`, delivery, retry counts (`ALARM_RETRY_MAX_TRIES`), exponential backoff with jitter, and abandonment. Pass `scheduler.hooks(id)` as a root actor's `ports.alarms`, and give the scheduler a `getActor(id)` that places the actor if it is not running — an alarm is a reason to wake a Durable Object, not something that needs one awake already.
387
+
388
+ A suspending browser host can project the scheduler's next wake through `BrowserAlarmCoordinator` from `@mcp-b/do-runtime/browser/alarm-coordinator`. The coordinator journals the physical hop, rejects stale projections, rearms a consumed watchdog, and reconciles after background-worker restart. The host supplies durable journal storage, the physical alarm calls, and delivery back into its scheduler; logical delivery policy remains in `AlarmScheduler`.
219
389
 
220
390
  ### Facets
221
391
 
@@ -241,6 +411,16 @@ registry is populated before the constructor, so SDKs can lazily rebuild their
241
411
  connection wrappers without another upgrade or connect hook. Closed sockets are
242
412
  removed before `webSocketClose` runs.
243
413
 
414
+ `HibernationMirror` is the package's in-memory reference implementation. Seed a
415
+ replacement mirror with the prior socket snapshot and auto-response pair, then
416
+ pass that mirror to `ports.hibernation` and its `snapshot()` to `webSockets`.
417
+ Browser hosts can install the remaining Request/Response upgrade accommodation
418
+ from `@mcp-b/do-runtime/browser`; the runtime itself supplies `WebSocketPair`.
419
+ Hosts whose actor lives in another Worker can use `MessagePortWebSocket`,
420
+ `createMessagePortWebSocketConstructor`, and `serveMessagePortWebSockets` from
421
+ `@mcp-b/do-runtime/browser/message-port-websocket`; binary frames stay in
422
+ structured clone and each socket gets one dedicated `MessagePort`.
423
+
244
424
  `container.quiescence()` reports armed timers, pending `waitUntil` work, input
245
425
  lock state, and output-gate breakage without waiting. `drainWaitUntil()` is for
246
426
  shutdown and intentionally never settles while a live interval remains armed.
@@ -280,10 +460,12 @@ This is `0.x`. The public surface is what [`src/index.ts`](src/index.ts) and the
280
460
  | `src/io/` | Gates, invocation context, actor storage engine, ids, Worker channels |
281
461
  | `src/api/` | Workers-facing APIs: `DurableObjectState`, SQL, WebSocket, Worker Loader, `cloudflare:workers` |
282
462
  | `src/server/` | Actor containers, facet lifecycle, deletion recovery, alarm scheduling |
463
+ | `src/browser/` | Physical alarm projection, offscreen-document recovery, and MessagePort-backed WebSockets for browser hosts |
283
464
  | `src/transport/` | The one `MessagePort` Cap'n Web session adapter |
284
465
  | `backends/` | `node:sqlite` and sqlite-wasm/OPFS `SqlDatabaseProvider`s |
285
466
  | `conformance/` | One suite, three hosts: workerd, Node, browser; plus the probe fixture and benchmarks |
286
467
  | `examples/` | Runnable browser hosts: an MV3 extension and an in-page vibe-coding platform |
468
+ | `docs/migrations.md` | How Rook evolves Wrangler declarations, application SQL, and persisted Agent state without duplicating Cloudflare's migration machinery |
287
469
  | `docs/decisions.md` | The numbered invariants and decisions that source comments cite (`§1.2`, `decision 8`) |
288
470
 
289
471
  The `util → io → api → server` direction follows workerd's own layering, enforced with TypeScript project references. Source comments cite the workerd file and line they port (`← io-gate.c++:142`), and every deliberate divergence is recorded beside its implementation and in a conformance row.
@@ -305,11 +487,19 @@ The workerd lane is what makes the others mean something: every row it passes is
305
487
  ```bash
306
488
  git clone https://github.com/WebMCP-org/do-runtime
307
489
  cd do-runtime
490
+ pnpm sdk:setup
308
491
  pnpm install
309
492
  pnpm exec playwright install chromium # browser lane only
310
493
  pnpm typecheck && pnpm test
494
+ pnpm sdk:check && pnpm sdk:test # Rook's Agents SDK fork
311
495
  ```
312
496
 
497
+ The repository has two independently tested layers: the runtime at the root
498
+ and Rook's six-package Agents SDK fork in
499
+ [`vendor/agents/`](vendor/agents/README.md). The examples consume the fork's
500
+ built `agents` package through a `file:` dependency, which resolves their
501
+ own peer dependencies without installing a second SDK implementation.
502
+
313
503
  Change runtime behaviour with the corresponding workerd source open (line citations use release `v1.20260713.1`; the conformance oracle is pinned to `v1.20260820.1`). Ask the workerd lane an observable question before inventing a local rule; record any intentional divergence in the table above and in a conformance row. Keep host seams small and typed, keep gates internal, and keep product knowledge out of the port. See [`docs/decisions.md`](docs/decisions.md) for the invariants the code cites.
314
504
 
315
505
  ## Acknowledgements
@@ -23,7 +23,7 @@
23
23
  * drives this file directly, and by the conformance suite, which runs the whole
24
24
  * package over it.
25
25
  */
26
- import { type SqlDatabase, type SqlDatabaseProvider, type SqlDatabaseSnapshotProvider, type SqlDatabaseStatement, type SqlResult, type SqlValue } from "../src/util/sqlite.js";
26
+ import { type SqlDatabase, type SqlDatabaseProvider, type SqlDatabaseSnapshot, type SqlDatabaseSnapshotProvider, type SqlDatabaseStatement, type SqlResult, type SqlValue } from "../src/util/sqlite.js";
27
27
  /** ← `PreparedStatement`, the members used here. */
28
28
  export interface SqliteWasmStatement {
29
29
  readonly columnCount: number;
@@ -113,6 +113,11 @@ export declare class SqliteWasmActorStorage implements SqlDatabaseProvider {
113
113
  copyFrom(source: SqliteWasmActorStorage): Promise<void>;
114
114
  }
115
115
  export declare function createSqliteWasmProvider(host: SqliteWasmHost, options: SqliteWasmProviderOptions): SqlDatabaseSnapshotProvider;
116
+ /** A failed rollback retains the original images so the host can recover to another prefix. */
117
+ export declare class SqliteWasmRestoreError extends AggregateError {
118
+ readonly recoverySnapshot: SqlDatabaseSnapshot;
119
+ constructor(errors: unknown[], recoverySnapshot: SqlDatabaseSnapshot);
120
+ }
116
121
  export declare class SqliteWasmDatabase implements SqlDatabase {
117
122
  #private;
118
123
  private readonly onClose;
@@ -81,8 +81,11 @@ var SqliteWasmActorStorage = class {
81
81
  bytes: new Uint8Array(await source.#host.pool.exportFile(file))
82
82
  });
83
83
  }
84
- this.deleteAll();
85
- for (const { name, bytes } of images) await this.#host.pool.importDb(`${this.#prefix}.${name}.sqlite`, bytes);
84
+ this.close();
85
+ await replaceDatabases(this.#host.pool, this.#prefix, images.map(({ name, bytes }) => ({
86
+ name,
87
+ image: bytes
88
+ })));
86
89
  }
87
90
  #ownedFiles() {
88
91
  return this.#host.pool.getFileNames().filter((name) => name.startsWith(`${this.#prefix}.`));
@@ -126,11 +129,73 @@ function createSqliteWasmProvider(host, options) {
126
129
  requireClosed(openDatabases);
127
130
  requireValidSqlDatabaseSnapshot(snapshot);
128
131
  requireImportableRuntimeStorage(snapshot);
129
- for (const file of ownedFiles()) host.pool.unlink(file);
130
- for (const { name, image } of snapshot.databases) await host.pool.importDb(`${prefix}.${name}.sqlite`, new Uint8Array(image));
132
+ await replaceDatabases(host.pool, prefix, snapshot.databases);
131
133
  }
132
134
  };
133
135
  }
136
+ /** A failed rollback retains the original images so the host can recover to another prefix. */
137
+ var SqliteWasmRestoreError = class extends AggregateError {
138
+ recoverySnapshot;
139
+ constructor(errors, recoverySnapshot) {
140
+ super(errors, "SQLite replacement and rollback failed; restore recoverySnapshot to an idle provider.");
141
+ this.recoverySnapshot = recoverySnapshot;
142
+ this.name = "SqliteWasmRestoreError";
143
+ }
144
+ };
145
+ async function replaceDatabases(pool, prefix, databases) {
146
+ const replacement = databases.map(({ name, image }) => ({
147
+ name,
148
+ image: new Uint8Array(image)
149
+ }));
150
+ const ownedFiles = () => pool.getFileNames().filter((file) => file.startsWith(`${prefix}.`));
151
+ const files = ownedFiles();
152
+ requireNoRecoverySidecars(files);
153
+ const original = [];
154
+ for (const file of files) {
155
+ const name = file.slice(prefix.length + 1, -7);
156
+ requireSafeDatabaseName(name);
157
+ original.push({
158
+ name,
159
+ image: new Uint8Array(await pool.exportFile(file))
160
+ });
161
+ }
162
+ const touched = /* @__PURE__ */ new Set();
163
+ try {
164
+ for (const file of files) {
165
+ touched.add(file);
166
+ if (!pool.unlink(file)) throw new Error(`SAH pool did not unlink ${file}`);
167
+ }
168
+ for (const { name, image } of replacement) {
169
+ const file = `${prefix}.${name}.sqlite`;
170
+ touched.add(file);
171
+ await pool.importDb(file, image);
172
+ }
173
+ } catch (error) {
174
+ const errors = [error];
175
+ for (const file of ownedFiles()) {
176
+ if (!touched.has(file)) continue;
177
+ try {
178
+ if (!pool.unlink(file)) throw new Error(`SAH pool did not unlink ${file}`);
179
+ } catch (rollbackError) {
180
+ errors.push(rollbackError);
181
+ }
182
+ }
183
+ for (const { name, image } of original) {
184
+ const file = `${prefix}.${name}.sqlite`;
185
+ if (!touched.has(file)) continue;
186
+ try {
187
+ await pool.importDb(file, new Uint8Array(image));
188
+ } catch (rollbackError) {
189
+ errors.push(rollbackError);
190
+ }
191
+ }
192
+ if (errors.length > 1) throw new SqliteWasmRestoreError(errors, {
193
+ version: 1,
194
+ databases: original
195
+ });
196
+ throw error;
197
+ }
198
+ }
134
199
  var SqliteWasmDatabase = class {
135
200
  onClose;
136
201
  #host;
@@ -255,6 +320,6 @@ function firstCompleteStatement(capi, sql) {
255
320
  return sql;
256
321
  }
257
322
  //#endregion
258
- export { SqliteWasmActorStorage, SqliteWasmDatabase, createSqliteWasmProvider };
323
+ export { SqliteWasmActorStorage, SqliteWasmDatabase, SqliteWasmRestoreError, createSqliteWasmProvider };
259
324
 
260
325
  //# sourceMappingURL=sqlite-wasm.js.map