Skip to content

ergo-router

npm version

Node.js ≥ 22 Pure 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.

BehaviorStandardDescription
405 + AllowRFC 9110 §15.5.6Requests to a known path with an unsupported method receive 405 with an Allow header listing valid methods
HEAD derivationRFC 9110 §9.3.2HEAD requests automatically derive from GET handlers
OPTIONSRFC 9110 §9.3.7OPTIONS requests return Allow header with supported methods
PATCH requiredCustomRoutes with PUT must also register PATCH (or explicitly opt out)
MiddlewareDescriptionStandard
Security headersHSTS, CSP, X-Content-Type-OptionsRFC 6797
CORSCross-Origin Resource SharingFetch Standard
Rate limitingGlobal rate limiter with 429 responsesRFC 6585 §4
Request IDX-Request-Id generation and propagationConvention

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 valueDefault valueResolved valueEffect
undefined (omitted){...}{...}Inherits default
falseanydisabledExplicitly disables — removes from pipeline
trueany{}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,
});

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.

OptionTypeDefaultDescription
noSendbooleanfalseSkip the automatic send() call after the pipeline completes. The handler is responsible for writing the full HTTP response.
sendobjectPer-route options passed to send(). Overrides router-level send defaults.
catchHandlerfunctionPer-route error handler. Receives (req, res, err) when the pipeline throws. Overrides router-level catchHandler.

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();
});
},
});

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",
});
OptionTypeDefaultDescription
titlestring'API'API title for the info object
versionstring'1.0.0'API version for the info object
descriptionstringAPI description
serversobject[]Server objects for the servers array
infoobjectAdditional 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
AspectBehaviorExplanation
Copy semanticsRoutes are copied at mount timeAdding routes to the child after mount() has no effect on the parent
Defaults isolationChild defaults stay with the childParent defaults do not merge into child routes; child routes use the defaults they were registered with
TransportParent-governedTransport 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 scopingParent router.use() does not apply to mounted routes; child router.use() is baked into child pipelines at registration time
strictPatch / strictBodyParent-governedContent-Type enforcement is applied at parent dispatch, before route matching
OrderingRegister routes before mountingRoutes must be added to the child router before calling parent.mount()
Chainablemount() returns the parentAllows 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");
},
});
OptionTypeDefaultDescription
portnumber3000Port to listen on
hostnamestring'0.0.0.0'Hostname to bind to
logobjectconsoleLogger with .info(), .warn(), .error() methods
signalsstring[]['SIGINT', 'SIGTERM']OS signals that trigger shutdown
timeoutnumber5000Maximum time (ms) to wait for connections to drain before forcing exit
exitfunctionprocess.exitExit function (override for testing)
onStartupfunctionAsync hook called before server.listen(). Receives {log}. Rejection prevents the server from starting.
onShutdownfunctionAsync 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.