@setu-ts/kernel 0.0.0-stage → 0.10.0

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