@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,2326 @@
1
+ /**
2
+ * Application — the kernel entry point. Orchestrates plugin registration,
3
+ * environment validation, middleware pipeline compilation, and request
4
+ * handling with lifecycle hooks.
5
+ *
6
+ * @module
7
+ */
8
+ import {
9
+ CAPABILITIES,
10
+ ERROR_RESPONDER_STATE_KEY,
11
+ errorResponderOf,
12
+ isPromiseLike,
13
+ parseFormBody,
14
+ parseJsonBody,
15
+ respondWithError,
16
+ serializeError,
17
+ setUpgradeIntent,
18
+ } from '@setu-ts/common';
19
+ import type {
20
+ CliCommandHandler,
21
+ DecoratorHandler,
22
+ EnvVarSpec,
23
+ FormBody,
24
+ HealthIndicatorFn,
25
+ IApplication,
26
+ IConfig,
27
+ IContainer,
28
+ IDiagnosticsSource,
29
+ IErrorResponder,
30
+ IHttpAdapter,
31
+ ILogger,
32
+ IMetadataStore,
33
+ IMiddlewareApi,
34
+ IPlugin,
35
+ IPluginContext,
36
+ IRequest,
37
+ IRequestContext,
38
+ IRouterApi,
39
+ IRuntimeServices,
40
+ ITelemetryService,
41
+ IWebSocketService,
42
+ MetricConfig,
43
+ RouteDefinition,
44
+ StartOptions,
45
+ WebSocketUpgradeDecision,
46
+ } from '@setu-ts/common';
47
+ import type { IGrpcService } from '@setu-ts/common';
48
+
49
+ import { MiddlewarePipeline } from '../pipeline/middleware-pipeline.ts';
50
+ import { executeChain } from '../pipeline/execute-chain.ts';
51
+ import type { ChainObserver } from '../pipeline/execute-chain.ts';
52
+ import { findUnsatisfiedConsumers, resolvePluginOrder } from '../registry/plugin-resolver.ts';
53
+ import { ServiceRegistry } from '../registry/service-registry.ts';
54
+ import { Router } from '../router/router.ts';
55
+ import type { RouteEntry } from '../router/router.ts';
56
+ import { isPathDecodable } from '../router/route-matcher.ts';
57
+ import { LifecycleManager } from '../lifecycle/lifecycle-manager.ts';
58
+ import { createRequestContext } from '../context/request-context.ts';
59
+ import type { RequestContextHandle } from '../context/request-context.ts';
60
+ import { coerceInjectBody } from './inject-body.ts';
61
+ import { ResponseBuilder } from '../context/response.ts';
62
+ import { DiagnosticsCollector } from '../diagnostics/collector.ts';
63
+ import { compileLabelAllowlists } from '../diagnostics/projection.ts';
64
+ import type {
65
+ KernelDiagnosticsLabelOptions,
66
+ KernelDiagnosticsOptions,
67
+ } from '../diagnostics/projection.ts';
68
+ import type {
69
+ DiagnosticsBatch,
70
+ DiagnosticsEventOutcome,
71
+ DiagnosticsSnapshot,
72
+ } from '@setu-ts/common';
73
+
74
+ export type { KernelDiagnosticsLabelOptions, KernelDiagnosticsOptions };
75
+
76
+ /**
77
+ * Kernel-internal handle on an application's diagnostics collector.
78
+ *
79
+ * `IApplication.diagnostics` deliberately hands out a frozen two-method facade,
80
+ * so the collector's writer surface is unreachable from a plugin — which is the
81
+ * point, since `IPluginContext.app` gives every plugin the application. Kernel
82
+ * tests still need the real object to drive its `safeObserve` boundary, so it
83
+ * lives here: a module-scoped `WeakMap` that is NOT on the package barrel and
84
+ * that a plugin cannot enumerate off the instance the way a symbol property
85
+ * could. The `ServiceRegistry.peekResolved` precedent — an internal seam whose
86
+ * absence from the public surface is the whole design.
87
+ *
88
+ * @internal
89
+ */
90
+ const COLLECTORS = new WeakMap<IApplication, DiagnosticsCollector>();
91
+
92
+ /**
93
+ * Returns an application's diagnostics collector, or `undefined` when
94
+ * diagnostics were not enabled.
95
+ *
96
+ * @param app - The application to look up
97
+ * @returns The collector, or `undefined`
98
+ * @internal Kernel-internal; never exported from `src/index.ts`.
99
+ */
100
+ export function collectorOf(app: IApplication): DiagnosticsCollector | undefined {
101
+ return COLLECTORS.get(app);
102
+ }
103
+
104
+ /** Options for {@linkcode createApplication}. */
105
+ export interface ApplicationOptions {
106
+ /** Plugins to pre-register before {@linkcode IApplication.start}. */
107
+ plugins?: IPlugin[];
108
+ /**
109
+ * Explicit kernel diagnostics activation. An omitted option leaves
110
+ * {@linkcode IApplication.diagnostics} absent and creates no collector,
111
+ * metadata mirror, ring buffer, or timer anywhere in the kernel; supplying
112
+ * an object (even `{}`) enables collection. Malformed label allowlists
113
+ * reject construction — the one diagnostics failure that may.
114
+ *
115
+ * @since 0.8.0
116
+ */
117
+ diagnostics?: KernelDiagnosticsOptions;
118
+ }
119
+
120
+ /**
121
+ * Inject request shape for {@linkcode IKernelApplication.inject}.
122
+ *
123
+ * @since 0.1.0
124
+ */
125
+ export interface InjectRequest {
126
+ /** Socket peer to carry into `IRequest.ip`; omitted by default. */
127
+ ip?: string;
128
+ /** HTTP method. */
129
+ method: string;
130
+ /** Full request URL. */
131
+ url: string;
132
+ /** Request headers. */
133
+ headers?: Record<string, string> | Headers;
134
+ /**
135
+ * Request body. Carried VERBATIM for every byte-ish shape, but the
136
+ * content-type default differs between them: a `Uint8Array` and an
137
+ * `ArrayBuffer` get NONE, since only the caller knows whether those bytes
138
+ * are multipart, JSON, or an image, while a `Blob` DECLARES its own and
139
+ * contributes a non-empty `.type` as the default — matching the platform,
140
+ * whose `new Request(url, { body: blob })` sets the header from it and omits
141
+ * it when the blob has none. A `URLSearchParams` is serialised with its own
142
+ * `toString()` and defaults the content type to
143
+ * `application/x-www-form-urlencoded`; a plain object is JSON-serialised and
144
+ * a bare `string` is carried as-is, both defaulting to `application/json`.
145
+ * An explicitly supplied content type always wins, and the default is
146
+ * written onto a COPY of `headers`, so a `Headers` instance reused across
147
+ * two injected requests is never mutated.
148
+ *
149
+ * Any other shape — an array, a `Date`, a class instance, a number — is
150
+ * REFUSED with a `TypeError` naming the received type, rather than silently
151
+ * JSON-stringified as before (which turned a `Uint8Array` into
152
+ * `{"0":97,…}` and every other non-string into `{}`).
153
+ *
154
+ * The last arm is deliberately `Record<string, unknown>`, not `object`:
155
+ * `object` admits arrays, `Date`s, streams and every class instance, so the
156
+ * union would go on accepting exactly the values that used to silently
157
+ * JSON-coerce.
158
+ */
159
+ body?: string | Uint8Array | ArrayBuffer | Blob | URLSearchParams | Record<string, unknown>;
160
+ }
161
+
162
+ /**
163
+ * Inject response shape returned by {@linkcode IKernelApplication.inject}.
164
+ *
165
+ * @since 0.1.0
166
+ */
167
+ export interface InjectResponse {
168
+ /** Response status code. */
169
+ readonly statusCode: number;
170
+ /** Response headers. */
171
+ readonly headers: Headers;
172
+ /**
173
+ * Raw response body as text. A byte body (from `response.send(bytes)`) is
174
+ * UTF-8 decoded; `null` only when the response genuinely has no body.
175
+ */
176
+ readonly body: string | null;
177
+ /**
178
+ * Parses the response body as JSON.
179
+ *
180
+ * @throws {Error} If the response has no body
181
+ * @throws {SyntaxError} If the body is not valid JSON
182
+ */
183
+ json<T>(): T;
184
+ }
185
+
186
+ /** Kernel application extends IApplication with inject() capability. */
187
+ export interface IKernelApplication extends IApplication {
188
+ /**
189
+ * Synthesizes an incoming request and runs it through the full pipeline
190
+ * without requiring a listening server.
191
+ *
192
+ * @param request - The synthetic request
193
+ * @returns The inject response
194
+ */
195
+ inject(request: InjectRequest): Promise<InjectResponse>;
196
+ /**
197
+ * Removes a pending plugin by name before the application starts.
198
+ *
199
+ * Plugins do not run until `start()`, so removing one here means its
200
+ * `register()` — and any eager side effect inside it, such as a database
201
+ * adapter's `connect()` — never happens at all. That is the one thing
202
+ * overriding a capability cannot do: an override replaces what consumers
203
+ * resolve, after the real plugin has already run.
204
+ *
205
+ * Removing a plugin another plugin declares in `dependencies` is not
206
+ * refused here; `start()` reports it — naming the dependent plugin and the unsatisfied
207
+ * capability, though not the removed plugin's own name when the two differ
208
+ * (`database-plugin` provides `database`).
209
+ *
210
+ * Removes **every** pending plugin carrying the name, not the first. Two may
211
+ * share one — the kernel refuses duplicates at `start()`, not at `register()`
212
+ * — and dropping one would leave the other running while the caller was told
213
+ * the name was gone.
214
+ *
215
+ * @param name - The plugin's `name`, as declared on `IPlugin`
216
+ * @returns `true` when at least one plugin was removed, `false` when none carried that name
217
+ * @throws {Error} If startup has begun — including after a `start()` that
218
+ * FAILED, since the plugins that ran before the failure cannot be un-run
219
+ * @example
220
+ * ```typescript
221
+ * const app = createApp(); // the project's own composition root
222
+ * app.unregister('database'); // the real adapter never connects
223
+ * await app.start();
224
+ * ```
225
+ * @since 0.6.0
226
+ */
227
+ unregister(name: string): boolean;
228
+ /**
229
+ * Reports whether a plugin carrying this name is pending.
230
+ *
231
+ * A pure read — it neither resolves nor constructs anything. It exists so a
232
+ * caller applying several exclusions can validate the whole set BEFORE
233
+ * removing any of them: `unregister` mutates immediately, so removing as you
234
+ * go leaves earlier exclusions applied when a later name turns out to be
235
+ * misspelled, handing a caller that catches the error a silently altered
236
+ * application.
237
+ *
238
+ * Answers against the registered plugin list, which startup does NOT clear — so
239
+ * after `start()` it still reports `true` for a plugin that has already run.
240
+ * Its purpose is pre-`start()` validation, and `unregister` throws once any
241
+ * plugin has registered, so a later answer is not actionable either way.
242
+ *
243
+ * @param name - The plugin's `name`, as declared on `IPlugin`
244
+ * @returns `true` when at least one pending plugin carries that name
245
+ * @since 0.6.0
246
+ */
247
+ hasPlugin(name: string): boolean;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Application class (internal — not exported from index)
252
+ // ---------------------------------------------------------------------------
253
+
254
+ class Application implements IKernelApplication {
255
+ readonly #registry = new ServiceRegistry();
256
+ readonly #pipeline = new MiddlewarePipeline();
257
+ #registeringPlugin: string | undefined;
258
+ readonly #router = new Router(() => this.#registeringPlugin);
259
+ readonly #lifecycle = new LifecycleManager();
260
+ readonly #plugins: IPlugin[] = [];
261
+ readonly #envSpecs: {
262
+ name: string;
263
+ spec: Readonly<Record<string, EnvVarSpec>>;
264
+ }[] = [];
265
+ #started = false;
266
+ /**
267
+ * Whether any plugin has begun registering. Unlike {@linkcode #started} this is
268
+ * never reset: plugins that already ran cannot be un-run. `start()` rolls
269
+ * `#started` back on failure, but only a failure BEFORE this flag is set can be
270
+ * corrected and retried — once a plugin's `register()` has run, its services,
271
+ * hooks, routes and middleware (and any side effect outside the kernel) survive
272
+ * the rollback, so `start()` reads this to refuse a retry by name rather than
273
+ * failing on the leftover state. `unregister` reads it so it can never report
274
+ * having removed a plugin whose `register()` has already executed.
275
+ *
276
+ * Set inside the registration loop rather than on entry to `#runStartup`,
277
+ * because plugin RESOLUTION can fail before anything runs — and correcting
278
+ * that failure by removing the offending plugin is exactly what `unregister`
279
+ * is for.
280
+ */
281
+ #registrationStarted = false;
282
+ /** The plugins in registration order, once `start()` has resolved them. */
283
+ #ordered: readonly IPlugin[] = [];
284
+ #serverHandle: unknown = null;
285
+ #inFlight = 0;
286
+ #stopping = false;
287
+ /** Cached in-flight/completed shutdown, so stop() runs its side effects once. */
288
+ #stopPromise: Promise<void> | null = null;
289
+ /**
290
+ * The kernel diagnostics collector — `undefined` unless the application was
291
+ * created with `diagnostics` explicitly enabled. Every read of this field
292
+ * gates instrumentation, so the disabled path performs no additional work.
293
+ */
294
+ readonly #collector: DiagnosticsCollector | undefined;
295
+ /**
296
+ * The frozen read-only facade `diagnostics` hands out. The collector itself
297
+ * is NEVER exposed: it carries the writer surface (`markClosed`,
298
+ * `markStartupFailed`, `safeObserve`, every `observe*`), and `ctx.app` gives
299
+ * every plugin the application, so returning the collector put those methods
300
+ * one cast away from any plugin in the process. Reproduced before fixing: a
301
+ * plugin calling `markClosed(false)` through that cast left the reader
302
+ * permanently `closed` with empty topology while the application went on
303
+ * serving — the contract's "never a writer" broken with no error anywhere.
304
+ */
305
+ readonly #diagnostics: IDiagnosticsSource | undefined;
306
+
307
+ constructor(diagnostics?: KernelDiagnosticsOptions) {
308
+ // Bound so `fetch` survives being passed around as a bare function. The
309
+ // Workers entry this framework documents is `export default { fetch:
310
+ // app.fetch }`, and Cloudflare invokes it as `exported.fetch(request, …)`,
311
+ // so `this` is the exported object: unbound, the `#registry` read below
312
+ // threw "Cannot read private member" and every request answered 500.
313
+ // Measured on workerd. Hono binds its `app.fetch` for the same reason.
314
+ this.fetch = this.fetch.bind(this);
315
+ this.#registry.setMissExplanation((token) => this.#explainMissingCapability(token));
316
+ if (diagnostics === undefined) {
317
+ return;
318
+ }
319
+ // Construction is the ONLY place option validation may reject: malformed
320
+ // label allowlists throw here, value-free, before any collection exists.
321
+ const collector = new DiagnosticsCollector(compileLabelAllowlists(diagnostics));
322
+ this.#collector = collector;
323
+ // Frozen so the facade cannot be re-pointed either: a consumer holding it
324
+ // can reach exactly the two contract methods and nothing else.
325
+ COLLECTORS.set(this, collector);
326
+ this.#diagnostics = Object.freeze({
327
+ snapshot: (): DiagnosticsSnapshot => collector.snapshot(),
328
+ read: (after: number, limit?: number): DiagnosticsBatch => collector.read(after, limit),
329
+ });
330
+ collector.safeObserve('topology', () => {
331
+ this.#lifecycle.setLifecycleObserver({
332
+ clock: () => collector.monotonicMs(),
333
+ onHook: (observation) => {
334
+ collector.observeLifecycleHook(observation);
335
+ },
336
+ });
337
+ });
338
+ // Diagnostics sinks, installed beside (never instead of) the logging
339
+ // observer registered at startup. They are installed HERE, at
340
+ // construction, and NOT in `#runStartup()`: a route or a capability
341
+ // registered between `createApplication()` and `start()` is registered
342
+ // before startup runs, and a sink attached later cannot see it. That is
343
+ // not an edge case — a CLI-scaffolded project's `createApp()` registers
344
+ // its routes exactly there, through the generated
345
+ // `registerGeneratedRoutes(app.router, …)` setup call and the hello-world
346
+ // route, so the snapshot showed NO routes at all for the default template
347
+ // while the application served them.
348
+ //
349
+ // Composition-time registrations carry no owner, because no plugin's
350
+ // `register()` is running: `#registeringPlugin` is `undefined` and the
351
+ // node is recorded without an `owns` edge. That is the honest answer —
352
+ // the application registered it, not a plugin.
353
+ this.#registry.setDiagnosticsSink((event) => {
354
+ collector.safeObserve('topology', () => {
355
+ collector.capabilityRegistrationObserved(event, this.#registeringPlugin);
356
+ // The runtime capability's OWN registration is the diagnostics
357
+ // epoch: origin and instance UUID start there — the earliest moment
358
+ // they can honestly exist — so every later observation carries
359
+ // timings while earlier ones stay null.
360
+ if (event.token === CAPABILITIES.RUNTIME && event.kind !== 'unregister') {
361
+ const runtime = this.#registry.peekResolved<IRuntimeServices>(CAPABILITIES.RUNTIME);
362
+ if (runtime !== undefined) {
363
+ collector.initializeRuntime(runtime, () =>
364
+ this.#registry.peekResolved<ITelemetryService>(CAPABILITIES.TELEMETRY),
365
+ );
366
+ }
367
+ }
368
+ });
369
+ });
370
+ this.#router.setDiagnosticsSink((event) => {
371
+ collector.safeObserve('topology', () => {
372
+ collector.routeRegistered(event);
373
+ });
374
+ });
375
+ }
376
+
377
+ /**
378
+ * The read-only diagnostics reader when diagnostics are enabled; `undefined`
379
+ * — never a stub — when they are not. This member is the consumer's whole
380
+ * access path: pull-only reads, never a registry token, never a writer.
381
+ *
382
+ * Returns a frozen two-method facade rather than the collector, so "never a
383
+ * writer" holds at RUNTIME and not only in the type — see
384
+ * {@linkcode Application.#diagnostics}.
385
+ * @since 0.8.0
386
+ */
387
+ get diagnostics(): IDiagnosticsSource | undefined {
388
+ return this.#diagnostics;
389
+ }
390
+ /**
391
+ * The application's resolved error responder, read from the compiled
392
+ * pipeline's `errorHandler` brand at startup (M70f re-review, findings 1 & 2).
393
+ *
394
+ * `errorHandler` publishes its responder into `ctx.state` per request, but
395
+ * three kernel sites run BEFORE any middleware: the shutdown-drain `503`,
396
+ * the malformed-request `400`, and the request-lifecycle hooks. Those sites
397
+ * cannot read `ctx.state` (no context yet, or the context is fresh), so the
398
+ * kernel seeds the SAME resolved responder into their state from this cache.
399
+ * `undefined` when no `errorHandler` is registered, in which case every site
400
+ * keeps the byte-identical no-handler fallback.
401
+ */
402
+ #errorResponder: IErrorResponder | undefined = undefined;
403
+
404
+ get services() {
405
+ return this.#registry;
406
+ }
407
+
408
+ get middleware(): IMiddlewareApi {
409
+ return this.#pipeline;
410
+ }
411
+
412
+ get router(): IRouterApi {
413
+ return this.#router;
414
+ }
415
+
416
+ register(plugin: IPlugin): IApplication {
417
+ if (this.#started) {
418
+ throw new Error('Cannot register plugins after the application has started.');
419
+ }
420
+ this.#plugins.push(plugin);
421
+ return this;
422
+ }
423
+
424
+ hasPlugin(name: string): boolean {
425
+ return this.#plugins.some((plugin) => plugin.name === name);
426
+ }
427
+
428
+ unregister(name: string): boolean {
429
+ // `#registrationStarted`, not `#started`: a FAILED `start()` rolls `#started`
430
+ // back, but the plugins that ran before the failure have already run — their `register()` executed and
431
+ // their services are in the registry. Removing one from the pending list
432
+ // then could not deliver what this method promises, and returning `true`
433
+ // would report a removal that did not happen.
434
+ if (this.#registrationStarted) {
435
+ throw new Error(
436
+ 'Cannot unregister plugins once startup has begun. Plugins that already ' +
437
+ 'registered cannot be un-run, so build a fresh application instead.',
438
+ );
439
+ }
440
+ // Removes EVERY match, not the first. Two pending plugins may share a name
441
+ // — `assertUniqueNames` refuses that at `start()`, not at `register()` — and
442
+ // removing one would leave the other running while the caller was told the
443
+ // name was dropped. It also converts that loud startup failure into a
444
+ // silently-running plugin, since the duplicate is gone by the time the
445
+ // resolver looks.
446
+ const before = this.#plugins.length;
447
+ for (let index = this.#plugins.length - 1; index >= 0; index--) {
448
+ if (this.#plugins[index]?.name === name) {
449
+ this.#plugins.splice(index, 1);
450
+ }
451
+ }
452
+ return this.#plugins.length !== before;
453
+ }
454
+
455
+ /**
456
+ * Explains a lookup the registry could not satisfy, in the two cases where
457
+ * the generic advice ("register a plugin that provides it") is wrong:
458
+ *
459
+ * - **Before any plugin has registered.** Plugins register their
460
+ * capabilities during `start()`, so a lookup at module scope, or anywhere
461
+ * before `start()`, finds nothing even when the plugin is listed.
462
+ * - **During a plugin's `register()`, for a token a later plugin provides.**
463
+ * The provider has not run yet; declaring the dependency orders it first.
464
+ *
465
+ * Anything else keeps the generic advice: the capability really is absent.
466
+ *
467
+ * @param token - The token the lookup missed
468
+ * @returns The explanation, or `undefined` for the generic advice
469
+ */
470
+ #explainMissingCapability(token: string): string | undefined {
471
+ if (!this.#registrationStarted) {
472
+ return (
473
+ 'The application has not started: plugins register their capabilities during ' +
474
+ 'start(), so a lookup before then finds nothing. Resolve it inside a route handler or ' +
475
+ 'a lifecycle hook, or after `await app.start()`.'
476
+ );
477
+ }
478
+ const current = this.#registeringPlugin;
479
+ if (current === undefined) {
480
+ return undefined;
481
+ }
482
+ const position = this.#ordered.findIndex((plugin) => plugin.name === current);
483
+ const provider = this.#ordered
484
+ .slice(position + 1)
485
+ .find((plugin) => plugin.provides?.includes(token) === true);
486
+ if (provider === undefined) {
487
+ return undefined;
488
+ }
489
+ // Every plugin implicitly depends on the runtime provider, so a dependency
490
+ // edge from it to a later plugin would be a cycle, not a fix.
491
+ if (this.#ordered[position]?.provides?.includes(CAPABILITIES.RUNTIME) === true) {
492
+ return (
493
+ `Plugin '${provider.name}' provides it, but registers after '${current}', which ` +
494
+ `provides the runtime and so always registers first. Resolve '${token}' in a lifecycle ` +
495
+ `hook such as onInit instead of during register().`
496
+ );
497
+ }
498
+ return (
499
+ `Plugin '${provider.name}' provides it, but registers after '${current}'. Add ` +
500
+ `'${token}' to the dependencies (or optionalDependencies) of '${current}' so it ` +
501
+ `registers first.`
502
+ );
503
+ }
504
+
505
+ async start(options?: StartOptions): Promise<void> {
506
+ if (this.#started) {
507
+ throw new Error('Application has already been started.');
508
+ }
509
+ // A failed start is retryable only if it failed before any plugin ran
510
+ // `register()` (plugin resolution: a missing runtime provider, an
511
+ // unsatisfied dependency, a cycle). After that, every plugin that ran left
512
+ // its services in a registry that may already be sealed, its lifecycle
513
+ // hooks, its routes and its middleware behind — and possibly a resource its
514
+ // `onClose` did not release. A second attempt would re-run `register()`
515
+ // against that state and fail on it (`Capability 'runtime' is already
516
+ // registered`), so refuse by name instead of pretending it can be undone.
517
+ if (this.#registrationStarted) {
518
+ throw new Error(
519
+ 'Cannot retry start() after plugins have registered: a previous start() failed ' +
520
+ 'after plugin registration began, and plugins that already ran cannot be un-run. ' +
521
+ 'Create a new application instead.',
522
+ );
523
+ }
524
+ // Mark as started up-front so plugins cannot register more plugins during
525
+ // startup, but roll it back if any startup step throws (see the catch
526
+ // below) so a start that failed before any plugin registered can be
527
+ // corrected and retried; later failures are refused above.
528
+ this.#started = true;
529
+ try {
530
+ await this.#runStartup(options);
531
+ // Startup fully completed (including listen): the application serves.
532
+ this.#collector?.markRunning();
533
+ } catch (error) {
534
+ // Plugins may have connected resources and registered their `onClose`
535
+ // hook before a later registration, environment validation, or `onInit`
536
+ // step fails. `stop()` deliberately no-ops after a failed start, so this
537
+ // is the only lifecycle path that can release those partial-start
538
+ // resources. Preserve the startup failure: it is the actionable error
539
+ // the caller needs to correct.
540
+ try {
541
+ await this.#lifecycle.runClose();
542
+ } catch {
543
+ // Best effort. A broken cleanup must not hide the startup failure, and
544
+ // later hooks cannot be reached once `runClose()` rejects.
545
+ }
546
+ this.#started = false;
547
+ // Terminal failure state: retained node/edge/event buffers are cleared
548
+ // inside, while the original error below is preserved untouched.
549
+ this.#collector?.markStartupFailed();
550
+ throw error;
551
+ }
552
+ }
553
+
554
+ async #runStartup(options?: StartOptions): Promise<void> {
555
+ this.#collector?.markStarting();
556
+ // 1. Resolve plugin order — throws without runtime provider
557
+ const resolveOperation = this.#collector?.beginOperation();
558
+ let ordered;
559
+ try {
560
+ ordered = resolvePluginOrder(this.#plugins);
561
+ this.#ordered = ordered;
562
+ } catch (error) {
563
+ if (resolveOperation !== undefined) {
564
+ this.#collector?.observeLifecycleEvent(resolveOperation, 'resolve', null, 'error');
565
+ }
566
+ throw error;
567
+ }
568
+ if (resolveOperation !== undefined) {
569
+ this.#collector?.observeLifecycleEvent(resolveOperation, 'resolve', null, 'ok');
570
+ }
571
+
572
+ // The optional getters (config/logger/metadata/container) and the
573
+ // mandatory runtime are resolved lazily via a Proxy. Runtime is fetched
574
+ // on first access so the runtime-providing plugin (which registers
575
+ // first) can populate CAPABILITIES.RUNTIME before any other plugin
576
+ // touches ctx.runtime. Arrow functions below capture `this` lexically.
577
+ const registry = this.#registry;
578
+ const envSpecs = this.#envSpecs;
579
+ const registrationOwner = (): string | undefined => this.#registeringPlugin;
580
+ registry.setObserver((kind, token) => {
581
+ this.#reportRegistryMutation(kind, token, this.#registeringPlugin);
582
+ });
583
+ // The diagnostics SINKS are installed in the constructor (see there);
584
+ // this local is the startup path's own handle on the collector.
585
+ const collector = this.#collector;
586
+ // Name of the plugin whose `register()` is currently running — read by
587
+ // `environment.validate` to attribute each env-var declaration. `undefined`
588
+ // outside the registration loop (e.g. a `validate` call from a lifecycle
589
+ // hook), which the fallback message covers.
590
+ const base: Omit<IPluginContext, 'config' | 'logger' | 'metadata' | 'container' | 'runtime'> = {
591
+ services: registry,
592
+ middleware: this.#pipeline,
593
+ router: this.#router,
594
+ lifecycle: this.#lifecycle,
595
+ health: {
596
+ register(name: string, indicator: HealthIndicatorFn): void {
597
+ registry.register(
598
+ CAPABILITIES.HEALTH_INDICATOR,
599
+ { name, check: indicator },
600
+ { multi: true },
601
+ );
602
+ },
603
+ },
604
+ metrics: {
605
+ register(name: string, config: MetricConfig): void {
606
+ registry.register(CAPABILITIES.METRIC_REGISTRATION, { name, config }, { multi: true });
607
+ },
608
+ },
609
+ openapi: {
610
+ addSchema(name: string, schema: unknown): void {
611
+ registry.register(CAPABILITIES.OPENAPI_SCHEMA, { name, schema }, { multi: true });
612
+ },
613
+ },
614
+ decorators: {
615
+ register(name: string, handler: DecoratorHandler): void {
616
+ registry.register(CAPABILITIES.DECORATOR_HANDLER, { name, handler }, { multi: true });
617
+ },
618
+ },
619
+ cli: {
620
+ register(name: string, handler: CliCommandHandler): void {
621
+ registry.register(CAPABILITIES.CLI_COMMAND, { name, handler }, { multi: true });
622
+ },
623
+ },
624
+ environment: {
625
+ validate(spec: Readonly<Record<string, EnvVarSpec>>): void {
626
+ // Attribute the declaration to the plugin currently registering, so a
627
+ // violation message names the plugin that asked for the variable. One
628
+ // context object is shared by every plugin, so the declaring plugin is
629
+ // read from the registration cursor rather than captured per context.
630
+ envSpecs.push({ name: registrationOwner() ?? 'the application', spec });
631
+ },
632
+ },
633
+ options: {},
634
+ app: this,
635
+ };
636
+
637
+ const lazyGetters: Record<string, () => object> = {
638
+ runtime: () => registry.get<IRuntimeServices>(CAPABILITIES.RUNTIME),
639
+ config: () => registry.get<IConfig>(CAPABILITIES.CONFIG),
640
+ logger: () => registry.get<ILogger>(CAPABILITIES.LOGGER),
641
+ metadata: () => registry.get<IMetadataStore>(CAPABILITIES.METADATA_STORE),
642
+ container: () => registry.get<IContainer>(CAPABILITIES.DI_CONTAINER),
643
+ };
644
+ const lazyAvailable: Record<string, () => boolean> = {
645
+ runtime: () => registry.has(CAPABILITIES.RUNTIME),
646
+ config: () => registry.has(CAPABILITIES.CONFIG),
647
+ logger: () => registry.has(CAPABILITIES.LOGGER),
648
+ metadata: () => registry.has(CAPABILITIES.METADATA_STORE),
649
+ container: () => registry.has(CAPABILITIES.DI_CONTAINER),
650
+ };
651
+
652
+ const ctx: IPluginContext = new Proxy(base as Record<string, unknown>, {
653
+ get(target, prop: string, receiver) {
654
+ if (prop in lazyGetters) {
655
+ return lazyAvailable[prop]() ? lazyGetters[prop]() : undefined;
656
+ }
657
+ return Reflect.get(target, prop, receiver);
658
+ },
659
+ has(target, prop: string) {
660
+ if (prop in lazyGetters) {
661
+ return lazyAvailable[prop]();
662
+ }
663
+ return Reflect.has(target, prop);
664
+ },
665
+ ownKeys(target) {
666
+ const keys = Reflect.ownKeys(target);
667
+ for (const k of Object.keys(lazyGetters)) {
668
+ if (lazyAvailable[k]() && !keys.includes(k)) {
669
+ keys.push(k);
670
+ }
671
+ }
672
+ return keys;
673
+ },
674
+ getOwnPropertyDescriptor(target, prop: string) {
675
+ if (prop in lazyGetters) {
676
+ if (!lazyAvailable[prop]()) {
677
+ return undefined;
678
+ }
679
+ return {
680
+ enumerable: true,
681
+ configurable: true,
682
+ writable: false,
683
+ value: lazyGetters[prop](),
684
+ };
685
+ }
686
+ return Reflect.getOwnPropertyDescriptor(target, prop);
687
+ },
688
+ }) as unknown as IPluginContext;
689
+
690
+ // 3. Register each plugin in resolved order, then run the onRegister
691
+ // hooks it just added — these run "during the owning plugin's
692
+ // registration" (after that plugin, before the next), distinct from
693
+ // the onInit hooks that run once all plugins have registered.
694
+ try {
695
+ for (const plugin of ordered) {
696
+ // Set here, not at the top of `#runStartup`: `resolvePluginOrder` above
697
+ // can throw (an unsatisfied dependency, a cycle, no runtime provider)
698
+ // before any plugin has run, and that is the most correctable startup
699
+ // failure there is — gating `unregister` on it would refuse to let the
700
+ // caller remove the plugin that caused it.
701
+ this.#registrationStarted = true;
702
+ this.#registeringPlugin = plugin.name;
703
+ // Capture the plugin at its registration boundary: node plus DECLARED
704
+ // edges, before `register()` runs and while the owner attribution is
705
+ // unambiguous. Labels are projected through the allowlists here.
706
+ const pluginNodeId = collector?.pluginRegistered({
707
+ name: plugin.name,
708
+ version: plugin.version,
709
+ provides: plugin.provides ?? [],
710
+ requires: plugin.dependencies ?? [],
711
+ optionalDependencies: plugin.optionalDependencies ?? [],
712
+ consumes: plugin.consumes ?? [],
713
+ });
714
+ const registerOperation = collector?.beginOperation();
715
+ try {
716
+ await plugin.register(ctx);
717
+ await this.#lifecycle.runRegister();
718
+ } catch (error) {
719
+ if (registerOperation !== undefined) {
720
+ collector?.observeLifecycleEvent(
721
+ registerOperation,
722
+ 'register',
723
+ pluginNodeId ?? null,
724
+ 'error',
725
+ );
726
+ }
727
+ throw error;
728
+ }
729
+ if (registerOperation !== undefined) {
730
+ collector?.observeLifecycleEvent(
731
+ registerOperation,
732
+ 'register',
733
+ pluginNodeId ?? null,
734
+ 'ok',
735
+ );
736
+ }
737
+ }
738
+ } finally {
739
+ this.#registeringPlugin = undefined;
740
+ }
741
+
742
+ // 4. Validate collected env specs against runtime.env
743
+ const runtime = this.#registry.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
744
+ // Fallback initialization for a runtime registered as a LAZY factory,
745
+ // whose registration cannot be observed as a resolved instance at its
746
+ // mutation. `initializeRuntime` is idempotent, so an application whose
747
+ // runtime registered normally was already initialized in the sink above.
748
+ collector?.initializeRuntime(runtime, () =>
749
+ this.#registry.peekResolved<ITelemetryService>(CAPABILITIES.TELEMETRY),
750
+ );
751
+ this.#validateEnvironment(runtime);
752
+
753
+ // 5. Run init hooks
754
+ await this.#lifecycle.runInit();
755
+
756
+ // 5b. Warn about consumed capabilities that no plugin provides. Checked
757
+ // here — after all plugins have registered and run their init hooks —
758
+ // so the live registry reflects every capability, including any
759
+ // registered imperatively rather than declared in `provides`.
760
+ this.#warnUnsatisfiedConsumers();
761
+
762
+ // 6. Compile the middleware pipeline
763
+ const chain = this.#pipeline.compile();
764
+ // Capture the compiled global pipeline once, in execution order — the
765
+ // stable priority sort — so middleware nodes and stage records agree.
766
+ collector?.middlewareCompiled(this.#pipeline.compiledDescriptors());
767
+
768
+ // 6b. Resolve the application's error responder from the pipeline (M70f
769
+ // re-review, findings 1 & 2). `errorHandler` brands its middleware
770
+ // function with the responder it built at factory time; the kernel
771
+ // caches that ONE instance so the pre-pipeline sites — the drain 503,
772
+ // the malformed-request 400, and the request-lifecycle hooks — can
773
+ // seed it into their state before they run, where `errorHandler`'s own
774
+ // `ctx.state` publication cannot reach them. The LAST branded stage
775
+ // in execution order (the innermost, i.e. the highest priority number
776
+ // among the selected stages) wins, matching the responder that stage
777
+ // publishes into `ctx.state` for in-pipeline sites.
778
+ this.#errorResponder = this.#resolveErrorResponder(chain);
779
+
780
+ // 7. Run bootstrap hooks
781
+ await this.#lifecycle.runBootstrap();
782
+ registry.seal();
783
+
784
+ // 8. Set the handler (always, so app.fetch works even without listen — CF Workers path)
785
+ if (this.#registry.has(CAPABILITIES.HTTP_ADAPTER)) {
786
+ const adapter = this.#registry.get<IHttpAdapter>(CAPABILITIES.HTTP_ADAPTER);
787
+ adapter.setHandler((request: IRequest) => this.#handleRequest(request));
788
+ }
789
+
790
+ // 9. Listen only if adapter + port are available
791
+ if (options?.port !== undefined) {
792
+ if (!this.#registry.has(CAPABILITIES.HTTP_ADAPTER)) {
793
+ throw new Error(
794
+ `Cannot start HTTP server on port ${options.port}: no 'http-adapter' capability is registered. ` +
795
+ `Register the RuntimePlugin or a custom IHttpAdapter.`,
796
+ );
797
+ }
798
+ const adapter = this.#registry.get<IHttpAdapter>(CAPABILITIES.HTTP_ADAPTER);
799
+ const listenOperation = collector?.beginOperation();
800
+ try {
801
+ this.#serverHandle = await adapter.listen(options.port, options.hostname);
802
+ } catch (error) {
803
+ if (listenOperation !== undefined) {
804
+ collector?.observeLifecycleEvent(listenOperation, 'listen', null, 'error');
805
+ }
806
+ throw error;
807
+ }
808
+ if (listenOperation !== undefined) {
809
+ collector?.observeLifecycleEvent(listenOperation, 'listen', null, 'ok');
810
+ }
811
+ this.#announceListening(options.port, options.hostname);
812
+ }
813
+ }
814
+
815
+ stop(): Promise<void> {
816
+ // No-op if the application never started (e.g. a failed start() or a
817
+ // bare createApplication() used only for inject()). Avoids a confusing
818
+ // "No service registered for capability 'runtime'" from #drainRequests.
819
+ if (!this.#started) {
820
+ return Promise.resolve();
821
+ }
822
+ // Idempotent: a second call — concurrent or later — returns the same
823
+ // shutdown promise instead of re-running the drain and the shutdown/close
824
+ // hooks. Without this, repeated stop() calls fire onShutdown/onClose more
825
+ // than once.
826
+ if (this.#stopPromise !== null) {
827
+ return this.#stopPromise;
828
+ }
829
+ this.#stopPromise = this.#doStop();
830
+ return this.#stopPromise;
831
+ }
832
+
833
+ async #doStop(): Promise<void> {
834
+ // Diagnostics state flips to `stopping` as the shutdown begins; the
835
+ // terminal `closed` transition and the retained-buffer clear run in the
836
+ // `finally` below, whatever the shutdown outcome — including a rejecting
837
+ // shutdown or close hook.
838
+ this.#collector?.markStopping();
839
+ let shutdownFailed = false;
840
+ try {
841
+ await this.#runShutdownSequence();
842
+ } catch (error) {
843
+ shutdownFailed = true;
844
+ throw error;
845
+ } finally {
846
+ this.#collector?.markClosed(shutdownFailed);
847
+ }
848
+ }
849
+
850
+ async #runShutdownSequence(): Promise<void> {
851
+ // Runs FIRST, while the application is still serving normally, so a hook
852
+ // can tell the outside world to stop routing here before that becomes
853
+ // true — deregistering from a service registry, for instance.
854
+ //
855
+ // Guarded rather than awaited unconditionally: `await` on an
856
+ // already-resolved promise still defers everything below by a microtask,
857
+ // which would move when `#stopping` flips and hand a 404 to a request that
858
+ // used to get a 503. With no hook registered the flag is still set in the
859
+ // same synchronous turn as before, so opting out costs nothing.
860
+ //
861
+ // A rejection is CAPTURED rather than allowed to propagate from here.
862
+ // Letting it escape would abort the drain, the socket close, and the
863
+ // shutdown/close hooks, leaving an application that keeps serving — and
864
+ // because `#stopPromise` caches the result, one that can never be stopped
865
+ // at all. The error is still surfaced to the caller, but only after the
866
+ // shutdown it must not prevent has finished.
867
+ let stoppingError: { readonly cause: unknown } | null = null;
868
+ if (this.#lifecycle.hasStopping()) {
869
+ try {
870
+ await this.#lifecycle.runStopping();
871
+ } catch (error) {
872
+ stoppingError = { cause: error };
873
+ }
874
+ }
875
+
876
+ // Set before the next await so a request arriving during the drain window
877
+ // sees the shutting-down state and gets a 503.
878
+ this.#stopping = true;
879
+
880
+ // Wait for in-flight requests to drain (max 10s). A successfully-started
881
+ // application always has a runtime registered (start() requires a runtime
882
+ // provider) and stop() short-circuits before this when never started, so
883
+ // the runtime is always available here.
884
+ await this.#drainRequests();
885
+
886
+ // Close server if listening
887
+ if (this.#serverHandle !== null) {
888
+ const adapter = this.#registry.get<IHttpAdapter>(CAPABILITIES.HTTP_ADAPTER);
889
+ await adapter.close(this.#serverHandle);
890
+ this.#serverHandle = null;
891
+ }
892
+
893
+ // Run shutdown hooks (LIFO)
894
+ await this.#lifecycle.runShutdown();
895
+
896
+ // Run close hooks
897
+ await this.#lifecycle.runClose();
898
+
899
+ // Surfaced last: the application is fully stopped by now, so the caller
900
+ // learns the stopping hook failed without that failure having blocked the
901
+ // shutdown.
902
+ if (stoppingError !== null) {
903
+ throw stoppingError.cause;
904
+ }
905
+ }
906
+
907
+ /** Delegates a web-standard Request to the registered IHttpAdapter. */
908
+ // Declared `async` so the no-adapter case REJECTS rather than throwing
909
+ // synchronously: this method returns a promise, and a sync throw escapes a
910
+ // caller's `.catch(…)` — including the `export default { fetch: app.fetch }`
911
+ // Workers entry point, where it would surface as an unhandled exception
912
+ // instead of a failed request.
913
+ async fetch(request: Request): Promise<Response> {
914
+ if (!this.#registry.has(CAPABILITIES.HTTP_ADAPTER)) {
915
+ throw new Error(
916
+ 'No HTTP adapter registered. Call register(RuntimePlugin) or provide a custom IHttpAdapter.',
917
+ );
918
+ }
919
+ const adapter = this.#registry.get<IHttpAdapter>(CAPABILITIES.HTTP_ADAPTER);
920
+ return await adapter.fetch(request);
921
+ }
922
+
923
+ /** Synthesizes an inject request and runs it through the full pipeline. */
924
+ async inject(request: InjectRequest): Promise<InjectResponse> {
925
+ // Per-shape coercion (M95c §3.9): bytes verbatim, Blob awaited,
926
+ // URLSearchParams through its own serialisation, JSON only for a plain
927
+ // object — and a named refusal for anything else, since a JavaScript
928
+ // caller can still hand `inject()` a value the type cannot see.
929
+ const { bytes: bodyBytes, defaultContentType } = await coerceInjectBody(request.body);
930
+
931
+ // A COPY, never the caller's own `Headers`. The per-shape default below
932
+ // WRITES here, and reusing one `Headers` instance across two injected
933
+ // requests is ordinary in a test: the first request's `content-type` used
934
+ // to stick to the caller's object, so the second silently inherited it and
935
+ // `!headers.has('content-type')` then declined to set the right one — a
936
+ // JSON body following a form body arrived as a form. Same aliasing hazard
937
+ // the byte copy in `coerceInjectBody` guards, one field over; a handler
938
+ // mutating `ctx.request.headers` is now contained too.
939
+ const headers = new Headers(request.headers ?? {});
940
+
941
+ // The default is PER SHAPE: a URLSearchParams body defaulting to
942
+ // `application/json` would arrive as a non-form and the form parse would
943
+ // refuse the input this widening exists to carry; a `Uint8Array` or an
944
+ // `ArrayBuffer` gets NO default at all because only the caller knows its
945
+ // encoding; and a `Blob` declares its own through `.type` (M99c), the
946
+ // same header the platform sets for `new Request(url, { body: blob })`.
947
+ if (
948
+ bodyBytes !== undefined &&
949
+ defaultContentType !== undefined &&
950
+ !headers.has('content-type')
951
+ ) {
952
+ headers.set('content-type', defaultContentType);
953
+ }
954
+
955
+ // Normalize relative paths to a full URL so createRequestContext can parse
956
+ // query params without throwing. Only normalize paths starting with `/` —
957
+ // arbitrary strings like `'not-a-valid-url'` should still reach
958
+ // createRequestContext so its URL-parse failure is surfaced as a 400.
959
+ const fullUrl = request.url.startsWith('/') ? `http://localhost${request.url}` : request.url;
960
+
961
+ // An HTTP adapter attaches the undisturbed web `Request` as `IRequest.raw`,
962
+ // and the terminal handler reads it to decide a WebSocket upgrade or gRPC
963
+ // dispatch. Omitting it here would make `inject()` structurally unable to
964
+ // reach either path — the request would silently fall through to the 404
965
+ // and a test could never tell the difference.
966
+ //
967
+ // Built defensively: a malformed URL must still reach
968
+ // `createRequestContext`, whose parse failure is surfaced as a 400, rather
969
+ // than throwing out of `inject()` here.
970
+ let raw: Request | undefined;
971
+ try {
972
+ // A second copy, deliberately: `coerceInjectBody` already returned bytes
973
+ // the caller cannot alias, and this keeps `ctx.request.raw`'s body
974
+ // independent of the array `bytes()` hands the handler, so a handler that
975
+ // mutates what it read cannot change what `raw` reports. `inject()` is an
976
+ // in-process test entry point and the pre-M95c path re-encoded the body
977
+ // on every call, so the copy is not a regression.
978
+ const requestBody = bodyBytes === undefined ? undefined : new Uint8Array(bodyBytes);
979
+ raw = new Request(fullUrl, {
980
+ method: request.method,
981
+ headers,
982
+ ...(requestBody !== undefined && request.method !== 'GET' && request.method !== 'HEAD'
983
+ ? { body: requestBody }
984
+ : {}),
985
+ });
986
+ } catch {
987
+ raw = undefined;
988
+ }
989
+
990
+ // The form read is memoized like `json()`'s: the in-flight promise is
991
+ // cached (a rejection with it), so two middleware reading the same
992
+ // injected body trigger one parse, and a body that is not a form keeps
993
+ // rejecting identically on a retry.
994
+ let form: Promise<FormBody> | undefined;
995
+
996
+ const syntheticRequest: IRequest = {
997
+ ...(request.ip !== undefined ? { ip: request.ip } : {}),
998
+ method: request.method as IRequest['method'],
999
+ url: fullUrl,
1000
+ get path() {
1001
+ return new URL(fullUrl).pathname;
1002
+ },
1003
+ headers,
1004
+ ...(raw !== undefined ? { raw } : {}),
1005
+ json<T>(): Promise<T> {
1006
+ // The shared parse (X37-1): a malformed body rejects with the
1007
+ // `400`-branded MalformedRequestBodyError, so `inject()` observes the
1008
+ // same failure the served path produces. The empty-body arm keeps its
1009
+ // `{}` default — an in-process inject that sent no body is not a
1010
+ // malformed body.
1011
+ return Promise.resolve().then(
1012
+ () =>
1013
+ parseJsonBody(
1014
+ bodyBytes === undefined ? '{}' : new TextDecoder().decode(bodyBytes),
1015
+ ) as T,
1016
+ );
1017
+ },
1018
+ text(): Promise<string> {
1019
+ return Promise.resolve(bodyBytes === undefined ? '' : new TextDecoder().decode(bodyBytes));
1020
+ },
1021
+ bytes(): Promise<Uint8Array> {
1022
+ return Promise.resolve(bodyBytes ?? new Uint8Array(0));
1023
+ },
1024
+ formData(): Promise<FormBody> {
1025
+ // The shared parse (M94b): a non-form content-type rejects with the
1026
+ // `415`-branded UnsupportedFormEncodingError, so an injected request
1027
+ // observes what a served request observes. A `Uint8Array` or an
1028
+ // `ArrayBuffer` sets NO default content type (M95c §3.9), so an
1029
+ // injected multipart in those shapes must supply its own — exactly
1030
+ // what a served request requires. A `Blob` supplies it from `.type`
1031
+ // (M99c), as the platform does.
1032
+ form ??= Promise.resolve().then(() =>
1033
+ parseFormBody(bodyBytes ?? new Uint8Array(0), headers.get('content-type')),
1034
+ );
1035
+ return form;
1036
+ },
1037
+ };
1038
+
1039
+ const response = await this.#handleRequest(syntheticRequest);
1040
+ const snapshot = response.snapshot();
1041
+
1042
+ // A streaming response cannot be presented as a `string` body without
1043
+ // draining the live stream, which would consume it. Say so explicitly
1044
+ // rather than reporting `body: null`, which reads as "empty response".
1045
+ if (snapshot.streaming) {
1046
+ throw new Error(
1047
+ 'inject() cannot read a streaming response body. Call app.fetch() with a web Request ' +
1048
+ 'and read the returned Response body stream instead.',
1049
+ );
1050
+ }
1051
+
1052
+ // Decode a byte body (from `response.send(bytes)`) rather than dropping it:
1053
+ // reporting `null` made a non-empty response look empty, and made `json()`
1054
+ // throw "No JSON body available" for a perfectly valid JSON payload sent
1055
+ // as bytes.
1056
+ const body =
1057
+ snapshot.body === null
1058
+ ? null
1059
+ : typeof snapshot.body === 'string'
1060
+ ? snapshot.body
1061
+ : new TextDecoder().decode(snapshot.body);
1062
+
1063
+ return {
1064
+ statusCode: snapshot.status,
1065
+ headers: snapshot.headers,
1066
+ body,
1067
+ json<T>(): T {
1068
+ if (body === null) {
1069
+ throw new Error('No JSON body available');
1070
+ }
1071
+ return JSON.parse(body);
1072
+ },
1073
+ };
1074
+ }
1075
+
1076
+ // ---------------------------------------------------------------------------
1077
+ // Private methods
1078
+ // ---------------------------------------------------------------------------
1079
+
1080
+ #handleRequest(request: IRequest): ResponseBuilder | Promise<ResponseBuilder> {
1081
+ if (this.#stopping) {
1082
+ const builder = new ResponseBuilder();
1083
+ // Pre-pipeline: no request context exists yet, so the responder (if any)
1084
+ // is seeded from the startup cache — the same instance `errorHandler`
1085
+ // would publish into `ctx.state` inside the pipeline — into the fresh
1086
+ // state map (M70f re-review, finding 1). With no `errorHandler` the
1087
+ // cache is empty and the framework-default shape is written. The status
1088
+ // is still written here, so metrics keep seeing 503.
1089
+ const state = new Map<string, unknown>();
1090
+ if (this.#errorResponder !== undefined) {
1091
+ state.set(ERROR_RESPONDER_STATE_KEY, this.#errorResponder);
1092
+ }
1093
+ respondWithError(
1094
+ { state, response: builder, ...this.#safeRequestTarget(request) },
1095
+ { status: 503, title: 'Service Unavailable' },
1096
+ );
1097
+ return builder;
1098
+ }
1099
+
1100
+ const runtime = this.#registry.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
1101
+
1102
+ // Build the per-request context and validate the request path BEFORE
1103
+ // entering in-flight accounting. Two client errors are caught here:
1104
+ // 1. A malformed request URL makes `new URL()` throw. The throw is
1105
+ // caught wherever it surfaces rather than at one assumed site: the
1106
+ // context resolves its query lazily (M87), so construction no longer
1107
+ // parses the URL, and for an `inject()`-built request `path` is itself
1108
+ // a lazy getter that parses. Reading `path` inside the `try` covers
1109
+ // both, and the adapter path throws earlier still, in its own mapping.
1110
+ // 2. A malformed percent-escape in the path (e.g. `%zz`) would
1111
+ // otherwise surface as a 500 from the router's decodeURIComponent.
1112
+ // Both are rejected as a 400. Because #inFlight is incremented only
1113
+ // AFTER this succeeds, a rejected request can never leak the counter and
1114
+ // stall shutdown drain (the throw would otherwise escape the finally).
1115
+ let handle: RequestContextHandle;
1116
+ let path: string;
1117
+ try {
1118
+ handle = createRequestContext(request, this.#registry, runtime);
1119
+ path = handle.ctx.request.path;
1120
+ } catch {
1121
+ return this.#badRequest(request);
1122
+ }
1123
+ if (!isPathDecodable(path)) {
1124
+ return this.#badRequest(request);
1125
+ }
1126
+ const ctx = handle.ctx;
1127
+
1128
+ // Seed the application's resolved error responder into the fresh context
1129
+ // BEFORE the request-lifecycle hooks run (M70f re-review, finding 2). The
1130
+ // hooks execute before the middleware pipeline, so `errorHandler`'s own
1131
+ // `ctx.state` publication has not happened yet: without this seed, a
1132
+ // throwing `onRequest` hook would fall through to the no-handler fallback
1133
+ // even when an `errorHandler` is registered. `errorHandler` re-publishes
1134
+ // the SAME instance inside the pipeline, so the per-request set is
1135
+ // idempotent and in-pipeline sites are unaffected.
1136
+ if (this.#errorResponder !== undefined) {
1137
+ ctx.state.set(ERROR_RESPONDER_STATE_KEY, this.#errorResponder);
1138
+ }
1139
+
1140
+ // The request operation is keyed in the collector's private WeakMap —
1141
+ // never in application state, never a caller-supplied id.
1142
+ this.#collector?.beginRequestOperation(ctx);
1143
+
1144
+ this.#inFlight++;
1145
+ try {
1146
+ const pending = this.#runRequest(ctx, handle, path, request);
1147
+ if (pending === undefined) {
1148
+ this.#inFlight--;
1149
+ this.#completeRequest(ctx, 'ok');
1150
+ return ctx.response as ResponseBuilder;
1151
+ }
1152
+ return pending.then(
1153
+ () => {
1154
+ this.#inFlight--;
1155
+ this.#completeRequest(ctx, 'ok');
1156
+ return ctx.response as ResponseBuilder;
1157
+ },
1158
+ (error: unknown) => this.#failRequest(error, ctx),
1159
+ );
1160
+ } catch (error) {
1161
+ return this.#failRequest(error, ctx);
1162
+ }
1163
+ }
1164
+
1165
+ /**
1166
+ * Emits the request operation's completion record. Reading the status AFTER
1167
+ * the response is final keeps the record honest about what was served; the
1168
+ * operation is unkeyed inside the collector, so a double completion cannot
1169
+ * occur.
1170
+ *
1171
+ * @param ctx - The live request context
1172
+ * @param outcome - How the request completed
1173
+ */
1174
+ #completeRequest(ctx: IRequestContext, outcome: DiagnosticsEventOutcome): void {
1175
+ this.#collector?.endRequestOperation(ctx, {
1176
+ outcome,
1177
+ statusCode: (ctx.response as ResponseBuilder).statusCode,
1178
+ });
1179
+ }
1180
+
1181
+ /**
1182
+ * Runs the request body, returning `undefined` when it completed
1183
+ * SYNCHRONOUSLY and a promise otherwise (M87).
1184
+ *
1185
+ * The framework's own layers are the ones being made sync-capable here:
1186
+ * an application registering no lifecycle hooks and no global middleware
1187
+ * previously still paid the full `async`/`await` machinery of this method,
1188
+ * `MiddlewarePipeline.execute` and `executeChain` — several microtask
1189
+ * boundaries per request before any user code ran. Returning `undefined`
1190
+ * rather than a resolved promise is what lets the caller, and ultimately
1191
+ * the HTTP adapter, hand a plain `Response` back to the server: node's
1192
+ * `responseViaCache` fast path is gated on the handler NOT returning a
1193
+ * promise, so a single eagerly-async link anywhere in the chain forecloses
1194
+ * it for every request.
1195
+ *
1196
+ * @param ctx - The live request context
1197
+ * @param handle - The context handle, for installing matched route params
1198
+ * @param path - The request path, already resolved and validated
1199
+ * @param request - The normalized request
1200
+ * @returns `undefined` when complete, else a promise that settles when it is
1201
+ */
1202
+ #runRequest(
1203
+ ctx: IRequestContext,
1204
+ handle: RequestContextHandle,
1205
+ path: string,
1206
+ request: IRequest,
1207
+ ): undefined | Promise<unknown> {
1208
+ const requestHooks = this.#lifecycle.getRequestHooks();
1209
+ const responseHooks = this.#lifecycle.getResponseHooks();
1210
+ // `compile()` caches, so this is a null check after the first request.
1211
+ const chain = this.#pipeline.compile();
1212
+
1213
+ if (requestHooks.length === 0 && responseHooks.length === 0 && chain.length === 0) {
1214
+ return this.#dispatch(ctx, handle, path, request);
1215
+ }
1216
+ return this.#runRequestFull(ctx, handle, path, request, requestHooks, responseHooks);
1217
+ }
1218
+
1219
+ /**
1220
+ * The general request path: lifecycle hooks and/or global middleware are
1221
+ * registered, so at least one `await` is unavoidable.
1222
+ *
1223
+ * @param ctx - The live request context
1224
+ * @param handle - The context handle, for installing matched route params
1225
+ * @param path - The request path, already resolved and validated
1226
+ * @param request - The normalized request
1227
+ * @param requestHooks - Resolved `onRequest` hooks
1228
+ * @param responseHooks - Resolved `onResponse` hooks
1229
+ */
1230
+ async #runRequestFull(
1231
+ ctx: IRequestContext,
1232
+ handle: RequestContextHandle,
1233
+ path: string,
1234
+ request: IRequest,
1235
+ requestHooks: readonly ((ctx: IRequestContext) => void | Promise<void>)[],
1236
+ responseHooks: readonly ((ctx: IRequestContext) => void | Promise<void>)[],
1237
+ ): Promise<void> {
1238
+ const parentOperationId = this.#collector?.requestOperationIdOf(ctx);
1239
+ for (const hook of requestHooks) {
1240
+ await this.#observeRequestStage('request-hook', parentOperationId, () => hook(ctx));
1241
+ }
1242
+
1243
+ // Execute pipeline with route dispatch as terminal, observing each global
1244
+ // stage's completion when diagnostics are enabled.
1245
+ await this.#pipeline.execute(
1246
+ ctx,
1247
+ async () => {
1248
+ await this.#dispatch(ctx, handle, path, request);
1249
+ },
1250
+ this.#chainObserver('global', parentOperationId, undefined),
1251
+ );
1252
+
1253
+ for (const hook of responseHooks) {
1254
+ await this.#observeRequestStage('response-hook', parentOperationId, () => hook(ctx));
1255
+ }
1256
+ }
1257
+
1258
+ /**
1259
+ * Runs one request-scoped hook (`onRequest`/`onResponse`/`onError`),
1260
+ * observing its completion when diagnostics are enabled. Absent collector —
1261
+ * or no request operation, which cannot happen on a reached path — this is
1262
+ * a bare `await`, so the disabled path is unchanged.
1263
+ *
1264
+ * @param stage - Which hook boundary this is
1265
+ * @param parentOperationId - The request operation, when known
1266
+ * @param run - The hook invocation
1267
+ */
1268
+ async #observeRequestStage(
1269
+ stage: 'request-hook' | 'response-hook' | 'error-hook',
1270
+ parentOperationId: string | undefined,
1271
+ run: () => void | Promise<void>,
1272
+ ): Promise<void> {
1273
+ const collector = this.#collector;
1274
+ if (collector === undefined || parentOperationId === undefined) {
1275
+ await run();
1276
+ return;
1277
+ }
1278
+ const startedAtMs = collector.monotonicMs();
1279
+ try {
1280
+ await run();
1281
+ } catch (error) {
1282
+ collector.observeRequestStage({
1283
+ parentOperationId,
1284
+ stage,
1285
+ outcome: 'error',
1286
+ startedAtMs,
1287
+ durationMs: collector.elapsedSince(startedAtMs),
1288
+ });
1289
+ throw error;
1290
+ }
1291
+ collector.observeRequestStage({
1292
+ parentOperationId,
1293
+ stage,
1294
+ outcome: 'ok',
1295
+ startedAtMs,
1296
+ durationMs: collector.elapsedSince(startedAtMs),
1297
+ });
1298
+ }
1299
+
1300
+ /**
1301
+ * Builds the chain observer for one chain execution (global or route).
1302
+ * `undefined` when diagnostics are disabled, which keeps the executor's
1303
+ * instrumented path byte-identical to the original one.
1304
+ *
1305
+ * @param scope - Which chain the observer instruments
1306
+ * @param parentOperationId - The request operation the stages belong to
1307
+ * @param routeEntryIndex - The matched route's entry index (route chains)
1308
+ */
1309
+ #chainObserver(
1310
+ scope: 'global' | 'route',
1311
+ parentOperationId: string | undefined,
1312
+ routeEntryIndex: number | undefined,
1313
+ ): ChainObserver | undefined {
1314
+ const collector = this.#collector;
1315
+ if (collector === undefined) {
1316
+ return undefined;
1317
+ }
1318
+ return {
1319
+ clock: () => collector.monotonicMs(),
1320
+ onStage: (stage) => {
1321
+ collector.observeMiddlewareStage({
1322
+ parentOperationId: parentOperationId ?? '<unkeyed>',
1323
+ scope,
1324
+ position: stage.position,
1325
+ routeEntryIndex,
1326
+ outcome: stage.outcome,
1327
+ startedAtMs: stage.startedAtMs,
1328
+ durationMs: stage.durationMs,
1329
+ });
1330
+ },
1331
+ };
1332
+ }
1333
+
1334
+ /**
1335
+ * Terminal dispatch: protocol switch first, then HTTP route matching.
1336
+ *
1337
+ * Protocol dispatch runs BEFORE route matching, and after the pipeline.
1338
+ *
1339
+ * "After the pipeline" is the M70a security property: auth, metrics and the
1340
+ * shutdown drain apply to an upgrade and to an RPC exactly as they do to a
1341
+ * `GET /users`.
1342
+ *
1343
+ * "Before route matching" is precedence, and it is not optional. A WebSocket
1344
+ * upgrade is a protocol switch rather than an HTTP route, and a path inside
1345
+ * the gRPC `basePath` belongs to gRPC (M49). Deciding these only when
1346
+ * nothing matched lets ANY catch-all shadow both — and `react-router-plugin`
1347
+ * mounts exactly that, on all seven verbs, for SSR: a full-stack application
1348
+ * would answer a WebSocket client with an HTML page and a gRPC client with
1349
+ * the same.
1350
+ *
1351
+ * Both helpers open with the same two SYNCHRONOUS refusals — no raw
1352
+ * `Request`, or the capability is not registered — but both are `async`, so
1353
+ * an application registering neither still paid a promise allocation and a
1354
+ * microtask apiece on every request (M87). Hoisting those refusals to a
1355
+ * synchronous guard preserves the outcome exactly: absent the token, each
1356
+ * call returned `false` regardless.
1357
+ *
1358
+ * @param ctx - The live request context
1359
+ * @param handle - The context handle, for installing matched route params
1360
+ * @param path - The request path, already resolved and validated
1361
+ * @param request - The normalized request
1362
+ * @returns `undefined` when complete, else a promise that settles when it is
1363
+ */
1364
+ #dispatch(
1365
+ ctx: IRequestContext,
1366
+ handle: RequestContextHandle,
1367
+ path: string,
1368
+ request: IRequest,
1369
+ ): undefined | Promise<unknown> {
1370
+ if (
1371
+ ctx.raw !== undefined &&
1372
+ (this.#registry.has(CAPABILITIES.WEBSOCKET) || this.#registry.has(CAPABILITIES.GRPC))
1373
+ ) {
1374
+ return this.#dispatchProtocol(ctx, handle, path, request);
1375
+ }
1376
+ return this.#dispatchRoute(ctx, handle, path, request);
1377
+ }
1378
+
1379
+ /**
1380
+ * Protocol dispatch for an application that registered WebSocket or gRPC,
1381
+ * falling through to HTTP route matching when neither claims the request.
1382
+ *
1383
+ * @param ctx - The live request context
1384
+ * @param handle - The context handle, for installing matched route params
1385
+ * @param path - The request path, already resolved and validated
1386
+ * @param request - The normalized request
1387
+ */
1388
+ async #dispatchProtocol(
1389
+ ctx: IRequestContext,
1390
+ handle: RequestContextHandle,
1391
+ path: string,
1392
+ request: IRequest,
1393
+ ): Promise<void> {
1394
+ if (
1395
+ this.#registry.has(CAPABILITIES.WEBSOCKET) &&
1396
+ (await this.#observeProtocol(ctx, 'websocket-upgrade', () => this.#tryUpgrade(ctx)))
1397
+ ) {
1398
+ return;
1399
+ }
1400
+ if (
1401
+ this.#registry.has(CAPABILITIES.GRPC) &&
1402
+ (await this.#observeProtocol(ctx, 'grpc-dispatch', () => this.#tryGrpc(ctx)))
1403
+ ) {
1404
+ return;
1405
+ }
1406
+ await this.#dispatchRoute(ctx, handle, path, request);
1407
+ }
1408
+
1409
+ /**
1410
+ * Observes one protocol-dispatch boundary (WebSocket upgrade or gRPC
1411
+ * dispatch). A boundary that claims the request records its completion —
1412
+ * never a claim about subsequent socket frames or RPC internals — and a
1413
+ * thrown boundary records `error` and propagates unchanged.
1414
+ *
1415
+ * @param ctx - The live request context
1416
+ * @param stage - Which protocol boundary is being attempted
1417
+ * @param run - The boundary attempt, answering `true` when it claimed the request
1418
+ * @returns The attempt's answer, unchanged
1419
+ */
1420
+ async #observeProtocol(
1421
+ ctx: IRequestContext,
1422
+ stage: 'websocket-upgrade' | 'grpc-dispatch',
1423
+ run: () => Promise<boolean>,
1424
+ ): Promise<boolean> {
1425
+ const collector = this.#collector;
1426
+ if (collector === undefined) {
1427
+ return await run();
1428
+ }
1429
+ const parentOperationId = collector.requestOperationIdOf(ctx) ?? '<unkeyed>';
1430
+ const startedAtMs = collector.monotonicMs();
1431
+ try {
1432
+ const answered = await run();
1433
+ if (answered) {
1434
+ collector.observeHandlerStage({
1435
+ parentOperationId,
1436
+ stage,
1437
+ nodeId: null,
1438
+ outcome: 'ok',
1439
+ startedAtMs,
1440
+ durationMs: collector.elapsedSince(startedAtMs),
1441
+ statusCode: (ctx.response as ResponseBuilder).statusCode,
1442
+ });
1443
+ }
1444
+ return answered;
1445
+ } catch (error) {
1446
+ collector.observeHandlerStage({
1447
+ parentOperationId,
1448
+ stage,
1449
+ nodeId: null,
1450
+ outcome: 'error',
1451
+ startedAtMs,
1452
+ durationMs: collector.elapsedSince(startedAtMs),
1453
+ });
1454
+ throw error;
1455
+ }
1456
+ }
1457
+
1458
+ /**
1459
+ * HTTP route matching and handler invocation.
1460
+ *
1461
+ * Matching reuses the path resolved by the caller rather than parsing the
1462
+ * URL a second time (M87): the adapter's mapping already parsed it, and the
1463
+ * decodability guard already read it, so `path` is `url.pathname` by
1464
+ * construction on the adapter path and on the `inject()` path alike.
1465
+ *
1466
+ * @param ctx - The live request context
1467
+ * @param handle - The context handle, for installing matched route params
1468
+ * @param path - The request path, already resolved and validated
1469
+ * @param request - The normalized request
1470
+ * @returns `undefined` when complete, else a promise that settles when it is
1471
+ */
1472
+ #dispatchRoute(
1473
+ ctx: IRequestContext,
1474
+ handle: RequestContextHandle,
1475
+ path: string,
1476
+ request: IRequest,
1477
+ ): undefined | Promise<unknown> {
1478
+ const routeResult = this.#router.match(request.method, path);
1479
+
1480
+ if (routeResult === null) {
1481
+ respondWithError(ctx, { status: 404, title: 'Not Found' });
1482
+ return undefined;
1483
+ }
1484
+
1485
+ // Install matched params via the internal setter (no readonly cast)
1486
+ const { definition, params, entry } = routeResult;
1487
+ handle.setParams(params);
1488
+
1489
+ // Route middleware uses the same next()-chaining semantics as the global
1490
+ // pipeline: a stage that responds without calling next() short-circuits,
1491
+ // and the handler does not run. Defense-in-depth in executeChain also
1492
+ // stops stages after the response is ended. Positions follow the route's
1493
+ // own middleware array; the terminal observes the handler itself.
1494
+ const routeMiddleware = definition.middleware;
1495
+ if (routeMiddleware !== undefined && routeMiddleware.length > 0) {
1496
+ return executeChain(
1497
+ routeMiddleware,
1498
+ ctx,
1499
+ async () => {
1500
+ await this.#observeHandler(ctx, entry, async () => {
1501
+ await definition.handler(ctx);
1502
+ });
1503
+ },
1504
+ undefined,
1505
+ this.#chainObserver('route', this.#collector?.requestOperationIdOf(ctx), entry.index),
1506
+ );
1507
+ }
1508
+
1509
+ // Empty-chain bypass (M87). On an empty chain `executeChain` does exactly
1510
+ // one thing before the terminal: the defense-in-depth `ended` check, which
1511
+ // matters because a global stage may have ended the response AND called
1512
+ // `next()`. Keeping that check and invoking the handler directly is
1513
+ // behaviourally identical, and it removes the executor's two closures and
1514
+ // three microtask boundaries from every route declaring no middleware.
1515
+ if ((ctx.response as ResponseBuilder).ended) {
1516
+ return undefined;
1517
+ }
1518
+ return this.#invokeHandlerObserved(ctx, entry, definition);
1519
+ }
1520
+
1521
+ /**
1522
+ * Invokes a route's handler on the empty-chain bypass, observing its
1523
+ * completion when diagnostics are enabled WITHOUT changing the bypass's
1524
+ * synchronous contract: a sync handler still returns `undefined` from this
1525
+ * method and an async one still returns its promise.
1526
+ *
1527
+ * @param ctx - The live request context
1528
+ * @param entry - The matched route entry, naming the handler's node
1529
+ * @param definition - The route definition holding the handler
1530
+ * @returns `undefined` when complete synchronously, else the handler's promise
1531
+ */
1532
+ #invokeHandlerObserved(
1533
+ ctx: IRequestContext,
1534
+ entry: RouteEntry,
1535
+ definition: RouteDefinition,
1536
+ ): undefined | Promise<unknown> {
1537
+ const collector = this.#collector;
1538
+ if (collector === undefined) {
1539
+ const result = definition.handler(ctx);
1540
+ // Duck-typed, not `instanceof Promise`: a cross-realm or userland promise
1541
+ // satisfies `RouteHandler`'s declared return type structurally but fails
1542
+ // `instanceof`, and treating one as "already finished" would send the
1543
+ // response while the handler was still running (M87 review).
1544
+ // `Promise.resolve` returns a native promise unchanged, so the ordinary
1545
+ // async path allocates nothing extra.
1546
+ return isPromiseLike(result) ? Promise.resolve(result) : undefined;
1547
+ }
1548
+ const parentOperationId = collector.requestOperationIdOf(ctx) ?? '<unkeyed>';
1549
+ const nodeId = collector.routeNodeIdOf(entry.index) ?? null;
1550
+ const startedAtMs = collector.monotonicMs();
1551
+ let result: ReturnType<typeof definition.handler>;
1552
+ try {
1553
+ result = definition.handler(ctx);
1554
+ } catch (error) {
1555
+ collector.observeHandlerStage({
1556
+ parentOperationId,
1557
+ stage: 'handler',
1558
+ nodeId,
1559
+ outcome: 'error',
1560
+ startedAtMs,
1561
+ durationMs: collector.elapsedSince(startedAtMs),
1562
+ });
1563
+ throw error;
1564
+ }
1565
+ if (isPromiseLike(result)) {
1566
+ const pending = Promise.resolve(result);
1567
+ // Observation only: the ORIGINAL promise is returned to the caller and
1568
+ // its rejection propagates unchanged — this detached chain never
1569
+ // swallows or re-routes it.
1570
+ void pending.then(
1571
+ () => {
1572
+ collector.observeHandlerStage({
1573
+ parentOperationId,
1574
+ stage: 'handler',
1575
+ nodeId,
1576
+ outcome: 'ok',
1577
+ startedAtMs,
1578
+ durationMs: collector.elapsedSince(startedAtMs),
1579
+ statusCode: (ctx.response as ResponseBuilder).statusCode,
1580
+ });
1581
+ },
1582
+ () => {
1583
+ collector.observeHandlerStage({
1584
+ parentOperationId,
1585
+ stage: 'handler',
1586
+ nodeId,
1587
+ outcome: 'error',
1588
+ startedAtMs,
1589
+ durationMs: collector.elapsedSince(startedAtMs),
1590
+ });
1591
+ },
1592
+ );
1593
+ return pending;
1594
+ }
1595
+ collector.observeHandlerStage({
1596
+ parentOperationId,
1597
+ stage: 'handler',
1598
+ nodeId,
1599
+ outcome: 'ok',
1600
+ startedAtMs,
1601
+ durationMs: collector.elapsedSince(startedAtMs),
1602
+ statusCode: (ctx.response as ResponseBuilder).statusCode,
1603
+ });
1604
+ return undefined;
1605
+ }
1606
+
1607
+ /**
1608
+ * Runs the route chain's terminal handler, observing its completion when
1609
+ * diagnostics are enabled. The terminal is already async, so observation
1610
+ * adds no new microtask boundary to the disabled path.
1611
+ *
1612
+ * @param ctx - The live request context
1613
+ * @param entry - The matched route entry, naming the handler's node
1614
+ * @param run - The handler invocation
1615
+ */
1616
+ async #observeHandler(
1617
+ ctx: IRequestContext,
1618
+ entry: RouteEntry,
1619
+ run: () => Promise<void>,
1620
+ ): Promise<void> {
1621
+ const collector = this.#collector;
1622
+ if (collector === undefined) {
1623
+ await run();
1624
+ return;
1625
+ }
1626
+ const parentOperationId = collector.requestOperationIdOf(ctx) ?? '<unkeyed>';
1627
+ const nodeId = collector.routeNodeIdOf(entry.index) ?? null;
1628
+ const startedAtMs = collector.monotonicMs();
1629
+ try {
1630
+ await run();
1631
+ collector.observeHandlerStage({
1632
+ parentOperationId,
1633
+ stage: 'handler',
1634
+ nodeId,
1635
+ outcome: 'ok',
1636
+ startedAtMs,
1637
+ durationMs: collector.elapsedSince(startedAtMs),
1638
+ statusCode: (ctx.response as ResponseBuilder).statusCode,
1639
+ });
1640
+ } catch (error) {
1641
+ collector.observeHandlerStage({
1642
+ parentOperationId,
1643
+ stage: 'handler',
1644
+ nodeId,
1645
+ outcome: 'error',
1646
+ startedAtMs,
1647
+ durationMs: collector.elapsedSince(startedAtMs),
1648
+ });
1649
+ throw error;
1650
+ }
1651
+ }
1652
+
1653
+ /**
1654
+ * Failure path: runs `onError` hooks, reports, and writes the opaque 500.
1655
+ *
1656
+ * Stays synchronous when no `onError` hook is registered, so a throwing
1657
+ * handler in a hook-free application does not force the whole request onto
1658
+ * the microtask queue (M87).
1659
+ *
1660
+ * A hook that throws must not stop the remaining hooks from running, but its
1661
+ * failure must not vanish either: an audit logger, telemetry collector, or
1662
+ * transaction-rollback hook that fails silently leaves security
1663
+ * infrastructure blind. Suppressed errors are surfaced through the
1664
+ * sanctioned logger channel.
1665
+ *
1666
+ * @param error - The thrown value
1667
+ * @param ctx - The live request context
1668
+ * @returns The response, directly or once the hooks have run
1669
+ */
1670
+ #failRequest(error: unknown, ctx: IRequestContext): ResponseBuilder | Promise<ResponseBuilder> {
1671
+ const err = error instanceof Error ? error : new Error(String(error));
1672
+ const hooks = this.#lifecycle.getErrorHooks();
1673
+ if (hooks.length === 0) {
1674
+ return this.#finishFailure(err, ctx);
1675
+ }
1676
+ const parentOperationId = this.#collector?.requestOperationIdOf(ctx);
1677
+ return (async () => {
1678
+ for (const hook of hooks) {
1679
+ try {
1680
+ await this.#observeRequestStage('error-hook', parentOperationId, () => hook(err, ctx));
1681
+ } catch (hookError) {
1682
+ this.#reportSuppressedHookError(hookError);
1683
+ }
1684
+ }
1685
+ return this.#finishFailure(err, ctx);
1686
+ })();
1687
+ }
1688
+
1689
+ /**
1690
+ * Reports the unhandled error and writes the opaque 500, closing out the
1691
+ * request's in-flight accounting.
1692
+ *
1693
+ * X11-2: the unhandled error is visible to the operator. The body stays
1694
+ * opaque — the message is not disclosed to the client (M70b's
1695
+ * `maskInternalErrors` decision applied consistently); only the log carries
1696
+ * it.
1697
+ *
1698
+ * @param err - The normalized error
1699
+ * @param ctx - The live request context
1700
+ * @returns The response builder carrying the 500
1701
+ */
1702
+ #finishFailure(err: Error, ctx: IRequestContext): ResponseBuilder {
1703
+ // Decrement FIRST: the original body did this in a `finally`, so a throw
1704
+ // from the reporting or responder call could not leak the counter and
1705
+ // stall the shutdown drain. Ordering it ahead of both preserves that.
1706
+ this.#inFlight--;
1707
+ this.#reportUnhandledError(err, ctx);
1708
+ respondWithError(ctx, { status: 500, title: 'Internal Server Error' });
1709
+ // Recorded AFTER the response is final, so the status in the record is
1710
+ // the status that was actually served.
1711
+ this.#completeRequest(ctx, 'error');
1712
+ return ctx.response as ResponseBuilder;
1713
+ }
1714
+
1715
+ /**
1716
+ * Tries a WebSocket upgrade after the middleware pipeline and before route
1717
+ * matching when the request carries a raw `Request`. Consults the
1718
+ * `IWebSocketService`'s upgrade router; when it accepts, brands the
1719
+ * `IRequest` with the intent so the HTTP adapter can perform the handshake
1720
+ * after the framework handler returns.
1721
+ *
1722
+ * The brand goes on the `IRequest`, not `ctx.state`, because the adapter
1723
+ * holds the former and never sees the context — see {@linkcode
1724
+ * UPGRADE_INTENT}.
1725
+ *
1726
+ * @param ctx - The live request context
1727
+ * @returns `true` when this handler answered (intent recorded, or refused)
1728
+ */
1729
+ async #tryUpgrade(ctx: IRequestContext): Promise<boolean> {
1730
+ const raw = ctx.raw;
1731
+ // A custom adapter may omit `raw`; treat that as "no upgrade" and fall
1732
+ // through to the 404 rather than throwing.
1733
+ if (raw === undefined) {
1734
+ return false;
1735
+ }
1736
+
1737
+ // Resolved optionally — absent the capability, nothing changes. Probed with
1738
+ // `has` rather than a try/catch around `get`, because this now runs on
1739
+ // EVERY request rather than only on an unmatched one, and throw-driven
1740
+ // control flow on the hot path is what AI_GUIDELINES §14 exists to stop.
1741
+ if (!this.#registry.has(CAPABILITIES.WEBSOCKET)) {
1742
+ return false;
1743
+ }
1744
+ const wsService = this.#registry.get<IWebSocketService>(CAPABILITIES.WEBSOCKET);
1745
+
1746
+ if (wsService.routeUpgrade === undefined) {
1747
+ return false;
1748
+ }
1749
+
1750
+ // Upgrade detection is the ROUTER's job, not the kernel's — it returns
1751
+ // `null` for anything that is not an upgrade on a route it owns. Doing the
1752
+ // header check here as well would duplicate the rule (§11.1) and, worse,
1753
+ // would move a throwing header read outside the service's own reporting
1754
+ // wrapper, so a routing failure would be logged nowhere.
1755
+ //
1756
+ // This backstop is for a third-party service that does not report: before
1757
+ // M70a the adapter-side `UpgradeRouterStore` caught here, and letting a
1758
+ // routing bug escape into the generic 500 would lose the distinction
1759
+ // between "routing failed" and "the handler threw".
1760
+ let decision: WebSocketUpgradeDecision | null;
1761
+ try {
1762
+ // The pipeline has already run (line 643, above), so `ctx.request.user`
1763
+ // carries whatever the authentication band produced. The parameter is
1764
+ // optional on the contract (M73 §3.7): a service written before the
1765
+ // widening simply never receives it.
1766
+ decision = await wsService.routeUpgrade(raw, ctx.request.user);
1767
+ } catch (cause) {
1768
+ // Reported, not swallowed. The pre-M70a backstop in the adapter's
1769
+ // `UpgradeRouterStore` discarded the cause because the adapter holds no
1770
+ // logger; the kernel does, so silence here would be a choice rather than
1771
+ // a constraint.
1772
+ this.#reportUpgradeRouterFailure(cause);
1773
+ respondWithError(ctx, { status: 500, title: 'Internal Server Error' });
1774
+ return true;
1775
+ }
1776
+
1777
+ if (decision === null) {
1778
+ return false;
1779
+ }
1780
+
1781
+ if (!decision.accept) {
1782
+ respondWithError(ctx, { status: decision.status, title: 'Upgrade rejected' });
1783
+ return true;
1784
+ }
1785
+
1786
+ // RFC 6455 §4.1 forbids a body on the handshake. Refusing here makes the
1787
+ // behaviour one thing on all four adapters rather than a runtime-specific
1788
+ // failure inside the upgrade call. Read from the MAPPED body rather than
1789
+ // `content-length`, which is absent both on an in-process `Request` and on
1790
+ // any chunked upload.
1791
+ //
1792
+ // This comment previously also claimed the mapping "has already disturbed
1793
+ // it via `arrayBuffer()`". That is no longer true and the distinction is
1794
+ // load-bearing (M87): the mapping reads the body lazily, and `bytes()`
1795
+ // short-circuits a GET/HEAD carrying neither `content-length` nor
1796
+ // `transfer-encoding` WITHOUT touching the native request. So an ordinary
1797
+ // upgrade now reaches the handshake with its raw `Request` undisturbed —
1798
+ // which is exactly what `Deno.upgradeWebSocket` requires, and the hazard
1799
+ // M46 originally had to intercept the upgrade inside the adapter to avoid.
1800
+ // A handshake that does carry framing headers is disturbed by this read
1801
+ // and then refused below, so no upgrade is ever attempted on it.
1802
+ //
1803
+ // The read itself can REJECT, not merely return a non-empty body:
1804
+ // `RuntimePlugin({ maxBodyBytes })` bounds it, and a body past the cap
1805
+ // rejects with `RequestBodyTooLargeError`. The read is therefore wrapped in
1806
+ // a `try` whose `catch` settles the reserved slot before rethrowing — it is
1807
+ // the only call between the router's accept and `setUpgradeIntent` that can
1808
+ // throw, so nothing else needs the same treatment. Without it a rejection
1809
+ // escapes to the
1810
+ // fallback 500 with `onClose` never called, and since the router claims a
1811
+ // pending slot at accept time, `maxConnections` malformed requests from an
1812
+ // unauthenticated client permanently exhaust the budget — every later
1813
+ // conformant upgrade then gets 503. `websocket-service.ts` states that a
1814
+ // "refused or malformed upgrade can never leak a slot"; this is what makes
1815
+ // that true for a rejecting read as well as a returning one.
1816
+ let bodyLength: number;
1817
+ try {
1818
+ bodyLength = (await ctx.request.bytes()).length;
1819
+ } catch (cause) {
1820
+ decision.sink.onClose({ code: 1006, reason: 'Upgrade request body could not be read' });
1821
+ // Re-thrown rather than answered here: the body-limit refusal carries its
1822
+ // own status hint (413), and `errorHandler` is what turns that into a
1823
+ // response in the application's configured format. Answering a fixed 400
1824
+ // would discard the hint and report the wrong cause.
1825
+ throw cause;
1826
+ }
1827
+
1828
+ if (bodyLength > 0) {
1829
+ // The router already accepted, so it may be holding a reserved
1830
+ // connection slot for this socket. RFC 6455 close code 1006 (abnormal
1831
+ // closure) is the honest signal that no connection was ever established.
1832
+ decision.sink.onClose({ code: 1006, reason: 'Upgrade request carried a body' });
1833
+ respondWithError(ctx, { status: 400, title: 'Bad Request' });
1834
+ return true;
1835
+ }
1836
+
1837
+ setUpgradeIntent(ctx.request, {
1838
+ sink: decision.sink,
1839
+ ...(decision.protocol !== undefined ? { protocol: decision.protocol } : {}),
1840
+ });
1841
+ return true;
1842
+ }
1843
+
1844
+ /**
1845
+ * Tries gRPC dispatch after the middleware pipeline and before route
1846
+ * matching. Resolves `IGrpcService` from the registry, asks whether it
1847
+ * claims the path, and dispatches with a reconstructed web `Request`.
1848
+ *
1849
+ * @param ctx - The live request context
1850
+ * @returns `true` when gRPC answered (handler should return)
1851
+ */
1852
+ async #tryGrpc(ctx: IRequestContext): Promise<boolean> {
1853
+ const raw = ctx.raw;
1854
+ if (raw === undefined) {
1855
+ return false;
1856
+ }
1857
+
1858
+ // Resolved optionally — absent the capability, nothing changes. `has`
1859
+ // rather than a try/catch around `get`, for the reason given in
1860
+ // `#tryUpgrade`.
1861
+ if (!this.#registry.has(CAPABILITIES.GRPC)) {
1862
+ return false;
1863
+ }
1864
+ const grpcService = this.#registry.get<IGrpcService>(CAPABILITIES.GRPC);
1865
+
1866
+ if (!grpcService.available) {
1867
+ return false;
1868
+ }
1869
+
1870
+ // Prefix guard, BEFORE dispatch. `handleRequest` returns `Promise<Response>`
1871
+ // and never `null`, so without this every unmatched route in the
1872
+ // application would be answered by gRPC's plain-text 404 instead of the
1873
+ // kernel's own JSON one. An implementor that predates `claims` claims
1874
+ // nothing, which is the safe default.
1875
+ if (grpcService.claims === undefined || !grpcService.claims(raw)) {
1876
+ return false;
1877
+ }
1878
+
1879
+ // A refusal decidable from HEADERS is answered BEFORE the body is read,
1880
+ // and the ordering is the fix rather than an optimisation (V5-5). A
1881
+ // native `application/grpc` request is refused whatever its body holds,
1882
+ // but a client-streaming or bidirectional call holds its request stream
1883
+ // OPEN — so the read below never resolves and the caller receives no
1884
+ // frames at all instead of the refusal. `grpcurl` opens a bidirectional
1885
+ // reflection stream first, so it hung on every request, which is why
1886
+ // M70i's refusal being correct did not help.
1887
+ //
1888
+ // The kernel does not decide WHAT is refusable: the content types are the
1889
+ // plugin's to know (an exact-match set — `startsWith('application/grpc')`
1890
+ // would also claim `application/grpc-web+proto`, the format that works),
1891
+ // and §2.2 forbids importing it to find out.
1892
+ const refusal = grpcService.refuses?.(raw) ?? null;
1893
+ if (refusal !== null) {
1894
+ await this.#sendRpcResponse(ctx, refusal);
1895
+ return true;
1896
+ }
1897
+
1898
+ // Reconstruct a web Request from the mapped IRequest. Trailers do not
1899
+ // survive the round trip — M49 records that native gRPC-binary trailers
1900
+ // work on no runtime this plugin runs on.
1901
+ //
1902
+ // This read is what bounds the dispatch to requests whose body ENDS.
1903
+ // Since M87 the mapping is lazy, so the body is still unread here and the
1904
+ // buffering is this line's own doing, not something already spent — the
1905
+ // comment that used to say otherwise predated that change. Passing `raw`
1906
+ // straight through would preserve streaming, but only while nothing in
1907
+ // the pipeline has read the body, and the kernel cannot see whether
1908
+ // anything has; that is left as a named limitation rather than a guess.
1909
+ const bodyBytes = await ctx.request.bytes();
1910
+ const grpcRequest = new Request(ctx.request.url, {
1911
+ method: ctx.request.method,
1912
+ headers: ctx.request.headers,
1913
+ // A `Uint8Array` is a valid `BodyInit`, but its backing `ArrayBufferLike`
1914
+ // is not narrowed to `ArrayBuffer` under this compiler configuration.
1915
+ ...(bodyBytes.length > 0 ? { body: bodyBytes as unknown as BodyInit } : {}),
1916
+ });
1917
+
1918
+ const response = await grpcService.handleRequest(grpcRequest);
1919
+ await this.#sendRpcResponse(ctx, response);
1920
+ return true;
1921
+ }
1922
+
1923
+ /**
1924
+ * Writes an RPC {@linkcode Response} onto the framework response.
1925
+ *
1926
+ * One implementation for both exits — the header-only refusal and a
1927
+ * dispatched reply — so a refusal cannot come to carry different headers or
1928
+ * a different status mapping from an ordinary answer.
1929
+ *
1930
+ * @param ctx - The request context whose response is written
1931
+ * @param response - The RPC response to copy across
1932
+ */
1933
+ async #sendRpcResponse(ctx: IRequestContext, response: Response): Promise<void> {
1934
+ const snapshot = await response.arrayBuffer();
1935
+ const builder = ctx.response as ResponseBuilder;
1936
+ builder.status(response.status);
1937
+ // `appendHeader`, not `header`: `header` delegates to `Headers.set`, and a
1938
+ // Trailers-Only refusal or a Connect reply can carry repeated headers —
1939
+ // `Set-Cookie` above all, which `Headers.entries()` yields once per value
1940
+ // rather than comma-joining. Setting per entry would keep only the last.
1941
+ for (const [key, value] of response.headers.entries()) {
1942
+ builder.appendHeader(key, value);
1943
+ }
1944
+ builder.send(new Uint8Array(snapshot));
1945
+ }
1946
+
1947
+ /**
1948
+ * Reads the application's resolved error responder off the compiled pipeline
1949
+ * (M70f re-review, findings 1 & 2).
1950
+ *
1951
+ * `errorHandler` brands its middleware function with the responder it built
1952
+ * at factory time (see `brandErrorResponder` in `@setu-ts/common`). The
1953
+ * kernel caches that instance at startup so the pre-pipeline sites — which
1954
+ * run before the pipeline and cannot read `ctx.state` — can seed it into
1955
+ * their state. The LAST branded stage in execution order (the innermost,
1956
+ * i.e. the highest priority number among the selected stages) wins,
1957
+ * because that is the stage whose `ctx.state` publication an in-pipeline
1958
+ * site would see last. `undefined`
1959
+ * when no `errorHandler` is registered, in which case every site keeps the
1960
+ * byte-identical no-handler fallback.
1961
+ *
1962
+ * @param chain - The compiled middleware chain, in execution order
1963
+ */
1964
+ #resolveErrorResponder(chain: readonly unknown[]): IErrorResponder | undefined {
1965
+ let responder: IErrorResponder | undefined;
1966
+ for (const stage of chain) {
1967
+ // Every compiled stage is a middleware function; `errorResponderOf`
1968
+ // reads a brand off it, so narrow `unknown` to `object` first.
1969
+ if (typeof stage !== 'function') {
1970
+ continue;
1971
+ }
1972
+ const branded = errorResponderOf(stage);
1973
+ if (branded !== undefined) {
1974
+ responder = branded;
1975
+ }
1976
+ }
1977
+ return responder;
1978
+ }
1979
+
1980
+ /**
1981
+ * Builds a bare `400 Bad Request` response for a client error detected
1982
+ * before request processing begins (a malformed request URL or a
1983
+ * malformed percent-escape in the path). Kept as a helper so both
1984
+ * rejection sites in {@linkcode Application.#handleRequest} produce an
1985
+ * identical body.
1986
+ *
1987
+ * Pre-pipeline: no request context exists yet, so the responder is seeded
1988
+ * from the startup cache ({@linkcode Application.#errorResponder}) into the
1989
+ * fresh state map — the same responder `errorHandler` would publish into
1990
+ * `ctx.state` were this site inside the pipeline. With no `errorHandler`
1991
+ * registered the cache is empty and the framework-default shape is written.
1992
+ *
1993
+ * The target carries a SAFE request ({@linkcode Application.#safeRequestTarget}),
1994
+ * never the raw one: a malformed request URL keeps a throwing `path` getter
1995
+ * on the synthetic `IRequest`, and a Problem Details formatter that reads
1996
+ * `ctx.request.path` for the `instance` member would throw the URL parser
1997
+ * error again inside the responder — turning the configured 400 back into an
1998
+ * unhandled `TypeError: Invalid URL` (M70f re-review round 2, finding 1).
1999
+ */
2000
+ #badRequest(request: IRequest): ResponseBuilder {
2001
+ const builder = new ResponseBuilder();
2002
+ const state = new Map<string, unknown>();
2003
+ if (this.#errorResponder !== undefined) {
2004
+ state.set(ERROR_RESPONDER_STATE_KEY, this.#errorResponder);
2005
+ }
2006
+ respondWithError(
2007
+ { state, response: builder, ...this.#safeRequestTarget(request) },
2008
+ { status: 400, title: 'Bad Request' },
2009
+ );
2010
+ return builder;
2011
+ }
2012
+
2013
+ /**
2014
+ * Builds the `request` member of a pre-pipeline {@linkcode respondWithError}
2015
+ * target from a request whose path may be unreadable.
2016
+ *
2017
+ * A formatted error response needs the request path for the Problem Details
2018
+ * `instance` member, and the target's `request` field is typed to supply it.
2019
+ * But a request that failed URL parsing carries a `path` getter that throws
2020
+ * (the synthetic `IRequest` in {@linkcode Application.inject} and the
2021
+ * adapter-mapped requests both derive `path` from `new URL(...)`), so handing
2022
+ * the raw request to the responder would make the formatter re-throw inside
2023
+ * the very response that is supposed to report the failure.
2024
+ *
2025
+ * The safe target therefore carries the path only when it can be read
2026
+ * without throwing — captured once, up front — and omits the `request` field
2027
+ * entirely otherwise, which is the documented shape of
2028
+ * {@linkcode ErrorResponderTarget.request} ("when one exists"). A formatter
2029
+ * that receives no request answers without an `instance` member, which is
2030
+ * the correct RFC 9457 outcome for a request whose path cannot be known.
2031
+ *
2032
+ * @param request - The request that failed pre-pipeline validation
2033
+ * @returns `{ request: { path } }` when the path is readable, `{}` when it is not
2034
+ */
2035
+ #safeRequestTarget(request: IRequest): { request?: { readonly path: string } } {
2036
+ let path: string | undefined;
2037
+ try {
2038
+ path = request.path;
2039
+ } catch {
2040
+ path = undefined;
2041
+ }
2042
+ return path === undefined ? {} : { request: { path } };
2043
+ }
2044
+
2045
+ /**
2046
+ * Surfaces an exception thrown by an `IWebSocketService`'s upgrade router.
2047
+ *
2048
+ * The framework's own service reports its failures at their source, where it
2049
+ * has route context to add; this covers a third-party service that does not.
2050
+ * Guarded exactly like {@linkcode Application.#reportSuppressedHookError}: a
2051
+ * missing or itself-broken logger must not turn a refused upgrade into a
2052
+ * crashed request.
2053
+ *
2054
+ * @param cause - Whatever the router threw
2055
+ */
2056
+ #reportUpgradeRouterFailure(cause: unknown): void {
2057
+ try {
2058
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
2059
+ return;
2060
+ }
2061
+ const logger = this.#registry.get<ILogger>(CAPABILITIES.LOGGER);
2062
+ const err = cause instanceof Error ? cause : new Error(String(cause));
2063
+ logger.error('WebSocket upgrade router threw and was refused', {
2064
+ error: err.message,
2065
+ stack: err.stack,
2066
+ });
2067
+ } catch {
2068
+ // No safe channel remains — see #reportSuppressedHookError.
2069
+ }
2070
+ }
2071
+
2072
+ /** Reports a startup-time registry mutation when logging is available. */
2073
+ #reportRegistryMutation(
2074
+ kind: 'override' | 'unregister',
2075
+ token: string,
2076
+ plugin: string | undefined,
2077
+ ): void {
2078
+ try {
2079
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
2080
+ return;
2081
+ }
2082
+ const logger = this.#registry.get<ILogger>(CAPABILITIES.LOGGER);
2083
+ const owner = plugin ?? 'the application';
2084
+ const message = `Capability '${token}' was ${
2085
+ kind === 'override' ? 'overridden' : 'unregistered'
2086
+ } by ${owner}.`;
2087
+ if (kind === 'override') {
2088
+ logger.info(message);
2089
+ } else {
2090
+ logger.warn(message);
2091
+ }
2092
+ } catch {
2093
+ // No safe channel remains — a mutation must not make startup fail.
2094
+ }
2095
+ }
2096
+
2097
+ /**
2098
+ * Surfaces an exception thrown by an `onError` hook. The failure is
2099
+ * reported through {@linkcode CAPABILITIES.LOGGER} when a logger is
2100
+ * registered — the sanctioned framework logging channel (AI_GUIDELINES
2101
+ * §11.6 forbids `console` here). The whole method is guarded so a missing
2102
+ * or itself-broken logger can never propagate out of request handling: in
2103
+ * that doubly-degraded case there is no safe channel left and the error is
2104
+ * dropped rather than crashing the request.
2105
+ */
2106
+ #reportSuppressedHookError(hookError: unknown): void {
2107
+ try {
2108
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
2109
+ return;
2110
+ }
2111
+ const logger = this.#registry.get<ILogger>(CAPABILITIES.LOGGER);
2112
+ const err = hookError instanceof Error ? hookError : new Error(String(hookError));
2113
+ logger.error('onError hook threw and was suppressed', {
2114
+ error: err.message,
2115
+ stack: err.stack,
2116
+ });
2117
+ } catch {
2118
+ // The logger itself failed or none is resolvable — no safe channel
2119
+ // remains without violating the no-console rule; degrade silently.
2120
+ }
2121
+ }
2122
+
2123
+ /**
2124
+ * Surfaces an unhandled request error through the sanctioned logger channel
2125
+ * (X11-2). The response body stays opaque — the message is not disclosed to
2126
+ * the client (M70b's `maskInternalErrors` decision applied consistently) —
2127
+ * but the operator sees the message and stack, with the request identified.
2128
+ *
2129
+ * Guarded exactly like {@linkcode Application.#reportSuppressedHookError}: a
2130
+ * missing or itself-broken logger must not turn an already-failing request
2131
+ * into a crashed one.
2132
+ *
2133
+ * @param error - The unhandled error
2134
+ * @param ctx - The request context it occurred in
2135
+ */
2136
+ #reportUnhandledError(error: Error, ctx: IRequestContext): void {
2137
+ try {
2138
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
2139
+ // `msg` carries the label and `message` the error's own text, the same
2140
+ // two keys the console logger writes for the branch below.
2141
+ this.#registry.get<IRuntimeServices>(CAPABILITIES.RUNTIME).reportError?.({
2142
+ level: 'error',
2143
+ msg: 'Unhandled request error',
2144
+ ...serializeError(error),
2145
+ requestId: ctx.id,
2146
+ method: ctx.request.method,
2147
+ path: ctx.request.path,
2148
+ });
2149
+ return;
2150
+ }
2151
+ const logger = this.#registry.get<ILogger>(CAPABILITIES.LOGGER);
2152
+ logger.error('Unhandled request error', {
2153
+ ...serializeError(error),
2154
+ requestId: ctx.id,
2155
+ method: ctx.request.method,
2156
+ path: ctx.request.path,
2157
+ });
2158
+ } catch {
2159
+ // Reporting must never replace the original request failure.
2160
+ }
2161
+ }
2162
+
2163
+ /**
2164
+ * Emits a soft `warn`-level diagnostic for each capability a plugin declares
2165
+ * in `consumes` that no registered plugin provides. Unlike a missing
2166
+ * `dependencies` entry (which fails plugin resolution), an unsatisfied
2167
+ * `consumes` does not stop startup — the plugin resolves the capability
2168
+ * lazily at request time — but the deferred `services.get` would then throw
2169
+ * per request, so surfacing it once at startup, naming the consumer, is the
2170
+ * friendlier failure mode.
2171
+ *
2172
+ * Reported through {@linkcode CAPABILITIES.LOGGER} when a logger is
2173
+ * registered — the sanctioned framework logging channel (AI_GUIDELINES §11.6
2174
+ * forbids `console` here). The emission is guarded so a missing or
2175
+ * itself-broken logger degrades silently rather than turning an advisory
2176
+ * warning into a startup failure.
2177
+ */
2178
+ #warnUnsatisfiedConsumers(): void {
2179
+ const unsatisfied = findUnsatisfiedConsumers(this.#plugins, (token) =>
2180
+ this.#registry.has(token),
2181
+ );
2182
+ if (unsatisfied.length === 0) {
2183
+ return;
2184
+ }
2185
+ try {
2186
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
2187
+ return;
2188
+ }
2189
+ const logger = this.#registry.get<ILogger>(CAPABILITIES.LOGGER);
2190
+ for (const { plugin, capability } of unsatisfied) {
2191
+ logger.warn(
2192
+ `Plugin '${plugin}' consumes capability '${capability}', but no registered plugin provides it. ` +
2193
+ `Calls to services.get('${capability}') will fail at runtime — register a plugin that provides it.`,
2194
+ { plugin, capability },
2195
+ );
2196
+ }
2197
+ } catch {
2198
+ // The logger itself failed or none is resolvable — no safe channel
2199
+ // remains without violating the no-console rule; degrade silently.
2200
+ }
2201
+ }
2202
+
2203
+ /**
2204
+ * Announces a successful `adapter.listen` through the registered logger.
2205
+ *
2206
+ * Node and Bun print nothing of their own, so before this a booted project
2207
+ * looked the same whether it had bound a socket or was still starting. Deno
2208
+ * also prints its native "Listening on" banner; this line is additive there,
2209
+ * and both are accurate. A `port: 0` request reports an ephemeral port rather
2210
+ * than the literal zero. A missing or itself-broken logger degrades silently:
2211
+ * it must never turn a successful start into a failure.
2212
+ *
2213
+ * @param port - The port `adapter.listen` was asked for
2214
+ * @param hostname - The configured hostname, absent when none was supplied
2215
+ */
2216
+ #announceListening(port: number, hostname?: string): void {
2217
+ try {
2218
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
2219
+ return;
2220
+ }
2221
+ const logger = this.#registry.get<ILogger>(CAPABILITIES.LOGGER);
2222
+ if (port === 0) {
2223
+ logger.info('Server listening on an ephemeral port');
2224
+ return;
2225
+ }
2226
+ logger.info('Server listening', hostname === undefined ? { port } : { port, hostname });
2227
+ } catch {
2228
+ // The logger itself failed or none is resolvable — no safe channel
2229
+ // remains without violating the no-console rule; degrade silently.
2230
+ }
2231
+ }
2232
+
2233
+ #validateEnvironment(runtime: IRuntimeServices): void {
2234
+ const violations: string[] = [];
2235
+
2236
+ for (const { name, spec } of this.#envSpecs) {
2237
+ for (const [key, rules] of Object.entries(spec)) {
2238
+ const value = runtime.env[key];
2239
+
2240
+ if (value === undefined) {
2241
+ if (rules.required && rules.default === undefined) {
2242
+ violations.push(
2243
+ `Required environment variable '${key}' is missing (declared by ${name}).`,
2244
+ );
2245
+ }
2246
+ continue;
2247
+ }
2248
+
2249
+ // Type coercion checks
2250
+ if (rules.type === 'number') {
2251
+ // `Number('')`, `Number(' ')`, and other blank strings coerce to
2252
+ // 0 (not NaN), so an empty/whitespace value would otherwise pass as
2253
+ // a valid number. Reject blanks explicitly, and use `isFinite` so
2254
+ // `Infinity`/`-Infinity` are rejected too.
2255
+ const num = Number(value);
2256
+ if (value.trim() === '' || !Number.isFinite(num)) {
2257
+ violations.push(
2258
+ `Environment variable '${key}' expected number but got '${value}' (declared by ${name}).`,
2259
+ );
2260
+ }
2261
+ } else if (rules.type === 'boolean') {
2262
+ if (value !== 'true' && value !== 'false') {
2263
+ violations.push(
2264
+ `Environment variable '${key}' expected boolean but got '${value}' (declared by ${name}).`,
2265
+ );
2266
+ }
2267
+ }
2268
+ }
2269
+ }
2270
+
2271
+ if (violations.length > 0) {
2272
+ throw new Error(
2273
+ `Environment validation failed with ${violations.length} violation(s):\n` +
2274
+ violations.map((v) => ` - ${v}`).join('\n'),
2275
+ );
2276
+ }
2277
+ }
2278
+
2279
+ async #drainRequests(): Promise<void> {
2280
+ const runtime = this.#registry.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
2281
+ // Monotonic clock: this is a duration budget, so it must not be measured
2282
+ // with `now()` (wall clock), which an NTP step can move backwards — cutting
2283
+ // the drain short or extending it past the intended 10s.
2284
+ const deadline = runtime.hrtime() + 10_000;
2285
+ // Iteration cap: a manual test clock that never advances would otherwise
2286
+ // spin forever (the deadline never arrives). 200 polls × 50ms ≈ 10s of
2287
+ // real polling under a normal clock, and a hard ceiling regardless.
2288
+ let polls = 0;
2289
+ const MAX_POLLS = 200;
2290
+
2291
+ while (this.#inFlight > 0 && runtime.hrtime() < deadline && polls < MAX_POLLS) {
2292
+ polls++;
2293
+ // Poll via runtime.setTimeout (max 10s)
2294
+ await new Promise<void>((resolve) => {
2295
+ runtime.setTimeout(() => resolve(), 50);
2296
+ });
2297
+ }
2298
+ }
2299
+ }
2300
+
2301
+ /**
2302
+ * Creates a new kernel application instance.
2303
+ *
2304
+ * @param options - Optional application configuration including pre-registered plugins
2305
+ * @returns The kernel application instance with inject() capability
2306
+ * @example
2307
+ * ```typescript
2308
+ * import { createApplication } from '@setu-ts/kernel';
2309
+ *
2310
+ * const app = createApplication({
2311
+ * plugins: [RuntimePlugin()],
2312
+ * });
2313
+ *
2314
+ * await app.start({ port: 3000 });
2315
+ * ```
2316
+ * @since 0.1.0
2317
+ */
2318
+ export function createApplication(options?: ApplicationOptions): IKernelApplication {
2319
+ const app = new Application(options?.diagnostics);
2320
+ if (options?.plugins) {
2321
+ for (const plugin of options.plugins) {
2322
+ app.register(plugin);
2323
+ }
2324
+ }
2325
+ return app;
2326
+ }