@nwire/hooks
v0.15.1
Published
Nwire — the universal dispatch primitive. One hook() accepts both .use() (sequential koa-compose chain) and .on() (parallel listener) attachments. Lazy-memoized chain compose, per-step observation tap, AsyncLocalStorage-linked run topology. In-process onl
Readme
@nwire/hooks
The universal dispatch primitive. One hook() accepts both a sequential chain (.use) and parallel listeners (.on); every run is observable end-to-end and cancellable via AbortSignal.
pnpm add @nwire/hooksWhy
Every framework reinvents the same three patterns: middleware chains, event listeners, lifecycle hooks. They all need ordering, cancellation, tracing, and replay. @nwire/hooks is the one substrate the rest of Nwire builds on — handlers, transports, plugin lifecycle, framework events all use the same primitive.
About 100 lines of surface; sub-microsecond per-step overhead; standalone.
Surface
import { hook } from "@nwire/hooks";
const onRequest = hook<RequestCtx>("app.onRequest");
onRequest.use(fn, { priority?, name? }); // chain middleware — sequential, can short-circuit
onRequest.on(fn, { when?, priority? }); // listener — parallel, observes final ctx
onRequest.off(fn); // detach
onRequest.tap(observer); // subscribe to per-step observations
await onRequest.run(ctx, { signal? }); // run chain + listeners
await onRequest.runDetailed(ctx, opts); // same + return full step traceEvery ctx that flows through a hook gets two fields injected (non-enumerable, so structuredClone walks past them):
interface BaseHookCtx {
readonly signal: AbortSignal; // caller-supplied or non-aborting placeholder
set<K, V>(key: K, value: V): asserts this is this & { [P in K]: V };
}signal is the cooperative cancellation contract — every async step can forward it (fetch(url, { signal }), pg.query(…, { signal })) and react via signal.throwIfAborted().
.set() is the typed widening primitive — middleware contributes a new field, the TS assertion narrows ctx for downstream steps.
Consumer example
import { hook } from "@nwire/hooks";
type ReqCtx = { url: string; method: string; status?: number };
const onRequest = hook<ReqCtx>("app.onRequest");
// Logger — plain async fn, wraps + measures
onRequest.use(async (ctx, next) => {
const t = performance.now();
console.log(`→ ${ctx.method} ${ctx.url}`);
await next();
console.log(`← ${ctx.status} (${(performance.now() - t).toFixed(1)}ms)`);
});
// Auth — contributes via typed .set, short-circuits on rejection
onRequest.use(
async (ctx, next) => {
const token = readToken(ctx.url);
if (!token) {
ctx.set("status", 401); // short-circuit (no next())
return;
}
ctx.set("userId", await verify(token)); // ctx widened to include userId
await next();
},
{ priority: 100 },
);
// Metrics — listener, never mutates
onRequest.on((ctx) => metrics.tick(`http.${ctx.method}.${ctx.status}`));
await onRequest.run({ url: "/api/orders", method: "GET" });Cancellation in practice
const ac = new AbortController();
setTimeout(() => ac.abort(new Error("user cancelled")), 5000);
await onRequest.run(ctx, { signal: ac.signal });
// inside any step: signal.throwIfAborted() bails;
// the abort propagates through nested .run() calls tooCombine with timeouts:
const timeout = AbortSignal.timeout(2000);
await onRequest.run(ctx, { signal: AbortSignal.any([userSignal, timeout]) });Observability
.tap(observer) emits a StepObservation per chain/listener phase (start / end / error) with timing, runId, parentRunId. runDetailed() returns the full trace + outcome. Used by @nwire/scan to emit .nwire/hooks.json and by Studio for live tap streams.
Used by
@nwire/handler is built on hook<HandlerRunCtx>(). @nwire/forge runtime, framework-event bus, plugin lifecycle, and every transport in Nwire share the same substrate.
Scope
Standalone, in-process. Cross-process dispatch lives in @nwire/bus. Use this for middleware chains, lifecycle hooks, and pub-sub-within-process.
