Skip to content

ergo-router

npm version

Node.js ≥ 22Pure ESM
Terminal window
npm install @centralping/ergo-router @centralping/ergo

@centralping/ergo-router has a peer dependency on @centralping/ergo.

ergo-router provides path-based request dispatch built on find-my-way with automatic REST compliance and a declarative pipeline builder.

Behavior Standard Description
405 + Allow RFC 9110 §15.5.6 Requests to a known path with an unsupported method receive 405 with an Allow header listing valid methods
HEAD derivation RFC 9110 §9.3.2 HEAD requests automatically derive from GET handlers
OPTIONS RFC 9110 §9.3.7 OPTIONS requests return Allow header with supported methods
PATCH required Custom Routes with PUT must also register PATCH (or explicitly opt out)
Middleware Description Standard
Security headers HSTS, CSP, X-Content-Type-Options RFC 6797
CORS Cross-Origin Resource Sharing Fetch Standard
Rate limiting Global rate limiter with 429 responses RFC 6585 §4
Request ID X-Request-Id generation and propagation Convention

Declaratively compose ergo middleware for each route:

import createRouter from '@centralping/ergo-router';
const router = createRouter({
defaults: {
accepts: {types: ['application/json']},
},
});
router.post('/users', {
authorization: {strategies: [bearerStrategy]},
validate: {body: userSchema},
execute: createUser,
});

The execute function is the route handler — most handlers use (req, res, acc), and advanced handlers can also read a fourth argument responseAcc. acc is the domain accumulator carrying middleware outputs (auth identity, parsed body, etc.) plus router-seeded route params (acc.route.params). See the Accumulator Reference for the complete shape, or the Architecture page for the two-accumulator pipeline model.

When a route registers a config key that also appears in defaults, the route value replaces the default entirely — it does not merge.

For each config key, the pipeline builder resolves the effective value using route config first, then defaults:

Route value Default value Resolved value Effect
undefined (omitted) {...} {...} Inherits default
false any disabled Explicitly disables — removes from pipeline
true any {} Enables with empty options
{...} (object) {...} route {...} Route replaces default entirely

Setting a config key on a route replaces the default — properties are not deep-merged:

const router = createRouter({
defaults: {
authorization: {
strategies: [bearerStrategy, apiKeyStrategy],
},
},
});
// ⚠️ This route loses bearerStrategy and apiKeyStrategy —
// the route value replaces the default entirely
router.get('/admin', {
authorization: {strategies: [basicStrategy]},
execute: adminHandler,
});

To extend defaults rather than replace them, spread the default values into the route config manually:

const authDefaults = {
strategies: [bearerStrategy, apiKeyStrategy],
};
const router = createRouter({
defaults: {authorization: authDefaults},
});
// ✓ This route has all three strategies
router.get('/admin', {
authorization: {
strategies: [...authDefaults.strategies, basicStrategy],
},
execute: adminHandler,
});

ergo-router validates every config object at registration time — before any requests arrive. Three layers of validation catch errors early: unknown key detection with typo suggestions, value type enforcement, and semantic cross-key consistency checking.

Unrecognized config keys are detected using Levenshtein distance matching (threshold: 3 edits). When a close match exists, the error message includes a suggestion:

Error: Unknown config key "authorisation" in route config for POST /users (did you mean "authorization"?)

In strict mode (the default), unknown keys throw an Error that prevents server startup. In lenient mode, unknown keys log a console.warn prefixed with [ergo-router]:

[ergo-router] Unknown config key "authorisation" in route config for POST /users (did you mean "authorization"?)

To enable lenient mode:

const router = createRouter({strict: false});

Unknown key detection also validates router defaults and router options — not just route configs. A typo in defaults is caught at createRouter() time:

Error: Unknown config key "authoriation" in router defaults (did you mean "authorization"?)

For the complete list of valid keys by category, see the Route Config Key Reference.

Every config key has an expected type. Incorrect types are rejected at registration time with a descriptive error.

