Documentation
Install, sync, verify, read.
This page covers the documented browser path: install @gainratio/browser, sync a signed bundle in a Worker, verify it, and read the checked bytes. It also covers the documented verification core, keyrings, SQL, vectors, seal, errors, browser support, and limits.
Install
The package is @gainratio/browser, currently version 0.4.4. It is MIT licensed and published with npm provenance from CI. The old name @edgeproc/browser is deprecated.
npm install @gainratio/browser
pnpm and Bun work too. To pin an exact commit instead of a release, pnpm add github:gainratio/edgeproc-browser#<commit-sha> is documented by the project.
What you need
- For the page: a bundler. The documented setup uses Vite. The root
EngineClientexport contains no Worker URL; your own Worker file imports@gainratio/browser/workerso the bundler owns the URL. - For publishing: Node 22.13 or newer, and uv to run the Python
edge-procCLI. The package checks bundles; it does not make them. - For persistence: a browser with OPFS. If OPFS is refused, the Worker uses an in-memory SQLite database for its life and re-downloads next session.
Publish the bundle
Install the Python CLI with the bundles extra. --sequence is required: the browser refuses a pointer without a non-negative sequence and uses it to refuse rollbacks. Keep private.key secret and off the web server; serve public.key from your own origin over HTTPS.
uvx --from 'edge-proc[bundles]' edgeproc keygen --out keys
uvx --from 'edge-proc[bundles]' edgeproc publish --src data --origin-dir public/bundle \
--key keys/private.key --bundle-id prices --version v1 --sequence 1
The folder is ordinary static files: a signed pointer at latest, manifests under manifest/, and zstd-compressed chunks under chunk/. Any static web server or CDN can serve it.
Add the Worker
Create your own Worker entry. With Vite:
import "@gainratio/browser/worker";
Without a bundler, use the separate spawn helper from @gainratio/browser/spawn:
import { spawnEngineClient } from "@gainratio/browser/spawn";
const client = spawnEngineClient({ idleTimeoutMs: 60_000 });
The unbundled path exists because the root EngineClient export contains no Worker URL, so Vite does not emit a second, unused Worker next to yours. The project's Vite consumer test proves exactly one engine Worker is emitted.
Concepts
A published bundle is a content-addressed set of files plus one signature. The browser's job is to authenticate the signature, then prove every byte by hash before your app can read it.
origin/
latest signed pointer: manifest hash, version, sequence, signature
manifest/<sha256> the file list, addressed by its own hash
chunk/<sha256> zstd-compressed pieces, addressed by the hash of their contents
| Term | What it means |
|---|---|
| Pointer | The small signed file at latest. It names the manifest hash, version, sequence, and optional signed identity, key id, and expiry. |
| Manifest | The signed release's file list. It is fetched by its SHA-256 and lists each file's path, size, file hash, and chunk references. |
| Chunk | A zstd-compressed piece named by the SHA-256 of its plaintext. The Worker verifies its size and hash after a bounded decompression. |
| Sequence | A monotonic counter on the pointer. A lower sequence is a rollback; the same sequence with a different signed pointer is a fork. Both are refused. |
| Trust root | The public-key URL. It serves either exactly 32 bytes (a raw Ed25519 public key) or a strict edgeproc.keyring/v1 JSON keyring. |
| Cache | One SQLite database named ${cacheNamespace}-chunks, on OPFS when available, otherwise in memory. It holds chunks, manifests, the active pointer, and the rollback floor. |
| Promotion | The single transaction that makes a checked release active. It refuses unless every chunk the release needs is present and the floor does not go backwards. |
The Worker fetches the trust root with no HTTP-cache reuse, capped at 64 KiB. It verifies the pointer's Ed25519 signature over canonical JSON bytes, refuses a lower or forked sequence, fetches the manifest and checks it against the hash the pointer named, then fetches only missing chunks. Only when everything passes does the new version replace the old one. Any failure throws a typed error, and the previous good version stays.
.sqlite database (for example a product catalog or reference dataset), the Worker can open that verified file in OPFS with full-text search, JSON1, and vector search. The documented /sql, /sqlite, /vector, and /vector/sqlite paths are separate APIs over the same pinned SQLite build; they are not required for bundle verification.API
There are no environment variables or config files. Everything is an argument. The root entry gives you the Worker client, the checking core, errors, keyring helpers, and the network-monitor channel.
| Import | What it gives you |
|---|---|
@gainratio/browser | EngineClient, syncIndex, MemoryCacheStore, materializeFile, verifyEd25519, keyring helpers, error classes, and the network monitor channel. |
@gainratio/browser/worker | The Worker entry. Import it from your own worker file. |
@gainratio/browser/spawn | spawnEngineClient() for unbundled browser ESM. |
@gainratio/browser/vector | FlatVectorIndex and PackedVectorIndex, exact in-memory similarity search. |
@gainratio/browser/vector/sqlite | createSqliteVectorIndex, a vector index kept in SQLite in OPFS. |
@gainratio/browser/vector/sqlite/node | createNodeSqliteVectorIndex, the same SQLite runtime in Node, in memory. |
@gainratio/browser/sqlite | createSqliteStateStore, app state in one real SQLite file. |
@gainratio/browser/sql | openSqlDatabase, your own SQL with FTS5, JSON1, and vectors in one database. |
@gainratio/browser/sql/node | openNodeSqlDatabase for the same SQLite runtime in Node tests. |
@gainratio/browser/seal | Passphrase-encrypted files in the standard age format. |
Sync in a Worker
The main-thread client sends typed requests to the Worker. Every result says where the checked chunks live.
import { EngineClient } from "@gainratio/browser";
import EdgeProcWorker from "./edgeproc.worker?worker";
const client = new EngineClient(new EdgeProcWorker(), { idleTimeoutMs: 60_000 });
const result = await client.sync(bundleBaseUrl, pubkeyUrl, {
expectedBundleId: "my-bundle",
expectedChannel: "stable",
wantedPaths: ["catalog_meta.json", "images/"],
onProgress: ({ phase }) => renderPhase(phase),
});
const bytes = await client.readFile("catalog_meta.json"); // checked, or it throws
await client.clear(); // same cross-tab lock as sync and read
sync() takes the bundle base URL, the public-key URL, and options. readFile(path, { cacheFallback? }) materializes a file from the active manifest. clear(options?) resets the cache namespace under the same lock as sync and read.
| Option | Effect |
|---|---|
expectedBundleId, expectedChannel | Identity pins. They only work if the publisher signed them into the pointer (edgeproc publish --bind-identity --channel stable). undefined skips a pin; null requires the field to be absent. |
wantedPaths | undefined syncs every signed file. [] checks and promotes only the pointer and manifest, so an app can inspect the catalog first and fetch a chosen directory later. Paths or dir/ prefixes select a subset. |
cacheNamespace | Names the cache: its SQLite database (${cacheNamespace}-chunks) and its cross-tab lock. |
cacheFallback | "memory" (default) caches in RAM and re-downloads next session when OPFS is refused. "none" downloads nothing and rejects with EngineStorageUnavailableError. The Worker's first call fixes this value. |
indexedDbLayout | Only for a 0.2.x cache that kept its IndexedDB floor and chunks. Read once to migrate into SQLite, then deleted. |
onProgress | Called per phase (pointer, manifest, chunks, verify, promoted), per network read during chunks (at most four times a second), per checked chunk, per verified file, and on a retried chunk. |
idleTimeoutMs | On new EngineClient(worker, options). How long a Worker may go without any progress before WorkerTimeoutError; default 60 s. Every progress event re-arms the clock. |
A sync result carries version, manifestHash, chunksFetched, chunksReused, and bytesFetched. It adds cacheBackend ("sqlite-opfs" or "sqlite-memory") and cacheStorage, the typed status. The status is { persistence: "opfs", pool, file } or { persistence: "memory", reason, detail }, where the reason is "opfs-unavailable" or "pool-in-use".
import { EngineStorageUnavailableError } from "@gainratio/browser";
try {
await client.sync("/bundle", "/public.key", { cacheFallback: "none" });
} catch (error) {
if (error instanceof EngineStorageUnavailableError) {
showStorageBlocked(error.reason); // nothing was downloaded into RAM
} else throw error;
}
cacheFallback: "none" rejects at once with EngineStorageUnavailableError, which is an EngineOperationError with code: "storage". Its reason is "opfs-unavailable" or "pool-in-use". If readFile can be the first call (a reload that reads before it syncs), pass the same cacheFallback there.
Verify without a Worker, and in Node
syncIndex is the same checking logic without the Worker. You inject the transport, so it also runs in Node for tests and build scripts. Pass either verify (a single-key function) or keyring, not both. now injects the expiry clock in Unix seconds.
import { MemoryCacheStore, materializeFile, syncIndex, verifyEd25519 } from "@gainratio/browser";
const key = new Uint8Array(await readFile("public/public.key"));
const verify = (message, signature) => verifyEd25519(key, message, signature);
const store = new MemoryCacheStore();
const result = await syncIndex({ baseUrl: "/bundle", store, fetchBytes, verify });
const manifest = JSON.parse(new TextDecoder().decode(await store.getManifest(result.manifestHash)));
const bytes = await materializeFile(store, manifest, "prices.json");
The documented primitives are:
verifyEd25519(pubkeyRaw32, message, signatureBase64)resolves on a valid signature and throwsSignatureErrorotherwise.loadTrustRoot(url, fetchBytes)fetches and parses a trust root with no HTTP-cache reuse, capped at 64 KiB, and returns aKeyring.parseTrustRoot(bytes)parses a trust root you already have.verifyWithKeyring(keyring, message, signatureBase64, keyId?)verifies under the keyring, with revocation and optional key selection.syncIndex({ baseUrl, store, fetchBytes, verify | keyring, now?, ... })runs the pointer, manifest, chunk, and promotion state machine.materializeFile(store, manifest, path)reassembles and verifies one file from the store.MemoryCacheStoreis the in-memory store used by tests and Node.NETWORK_SENTINEL_CHANNELandisNetworkSentinelReportexpose the Worker's own request report to the page.
At runtime the package is browser-first: modules reference BroadcastChannel, PerformanceObserver, navigator.storage, and WorkerGlobalScope. It loads in Node and fails the moment it touches a browser global. The pure core (crypto, canonical JSON, integrity, zstd, and sync with MemoryCacheStore) runs anywhere.
The trust root: one key or a keyring
pubkeyUrl is fetched with no-store and capped at 64 KiB. Its format is detected:
- Exactly 32 bytes: a raw Ed25519 public key, treated as a keyring of one.
- Anything else: a strict JSON keyring.
{
"schema": "edgeproc.keyring/v1",
"keys": [
{ "key_id": "34750f98bd59fcfc", "public_key": "8a88e3dd…6f5c" },
{ "key_id": "6a3803d5f059902a", "public_key": "8139770e…b394" }
],
"revoked": []
}
key_id is the first 16 lowercase hex characters of the SHA-256 of the raw 32-byte public key (deriveKeyId) and must match its key. Unknown fields, duplicate ids, malformed hex, and a ring with no unrevoked key are rejected with KeyringError. The edgeproc keyring command builds these files.
A pointer may carry two optional signed fields. Both are left out of the signed bytes when absent, so older pointers still verify:
key_id: only that key may verify the pointer. A revoked id fails withKeyRevokedError; an unlisted one fails withUnknownKeyError. Withoutkey_id, any unrevoked key may verify; a revoked key never does.expires_at: Unix seconds. A pointer fetched from the network at or past its deadline fails withPointerExpiredError. Offline, an expired pointer whose bundle is already cached and checked is still served, withexpired: trueon the result, so an offline app keeps working and can tell the user the data may be stale.
To rotate keys: publish a keyring with both keys, sign the next pointer with the new key at a higher sequence, then revoke the old key. The last promoted pointer stays the rollback floor throughout. A key change never resets that floor, so an older release re-signed by a new key is still refused as a rollback.
SQL in the browser
@gainratio/browser/sql opens a named SQLite database in the library's Worker, on OPFS, with FTS5, JSON1, and sqlite-vector on the same connection. It gives you typed exec, query, transaction, executeMany, and prepared statements. Typed arrays such as Float32Array are bound as raw bytes, the BLOB layout sqlite-vector reads.
import { openSqlDatabase } from "@gainratio/browser/sql";
const db = await openSqlDatabase({ name: "notes" });
await db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5(body)");
await db.executeMany("INSERT INTO notes(body) VALUES (?)", [["buy milk"], ["call mum"]]);
const hits = await db.query("SELECT body FROM notes WHERE notes MATCH ? ORDER BY bm25(notes)", ["milk"]);
The pinned build is SQLite 3.53.4 with the Apache-2.0 sqlite-vector 1.1.2 extension statically linked into a 934,257-byte WASM file. It does not ship FAISS, an embedding model, or a backend. The persistent database uses the OPFS opfs-sahpool VFS, so one tab owns it; a second tab gets a clear open error after a bounded retry instead of silently sharing a file handle. fallback: "memory" is opt-in; without it, an unavailable pool fails closed.
Vectors
The vector API is a small interface, not a ranking framework. Search is exact FLOAT32 cosine distance; filters are parameterized equality checks ANDed together. For small or throwaway data, FlatVectorIndex from @gainratio/browser/vector uses the same contract with no WASM startup. For an immutable FLOAT32 matrix that already came from a signed bundle, the synchronous PackedVectorIndex copies and validates the matrix and zeroes its storage on disposal.
import { createSqliteVectorIndex } from "@gainratio/browser/vector/sqlite";
const index = await createSqliteVectorIndex({ name: "my-catalog", dimension: 384 });
await index.insert([{ id: "sku-1", vector: embedding, metadata: { tenant: "a" } }]);
const nearest = await index.search(query, 10, { tenant: "a" });
const candidates = await index.searchByIds(query, candidateIds); // one exact batch score
await index.deleteWhere({ tenant: "a" }); // needs a non-empty metadata filter
await index.clear(); // returns the exact count; removes every local vector
await index.dispose();
Persistent vector mode uses the OPFS SAH-pool VFS and runs in its own Worker. One index has one owner: a second tab gets a clear open error after a bounded retry. For a Node evaluation job that must use the same SQLite runtime, createNodeSqliteVectorIndex from @gainratio/browser/vector/sqlite/node is in memory by design.
Seal
@gainratio/browser/seal encrypts bytes with a passphrase into a standard age v1 file, opens it again, and can read older PBKDF2 + AES-GCM files. A user can also open a sealed file without your app with age -d backup.age > backup.sqlite. age-encryption (about 53 KB gzipped) loads on the first seal or open; the main entry never loads it.
import { checkNewPassphrase, openWithPassphrase, sealWithPassphrase } from "@gainratio/browser/seal";
const check = checkNewPassphrase(passphrase, confirmation);
if (!check.ok) throw new Error(check.reason); // "empty" | "too_short" | "mismatch"
const sealed = await sealWithPassphrase(bytes, passphrase);
if (!sealed.ok) throw new Error(sealed.reason);
const opened = await openWithPassphrase(sealed.bytes, passphrase);
if (!opened.ok) throw new Error(opened.reason);
The default work factor is 17 (scrypt N = 2^17, 128 MiB), the OWASP floor; pass workFactor from 10 to 20 to change it. Opening refuses work factors above 18 by default with too_costly, before any scrypt runs; pass maxWorkFactor: 20 for files written by tools that pick a higher factor. The default minimum passphrase length is 12 code points after NFC normalization. These functions return typed results and never throw for passphrase, format, memory, or availability failures. A wrong passphrase and a changed file both report wrong_passphrase_or_tampered on purpose. There are no public-key recipients, no streaming, and no passphrase strength scoring.
App state in one SQLite file is also documented, under @gainratio/browser/sqlite. Persistent state uses SQLite's official opfs-wl VFS and takes one exclusive per-store Web Lock, so several tabs and Workers take turns instead of failing. It needs a cross-origin-isolated page: serve Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (or credentialless). The rest of the package does not need those headers.
Every refusal and its error
A network outage is the only condition that may serve cache, and it has its own type (NetworkError). Through the Worker, every keyring and expiry failure arrives as EngineOperationError with code integrity.
| Failure | Error |
|---|---|
| Signature does not verify | SignatureError |
| Pointer names a revoked or unlisted signer | KeyRevokedError / UnknownKeyError (both SignatureError) |
| Trust root is malformed | KeyringError (an IntegrityError) |
Network pointer is past its signed expires_at | PointerExpiredError (an IntegrityError) |
Pointer has no non-negative sequence | IntegrityError |
| Chunk hash does not match its content address | IntegrityError |
| Decompressed size does not match the signed size | IntegrityError |
| Response past its byte cap | ResponseTooLargeError (an IntegrityError, so sync never falls back to cache for it) |
| Pointer sequence went backwards, or forked at equal sequence | RollbackError |
| Bundle exceeds a structural cap | SyncCapError |
| Worker died before replying | WorkerCrashError |
| Worker went silent | WorkerTimeoutError |
| Worker operation failed | EngineOperationError with code integrity, rollback, network, lock, storage, or internal |
Chunk transport failures classified as NetworkError get six bounded retries with exponential jitter, nine seconds maximum backoff. Integrity, signature, storage, and rollback failures are verdicts and are never retried. A chunk fetch that stalls for 30 s with no bytes is treated as a network failure and retried; chunks already verified and stored are never fetched again.
Browser support
This is an early release, and browser coverage is part of its limits. The project is explicit about what it tests:
| Engine | What CI covers | Storage |
|---|---|---|
| Chromium | The full browser suite, including OPFS, keyring trust roots, warm sync, SQLite state, vectors, and seal. | OPFS. |
| Firefox | One cross-browser test: OPFS open, memory fallback, a SQL round trip, export/import, memory-profile detection. | OPFS opens for real in the test. |
| WebKit (Playwright) | The same cross-browser test. Playwright's WebKit refuses the OPFS root, so the test checks the typed opfs-unavailable fallback, not OPFS itself. | In-memory fallback. Re-downloads next session. |
| Real Safari | Not tested. | Not known beyond the WebKit fallback. |
The documented browser engine path uses a Web Worker; syncIndex is the Worker-free checking core. If OPFS is refused (Safari private mode or Playwright WebKit) or another context holds the pool for more than five seconds, the Worker uses one in-memory SQLite database for its life and reports cacheBackend: "sqlite-memory". The in-memory rollback floor also lasts only for that Worker's life. Pass cacheFallback: "none" if your app must not run without persistent storage.
The chunk cache supports tabs taking turns: the engine Worker takes the namespace's cache Web Lock and the SQLite pool owner lock, runs queued work, then releases both. A Worker's first call fixes its cacheFallback; a later call with a different value is refused. The optional SQLite app-state store needs a cross-origin-isolated page; the rest of the package does not.
Key limits
- Pre-1.0. Only the latest published version gets fixes. 0.4.4 is current.
- The trust root is capped at 64 KiB and fetched with no HTTP-cache reuse.
- Idle timeout: 60 s by default. Any progress event re-arms it; a slow link is not a dead link.
- Stall window: 30 s with no bytes. A transfer that keeps delivering is never cut off, however slow.
- Retries: six bounded attempts for network-classified chunk failures, nine seconds maximum backoff.
- Progress: byte progress during
chunksis rate-limited to four events a second; chunk completions and retries are always reported.
- Storage: one SQLite database per cache on OPFS, or an in-memory database. The cache never uses IndexedDB, except to read a 0.2.x cache once and migrate it.
- Names: the chunk database is
${cacheNamespace}-chunks. Cache names and legacy layouts are local identifiers, not secrets. - SQL/vectors: persistent mode is one owner per OPFS pool. The chunk cache is different: tabs take turns under Web Locks.
- No config: there are no environment variables or config files. Everything is an argument.
- No publisher: the package does not sign or publish. Use the Python
edgeprocCLI. - No meaning: it returns checked bytes, not parsed records, rankings, or recommendations.
Next
- Copyable examples for Vite sync, Node verification, key rotation, SQL, vectors, seal, and tamper detection.
- Security and threat model, including key rotation, revocation, expiry, and reporting.
- The full API guide, architecture, SQL, seal, and SECURITY.md.