Roadmap: SpeedyDb browser stack → framework components
HISTORICAL. This document describes decisions made when the approved embedder was
LiquidAI/LFM2.5-Embedding-350M(1024-dim, CLS pooling,document:/query:prefixes). That model was retired for licensing reasons; the canonical model is nownomic-ai/nomic-embed-text-v1.5(768-dim, mean pooling,search_document:/search_query:). The LFM references below are preserved as a record of what was decided and why — they are not current defaults. See docs/migrations/lfm-to-nomic.md.
Goal. Turn the Stop 06 in-browser RAG stack (wasm engine + on-device embedder + on-device agent + durable storage + hybrid search) into framework-native, nearly-ready-to-go components — React, Vue, Angular, Svelte, Solid — that a developer drops into their app and adapts, with nothing leaving the device.
The shape of the work. ~70% is write-once and framework-agnostic; the per-framework cost is thin reactive bindings plus UI. The traps are not the frameworks — they're asset delivery, workers, and SSR. This doc lays out a layered architecture, the headless-core interface, the portability plan, per-framework bindings, and a phased build order.
1 · What already exists (assets in hand)
| Piece | File(s) | State |
|---|---|---|
Retrieval engine (WasmBrowserDb, WasmDurableStaging) |
site/pkg/ (Rust→wasm) |
Typed — wasm-bindgen emits speedydb.d.ts for all ~27 methods (findText, findSemantic, expandText, putSourceTextKeyed, putEmbeddings, takeDirty, hydrateSegment…) |
| Embedder host (approved LFM2.5-Embedding-350M, wllama) | site/embedder.js |
Headless; configureEmbedder/loadEmbedder/embedQuery/embedDocuments |
| Agent host (Qwen3-0.6B GGUF, wllama in the agent worker) | site/agent.js + site/agent-worker.js |
Headless; configureAgent/loadAgent/answer/isLowConfidence; load-stall watchdog |
| Durable persistence (IndexedDB write-behind) | site/durable.js |
Headless; openDurable/destroyDurable |
| On-device extraction (PDF/docx→text) | site/extract.js |
Headless; extractDocument |
| Orchestration + UI | site/app.js (2,336 lines) |
Vanilla + DOM-coupled — the one thing not yet reusable |
| React reference | FloatPlan use-rag.ts + rag-worker.ts |
Proves the binding is a thin veneer (~230 lines, only ~6 useState + 2 useEffect are React-specific) |
| Reuse recipe + honesty contracts | packages/core/docs/browser-rag-agent.md |
Written |
Only app.js is framework-coupled — and only ~30–40% of the Stop 06 block (lines 568–2336) is
reusable logic; the rest is DOM rendering (result cards, source-tab ring, file viewer, lightbox,
status primitives). Lines 1–567 (the marketing scroll animations) are entirely out of scope.
2 · Layered architecture
Layer 0 Engine site/pkg/ (Rust→wasm, already typed) ── done
Layer 1 Headless core @speedydb/core — SpeedyDbClient: subscribe()/getSnapshot() + methods
(framework-agnostic; the extraction of app.js's logic — the ONE big write-once effort)
Layer 2 Bindings @speedydb/core/{react,vue,svelte,solid,angular} (thin: dozens of lines each)
Layer 3 UI components copy-paste registry per framework (styled reference, user owns the code)
Everything above Layer 1 wraps the same core. Build the core once; the bindings are cheap; the UI is the variable, ongoing cost.
3 · Layer 1 — the headless core (SpeedyDbClient)
A framework-neutral store: one subscribe(listener) → unsubscribe, one cached immutable
getSnapshot(), plus imperative methods. Extracted from app.js's orchestration. Full typed
sketch: browser-components/speedydb-client.d.ts.
class SpeedyDbClient {
constructor(opts?: {
dbName?, segmentLen?, contentCapacity?, chunkBytes?, maxChunks?, maxUploadBytes?,
maxEmbedBackfill?, semanticWaitMs?, semanticWaitEmptyMs?, maxAgentPassages?,
widenRadius?, maxAgentContext?, assetBase?, // ← today's PKG_V/CHUNK_BYTES/… module consts
modules?: { pkg, durable, embedder, agent, extract }, // injectable for testing / swapping
})
subscribe(listener: (s: Snapshot) => void): () => void // fires on every mutation
getSnapshot(): Snapshot // cached — referentially stable between changes
warm(): Promise<Db> // lazy engine init (durable-first, memory fallback)
warmIfStored(): Promise<void> // only if the demo DB already exists (returning-visitor restore)
ingestFile(file: File): Promise<Source> // dispatch by ext + upload cap → chunk → store → embed
ingestSample(s): Promise<Source>
setSourceSelected(key, selected): void // search scope, without destroying stored rows
loadSemantic(): Promise<void> // start the approved embedder; pins the vector space
search(query: string): void // debounced hybrid RAG into the rag buffer (seq-guarded)
ask(query: string): Promise<void> // agent: retrieve → stream answer → widen-once on low confidence
expandRow(rowId, radius): Promise<string[]> // breadth expansion for a hit
setMode('rag' | 'agent'): void
prefetchAgent(): Promise<void> // start the agent download on engagement (never on bare load)
reset(): Promise<void>; flush(): void; dispose(): void
}
Snapshot (the single render surface): engineStatus 'cold'|'warming'|'live'|'live-mem'|'unavailable',
durable, restoredRowCount, pipeStage 0..4, sources[] ({key,name,type,selected,preview}),
anySelected, selectedNames, semantic {state,pct,draining,message}, searchMode,
ragResult {query,hits,noneReason}, agentResult {phase,status,answer,streaming,passages,lowConfidence},
agent {state,pct,busy,device}, unseenRag/unseenAgent, lastError. Hit = {row_id, text, ord, source, found:'text'|'vector'|'both', score?, _src?}.
Already-pure lift-and-shift (move verbatim): retrieveAll (app.js 1897–1911), mergeSemantic
(1861–1883), expandPassages, chunkText, hardSplit, putKeyedVerified, queryTokens, tokenRanges.
Three sharp edges to preserve (each caused a real bug this project already fixed):
- BigInt at the wasm boundary —
getText/expandText/putEmbeddingstakeBigInt;findTextreturns a plainNumberrow_id. This conversion must live in one adapter layer, not scattered. - The embedder runs one job at a time — which is the entire reason
retrieveAllracesembedQueryagainstSEMANTIC_WAIT_MS/SEMANTIC_WAIT_EMPTY_MS. Don't drop the race. drainMissingEmbeddingsmust stay a re-query-until-empty loop (withdraining/drainQueuedre-entrancy), not a one-shot pass, or rows ingested during the model download never get vectors.
4 · Layer 1 packaging — @speedydb/core
{
"name": "@speedydb/core", "type": "module",
"peerDependencies": { "@huggingface/transformers": "…", "@wllama/wllama": "…" },
"exports": {
".": "./dist/index.js", // barrel: SpeedyDbClient + re-exports
"./engine": "./pkg/speedydb.js", // wasm-bindgen glue (types: pkg/speedydb.d.ts)
"./engine/wasm": "./pkg/speedydb_bg.wasm", // raw binary — new URL(...import.meta.url)
"./agent": "./agent.js",
"./agent-worker": "./agent-worker.js", // new Worker(new URL('@speedydb/core/agent-worker', import.meta.url), {type:'module'})
"./embedder": "./embedder.js",
"./durable": "./durable.js",
"./extract": "./extract.js",
"./react": "./dist/react.js", "./vue": "./dist/vue.js", "./svelte": "./dist/svelte.js"
}
}
Types work: the engine is fully typed by wasm-bindgen, but findText/findSemantic are typed
any (hit shapes live only in doc comments) — hand-write TextHit/SemanticHit + a thin typed
wrapper. The four JS modules ship untyped today — hand-write agent.d.ts, embedder.d.ts,
durable.d.ts, extract.d.ts (AgentConfig, EmbedderConfig, the AgentHandle union, ChatMessage,
the onProgress/onText/onEach callback signatures, DurableHandle). Small — the four modules
total ~1,350 lines.
5 · The hard part — asset & bundler portability
This is the #1 cost, and it's framework-independent. The stack ships ~59 MB of runtime wasm plus ~1.1–1.4 GB of remote model weights per client (cached after first use).
| Asset | Size | Delivery |
|---|---|---|
speedydb_bg.wasm (engine) |
398 KB | copy-to-public / ?url import; Content-Type: application/wasm |
transformers.min.js |
558 KB | peer-dep @huggingface/transformers (bundler owns it) |
ORT …asyncify.wasm + .mjs (non-Safari) |
23.6 MB + 47 KB | copy-to-public — loaded at runtime via wasmPaths, invisible to bundlers |
ORT …threaded.wasm + .mjs (Safari plain build) |
12.9 MB + 24 KB | copy-to-public (runtime wasmPaths) |
wllama index.min.js |
309 KB | peer-dep @wllama/wllama |
wllama wllama.wasm (+ compat .js/.wasm) |
7.65 MB (+14.2 MB) | copy-to-public — new URL(...import.meta.url) / setCompat |
| Embedder GGUF (LFM2.5-Embedding-350M) | ~229 MB | CDN → Cache API; never bundled; configureEmbedder({modelUrl}) to self-host |
| Agent GGUF (Qwen3-0.6B, Q4_K_M) | ~397 MB | CDN → Cache API; never bundled; self-hostable via env.remoteHost |
The five real hazards:
- Asset invisibility (the big one). The four ORT files and two wllama wasm files are loaded at
runtime via
env.backends.onnx.wasm.wasmPaths/ the wllama constructor — they're not in any bundler's static graph, so nothing fingerprints or copies them automatically. They must be placed inpublic/with the runtime paths pointed at them. ?v=cache-buster queries defeat static analysis everywhere.new Worker(new URL('./agent-worker.js?v='+WORKER_V, …))and query-string dynamic imports break worker detection and code-splitting on Vite, webpack, Rollup, and Parcel. In a packaged build, make the worker URL a literal and move cache-busting to filename fingerprinting.- Cross-origin-isolation double-bind. The vendored ORT is the
-threadedbuild, but transformers.js forcesnumThreads=1without COOP/COEP. Ship single-thread-correct, document the COOP/COEP opt-in for speed. (The demo runs fine single-threaded — proven.) - Module-worker support is uneven (older Safari, some bundler outputs); keep the inline main-thread path as a loud last resort, never the default.
- ORT ↔ transformers.js version lock — the vendored ORT wasm must exactly match the pinned
onnxruntime-web. Add a CI check.
SSR guards (client-gate all of these): navigator.gpu (read at module import in agent.js to
derive the device — the trickiest), Worker, indexedDB, navigator.locks, WebAssembly,
self.crossOriginIsolated, import.meta.url. Per framework: Next dynamic(…, {ssr:false}) /
'use client'; Nuxt <ClientOnly> + import.meta.client; SvelteKit browser guard; Angular
isPlatformBrowser.
Recommended strategy. Hard split CODE (bundlable) from RUNTIME ASSETS (served, never
inlined): bundle the JS modules with @huggingface/transformers + @wllama/wllama as
peer-deps; deliver the ~59 MB of wasm via a documented per-bundler copy step (Vite publicDir,
CopyWebpackPlugin, rollup-copy, esbuild copy, parcel static-copy) — shipped as a @speedydb/core
CLI so users run one command. Make every asset root configurable (assetBase threaded into
wasmPaths, the wllama ctor, and engine init(module_or_path)), defaulting to new URL('./assets/', import.meta.url). Kill the ?v= queries in bundled builds.
6 · Layer 2 — framework bindings (thin)
FloatPlan proves the binding is a veneer: its ~230-line use-rag.ts is almost all
framework-neutral plumbing (worker lifecycle, id→promise map, progress aggregation) with only ~6
useState + 2 useEffect that are React. Lift that plumbing into the core and each adapter shrinks
to dozens of lines.
| Framework | Reactivity bridge | Public shape | Size | Key gotcha |
|---|---|---|---|---|
| React | useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) — built for exactly this |
useSpeedyDb() → {...snapshot, ingest, ask, search, configure} |
S | snapshot must be referentially stable (cache in core, never rebuild per call); RSC → 'use client', inert getServerSnapshot |
| Vue 3 | shallowRef(getSnapshot()), reassign on notify |
useSpeedyDb() composable + computed slices; onScopeDispose |
S | shallowRef + markRaw — never deep-proxy a snapshot holding Float32Arrays/transferables |
| Svelte | the store contract is {subscribe} |
createSpeedyDb() → {subscribe, ingest, ask, …}; $db.ready |
XS | must emit current value synchronously on first subscribe; SvelteKit browser guard |
| Solid | createStore + reconcile(getSnapshot()) |
createSpeedyDb() → [store, methods] |
S | reconcile buys granular updates; keep big arrays out of the diff; isServer guard |
| Angular | signal(getSnapshot()) updated by the subscription |
@Injectable() SpeedyDbService (signals; zoneless-ready) |
M | worker/token callbacks fire outside Zone.js — signals notify CD zone-independently; DestroyRef teardown; isPlatformBrowser |
7 · Layer 3 — UI components & distribution
The kit each framework needs: dropzone/ingest, source tabs/document list, search/ask
box, results list with row · source · #ord provenance + query highlighting, streaming
answer panel, sources panel (the passages the model received), model-download progress
(aggregated %, size label), device/status badge (honest WebGPU vs CPU — the worker's probed
device), error + watchdog surface, empty / nothing-found + confidence state.
Every component must preserve the honesty contracts (they're the product's trust story): sources shown = grounding; "nothing found" costs zero tokens and never invokes the model; the device label is honest; empty retrieval never reaches the model.
Distribution — shadcn-style copy-paste registry, not N locked libraries. Publish the tiny
headless bindings as the versioned dependency (the wiring is the value); ship the styled
components as source the user pulls into their repo via a CLI and restyles. Rationale: the value is
the correct wiring (consuming subscribe/getSnapshot, streaming ask(), honoring the honesty
contracts), not the pixels — and for a privacy product whose pitch is "nothing leaves the device,"
auditable in-repo source beats an opaque third-party package handling user documents. Locked styled
libraries (one themed npm package per framework) are the alternative — lowest friction to first
render, but styling lock-in, 5 packages to version, and opaque; only worth it later, for React first.
Starter templates (each pre-wires the asset/worker/SSR config — solving §5 for users): React+Vite, Next.js App Router (proves the SSR story), Vue+Vite / Nuxt, SvelteKit, SolidStart, Angular standalone, a framework-agnostic "vendor the core" quickstart (mirrors the current site), an offline PWA / browser-extension template (on-device RAG's natural home), and a bring-your-own-retrieval template (swap the wasm index behind the same passage contract).
8 · Phased build order
Estimates are relative T-shirt sizes, not calendar time. The critical path is Phase 0 → 1; after that, per-framework work parallelizes.
Phase 0 — Decouple in place · L · (no package yet)
Extract SpeedyDbClient from app.js inside the existing site; make Stop 06 render purely by
subscribing to it. Proves the core in the app that already exists, with zero packaging risk.
Slice-by-slice plan: browser-components/app-js-extraction.md (9 additive slices, site green at every step).
- Exit: Stop 06 behaves identically, driven entirely through
SpeedyDbClient;site/tests/extended to cover the client's state machine (ingest → search → ask → widen, mode isolation).
Phase 1 — Package @speedydb/core · L · (gates everything)
Publishable ESM: exports map, hand-written .d.ts + hit types, the asset/bundler story from §5
(literal worker URL, configurable assetBase, peer-deps, the copy-assets CLI), SSR guards, the
ORT↔transformers version-lock CI check.
Slice-by-slice plan: browser-components/phase-1-packaging.md (9 slices P0–P8, additive in place, site green until the P8 cutover).
- Exit:
npm i @speedydb/core→ headless RAG works in a bare Vite app (no UI); the copy-assets CLI works on Vite + webpack.Distribution settled, 2026-08-03: there is no public registry package.
@speedydb/coreis served as a self-hosted tarball from speedydb.org, so the exit criterion readsnpm i https://speedydb.org/packages/speedydb-core-<version>.tgz. The capability is unchanged and met; only the install argument differs. The wording above is left as written because it records what the phase set out to do.
Phase 2 — React reference (binding + UI + starters) · M
@speedydb/core/react (useSpeedyDb via useSyncExternalStore) + the copy-paste component registry
(reuse Stop 06's design + honesty contracts) + React-Vite and Next App Router starters.
- Exit: a Next app renders the full flow, SSR-safe, from the published package.
- Substantially de-risked by FloatPlan / ReferralBuddy.
Phase 3 — Fan out · M each (parallelizable)
Vue, Svelte (XS), Solid, Angular (M) bindings + starters (Nuxt, SvelteKit, SolidStart, Angular standalone); port the component registry per framework via the CLI.
Phase 4 — Hardening & ecosystem · ongoing
Offline PWA / extension template, bring-your-own-retrieval template, self-host / CSP (connect-src)
guide, cross-browser matrix (Safari plain-ORT build, module-worker fallback), a docs site.
9 · Non-goals & risks
Non-goals: shipping the model weights (always CDN + cached); SSR-rendering the RAG UI (it's a client-only island by nature); building five locked styled component libraries up front (copy-paste registry instead); changing the approved embedding model.
Standing risks: the ~59 MB wasm + ~1.4 GB model payload is inherent to on-device inference — the bundler/asset config is where users get stuck, so the copy-assets CLI + starters are load-bearing, not nice-to-have. Maintenance scales with frameworks × bundlers × major versions; the headless-core
- copy-paste-UI split is precisely what keeps that from exploding.
10 · Already de-risked
Engine typed (wasm-bindgen .d.ts) · all four hosts already headless with configure* APIs ·
FloatPlan proves the React binding + worker protocol + streaming ask() shape · the load-stall
watchdog and the site/tests/ agent contract suite already exist · packages/core/docs/browser-rag-agent.md
documents the reuse recipe and honesty contracts. The dominant unbuilt piece is Phase 0/1 — the
SpeedyDbClient extraction and the packaging/asset story.