Key Category Expected Type Notes
Pipeline keys (e.g. authorization, body, compress) boolean | object true enables with defaults, false disables, object passes options
execute function Required on every route config
use array | false Custom middleware array; false disables all
openapi plain object Annotation key — pass-through to metadata
send object Per-route send options
noSend boolean Skip automatic send()
catchHandler function Per-route error handler
onResponse function Post-send observation hook
redactHeaders Set Header names to redact in onResponse snapshots
Error: Invalid "authorization" in route config for POST /users: expected object or boolean, got string.
Error: Invalid "use" in route config for POST /users: expected an array or false, got string.

Type enforcement always throws regardless of the strict setting — an incorrect type will always block startup at registration time.

After keys and types are validated, ergo-router checks for cross-key contradictions using resolved values (route config merged with defaults per Config Resolution rules). Currently one semantic rule is enforced:

body: false + validate.body — disabling body parsing while configuring body validation is guaranteed to produce a runtime error because acc.body is never populated:

Error: Route config for POST /users has body: false but validate.body is configured. Body parsing must be enabled for body validation to work.

Semantic validation uses resolved config values — if body: false comes from defaults and validate.body comes from the route (or vice versa), the contradiction is still detected. This check always throws regardless of the strict setting.

Scope What Is Validated When It Runs Strict Mode Applies?
Route config Keys, value types, execute presence router.get(), router.post(), etc. Yes — unknown keys only
Router defaults Keys (pipeline keys, excluding execute), value types createRouter() Yes — unknown keys only
Router options Top-level keys (transport, strict, timing, etc.), value types createRouter() Yes — unknown keys only
Semantic consistency Cross-key contradictions on resolved config Route registration No — always throws

For error message formats, troubleshooting steps, and the warning code reference, see Debugging & Diagnostics.

ergo-router ships named presets — pre-built {transport, defaults} configuration objects for common API patterns. Import them from the package entry point and spread into createRouter():

import createRouter, {presets} from '@centralping/ergo-router';
const router = createRouter({
...presets.jsonApi,
defaults: {
...presets.jsonApi.defaults,
authorization: {strategies: [bearerStrategy]},
},
});

Standard JSON API with transport security and content negotiation.

Layer Key Value
transport requestId {} (enabled with defaults)
transport security {} (enabled with defaults)
defaults accepts {types: ['application/json']}

Server-Sent Events with compression disabled and no timeout.

Layer Key Value
transport requestId {} (enabled with defaults)
transport security {} (enabled with defaults)
defaults compress false (prevents buffering of streamed chunks)
defaults timeout false (SSE connections are long-lived)
defaults accepts {types: ['text/event-stream']}

Webhook receiver with idempotency enforcement.

Layer Key Value
transport requestId {} (enabled with defaults)
transport security {} (enabled with defaults)
defaults accepts {types: ['application/json']}
defaults idempotency {required: true}

Public read-only API with rate limiting and cache headers.

Layer Key Value
transport requestId {} (enabled with defaults)
transport security {} (enabled with defaults)
transport rateLimit {} (enabled with built-in defaults: 100 req/60s)
defaults accepts {types: ['application/json']}
defaults cacheControl {public: true, maxAge: 300}

Presets intentionally exclude deployment-specific concerns:

  • Authorization — auth strategies vary per project
  • CORS — origin allowlists are deployment-specific
  • Timing — observability is opt-in at the router level
  • Debug — development-only, not a preset concern

Presets are spread into createRouter() using the ... operator. Because JavaScript spread is shallow, nested objects must be spread explicitly when you need to extend (not replace) them:

// ⚠️ WRONG — preset defaults are lost entirely
const router = createRouter({
...presets.jsonApi,
defaults: {
authorization: {strategies: [bearerStrategy]},
},
});
// defaults.accepts is gone — presets.jsonApi.defaults was replaced
// ✓ CORRECT — spread nested objects explicitly
const router = createRouter({
...presets.jsonApi,
defaults: {
...presets.jsonApi.defaults,
authorization: {strategies: [bearerStrategy]},
},
});
// defaults.accepts is preserved from the preset

