Skip to content

@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

sh
npm install @lde/search-api-server

Run

sh
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-server

Or without Docker (the same environment variables apply):

sh
npx @lde/search-api-server

The 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:

js
// search-stub.mjs – replaces '@lde/search' inside the bundle
export const defineSearchType = (searchType) => searchType;
sh
npx esbuild search-schema.ts --bundle --format=esm \
  --alias:@lde/search=./search-stub.mjs \
  --outfile=search-schema.mjs

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

js
// 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

VariableDefaultMeaning
SCHEMA_MODULE/config/search-schema.mjsPath of the mounted schema-declaration module
PORT4000TCP port the server binds
GRAPHQL_ENDPOINT/graphqlPath serving GraphQL, the playground and the SDL
PLAYGROUNDtrueServe the playground on GET (false/0 disables)
MAX_DEPTHhandler default (15)Query depth cap
MAX_COSThandler default (5000)Query cost cap
TYPESENSE_HOSTrequiredTypesense host
TYPESENSE_PORT8108Typesense port
TYPESENSE_PROTOCOLhttphttp or https
TYPESENSE_API_KEYrequiredUse 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’s HEALTHCHECK uses 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):

sh
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 + ?sdl

CI 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:

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

Released under the MIT License.