Examples
Copy, run, verify.
Every example below is taken from the project README or documentation. The browser examples assume the bundle and public key produced by the quickstart. The expected output is the output the project tests, not an illustration.
edgeproc publish; the public key must be served from your own origin over HTTPS. Keep the private signing key off the web server.Vite: sync a signed bundle in a Worker
Keep the Worker entry in your own source so Vite owns its URL. This is the documented Vite setup.
import "@gainratio/browser/worker";
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("/bundle", "/public.key", {
expectedBundleId: "prices",
expectedChannel: "stable",
wantedPaths: ["prices.json"],
onProgress: ({ phase }) => renderPhase(phase),
});
const bytes = await client.readFile("prices.json");
console.log(`verified ${result.version} (${result.chunksFetched} fetched, ${result.chunksReused} reused)`);
console.log(new TextDecoder().decode(bytes));
expectedBundleId and expectedChannel only work if the publisher signed them into the pointer. undefined skips a pin; null requires the field to be absent. With the README's quickstart those pins are not signed, so drop both options or publish with --bind-identity --channel stable.
The first sync fetches the one data chunk. After that the checked copy is in OPFS:
verified v1 (1 fetched, 0 reused)
{"sku": "A-100", "name": "Blue mug", "price": 12.5}
verified v1 (0 fetched, 1 reused)
{"sku": "A-100", "name": "Blue mug", "price": 12.5}
Node: the same check without a browser
syncIndex is the checking logic without the Worker. You inject the transport, so the example provably makes no network calls. This is the README's check.mjs; save it in the project that has public/bundle and public/public.key and run node check.mjs.
import { readFile } from "node:fs/promises";
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);
// Stands in for a web server: "/bundle/latest" is read from public/bundle/latest.
const server = async (url) => new Uint8Array(await readFile(`public${url}`));
// A bad mirror: flips one byte in every data chunk it serves.
const tampered = async (url) => {
const bytes = await server(url);
if (url.includes("/chunk/")) bytes[9] ^= 1;
return bytes;
};
const store = new MemoryCacheStore();
const ok = await syncIndex({ baseUrl: "/bundle", store, fetchBytes: server, verify });
const manifest = JSON.parse(new TextDecoder().decode(await store.getManifest(ok.manifestHash)));
const bytes = await materializeFile(store, manifest, "prices.json");
console.log(`accepted ${ok.version}: ${new TextDecoder().decode(bytes).trim()}`);
await syncIndex({ baseUrl: "/bundle", store: new MemoryCacheStore(), fetchBytes: tampered, verify })
.catch((error) => console.log(`refused: ${error.name}: ${error.message}`));
accepted v1: {"sku": "A-100", "name": "Blue mug", "price": 12.5}
refused: IntegrityError: chunk 34fa454858ceada20cfa0cd5236eb70f90e1021ed3bd03a6c9d5332597b3cc12 failed content-address check
The repository's pnpm demo runs the same shape against a real committed 783-chunk fixture. It also proves a second sync over a filled store fetches no chunks, and that one bit flipped in the pinned key gives SignatureError.
Cache fallback: say what the browser did
Every sync result says where the checked chunks are kept. A memory backend is a warning, not a silent downgrade.
const result = await client.sync("/bundle", "/public.key");
if (result.cacheBackend === "sqlite-memory") {
showNotice("Offline copy will not survive a reload in this browser.");
}
if ("reason" in result.cacheStorage) {
console.warn(`memory cache: ${result.cacheStorage.reason}`);
}
If your app must not run without persistent storage, refuse the fallback. When OPFS cannot be opened, the Worker then downloads nothing and rejects at once:
import { EngineStorageUnavailableError } from "@gainratio/browser";
try {
await client.sync("/bundle", "/public.key", { cacheFallback: "none" });
} catch (error) {
if (error instanceof EngineStorageUnavailableError) {
showStorageBlocked(error.reason); // "opfs-unavailable" or "pool-in-use"
} else throw error;
}
The Worker's first call fixes its cacheFallback; a later call with a different value is refused. If readFile can be the first call, pass the same option there.
Key rotation: two keys, then revoke the old one
The trust-root URL serves either exactly 32 bytes (a raw Ed25519 key) or a strict edgeproc.keyring/v1 JSON keyring. A keyring lets you rotate without breaking clients that still hold an old cache.
{
"schema": "edgeproc.keyring/v1",
"keys": [
{ "key_id": "34750f98bd59fcfc", "public_key": "8a88e3dd…6f5c" },
{ "key_id": "6a3803d5f059902a", "public_key": "8139770e…b394" }
],
"revoked": []
}
import { loadTrustRoot, verifyWithKeyring } from "@gainratio/browser";
const keyring = await loadTrustRoot("/public.key", fetchBytes);
await verifyWithKeyring(keyring, message, signatureBase64, keyId);
To rotate: publish a keyring with both keys, sign the next pointer with the new key at a higher sequence, then revoke the old key. key_id is the first 16 lowercase hex characters of the SHA-256 of the raw 32-byte key (deriveKeyId). The last promoted pointer stays the rollback floor throughout, so a key change never lets an older release back in.
SQL: FTS5, JSON1, and vectors in one database
openSqlDatabase opens a real SQLite database in the library's Worker, on OPFS, with FTS5, JSON1, and sqlite-vector on the same connection. 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"]);
await db.close();
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. fallback: "memory" is opt-in and suits only small data you rebuild on every boot. Without it, an unavailable pool fails closed.
Vectors: exact cosine, stored in SQLite
Search is exact FLOAT32 cosine distance; filters are parameterized equality checks ANDed together. This example keeps the index in SQLite in OPFS. For small data, FlatVectorIndex uses the same contract without WASM startup.
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();
One index has one owner: a second tab gets a clear open error after a bounded retry. The same SQLite runtime is available in Node through createNodeSqliteVectorIndex, in memory by design.
Seal: passphrase-encrypted files in the age format
@gainratio/browser/seal writes standard age v1 files. A user can open one without your app with age -d backup.age > backup.sqlite. Seal and open take whole Uint8Arrays; there is no streaming and no public-key recipient mode.
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);
scrypt is synchronous: at the default work factor 17 it holds 128 MiB and blocks its thread for about half a second. Run it in a Worker. Passing workFactor changes the cost (10 to 20); 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.
Tamper detection in a real browser
After the quickstart has cached the bundle, flip one byte of the first stored data piece. Open the page in a new private window, because the normal window already holds the checked copy and will not download that piece again.
node -e "const fs=require('fs'),d='public/bundle/chunk/',p=d+fs.readdirSync(d)[0],b=fs.readFileSync(p);b[9]^=1;fs.writeFileSync(p,b)"
The page refuses the data instead of showing it:
refused (integrity): chunk 34fa454858ceada20cfa0cd5236eb70f90e1021ed3bd03a6c9d5332597b3cc12 failed content-address check
Run the same line once more to undo the change. This is the README's test: a changed chunk fails its content-address check; it is not served with a warning.
More
- Documentation: install, concepts, API, errors, browser support, and limits.
- Security: threat model, trust-root lifecycle, and reporting.
- The repository's examples directory, including
examples/quickstart.mjs, the source of the Node pattern above. - The full API guide and SQL guide.