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 middleware’s declared path (intrinsic or explicitly specified via{fn, setPath})responsemerges into the response accumulator (headers append, scalar properties overwrite)- A
responsewithstatusCodebreaks the pipeline immediately — no further middleware executes
Composition Format
Section titled “Composition Format”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}}));Sequential vs. Parallel Composition
Section titled “Sequential vs. Parallel Composition”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.
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/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 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.
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
- ergo-wire package — shared wire-format primitives
- ergo-fetch package — HTTP client for ergo-router APIs