diff --git a/CHANGELOG.md b/CHANGELOG.md index 425e91e..244c8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.17.3 - 2026-10-08 + +- `docs/agents.md` has a new step 12 for the browser runtime. It covers when + the browser runtime fits, the install with `@sqlite.org/sqlite-wasm`, the + entry points, a module worker example, authorization in the browser, the + platform limits, transmit to a Node.js or Rails server, and the checks. +- `examples/browser/draft-worker.js` and `examples/browser/page.js` are the + guide example. The Chromium suite runs them: state survives a reload, two + tabs share one draft, and the second tab continues after the holder tab + closes. `pnpm run check:documentation` fails when the guide does not embed + the current files. +- The example keeps short lease settings. With the default settings, a call + from the remaining tab timed out while it waited for the lease of the closed + tab. +- The documentation link check skips fenced code blocks, so code such as + `ref[operation](value)` is not read as a link. +- Correct the transmit docs: `registerTransmit` never reads the HTTP response, + so a server 422 stops retries only when the `deliver` callback throws + `NonRetryableError`. The agent guide shows that callback, `docs/api.md` says + so, and a new test proves that `NonRetryableError` from `deliver` + dead-letters the effect after one attempt, where a plain `Error` retries. +- The page example rejects every waiting and later call when the worker fails + to load. Before, a call waited with no end. + ## 0.17.2 - 2026-10-08 - The README names the agent guide at the start of Installation, and the agent diff --git a/docs/agents.md b/docs/agents.md index 898dea6..c8bb103 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -55,6 +55,9 @@ with Cloudflare. - Redis is optional. It only shortens wake-up latency and holds no durable state. +The browser runtime has different requirements. See +[step 12](#12-use-the-browser-runtime). + [Supported versions](support.md) lists the CI matrix. ## 4. Install @@ -325,7 +328,234 @@ When you explain Solid Objects to a user, state these limits: run. - The package is pre-1.0. It has no measured scale and no known third-party production use. -- The browser client is not the SQL runtime. The Cloudflare backend is - experimental. +- `solid-objects/browser` is a WebSocket client, not the SQL runtime. The + browser runtime in step 12 is tested in Chromium only. The Cloudflare backend + is experimental. The [correctness contract](correctness.md) is the source for each guarantee. + +## 12. Use the browser runtime + +The same `Actor` classes run in a browser module worker. The database is SQLite +WASM. Persistent state is in the origin private file system (OPFS), so it +survives a page reload. + +Use the browser runtime when one identity in the browser, such as a draft, a +game, or a form, must keep its state across reloads and tabs. It also fits +writes that must wait on the device until the network returns. Select a simpler +tool in these cases: + +| Requirement | Use instead | +| ---------------------------------------- | ----------------------------------------------- | +| A preference or another small value | `localStorage` | +| A cache of server responses | The Cache API or IndexedDB | +| State that must be correct for all users | A server runtime. The user controls the browser | +| Live updates from a server runtime only | `solid-objects/browser`, the WebSocket client | + +### Install + +```bash +npm install solid-objects @sqlite.org/sqlite-wasm +``` + +`@sqlite.org/sqlite-wasm` 3.50 or newer is an optional peer dependency. The +browser runtime needs it. + +### Choose the entry point + +| Need | Use | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| The runtime in a worker | `solid-objects/browser/host`. It exports `Actor`, `configure`, `sqliteWasm`, `sharedSqliteWasm`, and the tab host | +| One database for all tabs, a worker in each tab | `sharedSqliteWasm({ path })`. Storage is persistent by default | +| One worker in one tab only | `sqliteWasm({ path, storage: "persistent" })`. The default storage is `"temporary"` | +| One runtime for all tabs, with a leader tab | `startTabHost()` and `connectTabClient()` from `solid-objects/browser/tab-host` | +| Send local writes to a server | `this.transmit()` and `registerTransmit()`. See [Send writes to a server](#send-writes-to-a-server) | + +### Example + +This module worker hosts the runtime. Each tab starts one copy. +`sharedSqliteWasm` elects one tab to hold the database. The other tabs send +their SQL to that tab, and a new tab takes over when it closes. + +```javascript +import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host" + +class NoteDraft extends Actor { + static actorType = "NoteDraft" + + text = "" + revision = 0 + + edit({ text }) { + this.text = text + this.revision += 1 + return this.revision + } +} + +const allowNoteDrafts = ({ actorType }) => actorType === "NoteDraft" + +const runtime = configure({ + database: sharedSqliteWasm({ path: "notes.db" }), + authorizeMessage: allowNoteDrafts, + authorizeQuery: allowNoteDrafts, + processAliveThresholdMilliseconds: 750, + leaseDurationMilliseconds: 750, + leaseRenewalIntervalMilliseconds: 250, +}) +runtime.register(NoteDraft) +const installed = runtime.install() +installed.then(() => runtime.run(new AbortController().signal)) + +self.onmessage = async (event) => { + const { requestId, actorId, operation, argumentsValue } = event.data + try { + await installed + const value = await NoteDraft.ref(actorId)[operation](argumentsValue) + postMessage({ requestId, ok: true, value }) + } catch (error) { + postMessage({ requestId, ok: false, message: String(error?.message ?? error) }) + } +} +``` + +The page sends messages to the worker. It does not hold actor references: + +```javascript +const worker = new Worker(new URL("./draft-worker.js", import.meta.url), { type: "module" }) +const pending = new Map() +let nextRequestId = 0 +let workerFailure + +worker.onmessage = (event) => { + const { requestId, ok, value, message } = event.data + const request = pending.get(requestId) + pending.delete(requestId) + if (ok) { + request.resolve(value) + return + } + request.reject(new Error(message)) +} + +worker.onerror = (event) => { + workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`) + for (const request of pending.values()) request.reject(workerFailure) + pending.clear() +} + +export function callActor({ actorId, operation, argumentsValue }) { + if (workerFailure) return Promise.reject(workerFailure) + const requestId = nextRequestId++ + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) + worker.postMessage({ requestId, actorId, operation, argumentsValue }) + }) +} +``` + +CI runs this example in Chromium. It checks that state survives a reload, that +two tabs share one draft, and that the second tab continues after the first +tab closes. + +A closed tab does not shut down its runtime. Keep the short lease settings in +the example. With the default settings, the next tab waits for the old lease, +and calls time out before it expires. + +### Authorize in the browser + +The page and the worker run on the user's device, and the user can change +their code. A browser policy limits what your own page can call. It is not a +security boundary. Authorize again on the server for each write that leaves the +device. + +### Rules for browser actors + +- One worker hosts one runtime. Do not import `solid-objects/browser/host` in + a process that also imports the Node.js entry points. +- After the first `await` in an operation, `currentActor()`, + `applicationWritesForbidden()`, and the database deadline read as unset. Keep + guarded writes in synchronous actor code or in commit actions. +- Reminders and effects run only while a worker that calls + `runtime.run(signal)` is alive. When the user closes every tab, nothing runs. +- With `sharedSqliteWasm`, a statement can fail with `SharedDatabaseFailover` + when the holder tab closes during the statement. Write operations so that + they can run again, and retry the call. +- With `startTabHost()`, close the database in a `catch` block when + `startRuntime` fails. An open database blocks the next tab. + +### Platform limits + +- CI tests the browser runtime in Chromium only. Safari 16.4 and Firefox 111 + added the OPFS API that persistent storage needs. Test on each engine that + you support. +- Persistent storage needs a secure context (HTTPS or `localhost`) and a + dedicated worker. `storage: "persistent"` fails fast where OPFS is missing. +- An embedded WebView, such as Cordova, WKWebView, or Android WebView, can lack + OPFS when the device browser has it. Test the WebView itself. +- The browser can clear the storage of an origin. Call + `navigator.storage.persist()` to ask it to keep the data, and send important + writes to a server. + +### Send writes to a server + +An operation stages a write for the server in the same transaction as its +state change: + +```javascript +this.transmit().edit({ text }) +``` + +`registerTransmit({ runtime, deliver })` sends each staged write to the server. +The runtime does not read the HTTP response. Your `deliver` callback decides +what happens: + +```javascript +import { NonRetryableError } from "solid-objects/core" +import { registerTransmit } from "solid-objects/browser/host" + +registerTransmit({ + runtime, + deliver: async (envelope) => { + const response = await fetch("/sync", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + }) + if (response.status === 422) { + throw new NonRetryableError(`The server rejected effect ${envelope.effectId}`) + } + if (!response.ok) throw new Error(`Sync failed with HTTP ${response.status}`) + }, +}) +``` + +- A normal return marks the write as delivered. +- An `Error` makes the effect try again later. This is the offline case. +- A `NonRetryableError` moves the effect to dead letters with no retry. + +Set a high `maxAttempts` in `configure()`, because the default is 5 and a long +offline period can use all attempts. Run one effect worker for each local +runtime to keep the order of writes for each actor. + +The server receives the write with `receiveTransmitEnvelope({ runtime, envelope })` +in Node.js, or with `SolidObjects::Transmission.receive(envelope)` in Rails. +Authenticate the device before this call, because this delivery skips +`authorizeMessage`. Delivery is at least once. The server applies a repeated +write once, because it uses the effect ID as the idempotency key. In Node.js, +return HTTP 422 for `InvalidPayload` and `IdempotencyConflict`. The `deliver` +callback above turns that status into a `NonRetryableError`, so the device +stops the retries. + +The [public API](api.md#solid-objectstransmit) and the +[browser protocol](browser-protocol.md) give the full contract. + +### Verify a browser implementation + +1. Call an operation, reload the page, and confirm that the state is the same. +2. Send concurrent calls to one identity from two tabs. Assert the final state. +3. Close the tab that holds the database. Confirm that the other tab continues. +4. On each target engine, confirm that persistent storage opens, or fails with + a clear error. +5. If you send writes to a server, confirm that the server rejects a device + that it cannot authenticate, and applies a repeated write once. diff --git a/docs/api.md b/docs/api.md index 95f6245..a0caea6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -961,11 +961,13 @@ outbox with at-least-once delivery, per-actor order, and retry backoff. ``` The example uses a Fetch-style handler; any HTTP framework works. The 422 - matters: it tells the sending outbox to dead-letter the effect instead of - retrying it. `InvalidPayload` marks a malformed envelope, and - `IdempotencyConflict` marks a replay whose arguments changed; both are - permanently unappliable, and a 500 would make the outbox retry them - forever. + matters only when the sending `deliver` callback reads it: + `registerTransmit` never sees the HTTP response. Throw `NonRetryableError` + (from `solid-objects/core` in a browser) for a 422, and the outbox + dead-letters the effect instead of retrying it; any other error retries. + `InvalidPayload` marks a malformed envelope, and `IdempotencyConflict` + marks a replay whose arguments changed; both are permanently unappliable, + and a 500 would make the outbox retry them until `maxAttempts` runs out. - Per-actor order comes from an ordered drain: a claimed transmit effect transmits every undelivered envelope for its actor up to its own mailbox diff --git a/docs/releasing.md b/docs/releasing.md index 5338466..31bfe3f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -38,8 +38,10 @@ npm trust github solid-objects \ 3. Read `docs/virtual-actors.md` and `docs/agents.md` against the release. Correct any requirement, compatibility, or guarantee statement that the release changed. `pnpm run check:documentation` fails when the category - guide no longer embeds `examples/ticket-sale.ts`, and `pnpm run -test:package` runs that example against the packed tarball. + guide no longer embeds `examples/ticket-sale.ts`, or when the agent guide + no longer embeds the files in `examples/browser/`. `pnpm run test:package` + runs the ticket sale example against the packed tarball, and + `pnpm run test:browser` runs the browser example in Chromium. 4. Commit and push `main`. 5. Create and push an annotated tag matching the package version: diff --git a/examples/browser/draft-worker.js b/examples/browser/draft-worker.js new file mode 100644 index 0000000..83102e2 --- /dev/null +++ b/examples/browser/draft-worker.js @@ -0,0 +1,39 @@ +import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host" + +class NoteDraft extends Actor { + static actorType = "NoteDraft" + + text = "" + revision = 0 + + edit({ text }) { + this.text = text + this.revision += 1 + return this.revision + } +} + +const allowNoteDrafts = ({ actorType }) => actorType === "NoteDraft" + +const runtime = configure({ + database: sharedSqliteWasm({ path: "notes.db" }), + authorizeMessage: allowNoteDrafts, + authorizeQuery: allowNoteDrafts, + processAliveThresholdMilliseconds: 750, + leaseDurationMilliseconds: 750, + leaseRenewalIntervalMilliseconds: 250, +}) +runtime.register(NoteDraft) +const installed = runtime.install() +installed.then(() => runtime.run(new AbortController().signal)) + +self.onmessage = async (event) => { + const { requestId, actorId, operation, argumentsValue } = event.data + try { + await installed + const value = await NoteDraft.ref(actorId)[operation](argumentsValue) + postMessage({ requestId, ok: true, value }) + } catch (error) { + postMessage({ requestId, ok: false, message: String(error?.message ?? error) }) + } +} diff --git a/examples/browser/page.js b/examples/browser/page.js new file mode 100644 index 0000000..399171f --- /dev/null +++ b/examples/browser/page.js @@ -0,0 +1,30 @@ +const worker = new Worker(new URL("./draft-worker.js", import.meta.url), { type: "module" }) +const pending = new Map() +let nextRequestId = 0 +let workerFailure + +worker.onmessage = (event) => { + const { requestId, ok, value, message } = event.data + const request = pending.get(requestId) + pending.delete(requestId) + if (ok) { + request.resolve(value) + return + } + request.reject(new Error(message)) +} + +worker.onerror = (event) => { + workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`) + for (const request of pending.values()) request.reject(workerFailure) + pending.clear() +} + +export function callActor({ actorId, operation, argumentsValue }) { + if (workerFailure) return Promise.reject(workerFailure) + const requestId = nextRequestId++ + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) + worker.postMessage({ requestId, actorId, operation, argumentsValue }) + }) +} diff --git a/package.json b/package.json index 04c1d58..bb06a7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.17.2", + "version": "0.17.3", "description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL", "type": "module", "license": "MIT", diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs index fc91e66..8ad036a 100644 --- a/scripts/check-documentation.mjs +++ b/scripts/check-documentation.mjs @@ -14,7 +14,8 @@ const documentationPaths = [ for (const documentationPath of documentationPaths) { const source = await readFile(resolve(repositoryRoot, documentationPath), "utf8") - for (const match of source.matchAll(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { + const prose = source.replace(/^```[^\n]*\n[\s\S]*?^```$/gm, "") + for (const match of prose.matchAll(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { const link = match[1] if (!link || /^(?:https?:|mailto:)/.test(link)) continue const [encodedTarget = "", encodedAnchor] = link.split("#", 2) @@ -36,12 +37,26 @@ for (const documentationPath of documentationPaths) { } const embeddedExamples = [ - { documentation: "docs/virtual-actors.md", example: "examples/ticket-sale.ts" }, + { + documentation: "docs/virtual-actors.md", + example: "examples/ticket-sale.ts", + language: "typescript", + }, + { + documentation: "docs/agents.md", + example: "examples/browser/draft-worker.js", + language: "javascript", + }, + { + documentation: "docs/agents.md", + example: "examples/browser/page.js", + language: "javascript", + }, ] -for (const { documentation, example } of embeddedExamples) { +for (const { documentation, example, language } of embeddedExamples) { const documentationSource = await readFile(resolve(repositoryRoot, documentation), "utf8") const exampleSource = await readFile(resolve(repositoryRoot, example), "utf8") - if (!documentationSource.includes(`\`\`\`typescript\n${exampleSource}\`\`\``)) { + if (!documentationSource.includes(`\`\`\`${language}\n${exampleSource}\`\`\``)) { throw new Error(`${documentation} does not embed the current ${example}`) } } diff --git a/src/version.ts b/src/version.ts index 91b8da3..e589300 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.17.2" +export const VERSION = "0.17.3" diff --git a/test/browser-server.mjs b/test/browser-server.mjs index 29e012a..8c659c4 100644 --- a/test/browser-server.mjs +++ b/test/browser-server.mjs @@ -9,6 +9,7 @@ const root = resolve(import.meta.dirname, "../dist") const sqliteWasmRoot = resolve(import.meta.dirname, "../node_modules/@sqlite.org/sqlite-wasm/dist") const signalPolyfillRoot = resolve(import.meta.dirname, "../node_modules/signal-polyfill/dist") const browserFixtureRoot = resolve(import.meta.dirname, "browser") +const browserExampleRoot = resolve(import.meta.dirname, "../examples/browser") const contentTypes = { ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", @@ -120,6 +121,17 @@ const server = createServer(async (request, response) => { await serveFile({ response, path: resolve(browserFixtureRoot, pathname.slice(1)) }) return } + const isExample = pathname.startsWith("/examples/browser/") + const examplePath = resolve(browserExampleRoot, `.${pathname.slice("/examples/browser".length)}`) + if (isExample && !examplePath.startsWith(`${browserExampleRoot}/`)) { + response.writeHead(404) + response.end() + return + } + if (isExample) { + await serveFile({ response, path: examplePath }) + return + } if (pathname.startsWith("/vendor/signal-polyfill/")) { const vendorPath = resolve( signalPolyfillRoot, @@ -170,6 +182,7 @@ async function serveFile({ response, path }) { .toString("utf-8") .replaceAll('"@sqlite.org/sqlite-wasm"', '"/vendor/sqlite-wasm/index.mjs"') .replaceAll('"signal-polyfill"', '"/vendor/signal-polyfill/index.js"') + .replaceAll('"solid-objects/browser/host"', '"/browser/host.js"') } response.writeHead(200, { "content-type": contentType }) response.end(contents) diff --git a/test/browser/agent-guide-example.browser.ts b/test/browser/agent-guide-example.browser.ts new file mode 100644 index 0000000..31f2cd8 --- /dev/null +++ b/test/browser/agent-guide-example.browser.ts @@ -0,0 +1,82 @@ +import { expect, test, type Page } from "@playwright/test" + +interface DraftSnapshot { + text: string + revision: number +} + +async function openPage(page: Page): Promise { + await page.goto("/") +} + +type DraftCall = + | { actorId: string; operation: "edit"; argumentsValue: { text: string } } + | { actorId: string; operation: "snapshot"; argumentsValue?: never } + +function callActor(page: Page, call: DraftCall): Promise { + return page.evaluate( + async ({ modulePath, callValue }) => { + const { callActor: callFromPage } = await import(modulePath) + return callFromPage(callValue) + }, + { modulePath: "/examples/browser/page.js", callValue: call }, + ) +} + +test("runs the agent guide example with state that survives a reload", async ({ page }) => { + const actorId = `guide-note-${Date.now()}` + await openPage(page) + + const revisions = await Promise.all([ + callActor(page, { actorId, operation: "edit", argumentsValue: { text: "first" } }), + callActor(page, { actorId, operation: "edit", argumentsValue: { text: "second" } }), + ]) + expect([...revisions].sort()).toEqual([1, 2]) + + await page.reload() + + const snapshot = (await callActor(page, { actorId, operation: "snapshot" })) as DraftSnapshot + expect(snapshot.revision).toBe(2) + expect(["first", "second"]).toContain(snapshot.text) +}) + +test("shares one draft between tabs and continues after the holder tab closes", async ({ + context, +}) => { + const actorId = `guide-tabs-${Date.now()}` + const firstTab = await context.newPage() + const secondTab = await context.newPage() + await openPage(firstTab) + await openPage(secondTab) + + await callActor(firstTab, { actorId, operation: "snapshot" }) + + const revisions = await Promise.all([ + callActor(firstTab, { actorId, operation: "edit", argumentsValue: { text: "one" } }), + callActor(secondTab, { actorId, operation: "edit", argumentsValue: { text: "two" } }), + ]) + expect([...revisions].sort()).toEqual([1, 2]) + + await firstTab.close() + + expect( + await callActor(secondTab, { actorId, operation: "edit", argumentsValue: { text: "three" } }), + ).toBe(3) + expect(await callActor(secondTab, { actorId, operation: "snapshot" })).toEqual({ + text: "three", + revision: 3, + }) +}) + +test("rejects calls when the worker cannot load", async ({ page }) => { + test.setTimeout(10_000) + await page.route("**/examples/browser/draft-worker.js", (route) => route.fulfill({ status: 404 })) + await openPage(page) + + await expect(callActor(page, { actorId: "missing", operation: "snapshot" })).rejects.toThrow( + /worker/, + ) + await expect(callActor(page, { actorId: "missing", operation: "snapshot" })).rejects.toThrow( + /worker/, + ) +}) diff --git a/test/transmit.test.ts b/test/transmit.test.ts index c7f1d1a..e49348f 100644 --- a/test/transmit.test.ts +++ b/test/transmit.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest" import "../src/platform/node.js" import { Actor } from "../src/actor.js" -import { IdempotencyConflict } from "../src/errors.js" +import { IdempotencyConflict, NonRetryableError } from "../src/errors.js" import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" import type { SolidObjectsConfiguration } from "../src/configuration.js" import { sqlite } from "../src/database/sqlite.js" @@ -188,6 +188,25 @@ describe("sync bridge", () => { }) }) + it("dead-letters an envelope when deliver throws NonRetryableError", async () => { + let attempts = 0 + const local = testRuntime({ authorizeAdministration: () => true }) + registerTransmit({ + runtime: local, + deliver: async () => { + attempts += 1 + throw new NonRetryableError("the server answered 422") + }, + }) + await local.install() + + await local.ref(TransmitCounter, "rejected").increment({ amount: 1 }) + await local.testing.drain({ roles: ["actors", "effects"], maxPasses: 20 }) + + expect(attempts).toBe(1) + expect(await local.deadLetters.effects.all()).toHaveLength(1) + }) + it("recovers in order after an offline period", async () => { let online = false const { local, server } = await pairedRuntimes({