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,});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) when the pipeline throws. Overrides router-level catchHandler. |
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(); }); },});Pagination
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, before route matching |
| 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.