@setu-ts/kernel 0.0.0-bootstrap.0 → 0.9.0

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