Skip to content

Architecture

ergo is composed of two packages that work together to provide a complete REST API toolkit:

Package Responsibility
@centralping/ergo Core middleware library (auth, body, CORS, send, etc.)
@centralping/ergo-router REST-compliant routing and transport-level concerns

@centralping/ergo is a library, not a runnable server. It provides composable middleware functions that can be used standalone or assembled by @centralping/ergo-router into a full HTTP server with declarative route configuration.

Every ergo pipeline uses a two-accumulator composition model powered by compose-with. Two objects travel through the middleware chain:

  • Domain accumulator — inter-middleware data (parsed body, auth identity, cookies, URL parameters)
  • Response accumulator — HTTP response properties (status code, headers, body, error details)
Request
┌────────────────────────────────────────────────┐
│ compose-with pipeline │
│ │
│ domainAcc = {} responseAcc = {} │
│ │
│ middleware₁ → { value?, response? } │
│ value → merges into domainAcc at setPath │
│ response → merges into responseAcc │
│ │
│ middleware₂ → { value?, response? } │
│ ... │
│ │
│ BREAK when responseAcc.statusCode is set │
│ │
└────────────────────────────────────────────────┘
send(req, res, responseAcc, domainAcc)

Every middleware function returns {value?, response?}:

  • value merges into the domain accumulator at the middleware’s declared path (intrinsic or explicitly specified via {fn, setPath})
  • response merges into the response accumulator (headers append, scalar properties overwrite)
  • A response with statusCode breaks the pipeline immediately — no further middleware executes

Built-in domain-producing middleware factories carry an intrinsic accumulator path — compose them as bare functions. Custom middleware and overrides use {fn, setPath} config objects:

const pipeline = compose(
logger(),
cors(),
accepts({types: ['application/json']}),
authorization({strategies: [...]}),
body(),
(req, res, acc) => ({response: {body: acc.body.parsed}})
);

For custom middleware that produces domain data, use the explicit {fn, setPath} form:

const pipeline = compose(
logger(),
{fn: myCustomMiddleware(), setPath: 'custom'},
(req, res, acc) => ({response: {body: acc.custom}})
);

compose() runs middleware sequentially with a sync fast-path — when a middleware returns a non-Promise value, the next middleware executes immediately with no microtask overhead. Of ergo’s 20 built-in middleware, 18 are synchronous header checks or response decorators. Sequential composition eliminates unnecessary Promise.all microtask hops and accumulator copies for these synchronous functions.

compose.all() runs middleware in parallel via Promise.all. Each branch receives an isolated domain-accumulator copy, and all branches always run to completion — there is no mid-flight pipeline break when one sets statusCode. Use it only for independent async I/O with no live domain-accumulator dependencies between branches.

ergo-router’s four pipeline stages remain sequential (compose() / compose-with). compose.all() is a low-level primitive for custom standalone composition — not a drop-in replacement for stage ordering.

Composer Execution When to use
compose() Sequential, sync fast-path Default — most pipelines; required when middleware can short-circuit or depend on prior acc values
compose.all() Parallel via Promise.all 2+ independent async I/O calls with no cross-branch acc dependencies
import {compose} from '@centralping/ergo';
const sequential = compose(
logger(),
cors(),
accepts({types: ['application/json']}),
url()
);
// Two independent network round-trips. Both always run — a rate-limit
// rejection does not cancel the IdP call.
const parallel = compose.all(
rateLimit({max: 100, windowMs: 60_000, store: redisStore}),
authorization({strategies: [externalIdpStrategy]})
);

Profiling under 50 concurrent virtual users showed compose.all() is 2× slower than sequential for synchronous middleware due to Promise.all microtask overhead. The sync fast-path in compose() is the reason sequential is the default — it matches the performance characteristics of the middleware inventory.

The Fast Fail Pipeline arranges middleware into four ordered stages. The ordering is not arbitrary — it reflects a deliberate cost/risk gradient:

  1. Negotiation — cheap, stateless header and URL inspection. Logger runs first for request ID tracing. All other Stage 1 middleware are parallelizable since they read different parts of the request independently.
  2. Authorization — crypto and token verification. Runs before body parsing to avoid processing large payloads for unauthorized callers.
  3. Validation — the most expensive pre-execution work (body stream I/O, JSON Schema validation). Only runs for authenticated requests with negotiable content types.
  4. Execution — business logic. By this point the request has been fully vetted.

When any middleware sets responseAcc.statusCode, the serial loop breaks immediately. This replaces throw httpErrors() for expected outcomes (rate limit, auth failure, validation error), eliminating async throw/catch overhead.

handler.js (standalone) or auto-wrap.js (ergo-router) creates both accumulators, runs the pipeline, catches unexpected errors, and calls send(req, res, responseAcc, domainAcc) exactly once. send() is the sole authority for HTTP response formatting, including RFC 9457 error bodies.

Every http/ module exports a factory function that returns a middleware function. Configuration is captured at factory time; the returned function receives (req, res, domainAcc, responseAcc) at request time.

// Factory — called once at startup
const rateLimiter = rateLimit({max: 100, windowMs: 60_000});
// Middleware — called per request
// Returns { response: { statusCode: 429, retryAfter } } or headers
rateLimiter(req, res, domainAcc, responseAcc);

This pattern enables:

  • Zero per-request allocation for configuration
  • Middleware reuse across routes with different options
  • Declarative pipeline assembly in ergo-router’s pipeline builder

Core logic for cross-cutting concerns lives in lib/ as shared primitives — pure functions and classes with no transport or framework dependencies. Both ergo’s pipeline middleware (http/) and ergo-router’s transport layer consume the same primitives:

Shared Primitive Ergo Consumer Ergo-Router Consumer
lib/cors.js http/cors.js transport/cors.js
lib/rate-limit.js http/rate-limit.js transport/rate-limit.js
lib/security-headers.js http/security-headers.js transport/security-headers.js

This split eliminates code duplication between the two packages. Standalone ergo users get the primitives directly; ergo-router users get transport-level adapters that wrap the same underlying logic.

Directory Contains Dependencies
lib/ Pure logic: parsers, validators, stores, formatters Node.js built-ins only
http/ Pipeline middleware factories lib/ primitives + request/response types
utils/ Composition utilities, iterables, streams, buffers Node.js built-ins only

Both packages use "type": "module" (pure ESM). All source files use import/export with no require() or module.exports calls.

Node.js 22.12+ supports require(esm) (unflagged) as long as there is no top-level await. Both packages intentionally avoid top-level await, so CJS consumers on Node 22.12+ can require('@centralping/ergo') without any adapter.

The pipeline and utilities are built on Node.js streams, async iterators, and generators rather than callback chains:

Utility Purpose
utils/compose.js Keyed result accumulation with breakWhen predicate
utils/compose-with.js Two-accumulator path-based composition
utils/iterables/ Async iterable primitives (map, filter, reduce, chain, take, range)
utils/streams/ Node.js stream utilities (meter, tee)
utils/buffers/ Buffer-level KMP match/split for multipart parsing

ergo-router’s pipeline-builder.js assembles middleware into the four-stage pipeline from a declarative configuration object. Each route specifies which middleware to include and their options:

{
accepts: {types: ['application/json']},
authorization: {strategies: [bearerStrategy]},
body: true,
validate: {body: schema},
execute: handler
}

The builder resolves route-level overrides against router-level defaults, auto-includes method-appropriate middleware (url for GET/DELETE, body for POST/PUT/PATCH), and assembles the pipeline for compose-with. Built-in factories carry intrinsic accumulator paths; the builder constructs explicit {fn, setPath} config objects internally for any middleware that needs a path override.