Self-hosting SpeedyDb — CSP, air-gap, and the cross-browser matrix
SpeedyDb's pitch is "nothing leaves the device." This guide is the operator's side of that promise: exactly which origins the stack touches, how to reduce them to one (yours), and what each browser gets.
What fetches what
With speedydb-copy-assets run and new SpeedyDbClient({ assetBase })
pointing at its output, everything below except model weights is same-origin
static files — the 23-asset runtime closure, checksummed at copy time:
| Fetch | What | Origin |
|---|---|---|
| engine | pkg/speedydb.js + speedydb_bg.wasm (~0.4 MB) |
assetBase (yours) |
| hosts | agent.js · agent-worker.js · agent-models.js · ort-paths.js · embedder.js · durable.js · extract.js |
assetBase (yours) |
| storage + worker | opfs.js · db-worker.js · db-worker-server.js · db-worker-protocol.js · client-mirror.js (the DB-worker/mirror layer — served so OPFS-capable browsers run the client in a worker) |
assetBase (yours) |
| ORT runtime | vendor/transformers/ort-wasm-simd-threaded[.asyncify].{mjs,wasm} |
assetBase (yours) |
| wllama runtime | vendor/wllama/* (incl. the compat build) |
assetBase (yours) |
| transformers.js | vendor/transformers/transformers.min.js |
assetBase (yours) |
| embedder weights | nomic-embed-text-v1.5 GGUF Q4_K_M (~80 MB, → Cache API) |
HF CDN, or yours via configure({ embedder: { modelUrl } }) |
| agent weights | Qwen3-0.6B GGUF Q4_K_M (~397 MB), → Cache API. Fetched on demand, when someone asks for a generated answer — ingesting a document does not pull it |
HF CDN, or yours via configure({ agent: { modelUrl } }) |
| agent tokenizer | the answer model's tokenizer json (a few MB — the token-budget stage) | HF CDN, or yours via configure({ agent: { modelBase } }) |
Nothing else. No telemetry, no API calls, no postinstall fetches; retrieval, embedding, and generation never send document content anywhere.
Zero-external-origin deployment (the full self-host)
- Assets:
npx speedydb-copy-assets(adds the 60 MB runtime closure to your public dir; prints theassetBase). - Embedder weights: download the GGUF once and serve it —
client.configure({ embedder: { modelUrl: "/models/nomic-embed-text-v1.5.Q4_K_M.gguf" } }); - Agent weights: the answer model is a single GGUF file — serve it and
point
modelUrlat it. (This used to name MiniCPM5-1B, which has been removed; those paths no longer resolve.) Its tokenizer (json only, no weights) still mirrors under<modelBase>/<modelId>/, because the token-budget stage counts with the answer model's own tokenizer; without it the budget silently falls back to a chars/4 estimate —client.configure({ agent: { modelUrl: "/models/gguf/Qwen3-0.6B-Q4_K_M.gguf", modelBase: "/models/onnx/", // fetches /models/onnx/Qwen/Qwen3-0.6B/<tokenizer json> } });scripts/mirror-models.shlays both out for you.modelBasethreads BOTH decode paths (the worker's load message carries it; one sharedapplyModelBase— the same no-drift discipline as the ORT variant selection). - CSP: with 1–3 in place the whole stack runs under
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; img-src 'self' data:;'wasm-unsafe-eval'is required (three wasm runtimes).worker-src 'self'covers the whole worker tree — the DB worker (db-worker.js, where the client + OPFS live) and, nested beneath it, the agent worker; noblob:worker is used, so noworker-src blob:is needed. Without self-hosting, add the model hosts toconnect-src:https://huggingface.co https://cdn-lfs.huggingface.co https://cas-bridge.xethub.hf.co(redirect targets can change with HF's storage backend — verify in DevTools for your models once, then pin).
Weights land in the browser Cache API on first use either way; after that the deployment runs fully offline.
COOP/COEP (cross-origin isolation) — deliberately not required
The stack ships single-thread-correct: the vendored wllama is the
single-thread build, and without COOP/COEP transformers.js runs ORT with
numThreads = 1. That is a deliberate trade — static hosts (this repo's own
site included) often can't set COOP/COEP, and correctness must not depend on
headers. If you DO control headers, opting in
(Cross-Origin-Opener-Policy: same-origin,
Cross-Origin-Embedder-Policy: require-corp) lets ORT use threads for faster
CPU decode; everything keeps working identically without it. COI is an
optimization, never a gate.
Cross-browser matrix
| Concern | Mechanism | Chromium | Safari | Firefox |
|---|---|---|---|---|
| ORT build | ort-paths.js — ONE branch for worker + inline |
asyncify build | plain build (asyncify breaks there; transformers.js's own rule, replicated) | asyncify build |
| Agent decode | module worker (agent-worker.js) |
worker | worker | worker |
| …without module workers | inline main-thread fallback (loadAgent's workerInfra classification) |
— | — | — |
| WebGPU | the WORKER probes its own scope; q4f16 only with shader-f16, else q4 on GPU; no adapter → wasm + q4 before any download |
webgpu when real | wasm (worker WebGPU) | wasm/webgpu per version |
| Embedder runtime | wllama single-thread; compat build (self-hosted, CPU-pin patched) for engines without JSPI/Memory64 | main build | compat build | per version |
| Storage backend | OPFS (sync-access handles in the DB worker) where available, else IndexedDB write-behind, else in-memory — the snapshot says which (storage). OPFS-capable browsers run the whole client in a DB worker; others keep the main-thread client, unchanged. An existing IndexedDB store migrates to OPFS in place, byte-verified. |
OPFS | OPFS (16.4+) | OPFS (111+) |
| Durability | OPFS flush() is a real synchronous sync-handle flush; the IndexedDB path is advisory write-behind + a Web Locks single-tab guard |
✓ (OPFS) | ✓ | ✓ |
| No OPFS / IndexedDB / locked tab | falls down the chain to IndexedDB, then in-memory (live-mem, labeled honestly) |
✓ | ✓ | ✓ |
Every row is by-construction behavior with a test or an honest label behind
it; the device/status chips in the UI report what actually happened (the
worker's probed device, never "gpu" in navigator).
Decisions of record (closing Phase 1's open items)
- Agent self-host seam → shipped (Phase 4):
configureAgent({ modelBase })above; the embedder already hadmodelUrl. - COOP/COEP → single-thread-correct ships; COI is a documented opt-in optimization, not an exit gate (this guide).
- Geocode engine sharing → geocode stays a site-level consumer of the
staged
site/pkgengine copy: it is site content, not a package consumer, and the staging guard keeps its engine byte-identical to canonical. Revisit only if geocode ships as its own package.