Architecture
Overview
Section titled “Overview”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.
Two-Accumulator Pipeline Model
Section titled “Two-Accumulator Pipeline Model”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)Middleware Return Convention
Section titled “Middleware Return Convention”Every middleware function returns {value?, response?}:
valuemerges into the domain accumulator at the path specified by the config object’ssetPathpropertyresponsemerges into the response accumulator (headers append, scalar properties overwrite)- A
responsewithstatusCodebreaks the pipeline immediately — no further middleware executes
Composition Format
Section titled “Composition Format”Middleware are registered as {fn, setPath} config objects that pair the middleware
function with its domain accumulator key. Response-only middleware (those that
contribute only headers, not domain data) are plain functions — no wrapper needed:
const pipeline = compose( {fn: logger(), setPath: 'log'}, cors(), {fn: accepts({types: ['application/json']}), setPath: 'accepts'}, {fn: authorization({strategies: [...]}), setPath: 'auth'}, {fn: body(), setPath: 'body'}, (req, res, acc) => ({response: {body: acc.body.parsed}}));Four-Stage Fast Fail Ordering
Section titled “Four-Stage Fast Fail Ordering”The Fast Fail Pipeline arranges middleware into four ordered stages. The ordering is not arbitrary — it reflects a deliberate cost/risk gradient:
- 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.
- Authorization — crypto and token verification. Runs before body parsing to avoid processing large payloads for unauthorized callers.
- Validation — the most expensive pre-execution work (body stream I/O, JSON Schema validation). Only runs for authenticated requests with negotiable content types.
- Execution — business logic. By this point the request has been fully vetted.
Pipeline Break
Section titled “Pipeline Break”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.
send() Is Outside the Pipeline
Section titled “send() Is Outside the Pipeline”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.
Middleware Factory Pattern
Section titled “Middleware Factory Pattern”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 startupconst rateLimiter = rateLimit({ max: 100, windowMs: 60_000 });
// Middleware — called per request// Returns { response: { statusCode: 429, retryAfter } } or headersrateLimiter(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
Shared Primitives (lib/)
Section titled “Shared Primitives (lib/)”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.
lib/ vs http/ Boundary
Section titled “lib/ vs http/ Boundary”| 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 |
Module System
Section titled “Module System”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.
Stream and Iterator Architecture
Section titled “Stream and Iterator Architecture”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/observables/ | Observable-style async primitives |
utils/streams/ | Node.js stream utilities (meter, tee) |
utils/buffers/ | Buffer-level KMP match/split for multipart parsing |
Declarative Pipeline Assembly
Section titled “Declarative Pipeline Assembly”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 produces a {fn, setPath} config object array ready for
compose-with.
Further Reading
Section titled “Further Reading”- Accumulator Reference — complete per-middleware shape reference
- Fast Fail Pipeline — detailed stage-by-stage walkthrough
- Standards Compliance — RFC conformance across all middleware
- Security — OWASP API Top 10 mapping
- ergo package — middleware reference
- ergo-router package — routing and transport reference