@nwire/hooks 0.7.1 → 0.8.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,57 +1,745 @@
1
1
  # @nwire/hooks
2
2
 
3
- > Universal dispatch primitive. One `hook()` accepts both `.use()` (sequential chain) and `.on()` (parallel listener) attachments.
3
+ > The universal dispatch primitive. Every middleware, lifecycle phase, event
4
+ > subscription, plugin extension, and dev-tool tap in Nwire is built from one
5
+ > shape: a named hook that runs a koa-compose chain first, then fans out
6
+ > parallel listeners. Auditable, traceable, replayable, topology-aware.
4
7
 
5
- ## What it is
8
+ ```bash
9
+ pnpm add @nwire/hooks
10
+ ```
6
11
 
7
- Every middleware, lifecycle phase, event subscription, and plugin attachment in Nwire is built from one primitive: a named hook that runs a koa-compose chain first, then a parallel listener fan-out via `Promise.allSettled`. Built on [emittery](https://github.com/sindresorhus/emittery) (~3KB) plus a ~30 LOC composer. No other Nwire package required.
12
+ ---
8
13
 
9
- ## Install
14
+ ## TL;DR
10
15
 
11
- ```bash
12
- pnpm add @nwire/hooks
16
+ ```ts
17
+ import { hook } from "@nwire/hooks";
18
+
19
+ const request = hook<{ url: string; user?: string }>("http.request");
20
+
21
+ request.use(async (ctx, next) => {
22
+ ctx.user = await auth(ctx);
23
+ await next();
24
+ });
25
+ request.use(
26
+ async (ctx, next) => {
27
+ /* metrics */ await next();
28
+ },
29
+ { priority: 100 },
30
+ );
31
+ request.on((ctx) => analytics.track(ctx), { when: "success" });
32
+
33
+ await request.run({ url: "/api/x" });
34
+ ```
35
+
36
+ Two attachment kinds, one primitive:
37
+
38
+ - **`.use(fn)`** — chain step. koa-compose-shaped `(ctx, next)`. Can mutate ctx.
39
+ Can short-circuit by not calling `next()`. Throws bubble up.
40
+ - **`.on(fn)`** — listener. Observer `(ctx)`. Cannot mutate. Cannot bail.
41
+ Fired in parallel via `Promise.allSettled` after the chain settles.
42
+
43
+ Everything else — telemetry taps, source capture, run-id linkage, recording,
44
+ priorities, success/failure filters — sits on top of those two without
45
+ changing them.
46
+
47
+ ---
48
+
49
+ ## Why this exists
50
+
51
+ The Nwire stack used to ship six overlapping middleware/hook substrates —
52
+ one per package, each with its own composer, error policy, and observability
53
+ story. `@nwire/hooks` is the single primitive they all reduce to:
54
+
55
+ | Substrate | Maps to |
56
+ | --------------------------------------------- | ------------------------------------------------------------- |
57
+ | `@nwire/handler` `defineMiddleware` + `pipe` | chain via `.use()` |
58
+ | `@nwire/handler` `defineHook("after", fn)` | listener via `.on(fn, { when: "success" })` |
59
+ | `@nwire/forge` `runtime.use(mw)` | chain via `.use()` on the dispatch hook |
60
+ | `@nwire/http` `httpInterface().use()` | chain via `.use()` on the http-request hook |
61
+ | `@nwire/http` per-route `middleware: […]` | per-route hook + chain |
62
+ | `definePlugin({ middleware, actorHooks })` | chain (middleware) + listener (actorHooks) |
63
+ | `definePlugin({ before, after })` | chain veto (`before`) + filtered listener (`after`) |
64
+ | `@nwire/app` framework events — `parallel` | listener fan-out |
65
+ | `@nwire/app` framework events — `series` | chain without bail |
66
+ | `@nwire/app` framework events — `series-bail` | chain with `.runDetailed()` reading `outcome === "prevented"` |
67
+
68
+ If you're building a new extension point: it's a hook.
69
+
70
+ ---
71
+
72
+ ## The contract — behavior matrix
73
+
74
+ | Question | Answer |
75
+ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
76
+ | Chain ordering | Higher `priority` first (outermost). Stable on ties — equal priority preserves insertion order. |
77
+ | Listener ordering | Higher `priority` first. Listeners run in parallel; ordering is _attempt_ order only. |
78
+ | What if a chain step doesn't call `next()`? | Chain short-circuits. `outcome === "prevented"`. Listeners still fire (subject to `when`). |
79
+ | What if a chain step throws? | `.run()` rejects with the error. `outcome === "failed"`. Listeners still fire (subject to `when`). `ctx.error` is set. |
80
+ | What if a listener throws? | Reported via `onListenerError` (default: `console.error`). The run does **not** fail. Opt in with `strictListeners: true`. |
81
+ | Can a listener cancel the run? | No. |
82
+ | Can a chain step mutate ctx? | Yes. Listeners see the post-chain ctx. |
83
+ | Can listeners run before the chain finishes? | No. |
84
+ | Is `next()` allowed to be called twice? | No — throws `Error("next() called multiple times in @nwire/hooks chain")`. |
85
+ | Are taps observers or participants? | Observers. Tap throws are swallowed. |
86
+ | Run-id propagation across nested `.run()` calls? | Automatic via `AsyncLocalStorage`. Inner run's `parentRunId` = outer run's `runId`. |
87
+ | Is recording side-effect free? | No — it executes the hook normally and snapshots ctx before + after. Replay re-executes the chain. |
88
+
89
+ ---
90
+
91
+ ## Core API
92
+
93
+ ### `hook(name, options?)`
94
+
95
+ ```ts
96
+ import { hook } from "@nwire/hooks";
97
+
98
+ const lifecycle = hook<EndpointCtx>("endpoint.boot", {
99
+ strictListeners: false, // default
100
+ onListenerError: (err, ctx, hookName) => logger.error(...),
101
+ });
102
+ ```
103
+
104
+ Creates a fresh hook. Captures the call-site source location for Studio +
105
+ `nwire scan`. Adds it to the process-wide registry so `listHooks()` sees it.
106
+
107
+ ### `Hook<Ctx>` surface
108
+
109
+ ```ts
110
+ interface Hook<Ctx> {
111
+ readonly name: string;
112
+ readonly id: string; // process-unique
113
+
114
+ use(fn: ChainFn<Ctx>, opts?: UseOptions): this; // chain step
115
+ on(fn: ListenerFn<Ctx>, opts?: OnOptions): this; // listener
116
+ off(fn): this; // detach
117
+
118
+ run(ctx: Ctx, opts?: RunOptions): Promise<Ctx>;
119
+ runDetailed(ctx, opts?): Promise<RunResult<Ctx>>;
120
+
121
+ tap(observer): () => void; // per-step trace
122
+ stepCounts(): { chain: number; listeners: number };
123
+ }
124
+ ```
125
+
126
+ #### `UseOptions` / `OnOptions`
127
+
128
+ ```ts
129
+ interface UseOptions {
130
+ name?: string; // human label — shown in taps, OTel, Studio
131
+ priority?: number; // higher = earlier in chain (default 0)
132
+ }
133
+ interface OnOptions extends UseOptions {
134
+ when?: "always" | "success" | "failure"; // default "always"
135
+ }
136
+ ```
137
+
138
+ #### `RunOptions`
139
+
140
+ ```ts
141
+ interface RunOptions {
142
+ signal?: AbortSignal; // injects onto ctx.signal too; thrown if aborted before next step
143
+ parentRunId?: string; // override the ambient parent
144
+ }
145
+ ```
146
+
147
+ #### `RunResult<Ctx>`
148
+
149
+ ```ts
150
+ interface RunResult<Ctx> {
151
+ ctx: Ctx;
152
+ outcome: "completed" | "prevented" | "failed";
153
+ runId: string;
154
+ parentRunId?: string;
155
+ steps: StepObservation[];
156
+ durationMs: number;
157
+ error?: unknown; // set when outcome === "failed"
158
+ }
159
+ ```
160
+
161
+ `run(ctx)` is the simple form. It returns the (mutated) ctx and throws on
162
+ failure — the way every existing call site has worked for years.
163
+ `runDetailed(ctx)` returns the structured result instead. Use it when you
164
+ need the outcome distinction (e.g. framework events that need to know
165
+ "was this prevented?").
166
+
167
+ ### `compose(fns)` / `pipe(...fns)` / `withTimeout(ms, fn)` / `withRetry(opts, fn)`
168
+
169
+ Helpers for assembling reusable chain pieces before attaching them to a hook.
170
+
171
+ ```ts
172
+ import { pipe, withRetry, withTimeout } from "@nwire/hooks";
173
+
174
+ const protected = pipe(
175
+ authenticate,
176
+ withRetry({ attempts: 3, delayMs: 100 }, callExternalApi),
177
+ withTimeout(5_000, persistResult),
178
+ );
179
+ hook.use(protected);
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Observability — what every hook surfaces for free
185
+
186
+ Every hook emits a stream of `StepObservation` events. Subscribe with
187
+ `.tap(observer)` to wire it into:
188
+
189
+ - structured logs (`logger.info(obs)`)
190
+ - Studio Live (push over SSE)
191
+ - `nwire scan` (write to `.nwire/observations.json`)
192
+ - OTel adapter (one span per `start → end` pair)
193
+
194
+ ```ts
195
+ interface StepObservation {
196
+ hookName: string;
197
+ hookId: string;
198
+ runId: string;
199
+ parentRunId?: string;
200
+ stepId: number;
201
+ stepKind: "chain" | "listener";
202
+ stepName?: string; // from UseOptions.name / OnOptions.name
203
+ phase: "start" | "end" | "error";
204
+ ts: number; // performance.now()
205
+ durationMs?: number; // set on end + error
206
+ error?: unknown; // set on error
207
+ }
208
+ ```
209
+
210
+ ### Topology — caller / callee linkage
211
+
212
+ `AsyncLocalStorage` propagates the active `runId` through the chain. When
213
+ your chain step calls `await otherHook.run(...)`, the nested run picks up
214
+ the outer `runId` as its `parentRunId` automatically — no plumbing.
215
+
216
+ ```ts
217
+ import { hook, currentRun } from "@nwire/hooks";
218
+
219
+ const outer = hook<{}>("outer");
220
+ const inner = hook<{}>("inner");
221
+
222
+ outer.use(async (_, next) => {
223
+ await inner.run({}); // inner's parentRunId === outer's runId
224
+ await next();
225
+ });
226
+
227
+ inner.use(async () => {
228
+ console.log(currentRun()); // { runId, hookId, parentRunId }
229
+ });
230
+ ```
231
+
232
+ This is the substrate Studio's Trace page uses to render the causal tree.
233
+
234
+ ### Source location
235
+
236
+ `hook()` captures its call site at construction. `listHooks()` exposes it
237
+ to `nwire scan` and Studio so every hook in the manifest has a click-to-open
238
+ pill — same UX as actions/events/projections.
239
+
240
+ ```ts
241
+ import { listHooks } from "@nwire/hooks";
242
+
243
+ console.log(listHooks());
244
+ // [
245
+ // { id: "h1", name: "endpoint.boot", chain: 2, listeners: 1, source: { file, line } },
246
+ // { id: "h2", name: "http.request", chain: 4, listeners: 0, source: { file, line } },
247
+ // ]
248
+ ```
249
+
250
+ ### Recording + replay
251
+
252
+ ```ts
253
+ import { record, replay } from "@nwire/hooks";
254
+
255
+ // Production: capture the full trace from one run.
256
+ const recording = await record(myHook, ctx);
257
+ await fetch("/__nwire/recordings", { method: "POST", body: JSON.stringify(recording) });
258
+
259
+ // Dev / debug: replay against the same hook and assert on drift.
260
+ const result = await replay(myHook, recording);
261
+ if (!result.matches) console.warn("drift:", result.drift);
13
262
  ```
14
263
 
15
- ## Within nwire-app
264
+ `record` runs the hook once, snapshots `ctxIn`/`ctxOut` (via `structuredClone`
265
+ by default), and returns a self-contained, JSON-serializable trace. `replay`
266
+ re-runs the hook with the captured `ctxIn` (cloned) and compares step
267
+ sequences — flagging drift in step count, names, order, or outcome.
16
268
 
17
- For developers using this package as part of the Nwire stack. You usually never call `hook()` yourself — `@nwire/app` lifecycle, `@nwire/forge` events, and every plugin's middleware all build on it. Touch this package when authoring a plugin that needs custom hooks.
269
+ This is observational, not stubbing. Pure / idempotent chains match
270
+ deterministically; non-deterministic chains report exactly what diverged.
271
+
272
+ ---
273
+
274
+ ## Consumer APIs — the surfaces every nwire package presents
275
+
276
+ This is the locked-in contract each downstream package exposes to its users.
277
+ Internally they all reduce to `hook()`, but the public API stays familiar
278
+ and ergonomic. This table is the source of truth — anything outside it is
279
+ not a public API of that package.
280
+
281
+ ### `@nwire/forge`
282
+
283
+ ```ts
284
+ // Runtime dispatch middleware — onion around every action handler.
285
+ runtime.use(mw: DispatchMiddleware): void;
286
+ type DispatchMiddleware = (
287
+ next: () => Promise<EventMessage | EventMessage[] | void>,
288
+ action: ActionDefinition,
289
+ input: unknown,
290
+ ctx: HandlerContext,
291
+ ) => Promise<EventMessage | EventMessage[] | void>;
292
+ // → forge.action.dispatch hook · chain · priority by registration order
293
+
294
+ // Actor transition hook — observable, fires after every state change.
295
+ runtime.actorHook(fn: ActorTransitionHook): void;
296
+ type ActorTransitionHook = (
297
+ actor: ActorDefinition,
298
+ key: string,
299
+ fromState: string,
300
+ toState: string,
301
+ event: EventMessage,
302
+ envelope: MessageEnvelope,
303
+ ) => Promise<void> | void;
304
+ // → forge.actor.transition hook · listener · when=always
305
+ ```
306
+
307
+ ### `@nwire/forge` — plugin builder (closure form)
308
+
309
+ ```ts
310
+ definePlugin("name", ({ bind, resolve, on, before, after, middleware, actorHook, boot, shutdown }) => {
311
+ // — Generic framework event subscription.
312
+ on<TPayload>(event, handler, priority?): void;
313
+ // → underlying framework-event hook · chain or listener per event.mode
314
+
315
+ // — Action-scoped sugar.
316
+ before(actionName, fn): void;
317
+ // fn returns void | false. false short-circuits ("prevented"); throw fails.
318
+ // → action.before:<name> hook · chain · use+next bail
319
+ after(actionName, fn): void;
320
+ // fn observes result + durationMs. Errors logged, not fatal.
321
+ // → action.after:<name> hook · listener · when=success
322
+
323
+ // — Dispatch middleware (same shape as runtime.use).
324
+ middleware(mw: DispatchMiddleware): void;
325
+ // → forge.action.dispatch hook · chain
326
+
327
+ // — Actor transitions (same shape as runtime.actorHook).
328
+ actorHook(fn: ActorTransitionHook): void;
329
+ // → forge.actor.transition hook · listener
330
+
331
+ // — Plugin lifecycle. Run order = registration; shutdown is reverse.
332
+ boot(fn: () => Promise<void> | void): void;
333
+ shutdown(fn: () => Promise<void> | void): void;
334
+ });
335
+ ```
336
+
337
+ ### `@nwire/app` — framework events (3 dispatch modes)
338
+
339
+ ```ts
340
+ // Subscribe (priority desc; default 0).
341
+ bus.on(event, handler, priority?): () => void;
342
+
343
+ // Fire. Returns false only when a series-bail handler vetoed.
344
+ bus.fire(event, payload): Promise<boolean>;
345
+
346
+ // Per-firing observer — sees every event, cannot veto. For dev-logger,
347
+ // Studio Live, OTel.
348
+ bus.onFire(observer): () => void;
349
+ type FrameworkEventObservation = {
350
+ eventName: string;
351
+ payload: unknown;
352
+ mode: "parallel" | "series" | "series-bail";
353
+ phase: "fired" | "prevented";
354
+ ts: string;
355
+ };
356
+
357
+ // Modes — set when defining the event.
358
+ defineFrameworkEvent<TPayload>(name, mode: "parallel" | "series" | "series-bail");
359
+ // parallel → listener fan-out (Promise.allSettled, errors logged)
360
+ // series → chain without bail (sequential await; throw stops)
361
+ // series-bail → chain with bail (sequential await; return false short-circuits)
362
+ ```
363
+
364
+ ### `@nwire/handler` — resolver middleware
365
+
366
+ ```ts
367
+ defineMiddleware(name?, fn: (ctx, next) => Promise<void> | void): MiddlewareDefinition;
368
+ // → resolver hook · chain
369
+
370
+ defineHook("before", name?, fn: (ctx) => Promise<void> | void): HookDefinition;
371
+ // → resolver hook · listener · when=always · runs before handler
372
+ defineHook("after", name?, fn: (ctx, result) => Promise<void> | void): HookDefinition;
373
+ // → resolver hook · listener · when=success · runs after handler
374
+
375
+ pipe(...steps: PipeStep[]): MiddlewarePipe;
376
+ // compose middlewares + hooks; transports unwind into chain + listeners
377
+ ```
378
+
379
+ ### `@nwire/http` — request pipeline
380
+
381
+ ```ts
382
+ httpInterface(opts?)
383
+ .use(mw: KoaMiddleware): this;
384
+ // → http.request hook · chain · global to this interface
385
+ .provide(container): this;
386
+ .wire(route: RouteBinding, handler: HttpHandler): this;
387
+ // per-route middleware lives on the binding:
388
+ // route = post("/path", { body, middleware: [mw1, mw2], openapi })
389
+ // → http.request:<METHOD> <path> hook · chain · scoped to this route
390
+ .compile(): KoaApp;
391
+ .toExpress(): ExpressMiddleware;
392
+ ```
393
+
394
+ ### `@nwire/auth` — canonical resolvers + middleware
395
+
396
+ ```ts
397
+ // All canonical auth operations are resolvers (SignIn, SignOut, Refresh,
398
+ // Register, Me) and run through the @nwire/handler pipeline above.
399
+ identityPlugin({ adapter, scopes? }): PluginDefinition;
400
+ // contributes:
401
+ // middleware → forge.action.dispatch · chain · enriches ctx.envelope.user
402
+ // resolvers → /auth/*
403
+ ```
404
+
405
+ ### `@nwire/rbac`
406
+
407
+ ```ts
408
+ defineAbility((user, { allow, deny }) => { ... }): AbilityFactory;
409
+ rbacPlugin({ ability }): PluginDefinition;
410
+ // contributes:
411
+ // middleware → forge.action.dispatch · chain · enforces action.policy
412
+
413
+ // Resolver-level:
414
+ can(action: string, subject: string | Subject): MiddlewareDefinition;
415
+ ```
416
+
417
+ ### `@nwire/observability` (tracing + auth-as-plugin)
418
+
419
+ ```ts
420
+ tracingPlugin({ tracer? }): PluginDefinition;
421
+ // wires OTel via .tap() on the dispatch + framework-event hooks.
422
+
423
+ // Application code rarely calls hooks directly — observability plugs in
424
+ // at boot, taps every hook in listHooks(), and emits spans / logs.
425
+ ```
426
+
427
+ ### Test surface — `@nwire/test-kit`
428
+
429
+ ```ts
430
+ const harness = createTestHarness({ app });
431
+ // Every hook the app creates is .tap()-able through harness.observe():
432
+ harness.observe(hookName, (obs) => { ... });
433
+ // Recordings can be captured + diffed in tests:
434
+ const rec = await harness.record(hookName, ctx);
435
+ expect(rec.steps.map((s) => s.stepName)).toEqual([...]);
436
+ ```
437
+
438
+ ### Studio + scan
439
+
440
+ `nwire scan` emits `.nwire/hooks.json`:
441
+
442
+ ```json
443
+ [
444
+ {
445
+ "id": "h1",
446
+ "name": "forge.action.dispatch",
447
+ "chain": 4,
448
+ "listeners": 0,
449
+ "source": { "file": "...", "line": 12 }
450
+ },
451
+ {
452
+ "id": "h2",
453
+ "name": "nwire.app.booting",
454
+ "chain": 2,
455
+ "listeners": 0,
456
+ "source": { "file": "...", "line": 8 }
457
+ },
458
+ {
459
+ "id": "h3",
460
+ "name": "action.after:CreateStation",
461
+ "chain": 0,
462
+ "listeners": 2,
463
+ "source": { "file": "...", "line": 23 }
464
+ }
465
+ ]
466
+ ```
467
+
468
+ Studio surfaces a **Hooks** page that lists these with source pills, a per-hook
469
+ inspector showing attached chain + listeners with their names + priorities,
470
+ and a Trace mode that overlays live `.tap()` observations onto the causation
471
+ tree using `runId` / `parentRunId`.
472
+
473
+ ---
474
+
475
+ ## Recipes — wiring the substrate
476
+
477
+ ### 1. Forge — `runtime.use(middleware)`
478
+
479
+ ```ts
480
+ const dispatch = hook<DispatchCtx>("forge.action.dispatch");
481
+
482
+ dispatch.use(
483
+ async (ctx, next) => {
484
+ const span = tracer.startSpan(ctx.action.name);
485
+ try {
486
+ await next();
487
+ span.end();
488
+ } catch (err) {
489
+ span.recordException(err);
490
+ throw err;
491
+ }
492
+ },
493
+ { name: "tracing", priority: 100 },
494
+ );
495
+
496
+ // `runtime.use(mw)` is sugar:
497
+ runtime.use = (mw) => dispatch.use(adaptDispatchMiddleware(mw));
498
+ ```
499
+
500
+ ### 2. HTTP — global `.use()` + per-route `middleware: [...]`
501
+
502
+ ```ts
503
+ const httpRequest = hook<KoaCtx>("http.request"); // global
504
+ const routeRequest = (route) => hook<KoaCtx>(`http.request:${route.method} ${route.path}`); // per route
505
+
506
+ httpRequest.use(cors());
507
+ httpRequest.use(bodyParser());
508
+ const routeHook = routeRequest(route);
509
+ for (const mw of route.middleware) routeHook.use(mw);
510
+ routeHook.use(invokeHandler(route));
511
+
512
+ // At dispatch:
513
+ await httpRequest.run(ctx);
514
+ await routeHook.run(ctx);
515
+ ```
516
+
517
+ ### 3. Handler — `defineMiddleware` + `defineHook("after", ...)`
518
+
519
+ ```ts
520
+ // Migrate:
521
+ const authenticate = defineMiddleware(async (ctx, next) => { ... });
522
+ const auditLog = defineHook("after", async (ctx, result) => { ... });
523
+
524
+ // To:
525
+ resolverHook.use(authenticate.fn, { name: "authenticate" });
526
+ resolverHook.on((ctx) => auditLog.fn(ctx, ctx.result), { when: "success", name: "audit-log" });
527
+ ```
528
+
529
+ ### 4. Plugin — `before(actionName, fn)` (series-bail)
530
+
531
+ ```ts
532
+ const beforeAction = hook<BeforeCtx>("action.before:" + actionName);
533
+
534
+ beforeAction.use(async (ctx, next) => {
535
+ const allowed = await fn(ctx);
536
+ if (allowed === false) return; // short-circuit -> outcome "prevented"
537
+ await next();
538
+ });
539
+
540
+ // Dispatch:
541
+ const result = await beforeAction.runDetailed(ctx);
542
+ if (result.outcome === "prevented") return /* skip the action */;
543
+ ```
544
+
545
+ ### 5. Plugin — `after(actionName, fn)` (parallel observer, success-only)
546
+
547
+ ```ts
548
+ const afterAction = hook<AfterCtx>("action.after:" + actionName);
549
+ afterAction.on(fn, { when: "success", name: actionName + ":after" });
550
+ ```
551
+
552
+ ### 6. Plugin — `actorHooks.afterTransition`
553
+
554
+ ```ts
555
+ const actorTransition = hook<TransitionCtx>("actor.transition");
556
+ actorTransition.on((ctx) => userHook(ctx.actor, ctx.key, ctx.from, ctx.to, ctx.event));
557
+
558
+ // runtime emits on every transition:
559
+ await actorTransition.run({ actor, key, from, to, event, envelope });
560
+ ```
561
+
562
+ ### 7. App framework events — three modes
563
+
564
+ ```ts
565
+ const ev = hook<Payload>("nwire.app.booting", {
566
+ // strictListeners=false matches "parallel mode" — handler errors are logged.
567
+ strictListeners: false,
568
+ });
569
+
570
+ // parallel:
571
+ ev.on(handler, { priority });
572
+
573
+ // series:
574
+ ev.use(
575
+ async (ctx, next) => {
576
+ await handler(ctx);
577
+ await next();
578
+ },
579
+ { priority },
580
+ );
581
+
582
+ // series-bail:
583
+ ev.use(
584
+ async (ctx, next) => {
585
+ const ret = await handler(ctx);
586
+ if (ret === false) return; // short-circuit
587
+ await next();
588
+ },
589
+ { priority },
590
+ );
591
+
592
+ const r = await ev.runDetailed(payload);
593
+ const prevented = r.outcome === "prevented";
594
+ ```
595
+
596
+ ### 8. Studio / scan integration
597
+
598
+ ```ts
599
+ import { listHooks } from "@nwire/hooks";
600
+
601
+ // scan emits per-hook metadata to .nwire/hooks.json
602
+ const manifest = listHooks().map((h) => ({
603
+ id: h.id,
604
+ name: h.name,
605
+ chain: h.chain,
606
+ listeners: h.listeners,
607
+ source: h.source,
608
+ }));
609
+ ```
610
+
611
+ ### 9. OTel adapter
612
+
613
+ ```ts
614
+ import { trace } from "@opentelemetry/api";
615
+
616
+ const tracer = trace.getTracer("nwire");
617
+ const spans = new Map<string, ReturnType<typeof tracer.startSpan>>();
618
+
619
+ myHook.tap((obs) => {
620
+ const key = `${obs.runId}:${obs.stepId}:${obs.stepKind}`;
621
+ if (obs.phase === "start") {
622
+ spans.set(key, tracer.startSpan(`${obs.hookName}.${obs.stepName ?? obs.stepKind}`));
623
+ } else if (obs.phase === "end") {
624
+ spans.get(key)?.end();
625
+ spans.delete(key);
626
+ } else if (obs.phase === "error") {
627
+ const s = spans.get(key);
628
+ if (s) {
629
+ s.recordException(obs.error as Error);
630
+ s.end();
631
+ spans.delete(key);
632
+ }
633
+ }
634
+ });
635
+ ```
636
+
637
+ ### 10. Structured logger tap
638
+
639
+ ```ts
640
+ myHook.tap((obs) => {
641
+ logger.debug({
642
+ msg: "hook.step",
643
+ hook: obs.hookName,
644
+ runId: obs.runId,
645
+ parentRun: obs.parentRunId,
646
+ step: obs.stepName ?? `#${obs.stepId}`,
647
+ phase: obs.phase,
648
+ durationMs: obs.durationMs,
649
+ error: obs.error && (obs.error as Error).message,
650
+ });
651
+ });
652
+ ```
653
+
654
+ ### 11. AbortSignal
655
+
656
+ ```ts
657
+ const ac = new AbortController();
658
+ setTimeout(() => ac.abort(new Error("timeout")), 1000);
659
+
660
+ await myHook.run(ctx, { signal: ac.signal });
661
+ // ctx.signal is set so inner code can poll:
662
+ // if (ctx.signal?.aborted) return;
663
+ ```
664
+
665
+ ---
666
+
667
+ ## `createHooks()` — bundled named hosts
18
668
 
19
669
  ```ts
