@setu-ts/kernel 0.0.0-stage → 0.9.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.
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/dist/application/application.d.ts +172 -0
- package/dist/application/application.d.ts.map +1 -0
- package/dist/application/application.js +1862 -0
- package/dist/application/application.js.map +1 -0
- package/dist/application/inject-body.d.ts +58 -0
- package/dist/application/inject-body.d.ts.map +1 -0
- package/dist/application/inject-body.js +82 -0
- package/dist/application/inject-body.js.map +1 -0
- package/dist/context/request-context.d.ts +32 -0
- package/dist/context/request-context.d.ts.map +1 -0
- package/dist/context/request-context.js +136 -0
- package/dist/context/request-context.js.map +1 -0
- package/dist/context/response.d.ts +60 -0
- package/dist/context/response.d.ts.map +1 -0
- package/dist/context/response.js +193 -0
- package/dist/context/response.js.map +1 -0
- package/dist/diagnostics/buffer.d.ts +143 -0
- package/dist/diagnostics/buffer.d.ts.map +1 -0
- package/dist/diagnostics/buffer.js +185 -0
- package/dist/diagnostics/buffer.js.map +1 -0
- package/dist/diagnostics/collector.d.ts +284 -0
- package/dist/diagnostics/collector.d.ts.map +1 -0
- package/dist/diagnostics/collector.js +812 -0
- package/dist/diagnostics/collector.js.map +1 -0
- package/dist/diagnostics/projection.d.ts +153 -0
- package/dist/diagnostics/projection.d.ts.map +1 -0
- package/dist/diagnostics/projection.js +226 -0
- package/dist/diagnostics/projection.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle/lifecycle-manager.d.ts +136 -0
- package/dist/lifecycle/lifecycle-manager.d.ts.map +1 -0
- package/dist/lifecycle/lifecycle-manager.js +206 -0
- package/dist/lifecycle/lifecycle-manager.js.map +1 -0
- package/dist/pipeline/execute-chain.d.ts +71 -0
- package/dist/pipeline/execute-chain.d.ts.map +1 -0
- package/dist/pipeline/execute-chain.js +104 -0
- package/dist/pipeline/execute-chain.js.map +1 -0
- package/dist/pipeline/middleware-pipeline.d.ts +68 -0
- package/dist/pipeline/middleware-pipeline.d.ts.map +1 -0
- package/dist/pipeline/middleware-pipeline.js +82 -0
- package/dist/pipeline/middleware-pipeline.js.map +1 -0
- package/dist/registry/plugin-resolver.d.ts +42 -0
- package/dist/registry/plugin-resolver.d.ts.map +1 -0
- package/dist/registry/plugin-resolver.js +140 -0
- package/dist/registry/plugin-resolver.js.map +1 -0
- package/dist/registry/service-registry.d.ts +110 -0
- package/dist/registry/service-registry.d.ts.map +1 -0
- package/dist/registry/service-registry.js +197 -0
- package/dist/registry/service-registry.js.map +1 -0
- package/dist/router/route-matcher.d.ts +85 -0
- package/dist/router/route-matcher.d.ts.map +1 -0
- package/dist/router/route-matcher.js +102 -0
- package/dist/router/route-matcher.js.map +1 -0
- package/dist/router/router.d.ts +130 -0
- package/dist/router/router.d.ts.map +1 -0
- package/dist/router/router.js +350 -0
- package/dist/router/router.js.map +1 -0
- package/package.json +48 -3
- package/src/application/application.ts +2299 -0
- package/src/application/inject-body.ts +131 -0
- package/src/context/request-context.ts +169 -0
- package/src/context/response.ts +254 -0
- package/src/diagnostics/buffer.ts +241 -0
- package/src/diagnostics/collector.ts +1031 -0
- package/src/diagnostics/projection.ts +322 -0
- package/src/index.ts +20 -0
- package/src/lifecycle/lifecycle-manager.ts +287 -0
- package/src/pipeline/execute-chain.ts +164 -0
- package/src/pipeline/middleware-pipeline.ts +131 -0
- package/src/registry/plugin-resolver.ts +185 -0
- package/src/registry/service-registry.ts +267 -0
- package/src/router/route-matcher.ts +131 -0
- 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,287 @@
|
|
|
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'
|
|
26
|
+
| 'init'
|
|
27
|
+
| 'bootstrap'
|
|
28
|
+
| 'stopping'
|
|
29
|
+
| 'shutdown'
|
|
30
|
+
| 'close';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One hook invocation observed with its fixed phase plus execution ordinal —
|
|
34
|
+
* ownership is NEVER inferred, because the manager does not record it.
|
|
35
|
+
*
|
|
36
|
+
* @since 0.8.0
|
|
37
|
+
*/
|
|
38
|
+
export interface LifecycleHookObservation {
|
|
39
|
+
/** The lifecycle phase being drained. */
|
|
40
|
+
readonly phase: LifecycleHookPhase;
|
|
41
|
+
/** 1-based EXECUTION order within the phase (LIFO phases count downward). */
|
|
42
|
+
readonly ordinal: number;
|
|
43
|
+
/** Whether the hook threw (the error itself is never retained). */
|
|
44
|
+
readonly failed: boolean;
|
|
45
|
+
/** Monotonic start offset, or `null` when no runtime clock is available. */
|
|
46
|
+
readonly startedAtMs: number | null;
|
|
47
|
+
/** Inclusive monotonic elapsed ms, or `null`. */
|
|
48
|
+
readonly durationMs: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The diagnostics observer the application passes to instrument hook
|
|
53
|
+
* execution. Observation never changes hook ordering, aggregation, or
|
|
54
|
+
* propagation semantics.
|
|
55
|
+
*
|
|
56
|
+
* @since 0.8.0
|
|
57
|
+
*/
|
|
58
|
+
export interface LifecycleObserver {
|
|
59
|
+
/** Monotonic clock; returns `null` when no runtime clock is available. */
|
|
60
|
+
readonly clock: () => number | null;
|
|
61
|
+
/** Called once per executed hook, in execution order. */
|
|
62
|
+
readonly onHook: (observation: LifecycleHookObservation) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Default implementation of {@linkcode ILifecycleApi}. Stores hook arrays
|
|
67
|
+
* and exposes execution methods for each lifecycle phase.
|
|
68
|
+
*/
|
|
69
|
+
export class LifecycleManager implements ILifecycleApi {
|
|
70
|
+
readonly #register: VoidHook[] = [];
|
|
71
|
+
/** Count of onRegister hooks already drained by {@linkcode runRegister}. */
|
|
72
|
+
#registerCursor = 0;
|
|
73
|
+
readonly #init: VoidHook[] = [];
|
|
74
|
+
readonly #bootstrap: VoidHook[] = [];
|
|
75
|
+
readonly #request: RequestHook[] = [];
|
|
76
|
+
readonly #response: RequestHook[] = [];
|
|
77
|
+
readonly #error: ErrorHook[] = [];
|
|
78
|
+
readonly #stopping: VoidHook[] = [];
|
|
79
|
+
readonly #shutdown: VoidHook[] = [];
|
|
80
|
+
readonly #close: VoidHook[] = [];
|
|
81
|
+
#observer: LifecycleObserver | undefined;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Installs the diagnostics observer for hook invocations. Pass `undefined`
|
|
85
|
+
* to detach (teardown).
|
|
86
|
+
*
|
|
87
|
+
* @param observer - The observer, or `undefined`
|
|
88
|
+
* @since 0.8.0
|
|
89
|
+
*/
|
|
90
|
+
setLifecycleObserver(observer: LifecycleObserver | undefined): void {
|
|
91
|
+
this.#observer = observer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onRegister(fn: () => void | Promise<void>): void {
|
|
95
|
+
this.#register.push(fn);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
onInit(fn: () => void | Promise<void>): void {
|
|
99
|
+
this.#init.push(fn);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onBootstrap(fn: () => void | Promise<void>): void {
|
|
103
|
+
this.#bootstrap.push(fn);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
onRequest(fn: (ctx: IRequestContext) => void | Promise<void>): void {
|
|
107
|
+
this.#request.push(fn);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
onResponse(fn: (ctx: IRequestContext) => void | Promise<void>): void {
|
|
111
|
+
this.#response.push(fn);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onError(fn: (error: Error, ctx: IRequestContext) => void | Promise<void>): void {
|
|
115
|
+
this.#error.push(fn);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onStopping(fn: () => void | Promise<void>): void {
|
|
119
|
+
this.#stopping.push(fn);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onShutdown(fn: () => void | Promise<void>): void {
|
|
123
|
+
this.#shutdown.push(fn);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
onClose(fn: () => void | Promise<void>): void {
|
|
127
|
+
this.#close.push(fn);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Runs onRegister hooks added since the previous call, in registration
|
|
132
|
+
* order. The kernel invokes this immediately after each plugin's
|
|
133
|
+
* `register()` returns, so a plugin's onRegister hooks run "during the
|
|
134
|
+
* owning plugin's registration" (per {@linkcode ILifecycleApi.onRegister})
|
|
135
|
+
* — after that plugin and before the next one. A cursor tracks how many
|
|
136
|
+
* hooks have already run so each plugin only fires the hooks it added; a
|
|
137
|
+
* hook that registers a further onRegister hook drains it in the same pass.
|
|
138
|
+
*/
|
|
139
|
+
async runRegister(): Promise<void> {
|
|
140
|
+
while (this.#registerCursor < this.#register.length) {
|
|
141
|
+
const fn = this.#register[this.#registerCursor]!;
|
|
142
|
+
this.#registerCursor++;
|
|
143
|
+
await this.#runObserved('register-hook', this.#registerCursor, fn);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Runs all onInit hooks in registration order. */
|
|
148
|
+
async runInit(): Promise<void> {
|
|
149
|
+
let ordinal = 0;
|
|
150
|
+
for (const fn of this.#init) {
|
|
151
|
+
ordinal++;
|
|
152
|
+
await this.#runObserved('init', ordinal, fn);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Runs all onBootstrap hooks in registration order. */
|
|
157
|
+
async runBootstrap(): Promise<void> {
|
|
158
|
+
let ordinal = 0;
|
|
159
|
+
for (const fn of this.#bootstrap) {
|
|
160
|
+
ordinal++;
|
|
161
|
+
await this.#runObserved('bootstrap', ordinal, fn);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether any onStopping hook is registered.
|
|
167
|
+
*
|
|
168
|
+
* `Application` checks this rather than awaiting unconditionally: awaiting
|
|
169
|
+
* an already-resolved promise still defers the rest of `stop()` by a
|
|
170
|
+
* microtask, which would move when `#stopping` flips and change the answer a
|
|
171
|
+
* request arriving in that same tick gets. Branching keeps the new phase
|
|
172
|
+
* genuinely zero-width for an application that registers no hook.
|
|
173
|
+
*/
|
|
174
|
+
hasStopping(): boolean {
|
|
175
|
+
return this.#stopping.length > 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Runs stopping hooks in reverse registration order (LIFO), before the
|
|
180
|
+
* application starts refusing requests.
|
|
181
|
+
*/
|
|
182
|
+
async runStopping(): Promise<void> {
|
|
183
|
+
for (let i = this.#stopping.length - 1; i >= 0; i--) {
|
|
184
|
+
await this.#runObserved('stopping', this.#stopping.length - i, this.#stopping[i]!);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Runs shutdown hooks in reverse registration order (LIFO cleanup). */
|
|
189
|
+
async runShutdown(): Promise<void> {
|
|
190
|
+
for (let i = this.#shutdown.length - 1; i >= 0; i--) {
|
|
191
|
+
await this.#runObserved('shutdown', this.#shutdown.length - i, this.#shutdown[i]!);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Runs close hooks in registration order (after shutdown completes).
|
|
197
|
+
*
|
|
198
|
+
* EVERY hook runs, even when an earlier one rejects: a close hook releases
|
|
199
|
+
* one plugin's resources, and letting the first failure abort the loop meant
|
|
200
|
+
* one plugin that could not disconnect kept every later plugin from
|
|
201
|
+
* releasing anything — the M50 `onStopping` defect in a second place. The
|
|
202
|
+
* caller still learns about the failures, from an `AggregateError` raised
|
|
203
|
+
* once the whole list has run; a single failure is rethrown as itself, so an
|
|
204
|
+
* existing `instanceof` check on the hook's own error still matches.
|
|
205
|
+
*
|
|
206
|
+
* @throws {AggregateError} When more than one hook rejected
|
|
207
|
+
*/
|
|
208
|
+
async runClose(): Promise<void> {
|
|
209
|
+
const errors: unknown[] = [];
|
|
210
|
+
let ordinal = 0;
|
|
211
|
+
for (const fn of this.#close) {
|
|
212
|
+
ordinal++;
|
|
213
|
+
try {
|
|
214
|
+
await this.#runObserved('close', ordinal, fn);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
errors.push(error);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (errors.length === 1) {
|
|
220
|
+
throw errors[0];
|
|
221
|
+
}
|
|
222
|
+
if (errors.length > 1) {
|
|
223
|
+
throw new AggregateError(errors, 'One or more close hooks failed.');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Returns the request hooks list for the application to invoke during
|
|
229
|
+
* request processing.
|
|
230
|
+
*/
|
|
231
|
+
getRequestHooks(): readonly RequestHook[] {
|
|
232
|
+
return this.#request;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Returns the response hooks list for the application to invoke after
|
|
237
|
+
* the response is produced.
|
|
238
|
+
*/
|
|
239
|
+
getResponseHooks(): readonly RequestHook[] {
|
|
240
|
+
return this.#response;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Returns the error hooks list for the application to invoke when an
|
|
245
|
+
* error escapes middleware or a handler.
|
|
246
|
+
*/
|
|
247
|
+
getErrorHooks(): readonly ErrorHook[] {
|
|
248
|
+
return this.#error;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Runs one hook, observing its completion when an observer is installed.
|
|
253
|
+
* With no observer this is a bare `await fn()` — zero added work. A
|
|
254
|
+
* throwing hook is recorded as `failed` (never with the error itself) and
|
|
255
|
+
* re-thrown with its identity intact, so ordering, aggregation, and
|
|
256
|
+
* propagation semantics are unchanged.
|
|
257
|
+
*/
|
|
258
|
+
async #runObserved(phase: LifecycleHookPhase, ordinal: number, fn: VoidHook): Promise<void> {
|
|
259
|
+
const observer = this.#observer;
|
|
260
|
+
if (observer === undefined) {
|
|
261
|
+
await fn();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const startedAtMs = observer.clock();
|
|
265
|
+
let failed = false;
|
|
266
|
+
try {
|
|
267
|
+
await fn();
|
|
268
|
+
} catch (error) {
|
|
269
|
+
failed = true;
|
|
270
|
+
observer.onHook({
|
|
271
|
+
phase,
|
|
272
|
+
ordinal,
|
|
273
|
+
failed,
|
|
274
|
+
startedAtMs,
|
|
275
|
+
durationMs: monotonicElapsed(observer.clock, startedAtMs),
|
|
276
|
+
});
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
observer.onHook({
|
|
280
|
+
phase,
|
|
281
|
+
ordinal,
|
|
282
|
+
failed,
|
|
283
|
+
startedAtMs,
|
|
284
|
+
durationMs: monotonicElapsed(observer.clock, startedAtMs),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|