Web Workers

Runtime embeddings and scoring can run off the main thread without changing the search API. The application owns the worker entry, so its bundler can put the selected embedding provider and Wasm module in the worker chunk.

Browser setup

Create a worker entry beside the application code:

// search.worker.ts
import { createSearch, exposeSearch } from "@seekite/core"
import { seekiteEmbeddings } from "@seekite/embeddings-ternlight"

exposeSearch(createSearch({
  key: "search",
  embeddings: seekiteEmbeddings(),
}))

Create the proxy on the main thread:

// search.ts
import { workerSearch } from "@seekite/core"

export const search = workerSearch(
  () => new Worker(new URL("./search.worker.ts", import.meta.url), { type: "module" }),
)

const results = await search.query("canvas drawing")

Vite inlines import.meta.env.BASE_URL in worker bundles, so a relative asset key resolves correctly even though workers do not have a document.

workerSearch does not create the worker until load, query, or warmup is called. Calls made while an asynchronous worker factory is starting are queued on that one worker. If it crashes, outstanding calls reject and the next call creates a fresh worker.

Call warmup() on an intentional interaction such as focusing the search box. It loads the search runtime and embedding engine before the first typed query pays that cost:

searchInput.addEventListener("focus", () => {
  void search.warmup()
}, { once: true })

The proxy implements the same load, query, and synchronous loaded methods as createSearch. Its loaded() value is the latest worker-side snapshot received after an RPC completes.

SSR and cleanup

Do not call the proxy during server rendering, where the browser Worker global is unavailable. Defining it at module scope is safe because creation is lazy; invoke it only from client-side code. Framework UI components should construct or use it from a browser lifecycle hook.

Terminate a long-lived proxy when its application boundary is removed:

await search.destroy() // `terminate()` is an equivalent alias

Destroying rejects outstanding requests and permanently closes that proxy.

Node worker threads

The same protocol accepts Node's raw message event shape. Pass parentPort explicitly in the worker entry:

// search.worker.ts
import { parentPort } from "node:worker_threads"
import { createSearch, exposeSearch } from "@seekite/core"

if (!parentPort) throw new Error("search worker requires a parent port")
exposeSearch(createSearch({ url: "https://example.test/search" }), parentPort)

Then use a node:worker_threads worker as the factory result:

import { Worker } from "node:worker_threads"
import { workerSearch } from "@seekite/core"

const search = workerSearch(
  () => new Worker(new URL("./search.worker.js", import.meta.url)),
)

Only structured-clone-safe values cross the boundary. Worker exceptions are sent as plain { name, message } data and reconstructed as errors on the calling side; provider objects and other functions stay inside the worker.