@setu-ts/kernel 0.0.0-stage → 0.10.0

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.
Files changed (79) hide show
  1. package/AGENT_CARD.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -2
  4. package/dist/application/application.d.ts +174 -0
  5. package/dist/application/application.d.ts.map +1 -0
  6. package/dist/application/application.js +1905 -0
  7. package/dist/application/application.js.map +1 -0
  8. package/dist/application/inject-body.d.ts +58 -0
  9. package/dist/application/inject-body.d.ts.map +1 -0
  10. package/dist/application/inject-body.js +82 -0
  11. package/dist/application/inject-body.js.map +1 -0
  12. package/dist/context/request-context.d.ts +32 -0
  13. package/dist/context/request-context.d.ts.map +1 -0
  14. package/dist/context/request-context.js +136 -0
  15. package/dist/context/request-context.js.map +1 -0
  16. package/dist/context/response.d.ts +60 -0
  17. package/dist/context/response.d.ts.map +1 -0
  18. package/dist/context/response.js +193 -0
  19. package/dist/context/response.js.map +1 -0
  20. package/dist/diagnostics/buffer.d.ts +143 -0
  21. package/dist/diagnostics/buffer.d.ts.map +1 -0
  22. package/dist/diagnostics/buffer.js +190 -0
  23. package/dist/diagnostics/buffer.js.map +1 -0
  24. package/dist/diagnostics/collector.d.ts +284 -0
  25. package/dist/diagnostics/collector.d.ts.map +1 -0
  26. package/dist/diagnostics/collector.js +810 -0
  27. package/dist/diagnostics/collector.js.map +1 -0
  28. package/dist/diagnostics/projection.d.ts +153 -0
  29. package/dist/diagnostics/projection.d.ts.map +1 -0
  30. package/dist/diagnostics/projection.js +226 -0
  31. package/dist/diagnostics/projection.js.map +1 -0
  32. package/dist/index.d.ts +12 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +11 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/lifecycle/lifecycle-manager.d.ts +136 -0
  37. package/dist/lifecycle/lifecycle-manager.d.ts.map +1 -0
  38. package/dist/lifecycle/lifecycle-manager.js +206 -0
  39. package/dist/lifecycle/lifecycle-manager.js.map +1 -0
  40. package/dist/pipeline/execute-chain.d.ts +71 -0
  41. package/dist/pipeline/execute-chain.d.ts.map +1 -0
  42. package/dist/pipeline/execute-chain.js +104 -0
  43. package/dist/pipeline/execute-chain.js.map +1 -0
  44. package/dist/pipeline/middleware-pipeline.d.ts +68 -0
  45. package/dist/pipeline/middleware-pipeline.d.ts.map +1 -0
  46. package/dist/pipeline/middleware-pipeline.js +82 -0
  47. package/dist/pipeline/middleware-pipeline.js.map +1 -0
  48. package/dist/registry/plugin-resolver.d.ts +42 -0
  49. package/dist/registry/plugin-resolver.d.ts.map +1 -0
  50. package/dist/registry/plugin-resolver.js +140 -0
  51. package/dist/registry/plugin-resolver.js.map +1 -0
  52. package/dist/registry/service-registry.d.ts +110 -0
  53. package/dist/registry/service-registry.d.ts.map +1 -0
  54. package/dist/registry/service-registry.js +197 -0
  55. package/dist/registry/service-registry.js.map +1 -0
  56. package/dist/router/route-matcher.d.ts +85 -0
  57. package/dist/router/route-matcher.d.ts.map +1 -0
  58. package/dist/router/route-matcher.js +105 -0
  59. package/dist/router/route-matcher.js.map +1 -0
  60. package/dist/router/router.d.ts +130 -0
  61. package/dist/router/router.d.ts.map +1 -0
  62. package/dist/router/router.js +350 -0
  63. package/dist/router/router.js.map +1 -0
  64. package/package.json +49 -3
  65. package/src/application/application.ts +2326 -0
  66. package/src/application/inject-body.ts +130 -0
  67. package/src/context/request-context.ts +169 -0
  68. package/src/context/response.ts +246 -0
  69. package/src/diagnostics/buffer.ts +243 -0
  70. package/src/diagnostics/collector.ts +1030 -0
  71. package/src/diagnostics/projection.ts +322 -0
  72. package/src/index.ts +20 -0
  73. package/src/lifecycle/lifecycle-manager.ts +282 -0
  74. package/src/pipeline/execute-chain.ts +162 -0
  75. package/src/pipeline/middleware-pipeline.ts +129 -0
  76. package/src/registry/plugin-resolver.ts +185 -0
  77. package/src/registry/service-registry.ts +262 -0
  78. package/src/router/route-matcher.ts +134 -0
  79. package/src/router/router.ts +485 -0
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Diagnostics projection — the pure half of kernel diagnostics: label
3
+ * allowlist compilation and bounded primitive projection.
4
+ *
5
+ * Every string that can leave the process through a diagnostics snapshot or
6
+ * event batch passes through the allowlists compiled here, at the capture
7
+ * boundary and never again. An allowlist is an EXACT set of registration
8
+ * strings — no globs, no regular expressions — because approving a label is a
9
+ * disclosure decision the developer makes per name, not a pattern match to be
10
+ * widened.
11
+ *
12
+ * @module
13
+ */
14
+ import { hasForbiddenAliasCharacter } from '@setu-ts/common';
15
+ import type {
16
+ DiagnosticsEdge,
17
+ DiagnosticsNode,
18
+ DiagnosticsSnapshot,
19
+ HttpMethod,
20
+ } from '@setu-ts/common';
21
+
22
+ /**
23
+ * Explicit label allowlists for the four projected name families. A name
24
+ * leaves the process only when it exactly matches an entry here; unknown names
25
+ * authorize no output.
26
+ *
27
+ * @since 0.8.0
28
+ */
29
+ export interface KernelDiagnosticsLabelOptions {
30
+ /** Exact plugin `name` values that may appear as node labels. */
31
+ readonly plugins?: readonly string[];
32
+ /** Exact capability-token strings that may appear as node labels. */
33
+ readonly capabilities?: readonly string[];
34
+ /** Exact registered route patterns (including group prefix) that may appear as labels. */
35
+ readonly routes?: readonly string[];
36
+ /** Exact declared middleware names that may appear as labels. */
37
+ readonly middleware?: readonly string[];
38
+ }
39
+
40
+ /**
41
+ * Options enabling kernel diagnostics, supplied as
42
+ * `createApplication({ diagnostics })`. The presence of the option IS the
43
+ * activation: an omitted option creates no collector, metadata mirror, ring
44
+ * buffer, or timer anywhere in the kernel.
45
+ *
46
+ * @since 0.8.0
47
+ */
48
+ export interface KernelDiagnosticsOptions {
49
+ /**
50
+ * Explicit label allowlists. Omitted entirely, every projected node carries
51
+ * only its opaque identifier — no name, template, or version ever leaves the
52
+ * process.
53
+ */
54
+ readonly labels?: KernelDiagnosticsLabelOptions;
55
+ }
56
+
57
+ /** Maximum entries accepted in one label allowlist. */
58
+ export const MAX_LABEL_LIST_ENTRIES = 256;
59
+
60
+ /** Maximum UTF-8 byte length of one allowlist entry. */
61
+ export const MAX_LABEL_BYTES = 160;
62
+
63
+ /** A compiled allowlist set per label family. */
64
+ export interface DiagnosticsLabelAllowlists {
65
+ readonly plugins: ReadonlySet<string>;
66
+ readonly capabilities: ReadonlySet<string>;
67
+ readonly routes: ReadonlySet<string>;
68
+ readonly middleware: ReadonlySet<string>;
69
+ }
70
+
71
+ /**
72
+ * Compiles one allowlist, refusing malformed input with VALUE-FREE errors: an
73
+ * error must state the constraint it enforces, never echo the offending value,
74
+ * because a construction-time diagnostic failure is an attacker-reachable path
75
+ * for whatever the list contains.
76
+ *
77
+ * @param entries - The raw allowlist, or `undefined` for an empty set
78
+ * @param listName - Family name used in the error text only
79
+ * @returns The compiled exact-match set
80
+ * @throws {RangeError} When the list exceeds 256 entries, an entry exceeds 160
81
+ * UTF-8 bytes, or an entry contains a control character — without naming any entry
82
+ * @since 0.8.0
83
+ */
84
+ export function compileLabelAllowlist(
85
+ entries: readonly string[] | undefined,
86
+ listName: string,
87
+ ): ReadonlySet<string> {
88
+ if (entries === undefined) {
89
+ return new Set<string>();
90
+ }
91
+ if (entries.length > MAX_LABEL_LIST_ENTRIES) {
92
+ throw new RangeError(
93
+ `Invalid diagnostics labels.${listName}: more than ${MAX_LABEL_LIST_ENTRIES} entries.`,
94
+ );
95
+ }
96
+ const compiled = new Set<string>();
97
+ for (const entry of entries) {
98
+ const bytes = new TextEncoder().encode(entry).length;
99
+ if (bytes > MAX_LABEL_BYTES) {
100
+ throw new RangeError(
101
+ `Invalid diagnostics labels.${listName}: an entry exceeds the ${MAX_LABEL_BYTES}-byte bound.`,
102
+ );
103
+ }
104
+ if (hasForbiddenAliasCharacter(entry)) {
105
+ throw new RangeError(
106
+ `Invalid diagnostics labels.${listName}: an entry contains a control, format or line-separator character.`,
107
+ );
108
+ }
109
+ compiled.add(entry);
110
+ }
111
+ return compiled;
112
+ }
113
+
114
+ /**
115
+ * Compiles every label family of a {@linkcode KernelDiagnosticsOptions}.
116
+ *
117
+ * @param options - The diagnostics options, or `undefined` for all-empty lists
118
+ * @returns The compiled allowlists
119
+ * @throws {RangeError} When any list is malformed (see {@linkcode compileLabelAllowlist})
120
+ * @since 0.8.0
121
+ */
122
+ export function compileLabelAllowlists(
123
+ options: KernelDiagnosticsOptions | undefined,
124
+ ): DiagnosticsLabelAllowlists {
125
+ const labels = options?.labels;
126
+ return {
127
+ plugins: compileLabelAllowlist(labels?.plugins, 'plugins'),
128
+ capabilities: compileLabelAllowlist(labels?.capabilities, 'capabilities'),
129
+ routes: compileLabelAllowlist(labels?.routes, 'routes'),
130
+ middleware: compileLabelAllowlist(labels?.middleware, 'middleware'),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Projects a candidate registration string through an allowlist.
136
+ *
137
+ * Exact membership, never a truncation: a name either IS an approved label or
138
+ * it is omitted — truncating a 200-byte route pattern into an approved-looking
139
+ * prefix would mint a new name that was never disclosed on purpose.
140
+ *
141
+ * @param allowlist - The compiled exact-match set for this family
142
+ * @param candidate - The raw registration string
143
+ * @returns The candidate itself when approved, `undefined` otherwise
144
+ * @since 0.8.0
145
+ */
146
+ export function approvedLabel(
147
+ allowlist: ReadonlySet<string>,
148
+ candidate: string,
149
+ ): string | undefined {
150
+ return allowlist.has(candidate) ? candidate : undefined;
151
+ }
152
+
153
+ const HTTP_METHODS: ReadonlySet<string> = new Set([
154
+ 'GET',
155
+ 'HEAD',
156
+ 'POST',
157
+ 'PUT',
158
+ 'PATCH',
159
+ 'DELETE',
160
+ 'OPTIONS',
161
+ ]);
162
+
163
+ /**
164
+ * Projects a method string onto the {@linkcode HttpMethod} vocabulary.
165
+ * Unsupported input is omitted, never widened into a new name.
166
+ *
167
+ * @param method - The raw method string from a registration
168
+ * @returns The known verb, or `undefined` when outside the vocabulary
169
+ * @since 0.8.0
170
+ */
171
+ export function projectHttpMethod(method: string): HttpMethod | undefined {
172
+ return HTTP_METHODS.has(method) ? (method as HttpMethod) : undefined;
173
+ }
174
+
175
+ /**
176
+ * Bounded plugin-version grammar. A version is emitted only when it is a plain
177
+ * `major.minor.patch` semver (optionally with prerelease/build suffixes) within
178
+ * 64 characters — anything else (a git sha, a sentence, a hostile string) is
179
+ * omitted rather than projected.
180
+ *
181
+ * @param version - The raw plugin `version` string
182
+ * @returns The version when it passes the bounded grammar, `undefined` otherwise
183
+ * @since 0.8.0
184
+ */
185
+ export function boundedPluginVersion(version: string): string | undefined {
186
+ if (version.length === 0 || version.length > 64) {
187
+ return undefined;
188
+ }
189
+ const bounded = /^\d{1,5}\.\d{1,5}\.\d{1,5}(?:-[0-9A-Za-z.-]{1,64})?(?:\+[0-9A-Za-z.-]{1,64})?$/;
190
+ return bounded.test(version) ? version : undefined;
191
+ }
192
+
193
+ /**
194
+ * Inclusive monotonic elapsed time for one observed boundary.
195
+ *
196
+ * @param clock - The injected monotonic clock
197
+ * @param startedAtMs - The boundary's recorded start offset, or `null`
198
+ * @returns Elapsed ms, or `null` when either reading was unavailable
199
+ * @since 0.8.0
200
+ */
201
+ export function monotonicElapsed(
202
+ clock: () => number | null,
203
+ startedAtMs: number | null,
204
+ ): number | null {
205
+ if (startedAtMs === null) {
206
+ return null;
207
+ }
208
+ const finishedAtMs = clock();
209
+ return finishedAtMs === null ? null : finishedAtMs - startedAtMs;
210
+ }
211
+
212
+ /**
213
+ * Advances a monotonic counter with saturation at `Number.MAX_SAFE_INTEGER`.
214
+ * Returns `null` at saturation, which callers treat as "stop collection": a
215
+ * coarse failure state long before any identifier could wrap into a
216
+ * misleading value.
217
+ *
218
+ * @param current - The current counter value
219
+ * @returns The next value, or `null` when the counter is saturated
220
+ * @since 0.8.0
221
+ */
222
+ export function saturatingNext(current: number): number | null {
223
+ return current >= Number.MAX_SAFE_INTEGER ? null : current + 1;
224
+ }
225
+
226
+ /** Fixed v1 limits: composition nodes retained by the collector. */
227
+ export const MAX_NODES = 1024;
228
+
229
+ /** Fixed v1 limits: composition edges retained by the collector. */
230
+ export const MAX_EDGES = 4096;
231
+
232
+ /** Fixed v1 limits: snapshot budget as the exact UTF-8 byte length of the compact JSON. */
233
+ export const MAX_SNAPSHOT_BYTES = 262_144;
234
+
235
+ /**
236
+ * Applies the fixed 256 KiB snapshot budget to the final DTO: the exact UTF-8
237
+ * byte length of the compact JSON encoding of the RETURNED object is the
238
+ * number the wire consumer measures, so trimming runs on the final shape —
239
+ * omitting later entries (with their edges) and setting `truncated` until it
240
+ * fits.
241
+ *
242
+ * Extracted as a pure seam because the retained-field bounds make the budget
243
+ * unreachable through real captures (1,024 bounded nodes stay well under the
244
+ * cap); the defense still carries the bar, so the decidable trim is tested
245
+ * here directly rather than left behind an uncoverable branch.
246
+ *
247
+ * @param scalar - The snapshot's scalar members (instanceId, state, failureCode, droppedEvents)
248
+ * @param nodes - The projected nodes, oldest first
249
+ * @param edges - The projected edges, oldest first
250
+ * @param truncated - The topology truncation flag
251
+ * @returns The bounded snapshot DTO
252
+ * @since 0.8.0
253
+ */
254
+ export function applySnapshotBudget(
255
+ scalar: {
256
+ readonly instanceId: string | null;
257
+ readonly state: DiagnosticsSnapshot['state'];
258
+ readonly failureCode: DiagnosticsSnapshot['failureCode'];
259
+ readonly droppedEvents: number;
260
+ },
261
+ nodes: readonly DiagnosticsNode[],
262
+ edges: readonly DiagnosticsEdge[],
263
+ truncated: boolean,
264
+ ): DiagnosticsSnapshot {
265
+ const encoder = new TextEncoder();
266
+ const build = (
267
+ keptNodes: readonly DiagnosticsNode[],
268
+ keptEdges: readonly DiagnosticsEdge[],
269
+ isTruncated: boolean,
270
+ ): DiagnosticsSnapshot => ({
271
+ version: 1,
272
+ instanceId: scalar.instanceId,
273
+ state: scalar.state,
274
+ failureCode: scalar.failureCode,
275
+ nodes: keptNodes,
276
+ edges: keptEdges,
277
+ truncated: isTruncated,
278
+ droppedEvents: scalar.droppedEvents,
279
+ });
280
+ const measure = (candidate: DiagnosticsSnapshot): number =>
281
+ encoder.encode(JSON.stringify(candidate)).length;
282
+
283
+ // Fast path: nothing to trim. Returning the inputs untouched also keeps the
284
+ // untrimmed shape EXACTLY as it was — an edge whose endpoint is somehow
285
+ // absent from `nodes` survives here, as it always did, rather than being
286
+ // silently dropped by the retained-id filter below.
287
+ const whole = build(nodes, edges, truncated);
288
+ if (measure(whole) <= MAX_SNAPSHOT_BYTES) {
289
+ return whole;
290
+ }
291
+
292
+ // Trimming keeps a PREFIX of the nodes, and dropping a suffix entry can only
293
+ // shrink the encoding — so the encoded length is monotone in the retained
294
+ // count and the largest fitting prefix is found by bisection. Dropping one
295
+ // node per measurement instead is quadratic: 4,000 nodes cost ~2,700 full
296
+ // `JSON.stringify` + encode passes (measured at ~1,000 ms) where bisection
297
+ // costs ~12 (~4 ms). The measurement itself is unchanged — every candidate
298
+ // is the exact compact UTF-8 length of the snapshot that would be returned.
299
+ const at = (count: number): DiagnosticsSnapshot => {
300
+ const keptNodes = nodes.slice(0, count);
301
+ const keptIds = new Set(keptNodes.map((node) => node.id));
302
+ // Equivalent to the cumulative per-drop filter: an edge survives exactly
303
+ // when neither endpoint was dropped.
304
+ const keptEdges = edges.filter((edge) => keptIds.has(edge.from) && keptIds.has(edge.to));
305
+ return build(keptNodes, keptEdges, true);
306
+ };
307
+
308
+ let low = 0;
309
+ let high = nodes.length - 1;
310
+ let best = at(0);
311
+ while (low <= high) {
312
+ const mid = (low + high) >> 1;
313
+ const candidate = at(mid);
314
+ if (measure(candidate) <= MAX_SNAPSHOT_BYTES) {
315
+ best = candidate;
316
+ low = mid + 1;
317
+ } else {
318
+ high = mid - 1;
319
+ }
320
+ }
321
+ return best;
322
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @module
3
+ *
4
+ * Plugin kernel: plugin registry, service registry, middleware pipeline, router,
5
+ * and application lifecycle.
6
+ *
7
+ * Every export here is public API and documented in PUBLIC_API.md
8
+ * (AI_GUIDELINES §10).
9
+ */
10
+
11
+ export type {
12
+ ApplicationOptions,
13
+ IKernelApplication,
14
+ InjectRequest,
15
+ InjectResponse,
16
+ KernelDiagnosticsLabelOptions,
17
+ KernelDiagnosticsOptions,
18
+ } from './application/application.ts';
19
+
20
+ export { createApplication } from './application/application.ts';
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Lifecycle manager — registers and executes lifecycle hooks in the correct
3
+ * ordering. Shutdown hooks run in reverse (LIFO) for proper cleanup.
4
+ *
5
+ * @module
6
+ */
7
+ import type { IRequestContext } from '@setu-ts/common';
8
+ import type { ILifecycleApi } from '@setu-ts/common';
9
+
10
+ import { monotonicElapsed } from '../diagnostics/projection.ts';
11
+
12
+ type VoidHook = () => void | Promise<void>;
13
+ type RequestHook = (ctx: IRequestContext) => void | Promise<void>;
14
+ type ErrorHook = (error: Error, ctx: IRequestContext) => void | Promise<void>;
15
+
16
+ /**
17
+ * The lifecycle phases the manager itself drains, named exactly as the
18
+ * diagnostics stage vocabulary spells them. The request-scoped hooks
19
+ * (`onRequest`/`onResponse`/`onError`) are invoked by the application and
20
+ * observed there.
21
+ *
22
+ * @since 0.8.0
23
+ */
24
+ export type LifecycleHookPhase =
25
+ 'register-hook' | 'init' | 'bootstrap' | 'stopping' | 'shutdown' | 'close';
26
+
27
+ /**
28
+ * One hook invocation observed with its fixed phase plus execution ordinal —
29
+ * ownership is NEVER inferred, because the manager does not record it.
30
+ *
31
+ * @since 0.8.0
32
+ */
33
+ export interface LifecycleHookObservation {
34
+ /** The lifecycle phase being drained. */
35
+ readonly phase: LifecycleHookPhase;
36
+ /** 1-based EXECUTION order within the phase (LIFO phases count downward). */
37
+ readonly ordinal: number;
38
+ /** Whether the hook threw (the error itself is never retained). */
39
+ readonly failed: boolean;
40
+ /** Monotonic start offset, or `null` when no runtime clock is available. */
41
+ readonly startedAtMs: number | null;
42
+ /** Inclusive monotonic elapsed ms, or `null`. */
43
+ readonly durationMs: number | null;
44
+ }
45
+
46
+ /**
47
+ * The diagnostics observer the application passes to instrument hook
48
+ * execution. Observation never changes hook ordering, aggregation, or
49
+ * propagation semantics.
50
+ *
51
+ * @since 0.8.0
52
+ */
53
+ export interface LifecycleObserver {
54
+ /** Monotonic clock; returns `null` when no runtime clock is available. */
55
+ readonly clock: () => number | null;
56
+ /** Called once per executed hook, in execution order. */
57
+ readonly onHook: (observation: LifecycleHookObservation) => void;
58
+ }
59
+
60
+ /**
61
+ * Default implementation of {@linkcode ILifecycleApi}. Stores hook arrays
62
+ * and exposes execution methods for each lifecycle phase.
63
+ */
64
+ export class LifecycleManager implements ILifecycleApi {
65
+ readonly #register: VoidHook[] = [];
66
+ /** Count of onRegister hooks already drained by {@linkcode runRegister}. */
67
+ #registerCursor = 0;
68
+ readonly #init: VoidHook[] = [];
69
+ readonly #bootstrap: VoidHook[] = [];
70
+ readonly #request: RequestHook[] = [];
71
+ readonly #response: RequestHook[] = [];
72
+ readonly #error: ErrorHook[] = [];
73
+ readonly #stopping: VoidHook[] = [];
74
+ readonly #shutdown: VoidHook[] = [];
75
+ readonly #close: VoidHook[] = [];
76
+ #observer: LifecycleObserver | undefined;
77
+
78
+ /**
79
+ * Installs the diagnostics observer for hook invocations. Pass `undefined`
80
+ * to detach (teardown).
81
+ *
82
+ * @param observer - The observer, or `undefined`
83
+ * @since 0.8.0
84
+ */
85
+ setLifecycleObserver(observer: LifecycleObserver | undefined): void {
86
+ this.#observer = observer;
87
+ }
88
+
89
+ onRegister(fn: () => void | Promise<void>): void {
90
+ this.#register.push(fn);
91
+ }
92
+
93
+ onInit(fn: () => void | Promise<void>): void {
94
+ this.#init.push(fn);
95
+ }
96
+
97
+ onBootstrap(fn: () => void | Promise<void>): void {
98
+ this.#bootstrap.push(fn);
99
+ }
100
+
101
+ onRequest(fn: (ctx: IRequestContext) => void | Promise<void>): void {
102
+ this.#request.push(fn);
103
+ }
104
+
105
+ onResponse(fn: (ctx: IRequestContext) => void | Promise<void>): void {
106
+ this.#response.push(fn);
107
+ }
108
+
109
+ onError(fn: (error: Error, ctx: IRequestContext) => void | Promise<void>): void {
110
+ this.#error.push(fn);
111
+ }
112
+
113
+ onStopping(fn: () => void | Promise<void>): void {
114
+ this.#stopping.push(fn);
115
+ }
116
+
117
+ onShutdown(fn: () => void | Promise<void>): void {
118
+ this.#shutdown.push(fn);
119
+ }
120
+
121
+ onClose(fn: () => void | Promise<void>): void {
122
+ this.#close.push(fn);
123
+ }
124
+
125
+ /**
126
+ * Runs onRegister hooks added since the previous call, in registration
127
+ * order. The kernel invokes this immediately after each plugin's
128
+ * `register()` returns, so a plugin's onRegister hooks run "during the
129
+ * owning plugin's registration" (per {@linkcode ILifecycleApi.onRegister})
130
+ * — after that plugin and before the next one. A cursor tracks how many
131
+ * hooks have already run so each plugin only fires the hooks it added; a
132
+ * hook that registers a further onRegister hook drains it in the same pass.
133
+ */
134
+ async runRegister(): Promise<void> {
135
+ while (this.#registerCursor < this.#register.length) {
136
+ const fn = this.#register[this.#registerCursor]!;
137
+ this.#registerCursor++;
138
+ await this.#runObserved('register-hook', this.#registerCursor, fn);
139
+ }
140
+ }
141
+
142
+ /** Runs all onInit hooks in registration order. */
143
+ async runInit(): Promise<void> {
144
+ let ordinal = 0;
145
+ for (const fn of this.#init) {
146
+ ordinal++;
147
+ await this.#runObserved('init', ordinal, fn);
148
+ }
149
+ }
150
+
151
+ /** Runs all onBootstrap hooks in registration order. */
152
+ async runBootstrap(): Promise<void> {
153
+ let ordinal = 0;
154
+ for (const fn of this.#bootstrap) {
155
+ ordinal++;
156
+ await this.#runObserved('bootstrap', ordinal, fn);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Whether any onStopping hook is registered.
162
+ *
163
+ * `Application` checks this rather than awaiting unconditionally: awaiting
164
+ * an already-resolved promise still defers the rest of `stop()` by a
165
+ * microtask, which would move when `#stopping` flips and change the answer a
166
+ * request arriving in that same tick gets. Branching keeps the new phase
167
+ * genuinely zero-width for an application that registers no hook.
168
+ */
169
+ hasStopping(): boolean {
170
+ return this.#stopping.length > 0;
171
+ }
172
+
173
+ /**
174
+ * Runs stopping hooks in reverse registration order (LIFO), before the
175
+ * application starts refusing requests.
176
+ */
177
+ async runStopping(): Promise<void> {
178
+ for (let i = this.#stopping.length - 1; i >= 0; i--) {
179
+ await this.#runObserved('stopping', this.#stopping.length - i, this.#stopping[i]!);
180
+ }
181
+ }
182
+
183
+ /** Runs shutdown hooks in reverse registration order (LIFO cleanup). */
184
+ async runShutdown(): Promise<void> {
185
+ for (let i = this.#shutdown.length - 1; i >= 0; i--) {
186
+ await this.#runObserved('shutdown', this.#shutdown.length - i, this.#shutdown[i]!);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Runs close hooks in registration order (after shutdown completes).
192
+ *
193
+ * EVERY hook runs, even when an earlier one rejects: a close hook releases
194
+ * one plugin's resources, and letting the first failure abort the loop meant
195
+ * one plugin that could not disconnect kept every later plugin from
196
+ * releasing anything — the M50 `onStopping` defect in a second place. The
197
+ * caller still learns about the failures, from an `AggregateError` raised
198
+ * once the whole list has run; a single failure is rethrown as itself, so an
199
+ * existing `instanceof` check on the hook's own error still matches.
200
+ *
201
+ * @throws {AggregateError} When more than one hook rejected
202
+ */
203
+ async runClose(): Promise<void> {
204
+ const errors: unknown[] = [];
205
+ let ordinal = 0;
206
+ for (const fn of this.#close) {
207
+ ordinal++;
208
+ try {
209
+ await this.#runObserved('close', ordinal, fn);
210
+ } catch (error) {
211
+ errors.push(error);
212
+ }
213
+ }
214
+ if (errors.length === 1) {
215
+ throw errors[0];
216
+ }
217
+ if (errors.length > 1) {
218
+ throw new AggregateError(errors, 'One or more close hooks failed.');
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Returns the request hooks list for the application to invoke during
224
+ * request processing.
225
+ */
226
+ getRequestHooks(): readonly RequestHook[] {
227
+ return this.#request;
228
+ }
229
+
230
+ /**
231
+ * Returns the response hooks list for the application to invoke after
232
+ * the response is produced.
233
+ */
234
+ getResponseHooks(): readonly RequestHook[] {
235
+ return this.#response;
236
+ }
237
+
238
+ /**
239
+ * Returns the error hooks list for the application to invoke when an
240
+ * error escapes middleware or a handler.
241
+ */
242
+ getErrorHooks(): readonly ErrorHook[] {
243
+ return this.#error;
244
+ }
245
+
246
+ /**
247
+ * Runs one hook, observing its completion when an observer is installed.
248
+ * With no observer this is a bare `await fn()` — zero added work. A
249
+ * throwing hook is recorded as `failed` (never with the error itself) and
250
+ * re-thrown with its identity intact, so ordering, aggregation, and
251
+ * propagation semantics are unchanged.
252
+ */
253
+ async #runObserved(phase: LifecycleHookPhase, ordinal: number, fn: VoidHook): Promise<void> {
254
+ const observer = this.#observer;
255
+ if (observer === undefined) {
256
+ await fn();
257
+ return;
258
+ }
259
+ const startedAtMs = observer.clock();
260
+ let failed = false;
261
+ try {
262
+ await fn();
263
+ } catch (error) {
264
+ failed = true;
265
+ observer.onHook({
266
+ phase,
267
+ ordinal,
268
+ failed,
269
+ startedAtMs,
270
+ durationMs: monotonicElapsed(observer.clock, startedAtMs),
271
+ });
272
+ throw error;
273
+ }
274
+ observer.onHook({
275
+ phase,
276
+ ordinal,
277
+ failed,
278
+ startedAtMs,
279
+ durationMs: monotonicElapsed(observer.clock, startedAtMs),
280
+ });
281
+ }
282
+ }