The same applies to transport:

// ✓ CORRECT — extend transport with CORS
const router = createRouter({
...presets.jsonApi,
transport: {
...presets.jsonApi.transport,
cors: {origin: 'https://myapp.com'},
},
defaults: {
...presets.jsonApi.defaults,
authorization: {strategies: [bearerStrategy]},
},
});

Route-level values still follow the standard Config Resolution rules — route values replace defaults entirely. Presets populate defaults; routes override them.

For why the SSE preset disables compression, see the compress middleware guide — streaming responses bypass the threshold check and compression interferes with chunked event delivery.

Every key accepted in a declarative route config object or in defaults. Keys are grouped by role — pipeline middleware, route options, and annotations.

Pipeline keys resolve to middleware factories via the pipeline builder. Built-in factories carry an intrinsic accumulator path; the builder constructs explicit {fn, setPath} config objects internally when assembling the pipeline.

Config Key Accumulator Path Pipeline Stage Notes
tracing acc.trace Negotiation OpenTelemetry span and context
logger acc.log Negotiation Structured request metadata
rateLimit acc.rateLimit Negotiation Circuit breaker — response headers only
accepts acc.accepts Negotiation Content negotiation
preconditionRequired acc.precondition Negotiation Gate — enforces conditional headers (428); PUT/PATCH only when enabled
cookie acc.cookies Negotiation Cookie jar (parse + set)
url acc.url Negotiation Fast URL parser; auto-included for GET/DELETE
paginate acc.paginate Negotiation Parsed pagination params; auto-includes url
jsonApiQuery acc.jsonApiQuery Negotiation JSON:API query parameter parsing
prefer acc.prefer Negotiation RFC 7240 Prefer header
securityHeaders acc.security Negotiation HSTS, CSP, X-Content-Type-Options — response headers only
cacheControl acc.cache Negotiation Cache-Control directive — response headers only
csrf acc.csrf Authorization CSRF token issue/verify
authorization acc.auth Authorization Strategy-based auth
body acc.body Validation Request body parsing; auto-included for POST/PUT/PATCH
idempotency acc.idempotency Validation Idempotency-Key processing
validate acc.validation Validation JSON Schema validation ({body, query, params} or shorthand form)
timeout n/a Execution Request timeout — aborts pipeline on expiry
compress n/a Execution Response compression
use n/a Between Validation and Execution Custom middleware array; defaults and route arrays are concatenated
execute n/a Execution Route handler function

Route options are extracted from the config object and passed to the auto-wrap layer. They do not correspond to pipeline middleware.

Config Key Type Description
send object Per-route options passed to send() (e.g. responseSchema, envelope)
noSend boolean Skip automatic send() — handler writes the full response
catchHandler function Per-route error handler: (req, res, err, domainAcc)
onResponse function Post-send observation hook: (req, res, responseInfo, domainAcc)
redactHeaders Set<string> Per-route header redaction override for onResponse hook responseInfo.headers

See Route Options below for full documentation and examples.

Annotation keys pass through to route metadata without being consumed by the pipeline builder.

Config Key Description
openapi OpenAPI operation object merged onto auto-derived spec output. Not valid in defaults.

See OpenAPI Generation for usage.

router.use(...fns) registers middleware that runs before every route pipeline. Application middleware is prepended to each route’s pipeline array, executing before all four stages (Negotiation, Authorization, Validation, Execution):

router.use((req, res, acc) => {
res.on('finish', () => {
console.log(`${req.method} ${req.url} ${res.statusCode}`);
});
});

use() returns the router, so calls can be chained:

router
.use(requestLogger)
.use(requestMetrics)
.get('/users', {execute: listUsers});

Declarative route configs accept per-route options that control pipeline behavior. These options are extracted from the config object and passed to the auto-wrap layer — they do not correspond to pipeline middleware.

