@remelondb/driver-web
v0.3.3
Published
remelonDB SqliteDriver for the browser (SQLite-WASM + OPFS in a Worker)
Maintainers
Readme
@remelondb/driver-web
The browser SqliteDriver: SQLite-WASM running in a dedicated Worker,
persistent via the OPFS SyncAccessHandle pool VFS, reached over a
postMessage RPC. This is the architecture the whole seam was designed
around: OPFS sync-access handles exist only in workers, the main thread
can only reach a worker asynchronously, hence the Promise-shaped driver
contract everywhere.
Status: verified on OPFS in Chromium, Firefox, WebKit, and Safari
The worker-side server is transport-abstracted (Endpoint), so the test
suite runs the exact same server code against real SQLite-WASM
in-process under Node: driver → RPC → wasm SQLite, end to end,
including the full stack (Database, models, observation, sync) on top.
The storage: 'opfs' path additionally runs as a real-browser
conformance suite on all three engines, plus real Safari via
safaridriver (checklist below).
Usage
The recommended app bootstrap is the database manager — one shared open, retryable failure, takeover handled — with the React hook for lifecycle state:
import { createDatabaseManager, Database } from '@remelondb/core';
import { useDatabaseState } from '@remelondb/core/react';
import { WebSqliteDriver } from '@remelondb/driver-web';
const manager = createDatabaseManager({
open: (onTakenOver) =>
Database.open({
driver: new WebSqliteDriver({
shared: true,
takeover: true,
onTakenOver,
}),
schema,
migrations,
modelClasses: [Task],
name: 'app.db',
}),
});
// in a component: const { status, error } = useDatabaseState(manager)
// when status is 'ready': manager.databaseDatabase.open is the primitive underneath — fine to call directly in
tests and scripts; apps should let the manager own the lifecycle. The
todo-sync example's frontend/src/db.ts is the working reference.
- Persistence is never silently downgraded. The default
storage: 'opfs'fails loudly if OPFS is unavailable.open()rejects with a typedOpfsUnavailableError(code: 'OPFS_UNAVAILABLE'), refused up front — before any worker/broker spawns — when OPFS is blocked: an old browser, a sandboxed iframe, Node, or Firefox private browsing / "never remember history" / blocked site data, wherenavigator.storage.getDirectory()throwsSecurityErroreven over https. Catch it to degrade gracefully (show a message, or reopen withstorage: 'memory'). Passstorage: 'memory'directly when non-persistence is intended (previews, tests). - A transient OPFS pool lock is retried with bounded backoff. If the browser
still has not released it,
open()rejects with a typedOpfsPoolHeldError(code: 'OPFS_POOL_HELD') whosediagnosticpreserves the browser's finalNoModificationAllowedError. Firefox may require a full browser restart after a page worker dies holding the pool. - The OPFS SAH-pool VFS needs no COOP/COEP headers (unlike the SharedArrayBuffer-based VFS).
- The worker is spawned via
new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })— Vite and comparable bundlers resolve this pattern and bundle the worker (including the wasm asset from@sqlite.org/sqlite-wasm).
Multi-tab usage
The OPFS SAH-pool VFS acquires exclusive sync access handles: one connection per database, full stop. A second tab that tries to open the same database fails loudly (consistent with the no-silent-downgrade rule) — it does not corrupt anything, does not silently fall back to memory, and the error says what is going on: the database is open in another tab.
Single-owner with takeover is built into the driver. Tabs
coordinate through a Web Lock (remelondb:<name>) held for the
connection's lifetime:
const driver = new WebSqliteDriver({
takeover: true, // take the database from another tab
onTakenOver: () => {
// ...and learn when one takes it from us
showBanner('This app was opened in another tab.');
},
});- Default (
takeoverunset): opening a database another tab holds rejects with'<name>' is open in another tab or window. takeover: true:open()steals the lock. The losing driver terminates its worker — which is what releases the pool's handles; in-flight statements there are abandoned, committed data is safe on disk — fires itsonTakenOvercallback, and every later call on it rejects with a clear error. The winningopen()retries the handle acquisition for a bounded period, then reportsOpfsPoolHeldErrorif the browser has not released it.- Environments without the Web Locks API (Node tests, non-secure contexts) skip coordination entirely and behave as before.
- With shared mode active,
takeoverandonTakenOverare unused: every tab joins one owner, so there is nothing to take over. They apply where the driver falls back to single-tab mode.
Only one tab is active at a time, but the handoff is explicit and nothing can race.
The same rule rules out background sync on the web. A service worker woken by Web Push cannot open the database to sync: it either fails because a tab holds it, or takes it over and terminates that tab's worker. So Web Push can tell the user something changed; the data lands when the tab is next opened or focused. The Background Sync API is blocked for the same reason. Mobile has no such restriction — see when to sync.
For every tab live simultaneously, opt in to shared mode:
const driver = new WebSqliteDriver({ shared: true });All tabs then route through one SharedWorker broker to a single
connection: same-name opens share it instead of contending (no lock,
no takeover), commits in any tab reach every other tab's record cache
and observers, and db.write blocks are serialized across tabs by the
broker so racing read-modify-write converges (see "The RPC protocol"
below and docs/multi-tab.md for the design,
including the ordering invariant; docs/multi-tab.qnt
model-checks it). Where SharedWorker is unavailable (Chrome for
Android), the option gracefully falls back to the single-owner
behavior above — same API, same errors, the takeover UI simply becomes
reachable again.
The broker releases its own OPFS pool before replacing a silent compute
worker. If OpfsPoolHeldError still reaches the app, offer Retry; the holder
may be another live context, and Firefox may require a full browser restart
after an orphaned worker handle. Retry cannot safely steal from a live holder.
Sync also coordinates itself: synchronize runs only in the tab
holding the broker's sync lease (renewed on each tick, inherited when
the holder closes), so a naive per-tab sync interval is correct. The
remaining sharp edges before shared mode becomes the default are the
open questions in docs/multi-tab.md.
Bundlers (Vite)
When consuming this package from npm with Vite, exclude it from
dependency prebundling, or the worker URLs resolve into .vite/deps
where the worker files do not exist:
// vite.config.ts
export default defineConfig({
optimizeDeps: {
// prebundling rewrites the driver's worker URLs into .vite/deps,
// where the worker files do not exist
exclude: [
'@remelondb/driver-web',
'@remelondb/core',
'@sqlite.org/sqlite-wasm',
],
},
});Shared mode needs @remelondb/driver-web 0.1.2 or newer.
A complete minimal consumer (registry tarballs, Vite, single-tab and
shared mode) is scaffolded by scripts/vite-smoke.mjs — it runs in CI
against every change and doubles as the reference setup.
The RPC protocol
Every request carries an id; the answering side replies with
{id, ok, result} or {id, ok: false, error}. Requests and responses
are structured-clonable plain data (src/protocol.ts is the source of
truth). Who answers depends on the op:
| Op | Answered by | Purpose |
| ----------------------------- | ----------- | ---------------------------------------------------------------- |
| open | worker | open by name, report user_version (async: awaits pool install) |
| close | worker | close the named connection |
| query / execute | worker | one SELECT / one non-SELECT statement |
| executeBatch | worker | atomic multi-statement transaction |
| setUserVersion | worker | PRAGMA user_version after setup/migration |
| destroy | worker | delete the database and sidecar files |
| ping | worker | liveness probe (the broker checks its compute channel) |
| releasePool | worker | broker retirement: close databases and pause the OPFS VFS |
| acquireSlot / releaseSlot | broker | cross-tab write-block arbitration (never reach SQLite) |
| publishChanges | broker | relay a commit's change set to the other tabs |
| syncTurn | broker | sync-lease request: grant/renew for the holder, deny others |
In shared mode the broker additionally pushes three unsolicited control
messages to tabs: {control: 'spawnWorker'} asks a tab to host the compute
worker, {control: 'discardWorker'} terminates a late worker after another tab
won the spawn race, and {control: 'externalChanges', name, changes} carries
another tab's commit. A hosting tab transfers the worker channel back with
{control: 'adoptWorkerPort'}. In dedicated mode only the worker-answered ops
occur, over a direct Worker transport.
Layout
| Piece | Role |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| src/protocol.ts | RPC message types + the Endpoint transport abstraction |
| src/server.ts | worker-side server: connections by name, statement cache, boolean→0/1 binding, atomic batches with rollback, OPFS/memory storage resolution |
| src/worker.ts | browser Worker entry (sqlite-wasm init + server) |
| src/WebSqliteDriver.ts | main-thread driver: request/response correlation, seam contract |
Browser-verification checklist
- [x] Full driver contract against real sqlite-wasm (Node, in-process endpoint): round-trip, batch rollback, boolean binding, user_version, error surfaces
- [x] Full stack on the driver: Database + models + observation + sync
- [x] Loud failure when OPFS is unavailable (no silent downgrade)
- [x]
storage: 'opfs'in real Chromium, Firefox, and WebKit (vitest browser mode + Playwright): the FULL conformance suite on OPFS, persistence,destroy()unlinking pool + journal files, and durability across worker termination (page-reload equivalent). Run:pnpm --filter @remelondb/driver-web test:browser(BROWSER=firefox/BROWSER=webkitfor the others; WebKit needs the persistent context wired up invitest.webkit-provider.ts— ephemeral WebKit contexts have no OPFS backing store. WebKit OPFS is macOS-only: the Linux GTK port doesn't ship the sync-access-handle APIs, so CI runs WebKit on a macOS runner) - [x] Real Safari via
BROWSER=safari(webdriverio + safaridriver, macOS only): the full conformance suite, 51/51. One-time setup: enable "Allow remote automation" in Safari's Developer settings (sudo safaridriver --enable). Not headless — Safari can't. Only one automation session may exist; kill straysafaridriverprocesses if the session refuses to start. - [x] Vite production smoke:
pnpm --filter @remelondb/driver-web smoke:vite. It covers worker and wasm loading in browser mode, packs the tarballs, scaffolds the reference Vite app, runsvite buildandvite preview, and drives headless Chromium against the output. OPFS opens from the production bundle and data persists across a page reload. - [x] Multi-tab behavior: single-connection by design, documented below (see "Multi-tab usage")
Why browser tests run serially (fileParallelism: false)
The OPFS SAH pool is a single exclusive resource per origin — one worker
owns it at a time (see "Multi-tab usage"). Vitest runs test files in
parallel by default, so several *.browser.test.ts files then contend
for that one pool. Two failures come out of that contention, both most
visible on WebKit:
- a recycled pool slot returns stale state, so a just-created table
reads back missing (
SQLITE_ERROR: no such table) or a fresh database reads as corrupt (SQLITE_NOTADB); - the single-owner takeover in
sharedFallback.browser.test.tsnever gets the pool and times out.
Running the files serially (each owns the pool uncontended) fixes both:
the full suite is green across repeated WebKit runs with no retries. It
is not a product bug — an application opens one long-lived database and
never has two contexts race a cold pool this way. Ruled out as causes
along the way: the request dispatcher (serialized, covered by
server.concurrency.test.ts), pool capacity (fails at 512 too), unlink
accumulation (pool stays at one file), and stale sqlite-wasm (on the
latest, 3.53.0-build1). To see the raw flake, set fileParallelism back
to true.
