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 now nomic-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):

  1. BigInt at the wasm boundarygetText/expandText/putEmbeddings take BigInt; findText returns a plain Number row_id. This conversion must live in one adapter layer, not scattered.
  2. The embedder runs one job at a time — which is the entire reason retrieveAll races embedQuery against SEMANTIC_WAIT_MS/SEMANTIC_WAIT_EMPTY_MS. Don't drop the race.
  3. drainMissingEmbeddings must stay a re-query-until-empty loop (with draining/drainQueued re-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:

  1. 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 in public/ with the runtime paths pointed at them.
  2. ?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.
  3. Cross-origin-isolation double-bind. The vendored ORT is the -threaded build, but transformers.js forces numThreads=1 without COOP/COEP. Ship single-thread-correct, document the COOP/COEP opt-in for speed. (The demo runs fine single-threaded — proven.)
  4. Module-worker support is uneven (older Safari, some bundler outputs); keep the inline main-thread path as a loud last resort, never the default.
  5. 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).

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).

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.

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

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.