@lde/search-api-server
The served @lde/search API as a bootable process and prebuilt Docker image: mount a schema-declaration module, point it at Typesense, and it serves /graphql – POST execution, the self-contained playground, the SDL – plus /health, with CORS and depth/cost limits on by default.
This is the composition layer (#600, layer 3) that binds the engine-agnostic @lde/search-api-graphql handler (layer 2) to the @lde/search-typesense engine. Use it for turnkey, non-JS and ops-driven deployments; a JS host that wants custom GraphQL fields mounts the handler itself instead (or builds FROM this image).
Installation
npm install @lde/search-api-serverRun
docker run --publish 4000:4000 \
--volume "$(pwd)/search-schema.mjs:/config/search-schema.mjs:ro" \
--env TYPESENSE_HOST=typesense.internal \
--env TYPESENSE_API_KEY=search-only-key \
ghcr.io/ldelements/search-api-serverOr without Docker (the same environment variables apply):
npx @lde/search-api-serverThe schema module
The mounted module default-exports the deployment’s search type declarations as plain data – it must not import @lde/search (bare specifiers do not resolve from a mounted file), and does not need to: the server validates the declarations at boot, exactly as it would a SHACL generator’s output. Optional functions (derive, transform) are allowed – it is a real JS module, only without imports; they are projection-time declarations a serving process never calls, carried so one module can drive both the indexer and this API.
Authoring in TypeScript works fine: compile the module to .mjs first (tsc or esbuild) and mount the output – with satisfies SearchType[] you get the full compile-time checking, and functions survive compilation.
Authoring in TypeScript and bundling
A TypeScript schema module naturally wants import { defineSearchType } from '@lde/search' for the typed literal capture. Do not bundle the real @lde/search into the mounted file: it drags CJS dependencies (jsonld, rdf-canonize) into what must be a lean ESM module. defineSearchType is an identity function at runtime (all its value is in the types), so alias it to a tiny stub when bundling:
// search-stub.mjs – replaces '@lde/search' inside the bundle
export const defineSearchType = (searchType) => searchType;npx esbuild search-schema.ts --bundle --format=esm \
--alias:@lde/search=./search-stub.mjs \
--outfile=search-schema.mjsThe output is plain data with optional functions – exactly what the images mount – while authoring keeps full type checking. The server re-validates the declarations at boot regardless, so the stub gives up nothing.
// search-schema.mjs
export default [
{
name: 'Dataset',
class: 'http://www.w3.org/ns/dcat#Dataset',
fields: [
{
name: 'title',
path: 'http://purl.org/dc/terms/title',
kind: 'text',
locales: ['nl', 'en'],
output: true,
searchable: { weight: 5 },
},
{
name: 'keyword',
path: 'http://www.w3.org/ns/dcat#keyword',
kind: 'keyword',
array: true,
facetable: true,
output: true,
},
],
},
];
// Optional: forwarded to buildGraphQLSchema (per-type options, maxPerPage, …).
export const schemaOptions = { maxPerPage: 50 };
// Optional: forwarded to createTypesenseSearchEngine (collection overrides, …).
export const engineOptions = {};Once the SHACL + search: generator lands (#495), mounted SHACL becomes an additional source for the same schema.
Configuration
| Variable | Default | Meaning |
|---|---|---|
SCHEMA_MODULE | /config/search-schema.mjs | Path of the mounted schema-declaration module |
PORT | 4000 | TCP port the server binds |
GRAPHQL_ENDPOINT | /graphql | Path serving GraphQL, the playground and the SDL |
PLAYGROUND | true | Serve the playground on GET (false/0 disables) |
MAX_DEPTH | handler default (15) | Query depth cap |
MAX_COST | handler default (5000) | Query cost cap |
TYPESENSE_HOST | required | Typesense host |
TYPESENSE_PORT | 8108 | Typesense port |
TYPESENSE_PROTOCOL | http | http or https |
TYPESENSE_API_KEY | required | Use a search-only key: the server only ever reads |
A misconfigured boot reports all problems in one error, not one per crash loop.
Endpoints
POST /graphql– GraphQL execution.GET /graphql– the self-contained playground (no external CDN, no framing headers, so a docs site can<iframe>it as a live client).GET /graphql?sdl– the printed SDL: the contract without a running introspection query.GET /health– liveness; the Docker image’sHEALTHCHECKuses it.GET /– redirects to the endpoint.
Building the image
The image is built from the workspace’s own outputs – the compiled package, the same-commit builds of its @lde/* dependencies and a pruned lockfile – never from npm, so it exists for any commit (ADR 15):
npx nx run @lde/search-api-server:docker:build # → packages-search-api-server
npx nx run @lde/search-api-server:docker:smoke # boots it and probes /health + ?sdlCI runs docker:smoke for affected PRs; each release rebuilds and pushes ghcr.io/ldelements/search-api-server:<version> (.github/workflows/docker.yml).
Programmatic use
The bin is a thin wrapper over the exported API, usable in tests or a custom boot:
import {
configFromEnvironment,
createSearchApiServer,
} from '@lde/search-api-server';
const server = await createSearchApiServer(configFromEnvironment(process.env));
const port = await server.start();start() resolves with the bound port, so PORT=0 binds an ephemeral one – handy for tests that run servers in parallel. stop() closes the server, letting in-flight requests finish while closing idle keep-alive connections; the bin calls it on SIGTERM/SIGINT, so the Docker image shuts down gracefully under an orchestrator.
Also exported: loadSchemaModule and its SchemaModule result – the schema-module loader with the read side’s optional exports (schemaOptions, engineOptions) validated, built on @lde/search/module’s shared loader – plus the ServerConfig and TypesenseConnection types configFromEnvironment produces.