Skip to content
ferroseekdocumentation homemain, packages at version v0.1.0
Ferroseek on GitHub

Embed the engine in a Worker

Embedding puts the search engine in the same Worker as your application. The index is loaded into the isolate’s WebAssembly memory on the first request, and every search after that is a function call: no HTTP request leaves the isolate.

This guide uses the package @ferroseek/engine from packages/ferroseek/. The example shop in examples/shop-worker/ is a complete Worker built this way, and the code below follows it.

The packages are not published to npm. The simplest way to use them today is to keep your Worker inside this repository: every directory under examples/ is an npm workspace, so a new Worker there can depend on the packages by name.

examples/my-worker/package.json
{
"name": "my-worker",
"private": true,
"type": "module",
"scripts": { "dev": "wrangler dev" },
"dependencies": {
"@ferroseek/engine": "*"
}
}

Run npm install at the repository root afterwards, and npm run wasm:build once so that packages/ferroseek/wasm/ferroseek.wasm exists.

The Worker needs three things in its Wrangler configuration:

  • a rule that imports .wasm files as compiled WebAssembly modules;
  • a static-assets directory that holds the index parts, bound as ASSETS;
  • run_worker_first for the index path, so the parts are only reachable through the binding and are never served to the public.
examples/my-worker/wrangler.jsonc
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-09-01",
"assets": {
"directory": "./public",
"binding": "ASSETS",
"run_worker_first": ["/search-index/*"]
},
"rules": [{ "type": "CompiledWasm", "globs": ["**/*.wasm"], "fallthrough": false }]
}

If the Worker also uses the official Elasticsearch client (see Connect with the client), add "compatibility_flags": ["nodejs_compat"]: the client imports node:* modules. The engine on its own does not need it.

Build an index (see Build and update the index) and copy it under the assets directory. The example shop keeps it in public/search-index/; the root script npm run shop:index copies worker/assets/index/ there:

public/
└── search-index/
├── manifest.json
├── part-000.bin
├── part-001.bin
└── part-002.bin

Each part is at most 20 MiB, below the 25 MiB per-file limit of Workers static assets.

createFerroseek returns an object with a standard fetch(request) method. Create it once, at module scope or lazily on the first request, and reuse it: the index is loaded by the first request that needs it, and concurrent first requests share that one load.

src/search.ts
import wasm from "@ferroseek/engine/wasm";
import { assetsIndexSource, createFerroseek, type Ferroseek } from "@ferroseek/engine";
export interface Env {
readonly ASSETS: Fetcher;
}
let engine: Ferroseek | undefined;
export function getEngine(env: Env): Ferroseek {
engine ??= createFerroseek({
wasm,
source: assetsIndexSource(env.ASSETS, "search-index"),
onError: (error, phase) => console.error(`search ${phase} failure`, error),
});
return engine;
}

assetsIndexSource(env.ASSETS, "search-index") reads search-index/manifest.json and the part files through the assets binding. The library never logs on its own; pass onError to see load failures and engine traps.

The engine answers the same REST routes as the standalone Worker. Build a Request with any origin (only the path and query string are read) and an Elasticsearch request body:

src/index.ts
import { getEngine, type Env } from "./search.ts";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const q = new URL(request.url).searchParams.get("q") ?? "";
const response = await getEngine(env).fetch(
new Request("http://search/products/_search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: { match: { name: q } },
size: 24,
}),
}),
);
return new Response(response.body, response);
},
} satisfies ExportedHandler<Env>;

The response is exactly what Elasticsearch would return, including the status code and the error envelope on failure. In application code you will usually not build these requests by hand: hand the engine to the official client instead, which gives you typed requests and responses and lets you switch to a remote server later. See Connect with the client.

The first request in a fresh isolate loads the index before it is answered. To load it earlier, call ready(), which resolves with load statistics:

const stats = await getEngine(env).ready();
// { loadMs, indexBytes, docCount }

If loading fails, the request that triggered it answers 503 index_unavailable_exception, and the next request tries again; a failed load is not cached. If the engine traps while answering a request, that request answers 500 and the instance is dropped, so the next request loads a fresh one.

A Worker isolate has 128 MB of memory. The 100k-product sample index is 58.3 MiB, and an isolate that has loaded it uses about 62 MB. Your application shares what is left, and each search request may take up to roughly 16 MiB of extra engine heap (see Limits and differences). WebAssembly memory never shrinks, so plan for the high-water mark, not the average.

The loader streams each part straight into WebAssembly memory instead of holding a whole part in JavaScript, so loading does not need a second copy of the index.

Option Type Meaning
wasm WebAssembly.Module The compiled engine: import wasm from "@ferroseek/engine/wasm"
source IndexSource Where the index parts come from, e.g. assetsIndexSource(env.ASSETS, "index")
auth AuthConfig Require Basic or ApiKey credentials. Omit when embedded
diagnostics boolean Add x-engine-time-us, x-wasm-memory-bytes and, on the loading request, x-index-load-ms
onError (error, phase) => void Receives load failures ("load") and engine traps ("request")

The full API is listed in Library API.