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

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.

import { createSearchClient } from "@ferroseek/client";
// Remote: a standalone Ferroseek Worker, or Elasticsearch
const remote = createSearchClient({
node: "https://search.example.com",
auth: { username: "shop", password: "change-me" },
});
// Embedded: any object with fetch(request), such as the engine from createFerroseek
const 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.

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.

examples/shop-worker/src/search.ts
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.

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:100k
npm run shop:index # copies the index into examples/shop-worker/public/search-index/
npm run wasm:build
npm run shop:dev # http://localhost:4092
curl -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:

examples/shop-worker/.dev.vars
SEARCH_URL=http://localhost:4090

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

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.