Option Type Default Description
noSend boolean false Skip the automatic send() call after the pipeline completes. The handler is responsible for writing the full HTTP response.
send object Per-route options passed to send(). Overrides router-level send defaults.
catchHandler function Per-route error handler. Receives (req, res, err, domainAcc) when the pipeline throws. Overrides router-level catchHandler. The fourth argument provides the domain accumulator state at the time of the error.
onResponse function Per-route post-send observation hook. Receives (req, res, responseInfo, domainAcc) after send() completes. Fires before the router-level hook.
redactHeaders Set<string> Per-route override for header redaction in onResponse hook snapshots. Overrides the router-level redactHeaders set. See Lifecycle Hooks.

When noSend: true, ergo-router runs the full pipeline (including OTEL tracing if configured) but does not call send() after the pipeline completes. The handler must write headers, the status code, and the response body directly via Node.js res methods.

Use cases: streaming responses, Server-Sent Events, file downloads, or any response format that send() does not support.

router.get('/events', {
noSend: true,
authorization: {strategies: [bearerStrategy]},
execute: (req, res, acc) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({ts: Date.now()})}\n\n`);
}, 1000);
req.on('close', () => {
clearInterval(interval);
res.end();
});
},
});

ergo-router supports onResponse observation hooks at two levels: router-level (via createRouter({onResponse, redactHeaders})) and per-route (via the onResponse and redactHeaders route options). Both hooks receive the same signature:

(req, res, responseInfo, domainAcc) => void | Promise

The router-level hook fires on every response — both pipeline-routed responses (after send()) and transport-level short-circuits (404, 405, 415, 429, OPTIONS 204, CORS preflight). The per-route hook fires only for pipeline-routed responses on that route.

For pipeline-routed responses:

  1. Route-level hook fires first (if defined)
  2. Router-level hook fires second (if defined)

Each hook is wrapped in its own try/catch — a route-level hook failure does not prevent the router-level hook from firing.

For transport-level short-circuits, only the router-level hook fires (no route matched, so no route-level hook exists).

Field Type Description
statusCode number Final HTTP status code
headers object Response headers snapshot — sensitive headers redacted per redactHeaders option
method string Request HTTP method
url string Request URL
bodySize number | undefined Content-Length value; undefined for stream bodies
duration number Elapsed time in milliseconds from dispatch entry (pipeline) or transport short-circuit
source 'pipeline' | 'transport' Response origin — 'pipeline' for routed requests, 'transport' for short-circuit responses (404, 405, 415, 429, OPTIONS, CORS preflight)

The router-level onResponse hook also fires for transport-level responses — requests that are short-circuited before any route pipeline runs. These include:

  • 404 — unknown path (no matching route)
  • 405 — wrong HTTP method for a known path
  • 415 — unsupported Content-Type
  • 429 — rate limited (transport-level rate limiter)
  • 204 OPTIONS — automatic OPTIONS response
  • CORS preflight — preflight handled by CORS transport (204 on success, 403 when the origin is disallowed)

For transport-level responses, responseInfo.source is 'transport' and domainAcc is undefined (no pipeline ran, so no domain accumulator was created). Per-route onResponse hooks do not fire for transport responses — only the router-level hook fires.

The redactHeaders option controls which response headers are replaced with '[REDACTED]' in the responseInfo.headers snapshot passed to onResponse hooks. Set it at the router level via createRouter({redactHeaders}) or override per-route via the redactHeaders route option.

authorization, proxy-authorization, cookie, set-cookie

Pass an empty Set to disable redaction entirely:

const router = createRouter({
redactHeaders: new Set(),
onResponse: (req, res, responseInfo) => {
// responseInfo.headers contains all header values unredacted
},
});

Per-route overrides replace the router-level set for that route:

router.post('/payments', {
redactHeaders: new Set(['authorization', 'proxy-authorization']),
onResponse: (req, res, responseInfo) => {
// Only authorization and proxy-authorization are redacted;
// cookie and set-cookie are visible for this route
},
execute: processPayment,
});

Usage — Audit Logging + Per-Route Metrics

Section titled “Usage — Audit Logging + Per-Route Metrics”
import createRouter from '@centralping/ergo-router';
const router = createRouter({
onResponse: (req, res, responseInfo, domainAcc) => {
auditLog.write({
method: responseInfo.method,
url: responseInfo.url,
status: responseInfo.statusCode,
duration: responseInfo.duration,
source: responseInfo.source,
user: domainAcc?.auth?.identity,
});
},
});
router.post('/payments', {
onResponse: (req, res, responseInfo) => {
metrics.histogram('payments.duration', responseInfo.duration);
metrics.increment(`payments.status.${responseInfo.statusCode}`);
},
authorization: {strategies: [bearerStrategy]},
validate: {body: paymentSchema},
execute: processPayment,
});

createRouter({timing: true}) injects an X-Response-Time header on every response, measuring pipeline execution time in milliseconds.

Value Effect
timing: false Default. No timing header, zero overhead.
timing: true Injects x-response-time header with 3 decimal places.
timing: {header, precision} Custom header name and/or decimal precision.
Option Type Default Description
header string 'x-response-time' Response header name
precision number 3 Decimal places for the millisecond value
HTTP/1.1 200 OK
x-response-time: 12.345
Content-Type: application/json

The value is a plain millisecond string (e.g., '12.345'), not the W3C Server-Timing format (metric;dur=12.345). If you need the W3C format for DevTools integration, set a custom header name — the value format remains a plain number regardless of the header name.

Timing measures elapsed time from auto-wrap handler entry (when the route pipeline begins) to res.writeHead (when response headers flush). This includes:

  • All pipeline middleware (negotiation, authorization, validation, execution)
  • send() processing (serialization, conditional evaluation, envelope)

This excludes:

  • Transport/dispatch overhead — CORS, rate limiting, request ID, and route matching happen before the pipeline
  • Short-circuit responses — 404 (no route), 405 (method not allowed), 415 (unsupported media type), and 429 (rate limited) are produced by dispatch() before the auto-wrap handler runs; these responses have no timing header
  • Bare function pipelines — routes registered as a single handler function (not a config object or array) bypass auto-wrap entirely
import createRouter from '@centralping/ergo-router';
const router = createRouter({
timing: true,
defaults: {
accepts: {types: ['application/json']},
},
});
router.get('/health', {
execute: () => ({response: {body: {ok: true}}}),
});
// GET /health → x-response-time: 0.456

For standalone (non-router) usage of the timing option, see the handler middleware guide.

ergo-router composes three independent behaviors into a full conditional request pattern — ETag generation, conditional header evaluation, and header-presence enforcement. All three are enabled via declarative config keys with sensible defaults:

Config Default Behavior Status Codes
send options etag: true on (send default) Generates a strong ETag from the response body
send options etag: true on (send default) Evaluates conditional headers when present 304, 412
preconditionRequired: true off Enforces that clients must send If-Match or If-Unmodified-Since 428

These three compose the full optimistic concurrency lifecycle:

  1. GET → response includes ETag header (automatic via send())
  2. PUT/PATCH with valid If-Match → update succeeds (200)
  3. PUT/PATCH with stale If-Match412 Precondition Failed (evaluated by send())
  4. PUT/PATCH without If-Match428 Precondition Required (enforced by preconditionRequired)
import createRouter from '@centralping/ergo-router';
const router = createRouter({
defaults: {
accepts: {types: ['application/json']},
},
});
router.get('/articles/:id', {
execute: async (req, res, acc) => {
const article = await db.findById(acc.route.params.id);
return {response: {body: article}};
},
});
// GET /articles/42 → 200 + ETag: "abc123"
router.put('/articles/:id', {
preconditionRequired: true,
// etag: true is the send() default — no explicit config needed
execute: async (req, res, acc) => {
const article = await db.update(acc.route.params.id, acc.body.parsed);
return {response: {body: article}};
},
});
// PUT without If-Match → 428 Precondition Required
// PUT with stale If-Match → 412 Precondition Failed
// PUT with current If-Match → 200 OK + new ETag

The paginate config key wires a two-sided contract: request parsing (the paginate() middleware stores structured query parameters at acc.paginate) and response metadata (send() reads your handler’s response.paginate to auto-generate RFC 8288 Link headers and X-Total-Count).

router.get('/articles', {
paginate: true,
execute: async (req, res, acc) => {
const {offset, limit} = acc.paginate;
const items = await db.find(offset, limit);
const total = await db.count();
return {
response: {body: items, paginate: {total}},
};
},
});
// GET /articles?page=2&per_page=10
// → Link: </articles?page=1&per_page=10>; rel="first", ...
// → X-Total-Count: 42
router.get('/events', {
paginate: {strategy: 'cursor'},
execute: async (req, res, acc) => {
const {cursor, limit} = acc.paginate;
const {items, nextCursor} = await db.findAfter(cursor, limit);
return {
response: {
body: items,
paginate: {nextCursor},
},
};
},
});
// GET /events?cursor=abc123&limit=10
// → Link: </events>; rel="first", </events?cursor=def456&limit=10>; rel="next"

For pagination options (custom page sizes, maximum bounds) and the full acc.paginate shape, see the paginate middleware guide. For end-to-end examples including edge cases, see the Pagination recipe.

generateOpenAPI(router, options?) produces an OpenAPI 3.1 specification document from the router’s registered routes. It auto-extracts path parameters, query parameters, request body schemas, security schemes, and content types from route configs and defaults.

import createRouter from '@centralping/ergo-router';
import generateOpenAPI from '@centralping/ergo-router/openapi';
const router = createRouter({
defaults: {
accepts: {types: ['application/json']},
authorization: {strategies: [bearerStrategy]},
},
});
router.get('/users/:id', {
validate: {
params: {
type: 'object',
properties: {id: {type: 'string'}},
},
},
openapi: {summary: 'Get user by ID', tags: ['Users']},
execute: getUser,
});
const spec = generateOpenAPI(router, {
title: 'My API',
version: '1.0.0',
description: 'User management service',
});
Option Type Default Description
title string 'API' API title for the info object
version string '1.0.0' API version for the info object
description string API description
servers object[] Server objects for the servers array
info object Additional info properties merged after title/version/description

generateOpenAPI derives spec content from route configs without manual annotation:

  • Path parameters — extracted from find-my-way route patterns (:id, :id(^\d+))
  • Query parameters — from validate.query schemas
  • Request body — from validate.body on POST/PUT/PATCH routes
  • Security schemes — from authorization.strategies (Bearer, Basic, API key)
  • Content types — from accepts.types

Config resolution follows the same precedence as the pipeline builder — route values override defaults, false disables, true enables with empty options.

The openapi key on route configs adds or overrides properties on the generated operation object. Annotations merge on top of auto-derived values, so you can enrich the spec with summaries, tags, custom responses, and descriptions:

router.post('/users', {
validate: {body: userSchema},
openapi: {
summary: 'Create a new user',
tags: ['Users'],
responses: {
201: {description: 'User created'},
409: {description: 'Email already exists'},
},
},
execute: createUser,
});

For serving the generated spec from a route and mounting an interactive API explorer, see the OpenAPI Serving recipe.

Sub-routers let you organize routes into groups with independent defaults, middleware, and auth strategies. Create a sub-router with createRouter(), register routes on it, then mount it at a prefix path on the parent router.

router.mount(prefix, subRouter)

Mounts all routes from subRouter at the given prefix path. Returns the parent router for chaining.

import createRouter from '@centralping/ergo-router';
const usersRouter = createRouter({
defaults: {
authorization: {strategies: [bearerStrategy]},
},
});
usersRouter.get('/', {execute: listUsers});
usersRouter.get('/:id', {execute: getUser});
usersRouter.post('/', {
validate: {body: userSchema},
execute: createUser,
});
const router = createRouter({transport: {/* ... */}});
router.mount('/users', usersRouter);
// Registers: GET /users, GET /users/:id, POST /users
Aspect Behavior Explanation
Copy semantics Routes are copied at mount time Adding routes to the child after mount() has no effect on the parent
Defaults isolation Child defaults stay with the child Parent defaults do not merge into child routes; child routes use the defaults they were registered with
Transport Parent-governed Transport middleware (CORS, rate limiting, security headers, request ID) runs at the parent dispatch level only; child transport config is not used
router.use() Per-router scoping Parent router.use() does not apply to mounted routes; child router.use() is baked into child pipelines at registration time
strictPatch / strictBody Parent-governed Content-Type enforcement is applied at parent dispatch, after route matching (unknown paths→404, wrong methods→405) but before pipeline execution
Ordering Register routes before mounting Routes must be added to the child router before calling parent.mount()
Chainable mount() returns the parent Allows router.mount('/a', a).mount('/b', b)

graceful(handler, options?) creates an HTTP server with full lifecycle management. It works with any http.Server handler — not coupled to ergo-router.

import createRouter, {graceful} from '@centralping/ergo-router';
const router = createRouter({/* ... */});
const {server, shutdown} = await graceful(router.handle(), {
port: 3000,
onStartup: async ({log}) => {
await connectDatabase();
log.info('Database connected');
},
onShutdown: async ({log, signal}) => {
await disconnectDatabase();
log.info('Cleanup complete');
},
});
Option Type Default Description
port number 3000 Port to listen on
hostname string '0.0.0.0' Hostname to bind to
log object console Logger with .info(), .warn(), .error() methods
signals string[] ['SIGINT', 'SIGTERM'] OS signals that trigger shutdown
timeout number 5000 Maximum time (ms) to wait for connections to drain before forcing exit
exit function process.exit Exit function (override for testing)
onStartup function Async hook called before server.listen(). Receives {log}. Rejection prevents the server from starting.
onShutdown function Async hook called after server.close(). Receives {log, signal}. Errors are caught and logged; shutdown continues.

Returns Promise<{server, shutdown}>:

  • server — the http.Server instance
  • shutdown — a function to trigger graceful shutdown programmatically (useful in tests)

Replace the default console with any object that provides .info(), .warn(), and .error() methods — for example, a structured JSON logger. For production integrations with pino or winston (including request ID correlation and trace enrichment), see the Structured Logging recipe.

import createRouter, {graceful} from '@centralping/ergo-router';
const log = {
info: (msg) => console.log(JSON.stringify({level: 'info', msg, ts: new Date().toISOString()})),
warn: (msg) => console.log(JSON.stringify({level: 'warn', msg, ts: new Date().toISOString()})),
error: (msg) => console.log(JSON.stringify({level: 'error', msg, ts: new Date().toISOString()})),
};
const router = createRouter({/* ... */});
await graceful(router.handle(), {port: 3000, log});

Override exit to prevent process.exit during tests, use port: 0 for an ephemeral port, and call the returned shutdown() for cleanup:

import {describe, it, after} from 'node:test';
import assert from 'node:assert/strict';
import {graceful} from '@centralping/ergo-router';
describe('server lifecycle', () => {
let shutdown;
after(async () => {
if (shutdown) await shutdown('test-cleanup');
});
it('starts on an ephemeral port', async () => {
const result = await graceful(
(req, res) => {res.writeHead(200); res.end('ok');},
{port: 0, exit() {}},
);
shutdown = result.shutdown;
const {port} = result.server.address();
const res = await fetch(`http://localhost:${port}/`);
assert.equal(res.status, 200);
});
});

For comprehensive testing patterns — including auth, validation, rate limiting, conditional requests, and test isolation — see the Testing Patterns recipe.

  • ergo — core middleware toolkit
  • ergo-wire — shared HTTP wire-format primitives
  • ergo-fetch — RFC-compliant HTTP client for ergo-router APIs