20
670
  import { createHooks, hook } from "@nwire/hooks";
21
671
 
22
672
  const lifecycle = createHooks({
23
- boot: hook<EndpointCtx>("endpoint.boot"),
24
- ready: hook<EndpointCtx>("endpoint.ready"),
25
- shutdown: hook<EndpointCtx>("endpoint.shutdown"),
673
+ registering: hook<RegisteringCtx>("nwire.app.registering"),
674
+ booting: hook<BootingCtx>("nwire.app.booting"),
675
+ ready: hook<ReadyCtx>("nwire.app.ready"),
676
+ shutdown: hook<ShutdownCtx>("nwire.app.shutdown"),
26
677
  });
27
678
 
28
- // Apply a plugin object same hook can take chains AND listeners:
679
+ // Apply a plugin across all four at once:
29
680
  lifecycle.use({
30
- boot: async (ctx, next) => {
31
- logger.info("booting");
681
+ booting: async (ctx, next) => {
682
+ logger.info("booting");
683
+ await next();
684
+ },
685
+ ready: { on: (ctx) => logger.info("ready", { port: ctx.port }) },
686
+ shutdown: async (ctx, next) => {
687
+ await flushOutbox();
32
688
  await next();
33
689
  },
34
- shutdown: { on: (ctx) => logger.info("shut down", ctx) },
35
690
  });
36
691
  ```
37
692
 
38
- ## The contractthe matrix
693
+ `.hooks` is the underlying record spread it to compose hosts:
694
+
695
+ ```ts
696
+ const full = createHooks({ ...lifecycle.hooks, request: hook<...>("http.request") });
697
+ ```
698
+
699
+ ---
700
+
701
+ ## Production guidance
702
+
703
+ - **Hot paths.** A `.use()` step adds one wrapper closure + one observation
704
+ emit. Per-request HTTP middleware on a busy server: ~250 ns/step in our
705
+ microbench. If you have a 100k-rps inner loop and don't need observability,
706
+ prefer a hand-rolled compose.
707
+ - **Listener errors.** Default policy is log + continue. This matches Node's
708
+ `EventEmitter` and Koa's "don't crash because metrics broke." Opt into
709
+ `strictListeners: true` for testing and any place where listener failure
710
+ must surface.
711
+ - **Taps.** Taps fire synchronously inside `.run()`. Don't do blocking I/O.
712
+ Buffer + flush async.
713
+ - **AbortSignal.** Aborting throws between steps. A long-running step that
714
+ never `await`s won't notice. Plumb `ctx.signal` into your I/O calls.
715
+ - **Topology.** `AsyncLocalStorage` survives microtasks and timers. It does
716
+ _not_ survive `setImmediate` if you escape the await chain. Stay in
717
+ `await` land if you care about parent linkage.
718
+ - **Recording size.** A recording grows linearly with step count; ctx
719
+ snapshots dominate the size. Use a custom `clone` to redact PII before
720
+ persisting.
721
+
722
+ ---
723
+
724
+ ## When NOT to use a hook
725
+
726
+ - A single, hard-coded step that has no extension point. Just call the
727
+ function.
728
+ - A synchronous calculation that never spans I/O. The async overhead is
729
+ wasted.
730
+ - Something that's fundamentally request/response with no observers and no
731
+ cancellability. A normal function is fine.
732
+
733
+ Hooks are for extension. If nobody will ever attach to it, don't make it
734
+ one.
39
735
 
40
- | Question | Behavior |
41
- | ------------------------------------------------ | ------------------------------------------- |
42
- | `.on()` runs if chain short-circuits (no throw)? | Yes — listeners observe outcome |
43
- | `.on()` runs if chain throws? | Yes — `ctx.error` is set first |
44
- | Can listeners mutate ctx? | No — `.on()` types ctx as `Readonly<Ctx>` |
45
- | Listeners awaited by `.run()`? | Yes — parallel, `Promise.allSettled` |
46
- | Listener throws → fails chain? | No — collected, routed to `onListenerError` |
47
- | Listener ordering | Unordered (parallel) |
48
- | Chain ordering | Insertion order (koa-compose) |
736
+ ---
49
737
 
50
- Escape hatch: `hook(name, { strictListeners: true })` re-throws the first listener error instead of routing it through `onListenerError`.
738
+ ## Versioning + stability
51
739
 
52
- ## API
740
+ `@nwire/hooks` follows the framework's semver. The contract matrix in this
741
+ README is the law — changing any row is a major bump.
53
742
 
54
- - `hook<Ctx>(name, options?)` — create one hook.
55
- - `Hook<Ctx>.use(fn)` / `.on(fn)` / `.off(fn)` / `.run(ctx)`.
56
- - `createHooks(record)` typed bundle with `.hooks` + `.use(plugin)`.
57
- - `compose(fns)` / `pipe(...fns)` / `withTimeout(ms, fn)` / `withRetry(opts, fn)` — composition helpers.
743
+ Already locked: `Hook<Ctx>` surface, `RunResult`, `StepObservation`,
744
+ `Recording`. New optional capabilities (more `RunOptions`, more tap fields)
745
+ are minor bumps; never breaking.