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)

  1. Assets: npx speedydb-copy-assets (adds the 60 MB runtime closure to your public dir; prints the assetBase).
  2. Embedder weights: download the GGUF once and serve it —
    client.configure({ embedder: { modelUrl: "/models/nomic-embed-text-v1.5.Q4_K_M.gguf" } });
    
  3. Agent weights: the answer model is a single GGUF file — serve it and point modelUrl at 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.sh lays both out for you. modelBase threads BOTH decode paths (the worker's load message carries it; one shared applyModelBase — the same no-drift discipline as the ORT variant selection).
  4. 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; no blob: worker is used, so no worker-src blob: is needed. Without self-hosting, add the model hosts to connect-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)