@produck/compose
v0.3.0
Published
Compose a list of handlers(functions) as a new handler.
Downloads
135
Readme
@produck/compose
Compose a list of handlers (functions) into a single middleware handler, inspired by koa-compose.
Overview
@produck/compose is a fixed-protocol handler composer. It assembles
multiple handler functions into a single callable workflow, where each handler
follows an invariant signature: (context, next) => R.
Unlike general-purpose pipeline or stream libraries, the protocol is not configurable — every handler receives exactly two arguments (a shared context and a control function), and the control flow follows a strict sequential model. This fixed protocol is the core constraint that makes the library predictable and composable.
Installation
npm install @produck/composeUsage
Start with the quick middleware chain in Quick start.
Protocol
The compose protocol is defined by two contracts:
- Handler —
(context: T, next: Next) => R - Next —
(context?: T) => unknown
Each handler receives the current context and a next function to pass
control downstream. Handlers execute in registration order on the way "down"
and in reverse order on the way "back up" (the onion / Koa model).
Execution flow
sequenceDiagram
participant C as caller
participant W as workflow
participant H0 as handler[0]
participant H1 as handler[1]
participant D as done
C->>W: workflow(context, done)
activate W
W->>H0: handler[0](context, next)
activate H0
Note over H0: pre
H0->>H1: next(newContext)
activate H1
Note over H1: pre
H1->>D: next()
activate D
D-->>H1: return
deactivate D
Note over H1: post
H1-->>H0: return
deactivate H1
Note over H0: post
H0-->>W: return
deactivate H0
W-->>C: return
deactivate WRules
- A handler may call
next()zero or one time. Calling it more than once throws an error. See single-call rule. - Code before
next()runs in registration order (downstream). - Code after
next()runs in reverse registration order (upstream). contextis the same object for all handlers — mutations are visible to every handler in the chain. See shared context adaptation.
The nature of next
next is a bare continuation — its guaranteed behavior is to invoke the
next handler in the chain and return its value. Compose itself does not define
what next() should return, whether it should be awaited, or what to do with
its result. See next return semantics.
When called with a newContext argument, that value replaces the current
context for all downstream handlers — see
context replacement. Each handler decides for
itself what next() means — whether to await it, assert its return value,
catch its rejection, or skip calling it altogether. Compose does not impose a
global contract on next's return semantics. This is not a limitation; it is
a deliberate property of the design: the meaning of next is defined
locally, by the handler that calls it, not globally by the composer.
Protocol adaptation via context
Because context is an opaque object shared by reference, the fixed
(context, next) signature can express a wide range of middleware protocols
by wrapping multi-argument signatures into a single context object:
| Original protocol | Adapted as context shape |
| ----------------------- | ------------------------- |
| (req, res, next) | ctx = { req, res } |
| (value, next) | ctx = { value } |
| (err, data, next) | ctx = { err, data } |
| (message, meta, next) | ctx = { message, meta } |
The only truly fixed element is next itself — a continuation function that
may optionally receive a new context to replace the current one for downstream
handlers. Everything else lives in context and is entirely caller-defined.
See context adaptation example.
In practice, most "protocol differences" across middleware systems are just
differences in how arguments are organized — they collapse naturally into a
shared context object. This is why a fixed-protocol composer like
@produck/compose can cover far more scenarios than its simple signature
might suggest.
API
compose(...handlers)
Composes zero or more handler functions into a single middleware function.
handlers— zero or more functions matching theHandlersignature.- Returns:
(context, done?) => anycontext— any value passed through the chain.done— optional final callback invoked only when the chain reaches the terminal link vianext()(default: no-op).
Handler<T, R>
type Handler<T, R> = (context: T, next: Next) => R;Next
type Next<T = unknown> = (context?: T) => unknown;When called without arguments, the current context is passed through to the next handler. When called with a value, that value becomes the context for all downstream handlers — see context replacement example.
The library does not restrict the type of the replacement value — it is possible to pass a completely different context shape to downstream handlers. This is uncommon in practice but remains a valid use of the protocol.
Application scenarios
Use the table below to jump from scenario to the closest runnable example.
| Scenario | Typical fit | Example | | ------------------------------------ | ------------------------------------------------------- | ----------------------------------------------------------- | | HTTP middleware pipelines | Logging, auth, parsing, routing in onion order | Quick start | | Lifecycle hooks | Ordered phases like connect -> migrate -> seed -> ready | With a done callback | | Plugin / extension chains | Wrap core behavior with metrics, cache, validation | Branching / forking | | Context isolation | Derive new context per scope without mutating parent | Context replacement | | Data transformation pipelines | Mutate shared context across sequential steps | Nested composition | | Conditional routing | Select different downstream paths by runtime state | Conditional downstream dispatch | | Nested composition (context slicing) | Split large flows into focused sub-workflows | Nested composition |
Usage limitations
Fixed protocol is not general-purpose
The (context, next) signature is designed for the onion model. If you need
arbitrary argument shapes, named hooks, event emitters, or configurable
middleware signatures, @produck/compose is not the right tool.
Single-call constraint per handler
Each handler may invoke next() only once. This prevents ambiguous control
flow, but precludes patterns like fan-out or multicast without explicit
wrapper handlers. See single-call rule.
No automatic error propagation
Errors thrown in a handler must be caught by an outer handler wrapping
next(). There is no built-in error middleware — error handling is explicit.
See error boundary example.
Synchronous by default
All handlers execute synchronously unless they return a Promise (or use
async). If one handler is async, all upstream handlers must await next()
to preserve ordering. See next return semantics.
Shared mutable context
context is passed by reference to every handler. Accidental mutation in one
handler can affect downstream or upstream handlers. Use
context replacement to pass a derived or
immutable object without mutating the original, or adopt defensive copying /
immutable patterns for complex workflows. See
nested composition.
Examples
Example index:
- Quick start
- With a done callback
- Lifecycle hooks (same example)
- Single-call rule
next()return semantics- Context adaptation
- Context replacement
- Branching / forking
- Plugin / extension chains (same example)
- Conditional downstream dispatch
- Nested composition (context slicing)
- Data transformation pipeline (same example)
- Workflow nesting
- Error boundary
- TypeScript
Quick start
import { compose } from '@produck/compose';
const middleware = compose(
(ctx, next) => {
console.log('-> first');
next();
console.log('<- first');
},
(ctx, next) => {
console.log('-> second');
next();
console.log('<- second');
},
);
middleware({});
// -> first
// -> second
// <- second
// <- first
With a done callback
compose(
(_, next) => next(),
(_, next) => next(),
)({}, () => console.log('done'));Single-call rule
const middleware = compose((_, next) => {
next();
next(); // throws: next() called multiple times
});
middleware({});next() return semantics
// Handler A validates next's return
const a = async (ctx, next) => {
const result = await next();
console.assert(result === 'ok');
};
// Handler B ignores next's return entirely
const b = (ctx, next) => {
next();
return 'early';
};
// Handler C wraps next in error handling
const c = async (ctx, next) => {
try {
return await next();
} catch (err) {
return 'fallback';
}
};Context adaptation
// Express-style middleware adapted via context
compose((ctx, next) => {
console.log(ctx.req.url);
next();
})({ req, res });Context replacement
Pass a new context to next() to replace the current context for downstream
handlers, without mutating the original object.
const workflow = compose(
(ctx, next) => {
// Derive a new context instead of mutating the original
next({ ...ctx, phase: 'processing' });
},
(ctx, next) => {
console.log(ctx.phase); // 'processing'
next();
},
);
workflow({ phase: 'init' });
Branching / forking
const workflowA = compose(fnA);
const workflowB = compose(fnB);
const workflow = compose((ctx, next) => {
return ctx.flag ? workflowA(ctx, next) : workflowB(ctx, next);
});Conditional downstream dispatch
Use nested composition when you need to route to different "next-level" handlers by condition.
const paidPath = compose(
(ctx, next) => {
ctx.steps.push('validate-payment');
next();
},
(ctx, next) => {
ctx.steps.push('charge-card');
next();
},
);
const freePath = compose((ctx, next) => {
ctx.steps.push('skip-payment');
next();
});
const checkout = compose(
(ctx, next) => {
return ctx.total > 0 ? paidPath(ctx, next) : freePath(ctx, next);
},
(ctx, next) => {
ctx.steps.push('finalize-order');
next();
},
);
const ctx = { total: 100, steps: [] };
checkout(ctx);
// ctx.steps = ['validate-payment', 'charge-card', 'finalize-order']
Nested composition (context slicing)
// Pricing sub-workflow — only cares about pricing data
const calcPrice = compose(
(ctx, next) => {
ctx.subtotal = ctx.quantity * ctx.unitPrice;
next();
},
(ctx, next) => {
ctx.tax = ctx.subtotal * 0.1;
next();
},
(ctx, next) => {
ctx.total = ctx.subtotal + ctx.tax;
next();
},
);
// Validation sub-workflow — only cares about order integrity
const validate = compose(
(ctx, next) => {
if (!ctx.items?.length) {
throw Error('empty order');
}
next();
},
(ctx, next) => {
ctx.items.forEach((v) => (v.checked = true));
next();
},
);
// Notification sub-workflow — only cares about delivery
const notify = compose(
(ctx, next) => {
ctx.log.push('email queued');
next();
},
(ctx, next) => {
ctx.log.push('sms queued');
next();
},
);
// Top-level: each sub-composition receives its own context slice
const placeOrder = compose(
(ctx, next) => validate({ items: ctx.orderItems }, next),
(ctx, next) => calcPrice(ctx.priceCtx, next),
(ctx, next) => notify(ctx.notifyCtx, next),
);
placeOrder({
orderItems: [{ sku: 'A', qty: 2 }],
priceCtx: { quantity: 2, unitPrice: 100 },
notifyCtx: { log: [] },
});
// -> notifyCtx.log === ['email queued', 'sms queued']
// -> priceCtx.total === 220Workflow nesting
A composed workflow is itself a valid handler — it matches the
(context, next) signature. This means you can pass a workflow directly as a
handler to another compose() call, creating a nested execution boundary
without any wrapper function.
const greet = compose(
(ctx, next) => {
ctx.log.push('hello');
next();
ctx.log.push('bye');
},
(ctx, next) => {
ctx.log.push('world');
next();
},
);
const main = compose(
(ctx, next) => {
ctx.log.push('main:pre');
next();
ctx.log.push('main:post');
},
greet, // ← composed workflow used directly as a handler
);
const result = { log: [] };
main(result);
// result.log = ['main:pre', 'hello', 'world', 'bye', 'main:post']Execution flow:
sequenceDiagram
participant C as caller
participant WM as main:workflow
participant M0 as main:handler[0]
participant WG as greet:workflow
participant G0 as greet:handler[0]
participant G1 as greet:handler[1]
participant D as done
C->>WM: main(context, done)
activate WM
WM->>M0: handler[0](context, next)
activate M0
Note over M0: push 'main:pre'
M0->>WG: greet(context, mainNext)
activate WG
WG->>G0: handler[0](context, next)
activate G0
Note over G0: push 'hello'
G0->>G1: next()
activate G1
Note over G1: push 'world'
G1->>D: next()
activate D
D-->>G1: return
deactivate D
Note over G1: post
G1-->>G0: return
deactivate G1
Note over G0: push 'bye'
G0-->>WG: return
deactivate G0
WG-->>M0: return
deactivate WG
Note over M0: push 'main:post'
M0-->>WM: return
deactivate M0
WM-->>C: return
deactivate WMError boundary
compose(
async (_, next) => {
try {
await next();
} catch (err) {
console.error('caught:', err);
}
},
async () => {
throw new Error('boom');
},
);TypeScript
import type { Handler } from '@produck/compose';
const handler: Handler<{ user: string }, Promise<void>> = async (ctx, next) => {
console.log(ctx.user);
await next();
};License
MIT
