ergo-router
Installation
Section titled “Installation”npm install @centralping/ergo-router @centralping/ergo@centralping/ergo-router has a peer dependency on @centralping/ergo.
Overview
Section titled “Overview”ergo-router provides path-based request dispatch built on
find-my-way with automatic REST
compliance and a declarative pipeline builder.
Automatic REST Behavior
Section titled “Automatic REST Behavior”| 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) |
Transport Middleware
Section titled “Transport Middleware”| 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 |
Pipeline Builder
Section titled “Pipeline Builder”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.
Config Resolution
Section titled “Config Resolution”When a route registers a config key that also appears in defaults, the
route value replaces the default entirely — it does not merge.
Resolution Order
Section titled “Resolution Order”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 |
Replace vs. Extend
Section titled “Replace vs. Extend”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 entirelyrouter.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 strategiesrouter.get('/admin', { authorization: { strategies: [...authDefaults.strategies, basicStrategy], }, execute: adminHandler,});Config Validation
Section titled “Config Validation”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.
Unknown Key Detection
Section titled “Unknown Key Detection”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.
Value Type Enforcement
Section titled “Value Type Enforcement”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.
Semantic Cross-Key Validation
Section titled “Semantic Cross-Key Validation”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.
Validation Scope
Section titled “Validation Scope”| 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.
Presets
Section titled “Presets”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]}, },});Available Presets
Section titled “Available Presets”presets.jsonApi
Section titled “presets.jsonApi”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']} |
presets.sse
Section titled “presets.sse”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']} |
presets.webhooks
Section titled “presets.webhooks”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} |
presets.public
Section titled “presets.public”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} |
What Presets Do NOT Include
Section titled “What Presets Do NOT Include”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
Shallow Spread Semantics
Section titled “Shallow Spread Semantics”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 entirelyconst router = createRouter({ ...presets.jsonApi, defaults: { authorization: {strategies: [bearerStrategy]}, },});// defaults.accepts is gone — presets.jsonApi.defaults was replaced
// ✓ CORRECT — spread nested objects explicitlyconst router = createRouter({ ...presets.jsonApi, defaults: { ...presets.jsonApi.defaults, authorization: {strategies: [bearerStrategy]}, },});// defaults.accepts is preserved from the presetThe same applies to transport:
// ✓ CORRECT — extend transport with CORSconst 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.
Route Config Key Reference
Section titled “Route Config Key Reference”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
Section titled “Pipeline Keys”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 Option Keys
Section titled “Route Option Keys”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
Section titled “Annotation Keys”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.
Application Middleware
Section titled “Application Middleware”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});Route Options
Section titled “Route Options”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. |
noSend — Manual Response Control
Section titled “noSend — Manual Response Control”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(); }); },});Lifecycle Hooks
Section titled “Lifecycle Hooks”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 | PromiseThe 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.
Execution Order
Section titled “Execution Order”For pipeline-routed responses:
- Route-level hook fires first (if defined)
- 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).
responseInfo Snapshot
Section titled “responseInfo Snapshot”| 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) |
Transport-Level Hooks
Section titled “Transport-Level Hooks”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.
Header Redaction
Section titled “Header Redaction”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.
Default Redacted Headers
Section titled “Default Redacted Headers”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,});Response Timing
Section titled “Response Timing”createRouter({timing: true}) injects an X-Response-Time header on
every response, measuring pipeline execution time in milliseconds.
Options
Section titled “Options”| 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 |
Example Response Header
Section titled “Example Response Header”HTTP/1.1 200 OKx-response-time: 12.345Content-Type: application/jsonThe 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.
What is Measured
Section titled “What is Measured”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.456For standalone (non-router) usage of the timing option, see the
handler middleware guide.
Conditional Requests
Section titled “Conditional Requests”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:
- GET → response includes
ETagheader (automatic viasend()) - PUT/PATCH with valid
If-Match→ update succeeds (200) - PUT/PATCH with stale
If-Match→412 Precondition Failed(evaluated bysend()) - PUT/PATCH without
If-Match→428 Precondition Required(enforced bypreconditionRequired)
Declarative Example
Section titled “Declarative Example”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 ETagPagination
Section titled “Pagination”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).
Offset Pagination
Section titled “Offset Pagination”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: 42Cursor Pagination
Section titled “Cursor Pagination”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.
OpenAPI Generation
Section titled “OpenAPI Generation”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',});Options
Section titled “Options”| 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 |
Auto-Extraction
Section titled “Auto-Extraction”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.queryschemas - Request body — from
validate.bodyon 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 Annotation Key
Section titled “The openapi Annotation Key”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
Section titled “Sub-Routers”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.
mount()
Section titled “mount()”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 /usersBehavioral Rules
Section titled “Behavioral Rules”| 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 Shutdown
Section titled “Graceful Shutdown”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'); },});Options
Section titled “Options”| 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. |
Return Value
Section titled “Return Value”Returns Promise<{server, shutdown}>:
server— thehttp.Serverinstanceshutdown— a function to trigger graceful shutdown programmatically (useful in tests)
Usage Examples
Section titled “Usage Examples”Custom Logger
Section titled “Custom Logger”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});Testing with Programmatic Shutdown
Section titled “Testing with Programmatic Shutdown”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.
Related Packages
Section titled “Related Packages”- ergo — core middleware toolkit
- ergo-wire — shared HTTP wire-format primitives
- ergo-fetch — RFC-compliant HTTP client for ergo-router APIs