Connect with the client
Application code should not know which search backend it talks to. The package @ferroseek/client (packages/search-client/) gives it one seam: a function that returns the official @elastic/elasticsearch client, version 8, configured only by connection settings. The same code then runs against:
- the engine embedded in the same Worker, called in-process;
- a standalone Ferroseek Worker over HTTP;
- a real Elasticsearch cluster over HTTP.
Everything above the connection is the official client’s own code: request serialisation, the X-Elastic-Product check, retries, ResponseError. Ferroseek only supplies a connection class that sends each request through a fetch-shaped function: global fetch for a remote server, or the engine’s fetch when embedded.
Create a client
Section titled “Create a client”import { createSearchClient } from "@ferroseek/client";
// Remote: a standalone Ferroseek Worker, or Elasticsearchconst remote = createSearchClient({ node: "https://search.example.com", auth: { username: "shop", password: "change-me" },});
// Embedded: any object with fetch(request), such as the engine from createFerroseekconst embedded = createSearchClient({ node: engine });
const result = await remote.search({ index: "products", query: { match: { name: "wireless speaker" } }, size: 24,});node is a URL (http or https) or an object with a fetch(request) method. auth is either { username, password } or { apiKey }, where apiKey is the base64 id:api_key value. options passes other client settings through, such as timeouts and retries; connection-related options (node, nodes, auth, cloud, Connection, ConnectionPool) are set by the package and cannot be overridden.
Invalid settings throw SearchConfigError when the client is created: a node that is not a URL, credentials inside the URL, both apiKey and a username, an empty username or API key.
Choose the backend from the environment
Section titled “Choose the backend from the environment”searchConfigFromEnv reads the choice from Worker environment variables and secrets, so switching backends never touches code:
| Variables | Backend |
|---|---|
SEARCH_URL unset |
The embedded engine, in-process |
SEARCH_URL=https://… |
Remote over HTTP, no credentials |
plus SEARCH_USERNAME and SEARCH_PASSWORD |
Remote with Basic authentication |
plus SEARCH_API_KEY |
Remote with ApiKey authentication |
Its second argument creates the embedded engine. It is only called when SEARCH_URL is unset, so a Worker configured for a remote backend never loads the index.
import wasm from "@ferroseek/engine/wasm";import { assetsIndexSource, createFerroseek } from "@ferroseek/engine";import { createSearchClient, searchConfigFromEnv, type SearchClient, type SearchMode } from "@ferroseek/client";import type { Env } from "./env.ts";
export interface Search { readonly client: SearchClient; readonly mode: SearchMode;}
/** Where `npm run shop:index` puts the index inside the assets directory. */const INDEX_ASSET_PATH = "search-index";
let search: Search | undefined;
export function getSearch(env: Env): Search { if (search === undefined) { const { mode, config } = searchConfigFromEnv(env as unknown as Record<string, unknown>, () => createFerroseek({ wasm, source: assetsIndexSource(env.ASSETS, INDEX_ASSET_PATH), onError: (error, phase) => console.error(`embedded search ${phase} failure`, error), }), ); search = { client: createSearchClient(config), mode }; } return search;}mode is "embedded" or "remote"; the example shop reports it in an x-search-backend response header so you can see which one served a request.
Inconsistent settings are rejected with SearchConfigError rather than guessed at: credentials without SEARCH_URL, a username without a password, both a password and an API key, or a variable that is set but empty.
Try the switch with the example shop
Section titled “Try the switch with the example shop”The example shop in examples/shop-worker/ serves /api/search and /api/category/:name listing pages with multi-select facets, and does all its searching through this seam. Run it embedded:
npm run index:build:100knpm run shop:index # copies the index into examples/shop-worker/public/search-index/npm run wasm:buildnpm run shop:dev # http://localhost:4092curl -s 'http://localhost:4092/api/search?q=shoe&brand=Lanfe&sort=price_asc'To use the standalone Worker instead, start it with npm run dev (port 4090) and give the shop a SEARCH_URL, for example in examples/shop-worker/.dev.vars:
SEARCH_URL=http://localhost:4090Pointing SEARCH_URL at an Elasticsearch 7.17 node that holds the same products index works the same way. The swap test runs all three configurations and checks that the shop’s responses are identical.
Errors
Section titled “Errors”Errors surface exactly as they do with Elasticsearch, because they come from the official client:
import { errors } from "@ferroseek/client";
try { await client.search({ index: "products", query: { match: { name: "shoe" } } });} catch (err) { if (err instanceof errors.ResponseError) { // The server answered with an error: err.meta.statusCode, err.body.error.type } else if (err instanceof errors.ElasticsearchClientError) { // No usable answer: connection failure, timeout, aborted request }}The package re-exports the client’s errors and its estypes request and response types.
Limits of the fetch connection
Section titled “Limits of the fetch connection”The connection sends whole request bodies and reads whole responses. Streamed request bodies and asStream responses are not supported and throw ConfigurationError; client helpers that depend on streaming are out of scope.