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

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:

src/edgeproc.worker.tsWorker entry
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
TermWhat it means
PointerThe small signed file at latest. It names the manifest hash, version, sequence, and optional signed identity, key id, and expiry.
ManifestThe signed release's file list. It is fetched by its SHA-256 and lists each file's path, size, file hash, and chunk references.
ChunkA zstd-compressed piece named by the SHA-256 of its plaintext. The Worker verifies its size and hash after a bounded decompression.
SequenceA 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 rootThe public-key URL. It serves either exactly 32 bytes (a raw Ed25519 public key) or a strict edgeproc.keyring/v1 JSON keyring.
CacheOne SQLite database named ${cacheNamespace}-chunks, on OPFS when available, otherwise in memory. It holds chunks, manifests, the active pointer, and the rollback floor.
PromotionThe 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.

What about a bundle that contains SQLite? If the bundle includes a .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.

ImportWhat it gives you
@gainratio/browserEngineClient, syncIndex, MemoryCacheStore, materializeFile, verifyEd25519, keyring helpers, error classes, and the network monitor channel.
@gainratio/browser/workerThe Worker entry. Import it from your own worker file.
@gainratio/browser/spawnspawnEngineClient() for unbundled browser ESM.
@gainratio/browser/vectorFlatVectorIndex and PackedVectorIndex, exact in-memory similarity search.
@gainratio/browser/vector/sqlitecreateSqliteVectorIndex, a vector index kept in SQLite in OPFS.
@gainratio/browser/vector/sqlite/nodecreateNodeSqliteVectorIndex, the same SQLite runtime in Node, in memory.
@gainratio/browser/sqlitecreateSqliteStateStore, app state in one real SQLite file.
@gainratio/browser/sqlopenSqlDatabase, your own SQL with FTS5, JSON1, and vectors in one database.
@gainratio/browser/sql/nodeopenNodeSqlDatabase for the same SQLite runtime in Node tests.
@gainratio/browser/sealPassphrase-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.

main threadVite setup
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.

OptionEffect
expectedBundleId, expectedChannelIdentity 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.
wantedPathsundefined 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.
cacheNamespaceNames 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.
indexedDbLayoutOnly for a 0.2.x cache that kept its IndexedDB floor and chunks. Read once to migrate into SQLite, then deleted.
onProgressCalled 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.
idleTimeoutMsOn 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".

refusing the memory fallbackfail closed
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.

syncIndexchecking core
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:

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:

public.keyedgeproc.keyring/v1
{
  "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:

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.

FTS5@gainratio/browser/sql
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.

SQLite vector index@gainratio/browser/vector/sqlite
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.

seal and open@gainratio/browser/seal
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.

FailureError
Signature does not verifySignatureError
Pointer names a revoked or unlisted signerKeyRevokedError / UnknownKeyError (both SignatureError)
Trust root is malformedKeyringError (an IntegrityError)
Network pointer is past its signed expires_atPointerExpiredError (an IntegrityError)
Pointer has no non-negative sequenceIntegrityError
Chunk hash does not match its content addressIntegrityError
Decompressed size does not match the signed sizeIntegrityError
Response past its byte capResponseTooLargeError (an IntegrityError, so sync never falls back to cache for it)
Pointer sequence went backwards, or forked at equal sequenceRollbackError
Bundle exceeds a structural capSyncCapError
Worker died before replyingWorkerCrashError
Worker went silentWorkerTimeoutError
Worker operation failedEngineOperationError 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:

EngineWhat CI coversStorage
ChromiumThe full browser suite, including OPFS, keyring trust roots, warm sync, SQLite state, vectors, and seal.OPFS.
FirefoxOne 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 SafariNot 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 chunks is 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 edgeproc CLI.
  • No meaning: it returns checked bytes, not parsed records, rankings, or recommendations.

Next