@setu-ts/kernel 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -2
  3. package/dist/application/application.d.ts +172 -0
  4. package/dist/application/application.d.ts.map +1 -0
  5. package/dist/application/application.js +1862 -0
  6. package/dist/application/application.js.map +1 -0
  7. package/dist/application/inject-body.d.ts +58 -0
  8. package/dist/application/inject-body.d.ts.map +1 -0
  9. package/dist/application/inject-body.js +82 -0
  10. package/dist/application/inject-body.js.map +1 -0
  11. package/dist/context/request-context.d.ts +32 -0
  12. package/dist/context/request-context.d.ts.map +1 -0
  13. package/dist/context/request-context.js +136 -0
  14. package/dist/context/request-context.js.map +1 -0
  15. package/dist/context/response.d.ts +60 -0
  16. package/dist/context/response.d.ts.map +1 -0
  17. package/dist/context/response.js +193 -0
  18. package/dist/context/response.js.map +1 -0
  19. package/dist/diagnostics/buffer.d.ts +143 -0
  20. package/dist/diagnostics/buffer.d.ts.map +1 -0
  21. package/dist/diagnostics/buffer.js +185 -0
  22. package/dist/diagnostics/buffer.js.map +1 -0
  23. package/dist/diagnostics/collector.d.ts +284 -0
  24. package/dist/diagnostics/collector.d.ts.map +1 -0
  25. package/dist/diagnostics/collector.js +812 -0
  26. package/dist/diagnostics/collector.js.map +1 -0
  27. package/dist/diagnostics/projection.d.ts +153 -0
  28. package/dist/diagnostics/projection.d.ts.map +1 -0
  29. package/dist/diagnostics/projection.js +226 -0
  30. package/dist/diagnostics/projection.js.map +1 -0
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +11 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/lifecycle/lifecycle-manager.d.ts +136 -0
  36. package/dist/lifecycle/lifecycle-manager.d.ts.map +1 -0
  37. package/dist/lifecycle/lifecycle-manager.js +206 -0
  38. package/dist/lifecycle/lifecycle-manager.js.map +1 -0
  39. package/dist/pipeline/execute-chain.d.ts +71 -0
  40. package/dist/pipeline/execute-chain.d.ts.map +1 -0
  41. package/dist/pipeline/execute-chain.js +104 -0
  42. package/dist/pipeline/execute-chain.js.map +1 -0
  43. package/dist/pipeline/middleware-pipeline.d.ts +68 -0
  44. package/dist/pipeline/middleware-pipeline.d.ts.map +1 -0
  45. package/dist/pipeline/middleware-pipeline.js +82 -0
  46. package/dist/pipeline/middleware-pipeline.js.map +1 -0
  47. package/dist/registry/plugin-resolver.d.ts +42 -0
  48. package/dist/registry/plugin-resolver.d.ts.map +1 -0
  49. package/dist/registry/plugin-resolver.js +140 -0
  50. package/dist/registry/plugin-resolver.js.map +1 -0
  51. package/dist/registry/service-registry.d.ts +110 -0
  52. package/dist/registry/service-registry.d.ts.map +1 -0
  53. package/dist/registry/service-registry.js +197 -0
  54. package/dist/registry/service-registry.js.map +1 -0
  55. package/dist/router/route-matcher.d.ts +85 -0
  56. package/dist/router/route-matcher.d.ts.map +1 -0
  57. package/dist/router/route-matcher.js +102 -0
  58. package/dist/router/route-matcher.js.map +1 -0
  59. package/dist/router/router.d.ts +130 -0
  60. package/dist/router/router.d.ts.map +1 -0
  61. package/dist/router/router.js +350 -0
  62. package/dist/router/router.js.map +1 -0
  63. package/package.json +48 -3
  64. package/src/application/application.ts +2299 -0
  65. package/src/application/inject-body.ts +131 -0
  66. package/src/context/request-context.ts +169 -0
  67. package/src/context/response.ts +254 -0
  68. package/src/diagnostics/buffer.ts +241 -0
  69. package/src/diagnostics/collector.ts +1031 -0
  70. package/src/diagnostics/projection.ts +322 -0
  71. package/src/index.ts +20 -0
  72. package/src/lifecycle/lifecycle-manager.ts +287 -0
  73. package/src/pipeline/execute-chain.ts +164 -0
  74. package/src/pipeline/middleware-pipeline.ts +131 -0
  75. package/src/registry/plugin-resolver.ts +185 -0
  76. package/src/registry/service-registry.ts +267 -0
  77. package/src/router/route-matcher.ts +131 -0
  78. package/src/router/router.ts +485 -0
@@ -0,0 +1,1862 @@
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
+ }
558
+ }
559
+ stop() {
560
+ // No-op if the application never started (e.g. a failed start() or a
561
+ // bare createApplication() used only for inject()). Avoids a confusing
562
+ // "No service registered for capability 'runtime'" from #drainRequests.
563
+ if (!this.#started) {
564
+ return Promise.resolve();
565
+ }
566
+ // Idempotent: a second call — concurrent or later — returns the same
567
+ // shutdown promise instead of re-running the drain and the shutdown/close
568
+ // hooks. Without this, repeated stop() calls fire onShutdown/onClose more
569
+ // than once.
570
+ if (this.#stopPromise !== null) {
571
+ return this.#stopPromise;
572
+ }
573
+ this.#stopPromise = this.#doStop();
574
+ return this.#stopPromise;
575
+ }
576
+ async #doStop() {
577
+ // Diagnostics state flips to `stopping` as the shutdown begins; the
578
+ // terminal `closed` transition and the retained-buffer clear run in the
579
+ // `finally` below, whatever the shutdown outcome — including a rejecting
580
+ // shutdown or close hook.
581
+ this.#collector?.markStopping();
582
+ let shutdownFailed = false;
583
+ try {
584
+ await this.#runShutdownSequence();
585
+ }
586
+ catch (error) {
587
+ shutdownFailed = true;
588
+ throw error;
589
+ }
590
+ finally {
591
+ this.#collector?.markClosed(shutdownFailed);
592
+ }
593
+ }
594
+ async #runShutdownSequence() {
595
+ // Runs FIRST, while the application is still serving normally, so a hook
596
+ // can tell the outside world to stop routing here before that becomes
597
+ // true — deregistering from a service registry, for instance.
598
+ //
599
+ // Guarded rather than awaited unconditionally: `await` on an
600
+ // already-resolved promise still defers everything below by a microtask,
601
+ // which would move when `#stopping` flips and hand a 404 to a request that
602
+ // used to get a 503. With no hook registered the flag is still set in the
603
+ // same synchronous turn as before, so opting out costs nothing.
604
+ //
605
+ // A rejection is CAPTURED rather than allowed to propagate from here.
606
+ // Letting it escape would abort the drain, the socket close, and the
607
+ // shutdown/close hooks, leaving an application that keeps serving — and
608
+ // because `#stopPromise` caches the result, one that can never be stopped
609
+ // at all. The error is still surfaced to the caller, but only after the
610
+ // shutdown it must not prevent has finished.
611
+ let stoppingError = null;
612
+ if (this.#lifecycle.hasStopping()) {
613
+ try {
614
+ await this.#lifecycle.runStopping();
615
+ }
616
+ catch (error) {
617
+ stoppingError = { cause: error };
618
+ }
619
+ }
620
+ // Set before the next await so a request arriving during the drain window
621
+ // sees the shutting-down state and gets a 503.
622
+ this.#stopping = true;
623
+ // Wait for in-flight requests to drain (max 10s). A successfully-started
624
+ // application always has a runtime registered (start() requires a runtime
625
+ // provider) and stop() short-circuits before this when never started, so
626
+ // the runtime is always available here.
627
+ await this.#drainRequests();
628
+ // Close server if listening
629
+ if (this.#serverHandle !== null) {
630
+ const adapter = this.#registry.get(CAPABILITIES.HTTP_ADAPTER);
631
+ await adapter.close(this.#serverHandle);
632
+ this.#serverHandle = null;
633
+ }
634
+ // Run shutdown hooks (LIFO)
635
+ await this.#lifecycle.runShutdown();
636
+ // Run close hooks
637
+ await this.#lifecycle.runClose();
638
+ // Surfaced last: the application is fully stopped by now, so the caller
639
+ // learns the stopping hook failed without that failure having blocked the
640
+ // shutdown.
641
+ if (stoppingError !== null) {
642
+ throw stoppingError.cause;
643
+ }
644
+ }
645
+ /** Delegates a web-standard Request to the registered IHttpAdapter. */
646
+ // Declared `async` so the no-adapter case REJECTS rather than throwing
647
+ // synchronously: this method returns a promise, and a sync throw escapes a
648
+ // caller's `.catch(…)` — including the `export default { fetch: app.fetch }`
649
+ // Workers entry point, where it would surface as an unhandled exception
650
+ // instead of a failed request.
651
+ async fetch(request) {
652
+ if (!this.#registry.has(CAPABILITIES.HTTP_ADAPTER)) {
653
+ throw new Error('No HTTP adapter registered. Call register(RuntimePlugin) or provide a custom IHttpAdapter.');
654
+ }
655
+ const adapter = this.#registry.get(CAPABILITIES.HTTP_ADAPTER);
656
+ return await adapter.fetch(request);
657
+ }
658
+ /** Synthesizes an inject request and runs it through the full pipeline. */
659
+ async inject(request) {
660
+ // Per-shape coercion (M95c §3.9): bytes verbatim, Blob awaited,
661
+ // URLSearchParams through its own serialisation, JSON only for a plain
662
+ // object — and a named refusal for anything else, since a JavaScript
663
+ // caller can still hand `inject()` a value the type cannot see.
664
+ const { bytes: bodyBytes, defaultContentType } = await coerceInjectBody(request.body);
665
+ // A COPY, never the caller's own `Headers`. The per-shape default below
666
+ // WRITES here, and reusing one `Headers` instance across two injected
667
+ // requests is ordinary in a test: the first request's `content-type` used
668
+ // to stick to the caller's object, so the second silently inherited it and
669
+ // `!headers.has('content-type')` then declined to set the right one — a
670
+ // JSON body following a form body arrived as a form. Same aliasing hazard
671
+ // the byte copy in `coerceInjectBody` guards, one field over; a handler
672
+ // mutating `ctx.request.headers` is now contained too.
673
+ const headers = new Headers(request.headers ?? {});
674
+ // The default is PER SHAPE: a URLSearchParams body defaulting to
675
+ // `application/json` would arrive as a non-form and the form parse would
676
+ // refuse the input this widening exists to carry; a `Uint8Array` or an
677
+ // `ArrayBuffer` gets NO default at all because only the caller knows its
678
+ // encoding; and a `Blob` declares its own through `.type` (M99c), the
679
+ // same header the platform sets for `new Request(url, { body: blob })`.
680
+ if (bodyBytes !== undefined && defaultContentType !== undefined && !headers.has('content-type')) {
681
+ headers.set('content-type', defaultContentType);
682
+ }
683
+ // Normalize relative paths to a full URL so createRequestContext can parse
684
+ // query params without throwing. Only normalize paths starting with `/` —
685
+ // arbitrary strings like `'not-a-valid-url'` should still reach
686
+ // createRequestContext so its URL-parse failure is surfaced as a 400.
687
+ const fullUrl = request.url.startsWith('/') ? `http://localhost${request.url}` : request.url;
688
+ // An HTTP adapter attaches the undisturbed web `Request` as `IRequest.raw`,
689
+ // and the terminal handler reads it to decide a WebSocket upgrade or gRPC
690
+ // dispatch. Omitting it here would make `inject()` structurally unable to
691
+ // reach either path — the request would silently fall through to the 404
692
+ // and a test could never tell the difference.
693
+ //
694
+ // Built defensively: a malformed URL must still reach
695
+ // `createRequestContext`, whose parse failure is surfaced as a 400, rather
696
+ // than throwing out of `inject()` here.
697
+ let raw;
698
+ try {
699
+ // A second copy, deliberately: `coerceInjectBody` already returned bytes
700
+ // the caller cannot alias, and this keeps `ctx.request.raw`'s body
701
+ // independent of the array `bytes()` hands the handler, so a handler that
702
+ // mutates what it read cannot change what `raw` reports. `inject()` is an
703
+ // in-process test entry point and the pre-M95c path re-encoded the body
704
+ // on every call, so the copy is not a regression.
705
+ const requestBody = bodyBytes === undefined ? undefined : new Uint8Array(bodyBytes);
706
+ raw = new Request(fullUrl, {
707
+ method: request.method,
708
+ headers,
709
+ ...(requestBody !== undefined && request.method !== 'GET' && request.method !== 'HEAD'
710
+ ? { body: requestBody }
711
+ : {}),
712
+ });
713
+ }
714
+ catch {
715
+ raw = undefined;
716
+ }
717
+ // The form read is memoized like `json()`'s: the in-flight promise is
718
+ // cached (a rejection with it), so two middleware reading the same
719
+ // injected body trigger one parse, and a body that is not a form keeps
720
+ // rejecting identically on a retry.
721
+ let form;
722
+ const syntheticRequest = {
723
+ method: request.method,
724
+ url: fullUrl,
725
+ get path() {
726
+ return new URL(fullUrl).pathname;
727
+ },
728
+ headers,
729
+ ...(raw !== undefined ? { raw } : {}),
730
+ json() {
731
+ // The shared parse (X37-1): a malformed body rejects with the
732
+ // `400`-branded MalformedRequestBodyError, so `inject()` observes the
733
+ // same failure the served path produces. The empty-body arm keeps its
734
+ // `{}` default — an in-process inject that sent no body is not a
735
+ // malformed body.
736
+ return Promise.resolve().then(() => parseJsonBody(bodyBytes === undefined ? '{}' : new TextDecoder().decode(bodyBytes)));
737
+ },
738
+ text() {
739
+ return Promise.resolve(bodyBytes === undefined ? '' : new TextDecoder().decode(bodyBytes));
740
+ },
741
+ bytes() {
742
+ return Promise.resolve(bodyBytes ?? new Uint8Array(0));
743
+ },
744
+ formData() {
745
+ // The shared parse (M94b): a non-form content-type rejects with the
746
+ // `415`-branded UnsupportedFormEncodingError, so an injected request
747
+ // observes what a served request observes. A `Uint8Array` or an
748
+ // `ArrayBuffer` sets NO default content type (M95c §3.9), so an
749
+ // injected multipart in those shapes must supply its own — exactly
750
+ // what a served request requires. A `Blob` supplies it from `.type`
751
+ // (M99c), as the platform does.
752
+ form ??= Promise.resolve().then(() => parseFormBody(bodyBytes ?? new Uint8Array(0), headers.get('content-type')));
753
+ return form;
754
+ },
755
+ };
756
+ const response = await this.#handleRequest(syntheticRequest);
757
+ const snapshot = response.snapshot();
758
+ // A streaming response cannot be presented as a `string` body without
759
+ // draining the live stream, which would consume it. Say so explicitly
760
+ // rather than reporting `body: null`, which reads as "empty response".
761
+ if (snapshot.streaming) {
762
+ throw new Error('inject() cannot read a streaming response body. Call app.fetch() with a web Request ' +
763
+ 'and read the returned Response body stream instead.');
764
+ }
765
+ // Decode a byte body (from `response.send(bytes)`) rather than dropping it:
766
+ // reporting `null` made a non-empty response look empty, and made `json()`
767
+ // throw "No JSON body available" for a perfectly valid JSON payload sent
768
+ // as bytes.
769
+ const body = snapshot.body === null
770
+ ? null
771
+ : typeof snapshot.body === 'string'
772
+ ? snapshot.body
773
+ : new TextDecoder().decode(snapshot.body);
774
+ return {
775
+ statusCode: snapshot.status,
776
+ headers: snapshot.headers,
777
+ body,
778
+ json() {
779
+ if (body === null) {
780
+ throw new Error('No JSON body available');
781
+ }
782
+ return JSON.parse(body);
783
+ },
784
+ };
785
+ }
786
+ // ---------------------------------------------------------------------------
787
+ // Private methods
788
+ // ---------------------------------------------------------------------------
789
+ #handleRequest(request) {
790
+ if (this.#stopping) {
791
+ const builder = new ResponseBuilder();
792
+ // Pre-pipeline: no request context exists yet, so the responder (if any)
793
+ // is seeded from the startup cache — the same instance `errorHandler`
794
+ // would publish into `ctx.state` inside the pipeline — into the fresh
795
+ // state map (M70f re-review, finding 1). With no `errorHandler` the
796
+ // cache is empty and the framework-default shape is written. The status
797
+ // is still written here, so metrics keep seeing 503.
798
+ const state = new Map();
799
+ if (this.#errorResponder !== undefined) {
800
+ state.set(ERROR_RESPONDER_STATE_KEY, this.#errorResponder);
801
+ }
802
+ respondWithError({ state, response: builder, ...this.#safeRequestTarget(request) }, { status: 503, title: 'Service Unavailable' });
803
+ return builder;
804
+ }
805
+ const runtime = this.#registry.get(CAPABILITIES.RUNTIME);
806
+ // Build the per-request context and validate the request path BEFORE
807
+ // entering in-flight accounting. Two client errors are caught here:
808
+ // 1. A malformed request URL makes `new URL()` throw. The throw is
809
+ // caught wherever it surfaces rather than at one assumed site: the
810
+ // context resolves its query lazily (M87), so construction no longer
811
+ // parses the URL, and for an `inject()`-built request `path` is itself
812
+ // a lazy getter that parses. Reading `path` inside the `try` covers
813
+ // both, and the adapter path throws earlier still, in its own mapping.
814
+ // 2. A malformed percent-escape in the path (e.g. `%zz`) would
815
+ // otherwise surface as a 500 from the router's decodeURIComponent.
816
+ // Both are rejected as a 400. Because #inFlight is incremented only
817
+ // AFTER this succeeds, a rejected request can never leak the counter and
818
+ // stall shutdown drain (the throw would otherwise escape the finally).
819
+ let handle;
820
+ let path;
821
+ try {
822
+ handle = createRequestContext(request, this.#registry, runtime);
823
+ path = handle.ctx.request.path;
824
+ }
825
+ catch {
826
+ return this.#badRequest(request);
827
+ }
828
+ if (!isPathDecodable(path)) {
829
+ return this.#badRequest(request);
830
+ }
831
+ const ctx = handle.ctx;
832
+ // Seed the application's resolved error responder into the fresh context
833
+ // BEFORE the request-lifecycle hooks run (M70f re-review, finding 2). The
834
+ // hooks execute before the middleware pipeline, so `errorHandler`'s own
835
+ // `ctx.state` publication has not happened yet: without this seed, a
836
+ // throwing `onRequest` hook would fall through to the no-handler fallback
837
+ // even when an `errorHandler` is registered. `errorHandler` re-publishes
838
+ // the SAME instance inside the pipeline, so the per-request set is
839
+ // idempotent and in-pipeline sites are unaffected.
840
+ if (this.#errorResponder !== undefined) {
841
+ ctx.state.set(ERROR_RESPONDER_STATE_KEY, this.#errorResponder);
842
+ }
843
+ // The request operation is keyed in the collector's private WeakMap —
844
+ // never in application state, never a caller-supplied id.
845
+ this.#collector?.beginRequestOperation(ctx);
846
+ this.#inFlight++;
847
+ try {
848
+ const pending = this.#runRequest(ctx, handle, path, request);
849
+ if (pending === undefined) {
850
+ this.#inFlight--;
851
+ this.#completeRequest(ctx, 'ok');
852
+ return ctx.response;
853
+ }
854
+ return pending.then(() => {
855
+ this.#inFlight--;
856
+ this.#completeRequest(ctx, 'ok');
857
+ return ctx.response;
858
+ }, (error) => this.#failRequest(error, ctx));
859
+ }
860
+ catch (error) {
861
+ return this.#failRequest(error, ctx);
862
+ }
863
+ }
864
+ /**
865
+ * Emits the request operation's completion record. Reading the status AFTER
866
+ * the response is final keeps the record honest about what was served; the
867
+ * operation is unkeyed inside the collector, so a double completion cannot
868
+ * occur.
869
+ *
870
+ * @param ctx - The live request context
871
+ * @param outcome - How the request completed
872
+ */
873
+ #completeRequest(ctx, outcome) {
874
+ this.#collector?.endRequestOperation(ctx, {
875
+ outcome,
876
+ statusCode: ctx.response.statusCode,
877
+ });
878
+ }
879
+ /**
880
+ * Runs the request body, returning `undefined` when it completed
881
+ * SYNCHRONOUSLY and a promise otherwise (M87).
882
+ *
883
+ * The framework's own layers are the ones being made sync-capable here:
884
+ * an application registering no lifecycle hooks and no global middleware
885
+ * previously still paid the full `async`/`await` machinery of this method,
886
+ * `MiddlewarePipeline.execute` and `executeChain` — several microtask
887
+ * boundaries per request before any user code ran. Returning `undefined`
888
+ * rather than a resolved promise is what lets the caller, and ultimately
889
+ * the HTTP adapter, hand a plain `Response` back to the server: node's
890
+ * `responseViaCache` fast path is gated on the handler NOT returning a
891
+ * promise, so a single eagerly-async link anywhere in the chain forecloses
892
+ * it for every request.
893
+ *
894
+ * @param ctx - The live request context
895
+ * @param handle - The context handle, for installing matched route params
896
+ * @param path - The request path, already resolved and validated
897
+ * @param request - The normalized request
898
+ * @returns `undefined` when complete, else a promise that settles when it is
899
+ */
900
+ #runRequest(ctx, handle, path, request) {
901
+ const requestHooks = this.#lifecycle.getRequestHooks();
902
+ const responseHooks = this.#lifecycle.getResponseHooks();
903
+ // `compile()` caches, so this is a null check after the first request.
904
+ const chain = this.#pipeline.compile();
905
+ if (requestHooks.length === 0 && responseHooks.length === 0 && chain.length === 0) {
906
+ return this.#dispatch(ctx, handle, path, request);
907
+ }
908
+ return this.#runRequestFull(ctx, handle, path, request, requestHooks, responseHooks);
909
+ }
910
+ /**
911
+ * The general request path: lifecycle hooks and/or global middleware are
912
+ * registered, so at least one `await` is unavoidable.
913
+ *
914
+ * @param ctx - The live request context
915
+ * @param handle - The context handle, for installing matched route params
916
+ * @param path - The request path, already resolved and validated
917
+ * @param request - The normalized request
918
+ * @param requestHooks - Resolved `onRequest` hooks
919
+ * @param responseHooks - Resolved `onResponse` hooks
920
+ */
921
+ async #runRequestFull(ctx, handle, path, request, requestHooks, responseHooks) {
922
+ const parentOperationId = this.#collector?.requestOperationIdOf(ctx);
923
+ for (const hook of requestHooks) {
924
+ await this.#observeRequestStage('request-hook', parentOperationId, () => hook(ctx));
925
+ }
926
+ // Execute pipeline with route dispatch as terminal, observing each global
927
+ // stage's completion when diagnostics are enabled.
928
+ await this.#pipeline.execute(ctx, async () => {
929
+ await this.#dispatch(ctx, handle, path, request);
930
+ }, this.#chainObserver('global', parentOperationId, undefined));
931
+ for (const hook of responseHooks) {
932
+ await this.#observeRequestStage('response-hook', parentOperationId, () => hook(ctx));
933
+ }
934
+ }
935
+ /**
936
+ * Runs one request-scoped hook (`onRequest`/`onResponse`/`onError`),
937
+ * observing its completion when diagnostics are enabled. Absent collector —
938
+ * or no request operation, which cannot happen on a reached path — this is
939
+ * a bare `await`, so the disabled path is unchanged.
940
+ *
941
+ * @param stage - Which hook boundary this is
942
+ * @param parentOperationId - The request operation, when known
943
+ * @param run - The hook invocation
944
+ */
945
+ async #observeRequestStage(stage, parentOperationId, run) {
946
+ const collector = this.#collector;
947
+ if (collector === undefined || parentOperationId === undefined) {
948
+ await run();
949
+ return;
950
+ }
951
+ const startedAtMs = collector.monotonicMs();
952
+ try {
953
+ await run();
954
+ }
955
+ catch (error) {
956
+ collector.observeRequestStage({
957
+ parentOperationId,
958
+ stage,
959
+ outcome: 'error',
960
+ startedAtMs,
961
+ durationMs: collector.elapsedSince(startedAtMs),
962
+ });
963
+ throw error;
964
+ }
965
+ collector.observeRequestStage({
966
+ parentOperationId,
967
+ stage,
968
+ outcome: 'ok',
969
+ startedAtMs,
970
+ durationMs: collector.elapsedSince(startedAtMs),
971
+ });
972
+ }
973
+ /**
974
+ * Builds the chain observer for one chain execution (global or route).
975
+ * `undefined` when diagnostics are disabled, which keeps the executor's
976
+ * instrumented path byte-identical to the original one.
977
+ *
978
+ * @param scope - Which chain the observer instruments
979
+ * @param parentOperationId - The request operation the stages belong to
980
+ * @param routeEntryIndex - The matched route's entry index (route chains)
981
+ */
982
+ #chainObserver(scope, parentOperationId, routeEntryIndex) {
983
+ const collector = this.#collector;
984
+ if (collector === undefined) {
985
+ return undefined;
986
+ }
987
+ return {
988
+ clock: () => collector.monotonicMs(),
989
+ onStage: (stage) => {
990
+ collector.observeMiddlewareStage({
991
+ parentOperationId: parentOperationId ?? '<unkeyed>',
992
+ scope,
993
+ position: stage.position,
994
+ routeEntryIndex,
995
+ outcome: stage.outcome,
996
+ startedAtMs: stage.startedAtMs,
997
+ durationMs: stage.durationMs,
998
+ });
999
+ },
1000
+ };
1001
+ }
1002
+ /**
1003
+ * Terminal dispatch: protocol switch first, then HTTP route matching.
1004
+ *
1005
+ * Protocol dispatch runs BEFORE route matching, and after the pipeline.
1006
+ *
1007
+ * "After the pipeline" is the M70a security property: auth, metrics and the
1008
+ * shutdown drain apply to an upgrade and to an RPC exactly as they do to a
1009
+ * `GET /users`.
1010
+ *
1011
+ * "Before route matching" is precedence, and it is not optional. A WebSocket
1012
+ * upgrade is a protocol switch rather than an HTTP route, and a path inside
1013
+ * the gRPC `basePath` belongs to gRPC (M49). Deciding these only when
1014
+ * nothing matched lets ANY catch-all shadow both — and `react-router-plugin`
1015
+ * mounts exactly that, on all seven verbs, for SSR: a full-stack application
1016
+ * would answer a WebSocket client with an HTML page and a gRPC client with
1017
+ * the same.
1018
+ *
1019
+ * Both helpers open with the same two SYNCHRONOUS refusals — no raw
1020
+ * `Request`, or the capability is not registered — but both are `async`, so
1021
+ * an application registering neither still paid a promise allocation and a
1022
+ * microtask apiece on every request (M87). Hoisting those refusals to a
1023
+ * synchronous guard preserves the outcome exactly: absent the token, each
1024
+ * call returned `false` regardless.
1025
+ *
1026
+ * @param ctx - The live request context
1027
+ * @param handle - The context handle, for installing matched route params
1028
+ * @param path - The request path, already resolved and validated
1029
+ * @param request - The normalized request
1030
+ * @returns `undefined` when complete, else a promise that settles when it is
1031
+ */
1032
+ #dispatch(ctx, handle, path, request) {
1033
+ if (ctx.raw !== undefined &&
1034
+ (this.#registry.has(CAPABILITIES.WEBSOCKET) || this.#registry.has(CAPABILITIES.GRPC))) {
1035
+ return this.#dispatchProtocol(ctx, handle, path, request);
1036
+ }
1037
+ return this.#dispatchRoute(ctx, handle, path, request);
1038
+ }
1039
+ /**
1040
+ * Protocol dispatch for an application that registered WebSocket or gRPC,
1041
+ * falling through to HTTP route matching when neither claims the request.
1042
+ *
1043
+ * @param ctx - The live request context
1044
+ * @param handle - The context handle, for installing matched route params
1045
+ * @param path - The request path, already resolved and validated
1046
+ * @param request - The normalized request
1047
+ */
1048
+ async #dispatchProtocol(ctx, handle, path, request) {
1049
+ if (this.#registry.has(CAPABILITIES.WEBSOCKET) &&
1050
+ await this.#observeProtocol(ctx, 'websocket-upgrade', () => this.#tryUpgrade(ctx))) {
1051
+ return;
1052
+ }
1053
+ if (this.#registry.has(CAPABILITIES.GRPC) &&
1054
+ await this.#observeProtocol(ctx, 'grpc-dispatch', () => this.#tryGrpc(ctx))) {
1055
+ return;
1056
+ }
1057
+ await this.#dispatchRoute(ctx, handle, path, request);
1058
+ }
1059
+ /**
1060
+ * Observes one protocol-dispatch boundary (WebSocket upgrade or gRPC
1061
+ * dispatch). A boundary that claims the request records its completion —
1062
+ * never a claim about subsequent socket frames or RPC internals — and a
1063
+ * thrown boundary records `error` and propagates unchanged.
1064
+ *
1065
+ * @param ctx - The live request context
1066
+ * @param stage - Which protocol boundary is being attempted
1067
+ * @param run - The boundary attempt, answering `true` when it claimed the request
1068
+ * @returns The attempt's answer, unchanged
1069
+ */
1070
+ async #observeProtocol(ctx, stage, run) {
1071
+ const collector = this.#collector;
1072
+ if (collector === undefined) {
1073
+ return await run();
1074
+ }
1075
+ const parentOperationId = collector.requestOperationIdOf(ctx) ?? '<unkeyed>';
1076
+ const startedAtMs = collector.monotonicMs();
1077
+ try {
1078
+ const answered = await run();
1079
+ if (answered) {
1080
+ collector.observeHandlerStage({
1081
+ parentOperationId,
1082
+ stage,
1083
+ nodeId: null,
1084
+ outcome: 'ok',
1085
+ startedAtMs,
1086
+ durationMs: collector.elapsedSince(startedAtMs),
1087
+ statusCode: ctx.response.statusCode,
1088
+ });
1089
+ }
1090
+ return answered;
1091
+ }
1092
+ catch (error) {
1093
+ collector.observeHandlerStage({
1094
+ parentOperationId,
1095
+ stage,
1096
+ nodeId: null,
1097
+ outcome: 'error',
1098
+ startedAtMs,
1099
+ durationMs: collector.elapsedSince(startedAtMs),
1100
+ });
1101
+ throw error;
1102
+ }
1103
+ }
1104
+ /**
1105
+ * HTTP route matching and handler invocation.
1106
+ *
1107
+ * Matching reuses the path resolved by the caller rather than parsing the
1108
+ * URL a second time (M87): the adapter's mapping already parsed it, and the
1109
+ * decodability guard already read it, so `path` is `url.pathname` by
1110
+ * construction on the adapter path and on the `inject()` path alike.
1111
+ *
1112
+ * @param ctx - The live request context
1113
+ * @param handle - The context handle, for installing matched route params
1114
+ * @param path - The request path, already resolved and validated
1115
+ * @param request - The normalized request
1116
+ * @returns `undefined` when complete, else a promise that settles when it is
1117
+ */
1118
+ #dispatchRoute(ctx, handle, path, request) {
1119
+ const routeResult = this.#router.match(request.method, path);
1120
+ if (routeResult === null) {
1121
+ respondWithError(ctx, { status: 404, title: 'Not Found' });
1122
+ return undefined;
1123
+ }
1124
+ // Install matched params via the internal setter (no readonly cast)
1125
+ const { definition, params, entry } = routeResult;
1126
+ handle.setParams(params);
1127
+ // Route middleware uses the same next()-chaining semantics as the global
1128
+ // pipeline: a stage that responds without calling next() short-circuits,
1129
+ // and the handler does not run. Defense-in-depth in executeChain also
1130
+ // stops stages after the response is ended. Positions follow the route's
1131
+ // own middleware array; the terminal observes the handler itself.
1132
+ const routeMiddleware = definition.middleware;
1133
+ if (routeMiddleware !== undefined && routeMiddleware.length > 0) {
1134
+ return executeChain(routeMiddleware, ctx, async () => {
1135
+ await this.#observeHandler(ctx, entry, async () => {
1136
+ await definition.handler(ctx);
1137
+ });
1138
+ }, undefined, this.#chainObserver('route', this.#collector?.requestOperationIdOf(ctx), entry.index));
1139
+ }
1140
+ // Empty-chain bypass (M87). On an empty chain `executeChain` does exactly
1141
+ // one thing before the terminal: the defense-in-depth `ended` check, which
1142
+ // matters because a global stage may have ended the response AND called
1143
+ // `next()`. Keeping that check and invoking the handler directly is
1144
+ // behaviourally identical, and it removes the executor's two closures and
1145
+ // three microtask boundaries from every route declaring no middleware.
1146
+ if (ctx.response.ended) {
1147
+ return undefined;
1148
+ }
1149
+ return this.#invokeHandlerObserved(ctx, entry, definition);
1150
+ }
1151
+ /**
1152
+ * Invokes a route's handler on the empty-chain bypass, observing its
1153
+ * completion when diagnostics are enabled WITHOUT changing the bypass's
1154
+ * synchronous contract: a sync handler still returns `undefined` from this
1155
+ * method and an async one still returns its promise.
1156
+ *
1157
+ * @param ctx - The live request context
1158
+ * @param entry - The matched route entry, naming the handler's node
1159
+ * @param definition - The route definition holding the handler
1160
+ * @returns `undefined` when complete synchronously, else the handler's promise
1161
+ */
1162
+ #invokeHandlerObserved(ctx, entry, definition) {
1163
+ const collector = this.#collector;
1164
+ if (collector === undefined) {
1165
+ const result = definition.handler(ctx);
1166
+ // Duck-typed, not `instanceof Promise`: a cross-realm or userland promise
1167
+ // satisfies `RouteHandler`'s declared return type structurally but fails
1168
+ // `instanceof`, and treating one as "already finished" would send the
1169
+ // response while the handler was still running (M87 review).
1170
+ // `Promise.resolve` returns a native promise unchanged, so the ordinary
1171
+ // async path allocates nothing extra.
1172
+ return isPromiseLike(result) ? Promise.resolve(result) : undefined;
1173
+ }
1174
+ const parentOperationId = collector.requestOperationIdOf(ctx) ?? '<unkeyed>';
1175
+ const nodeId = collector.routeNodeIdOf(entry.index) ?? null;
1176
+ const startedAtMs = collector.monotonicMs();
1177
+ let result;
1178
+ try {
1179
+ result = definition.handler(ctx);
1180
+ }
1181
+ catch (error) {
1182
+ collector.observeHandlerStage({
1183
+ parentOperationId,
1184
+ stage: 'handler',
1185
+ nodeId,
1186
+ outcome: 'error',
1187
+ startedAtMs,
1188
+ durationMs: collector.elapsedSince(startedAtMs),
1189
+ });
1190
+ throw error;
1191
+ }
1192
+ if (isPromiseLike(result)) {
1193
+ const pending = Promise.resolve(result);
1194
+ // Observation only: the ORIGINAL promise is returned to the caller and
1195
+ // its rejection propagates unchanged — this detached chain never
1196
+ // swallows or re-routes it.
1197
+ void pending.then(() => {
1198
+ collector.observeHandlerStage({
1199
+ parentOperationId,
1200
+ stage: 'handler',
1201
+ nodeId,
1202
+ outcome: 'ok',
1203
+ startedAtMs,
1204
+ durationMs: collector.elapsedSince(startedAtMs),
1205
+ statusCode: ctx.response.statusCode,
1206
+ });
1207
+ }, () => {
1208
+ collector.observeHandlerStage({
1209
+ parentOperationId,
1210
+ stage: 'handler',
1211
+ nodeId,
1212
+ outcome: 'error',
1213
+ startedAtMs,
1214
+ durationMs: collector.elapsedSince(startedAtMs),
1215
+ });
1216
+ });
1217
+ return pending;
1218
+ }
1219
+ collector.observeHandlerStage({
1220
+ parentOperationId,
1221
+ stage: 'handler',
1222
+ nodeId,
1223
+ outcome: 'ok',
1224
+ startedAtMs,
1225
+ durationMs: collector.elapsedSince(startedAtMs),
1226
+ statusCode: ctx.response.statusCode,
1227
+ });
1228
+ return undefined;
1229
+ }
1230
+ /**
1231
+ * Runs the route chain's terminal handler, observing its completion when
1232
+ * diagnostics are enabled. The terminal is already async, so observation
1233
+ * adds no new microtask boundary to the disabled path.
1234
+ *
1235
+ * @param ctx - The live request context
1236
+ * @param entry - The matched route entry, naming the handler's node
1237
+ * @param run - The handler invocation
1238
+ */
1239
+ async #observeHandler(ctx, entry, run) {
1240
+ const collector = this.#collector;
1241
+ if (collector === undefined) {
1242
+ await run();
1243
+ return;
1244
+ }
1245
+ const parentOperationId = collector.requestOperationIdOf(ctx) ?? '<unkeyed>';
1246
+ const nodeId = collector.routeNodeIdOf(entry.index) ?? null;
1247
+ const startedAtMs = collector.monotonicMs();
1248
+ try {
1249
+ await run();
1250
+ collector.observeHandlerStage({
1251
+ parentOperationId,
1252
+ stage: 'handler',
1253
+ nodeId,
1254
+ outcome: 'ok',
1255
+ startedAtMs,
1256
+ durationMs: collector.elapsedSince(startedAtMs),
1257
+ statusCode: ctx.response.statusCode,
1258
+ });
1259
+ }
1260
+ catch (error) {
1261
+ collector.observeHandlerStage({
1262
+ parentOperationId,
1263
+ stage: 'handler',
1264
+ nodeId,
1265
+ outcome: 'error',
1266
+ startedAtMs,
1267
+ durationMs: collector.elapsedSince(startedAtMs),
1268
+ });
1269
+ throw error;
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Failure path: runs `onError` hooks, reports, and writes the opaque 500.
1274
+ *
1275
+ * Stays synchronous when no `onError` hook is registered, so a throwing
1276
+ * handler in a hook-free application does not force the whole request onto
1277
+ * the microtask queue (M87).
1278
+ *
1279
+ * A hook that throws must not stop the remaining hooks from running, but its
1280
+ * failure must not vanish either: an audit logger, telemetry collector, or
1281
+ * transaction-rollback hook that fails silently leaves security
1282
+ * infrastructure blind. Suppressed errors are surfaced through the
1283
+ * sanctioned logger channel.
1284
+ *
1285
+ * @param error - The thrown value
1286
+ * @param ctx - The live request context
1287
+ * @returns The response, directly or once the hooks have run
1288
+ */
1289
+ #failRequest(error, ctx) {
1290
+ const err = error instanceof Error ? error : new Error(String(error));
1291
+ const hooks = this.#lifecycle.getErrorHooks();
1292
+ if (hooks.length === 0) {
1293
+ return this.#finishFailure(err, ctx);
1294
+ }
1295
+ const parentOperationId = this.#collector?.requestOperationIdOf(ctx);
1296
+ return (async () => {
1297
+ for (const hook of hooks) {
1298
+ try {
1299
+ await this.#observeRequestStage('error-hook', parentOperationId, () => hook(err, ctx));
1300
+ }
1301
+ catch (hookError) {
1302
+ this.#reportSuppressedHookError(hookError);
1303
+ }
1304
+ }
1305
+ return this.#finishFailure(err, ctx);
1306
+ })();
1307
+ }
1308
+ /**
1309
+ * Reports the unhandled error and writes the opaque 500, closing out the
1310
+ * request's in-flight accounting.
1311
+ *
1312
+ * X11-2: the unhandled error is visible to the operator. The body stays
1313
+ * opaque — the message is not disclosed to the client (M70b's
1314
+ * `maskInternalErrors` decision applied consistently); only the log carries
1315
+ * it.
1316
+ *
1317
+ * @param err - The normalized error
1318
+ * @param ctx - The live request context
1319
+ * @returns The response builder carrying the 500
1320
+ */
1321
+ #finishFailure(err, ctx) {
1322
+ // Decrement FIRST: the original body did this in a `finally`, so a throw
1323
+ // from the reporting or responder call could not leak the counter and
1324
+ // stall the shutdown drain. Ordering it ahead of both preserves that.
1325
+ this.#inFlight--;
1326
+ this.#reportUnhandledError(err, ctx);
1327
+ respondWithError(ctx, { status: 500, title: 'Internal Server Error' });
1328
+ // Recorded AFTER the response is final, so the status in the record is
1329
+ // the status that was actually served.
1330
+ this.#completeRequest(ctx, 'error');
1331
+ return ctx.response;
1332
+ }
1333
+ /**
1334
+ * Tries a WebSocket upgrade after the middleware pipeline and before route
1335
+ * matching when the request carries a raw `Request`. Consults the
1336
+ * `IWebSocketService`'s upgrade router; when it accepts, brands the
1337
+ * `IRequest` with the intent so the HTTP adapter can perform the handshake
1338
+ * after the framework handler returns.
1339
+ *
1340
+ * The brand goes on the `IRequest`, not `ctx.state`, because the adapter
1341
+ * holds the former and never sees the context — see {@linkcode
1342
+ * UPGRADE_INTENT}.
1343
+ *
1344
+ * @param ctx - The live request context
1345
+ * @returns `true` when this handler answered (intent recorded, or refused)
1346
+ */
1347
+ async #tryUpgrade(ctx) {
1348
+ const raw = ctx.raw;
1349
+ // A custom adapter may omit `raw`; treat that as "no upgrade" and fall
1350
+ // through to the 404 rather than throwing.
1351
+ if (raw === undefined) {
1352
+ return false;
1353
+ }
1354
+ // Resolved optionally — absent the capability, nothing changes. Probed with
1355
+ // `has` rather than a try/catch around `get`, because this now runs on
1356
+ // EVERY request rather than only on an unmatched one, and throw-driven
1357
+ // control flow on the hot path is what AI_GUIDELINES §14 exists to stop.
1358
+ if (!this.#registry.has(CAPABILITIES.WEBSOCKET)) {
1359
+ return false;
1360
+ }
1361
+ const wsService = this.#registry.get(CAPABILITIES.WEBSOCKET);
1362
+ if (wsService.routeUpgrade === undefined) {
1363
+ return false;
1364
+ }
1365
+ // Upgrade detection is the ROUTER's job, not the kernel's — it returns
1366
+ // `null` for anything that is not an upgrade on a route it owns. Doing the
1367
+ // header check here as well would duplicate the rule (§11.1) and, worse,
1368
+ // would move a throwing header read outside the service's own reporting
1369
+ // wrapper, so a routing failure would be logged nowhere.
1370
+ //
1371
+ // This backstop is for a third-party service that does not report: before
1372
+ // M70a the adapter-side `UpgradeRouterStore` caught here, and letting a
1373
+ // routing bug escape into the generic 500 would lose the distinction
1374
+ // between "routing failed" and "the handler threw".
1375
+ let decision;
1376
+ try {
1377
+ // The pipeline has already run (line 643, above), so `ctx.request.user`
1378
+ // carries whatever the authentication band produced. The parameter is
1379
+ // optional on the contract (M73 §3.7): a service written before the
1380
+ // widening simply never receives it.
1381
+ decision = await wsService.routeUpgrade(raw, ctx.request.user);
1382
+ }
1383
+ catch (cause) {
1384
+ // Reported, not swallowed. The pre-M70a backstop in the adapter's
1385
+ // `UpgradeRouterStore` discarded the cause because the adapter holds no
1386
+ // logger; the kernel does, so silence here would be a choice rather than
1387
+ // a constraint.
1388
+ this.#reportUpgradeRouterFailure(cause);
1389
+ respondWithError(ctx, { status: 500, title: 'Internal Server Error' });
1390
+ return true;
1391
+ }
1392
+ if (decision === null) {
1393
+ return false;
1394
+ }
1395
+ if (!decision.accept) {
1396
+ respondWithError(ctx, { status: decision.status, title: 'Upgrade rejected' });
1397
+ return true;
1398
+ }
1399
+ // RFC 6455 §4.1 forbids a body on the handshake. Refusing here makes the
1400
+ // behaviour one thing on all four adapters rather than a runtime-specific
1401
+ // failure inside the upgrade call. Read from the MAPPED body rather than
1402
+ // `content-length`, which is absent both on an in-process `Request` and on
1403
+ // any chunked upload.
1404
+ //
1405
+ // This comment previously also claimed the mapping "has already disturbed
1406
+ // it via `arrayBuffer()`". That is no longer true and the distinction is
1407
+ // load-bearing (M87): the mapping reads the body lazily, and `bytes()`
1408
+ // short-circuits a GET/HEAD carrying neither `content-length` nor
1409
+ // `transfer-encoding` WITHOUT touching the native request. So an ordinary
1410
+ // upgrade now reaches the handshake with its raw `Request` undisturbed —
1411
+ // which is exactly what `Deno.upgradeWebSocket` requires, and the hazard
1412
+ // M46 originally had to intercept the upgrade inside the adapter to avoid.
1413
+ // A handshake that does carry framing headers is disturbed by this read
1414
+ // and then refused below, so no upgrade is ever attempted on it.
1415
+ //
1416
+ // The read itself can REJECT, not merely return a non-empty body:
1417
+ // `RuntimePlugin({ maxBodyBytes })` bounds it, and a body past the cap
1418
+ // rejects with `RequestBodyTooLargeError`. The read is therefore wrapped in
1419
+ // a `try` whose `catch` settles the reserved slot before rethrowing — it is
1420
+ // the only call between the router's accept and `setUpgradeIntent` that can
1421
+ // throw, so nothing else needs the same treatment. Without it a rejection
1422
+ // escapes to the
1423
+ // fallback 500 with `onClose` never called, and since the router claims a
1424
+ // pending slot at accept time, `maxConnections` malformed requests from an
1425
+ // unauthenticated client permanently exhaust the budget — every later
1426
+ // conformant upgrade then gets 503. `websocket-service.ts` states that a
1427
+ // "refused or malformed upgrade can never leak a slot"; this is what makes
1428
+ // that true for a rejecting read as well as a returning one.
1429
+ let bodyLength;
1430
+ try {
1431
+ bodyLength = (await ctx.request.bytes()).length;
1432
+ }
1433
+ catch (cause) {
1434
+ decision.sink.onClose({ code: 1006, reason: 'Upgrade request body could not be read' });
1435
+ // Re-thrown rather than answered here: the body-limit refusal carries its
1436
+ // own status hint (413), and `errorHandler` is what turns that into a
1437
+ // response in the application's configured format. Answering a fixed 400
1438
+ // would discard the hint and report the wrong cause.
1439
+ throw cause;
1440
+ }
1441
+ if (bodyLength > 0) {
1442
+ // The router already accepted, so it may be holding a reserved
1443
+ // connection slot for this socket. RFC 6455 close code 1006 (abnormal
1444
+ // closure) is the honest signal that no connection was ever established.
1445
+ decision.sink.onClose({ code: 1006, reason: 'Upgrade request carried a body' });
1446
+ respondWithError(ctx, { status: 400, title: 'Bad Request' });
1447
+ return true;
1448
+ }
1449
+ setUpgradeIntent(ctx.request, {
1450
+ sink: decision.sink,
1451
+ ...(decision.protocol !== undefined ? { protocol: decision.protocol } : {}),
1452
+ });
1453
+ return true;
1454
+ }
1455
+ /**
1456
+ * Tries gRPC dispatch after the middleware pipeline and before route
1457
+ * matching. Resolves `IGrpcService` from the registry, asks whether it
1458
+ * claims the path, and dispatches with a reconstructed web `Request`.
1459
+ *
1460
+ * @param ctx - The live request context
1461
+ * @returns `true` when gRPC answered (handler should return)
1462
+ */
1463
+ async #tryGrpc(ctx) {
1464
+ const raw = ctx.raw;
1465
+ if (raw === undefined) {
1466
+ return false;
1467
+ }
1468
+ // Resolved optionally — absent the capability, nothing changes. `has`
1469
+ // rather than a try/catch around `get`, for the reason given in
1470
+ // `#tryUpgrade`.
1471
+ if (!this.#registry.has(CAPABILITIES.GRPC)) {
1472
+ return false;
1473
+ }
1474
+ const grpcService = this.#registry.get(CAPABILITIES.GRPC);
1475
+ if (!grpcService.available) {
1476
+ return false;
1477
+ }
1478
+ // Prefix guard, BEFORE dispatch. `handleRequest` returns `Promise<Response>`
1479
+ // and never `null`, so without this every unmatched route in the
1480
+ // application would be answered by gRPC's plain-text 404 instead of the
1481
+ // kernel's own JSON one. An implementor that predates `claims` claims
1482
+ // nothing, which is the safe default.
1483
+ if (grpcService.claims === undefined || !grpcService.claims(raw)) {
1484
+ return false;
1485
+ }
1486
+ // A refusal decidable from HEADERS is answered BEFORE the body is read,
1487
+ // and the ordering is the fix rather than an optimisation (V5-5). A
1488
+ // native `application/grpc` request is refused whatever its body holds,
1489
+ // but a client-streaming or bidirectional call holds its request stream
1490
+ // OPEN — so the read below never resolves and the caller receives no
1491
+ // frames at all instead of the refusal. `grpcurl` opens a bidirectional
1492
+ // reflection stream first, so it hung on every request, which is why
1493
+ // M70i's refusal being correct did not help.
1494
+ //
1495
+ // The kernel does not decide WHAT is refusable: the content types are the
1496
+ // plugin's to know (an exact-match set — `startsWith('application/grpc')`
1497
+ // would also claim `application/grpc-web+proto`, the format that works),
1498
+ // and §2.2 forbids importing it to find out.
1499
+ const refusal = grpcService.refuses?.(raw) ?? null;
1500
+ if (refusal !== null) {
1501
+ await this.#sendRpcResponse(ctx, refusal);
1502
+ return true;
1503
+ }
1504
+ // Reconstruct a web Request from the mapped IRequest. Trailers do not
1505
+ // survive the round trip — M49 records that native gRPC-binary trailers
1506
+ // work on no runtime this plugin runs on.
1507
+ //
1508
+ // This read is what bounds the dispatch to requests whose body ENDS.
1509
+ // Since M87 the mapping is lazy, so the body is still unread here and the
1510
+ // buffering is this line's own doing, not something already spent — the
1511
+ // comment that used to say otherwise predated that change. Passing `raw`
1512
+ // straight through would preserve streaming, but only while nothing in
1513
+ // the pipeline has read the body, and the kernel cannot see whether
1514
+ // anything has; that is left as a named limitation rather than a guess.
1515
+ const bodyBytes = await ctx.request.bytes();
1516
+ const grpcRequest = new Request(ctx.request.url, {
1517
+ method: ctx.request.method,
1518
+ headers: ctx.request.headers,
1519
+ // A `Uint8Array` is a valid `BodyInit`, but its backing `ArrayBufferLike`
1520
+ // is not narrowed to `ArrayBuffer` under this compiler configuration.
1521
+ ...(bodyBytes.length > 0 ? { body: bodyBytes } : {}),
1522
+ });
1523
+ const response = await grpcService.handleRequest(grpcRequest);
1524
+ await this.#sendRpcResponse(ctx, response);
1525
+ return true;
1526
+ }
1527
+ /**
1528
+ * Writes an RPC {@linkcode Response} onto the framework response.
1529
+ *
1530
+ * One implementation for both exits — the header-only refusal and a
1531
+ * dispatched reply — so a refusal cannot come to carry different headers or
1532
+ * a different status mapping from an ordinary answer.
1533
+ *
1534
+ * @param ctx - The request context whose response is written
1535
+ * @param response - The RPC response to copy across
1536
+ */
1537
+ async #sendRpcResponse(ctx, response) {
1538
+ const snapshot = await response.arrayBuffer();
1539
+ const builder = ctx.response;
1540
+ builder.status(response.status);
1541
+ // `appendHeader`, not `header`: `header` delegates to `Headers.set`, and a
1542
+ // Trailers-Only refusal or a Connect reply can carry repeated headers —
1543
+ // `Set-Cookie` above all, which `Headers.entries()` yields once per value
1544
+ // rather than comma-joining. Setting per entry would keep only the last.
1545
+ for (const [key, value] of response.headers.entries()) {
1546
+ builder.appendHeader(key, value);
1547
+ }
1548
+ builder.send(new Uint8Array(snapshot));
1549
+ }
1550
+ /**
1551
+ * Reads the application's resolved error responder off the compiled pipeline
1552
+ * (M70f re-review, findings 1 & 2).
1553
+ *
1554
+ * `errorHandler` brands its middleware function with the responder it built
1555
+ * at factory time (see `brandErrorResponder` in `@setu-ts/common`). The
1556
+ * kernel caches that instance at startup so the pre-pipeline sites — which
1557
+ * run before the pipeline and cannot read `ctx.state` — can seed it into
1558
+ * their state. The LAST branded stage in execution order (the innermost,
1559
+ * i.e. the highest priority number among the selected stages) wins,
1560
+ * because that is the stage whose `ctx.state` publication an in-pipeline
1561
+ * site would see last. `undefined`
1562
+ * when no `errorHandler` is registered, in which case every site keeps the
1563
+ * byte-identical no-handler fallback.
1564
+ *
1565
+ * @param chain - The compiled middleware chain, in execution order
1566
+ */
1567
+ #resolveErrorResponder(chain) {
1568
+ let responder;
1569
+ for (const stage of chain) {
1570
+ // Every compiled stage is a middleware function; `errorResponderOf`
1571
+ // reads a brand off it, so narrow `unknown` to `object` first.
1572
+ if (typeof stage !== 'function') {
1573
+ continue;
1574
+ }
1575
+ const branded = errorResponderOf(stage);
1576
+ if (branded !== undefined) {
1577
+ responder = branded;
1578
+ }
1579
+ }
1580
+ return responder;
1581
+ }
1582
+ /**
1583
+ * Builds a bare `400 Bad Request` response for a client error detected
1584
+ * before request processing begins (a malformed request URL or a
1585
+ * malformed percent-escape in the path). Kept as a helper so both
1586
+ * rejection sites in {@linkcode Application.#handleRequest} produce an
1587
+ * identical body.
1588
+ *
1589
+ * Pre-pipeline: no request context exists yet, so the responder is seeded
1590
+ * from the startup cache ({@linkcode Application.#errorResponder}) into the
1591
+ * fresh state map — the same responder `errorHandler` would publish into
1592
+ * `ctx.state` were this site inside the pipeline. With no `errorHandler`
1593
+ * registered the cache is empty and the framework-default shape is written.
1594
+ *
1595
+ * The target carries a SAFE request ({@linkcode Application.#safeRequestTarget}),
1596
+ * never the raw one: a malformed request URL keeps a throwing `path` getter
1597
+ * on the synthetic `IRequest`, and a Problem Details formatter that reads
1598
+ * `ctx.request.path` for the `instance` member would throw the URL parser
1599
+ * error again inside the responder — turning the configured 400 back into an
1600
+ * unhandled `TypeError: Invalid URL` (M70f re-review round 2, finding 1).
1601
+ */
1602
+ #badRequest(request) {
1603
+ const builder = new ResponseBuilder();
1604
+ const state = new Map();
1605
+ if (this.#errorResponder !== undefined) {
1606
+ state.set(ERROR_RESPONDER_STATE_KEY, this.#errorResponder);
1607
+ }
1608
+ respondWithError({ state, response: builder, ...this.#safeRequestTarget(request) }, { status: 400, title: 'Bad Request' });
1609
+ return builder;
1610
+ }
1611
+ /**
1612
+ * Builds the `request` member of a pre-pipeline {@linkcode respondWithError}
1613
+ * target from a request whose path may be unreadable.
1614
+ *
1615
+ * A formatted error response needs the request path for the Problem Details
1616
+ * `instance` member, and the target's `request` field is typed to supply it.
1617
+ * But a request that failed URL parsing carries a `path` getter that throws
1618
+ * (the synthetic `IRequest` in {@linkcode Application.inject} and the
1619
+ * adapter-mapped requests both derive `path` from `new URL(...)`), so handing
1620
+ * the raw request to the responder would make the formatter re-throw inside
1621
+ * the very response that is supposed to report the failure.
1622
+ *
1623
+ * The safe target therefore carries the path only when it can be read
1624
+ * without throwing — captured once, up front — and omits the `request` field
1625
+ * entirely otherwise, which is the documented shape of
1626
+ * {@linkcode ErrorResponderTarget.request} ("when one exists"). A formatter
1627
+ * that receives no request answers without an `instance` member, which is
1628
+ * the correct RFC 9457 outcome for a request whose path cannot be known.
1629
+ *
1630
+ * @param request - The request that failed pre-pipeline validation
1631
+ * @returns `{ request: { path } }` when the path is readable, `{}` when it is not
1632
+ */
1633
+ #safeRequestTarget(request) {
1634
+ let path;
1635
+ try {
1636
+ path = request.path;
1637
+ }
1638
+ catch {
1639
+ path = undefined;
1640
+ }
1641
+ return path === undefined ? {} : { request: { path } };
1642
+ }
1643
+ /**
1644
+ * Surfaces an exception thrown by an `IWebSocketService`'s upgrade router.
1645
+ *
1646
+ * The framework's own service reports its failures at their source, where it
1647
+ * has route context to add; this covers a third-party service that does not.
1648
+ * Guarded exactly like {@linkcode Application.#reportSuppressedHookError}: a
1649
+ * missing or itself-broken logger must not turn a refused upgrade into a
1650
+ * crashed request.
1651
+ *
1652
+ * @param cause - Whatever the router threw
1653
+ */
1654
+ #reportUpgradeRouterFailure(cause) {
1655
+ try {
1656
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
1657
+ return;
1658
+ }
1659
+ const logger = this.#registry.get(CAPABILITIES.LOGGER);
1660
+ const err = cause instanceof Error ? cause : new Error(String(cause));
1661
+ logger.error('WebSocket upgrade router threw and was refused', {
1662
+ error: err.message,
1663
+ stack: err.stack,
1664
+ });
1665
+ }
1666
+ catch {
1667
+ // No safe channel remains — see #reportSuppressedHookError.
1668
+ }
1669
+ }
1670
+ /** Reports a startup-time registry mutation when logging is available. */
1671
+ #reportRegistryMutation(kind, token, plugin) {
1672
+ try {
1673
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
1674
+ return;
1675
+ }
1676
+ const logger = this.#registry.get(CAPABILITIES.LOGGER);
1677
+ const owner = plugin ?? 'the application';
1678
+ const message = `Capability '${token}' was ${kind === 'override' ? 'overridden' : 'unregistered'} by ${owner}.`;
1679
+ if (kind === 'override') {
1680
+ logger.info(message);
1681
+ }
1682
+ else {
1683
+ logger.warn(message);
1684
+ }
1685
+ }
1686
+ catch {
1687
+ // No safe channel remains — a mutation must not make startup fail.
1688
+ }
1689
+ }
1690
+ /**
1691
+ * Surfaces an exception thrown by an `onError` hook. The failure is
1692
+ * reported through {@linkcode CAPABILITIES.LOGGER} when a logger is
1693
+ * registered — the sanctioned framework logging channel (AI_GUIDELINES
1694
+ * §11.6 forbids `console` here). The whole method is guarded so a missing
1695
+ * or itself-broken logger can never propagate out of request handling: in
1696
+ * that doubly-degraded case there is no safe channel left and the error is
1697
+ * dropped rather than crashing the request.
1698
+ */
1699
+ #reportSuppressedHookError(hookError) {
1700
+ try {
1701
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
1702
+ return;
1703
+ }
1704
+ const logger = this.#registry.get(CAPABILITIES.LOGGER);
1705
+ const err = hookError instanceof Error ? hookError : new Error(String(hookError));
1706
+ logger.error('onError hook threw and was suppressed', {
1707
+ error: err.message,
1708
+ stack: err.stack,
1709
+ });
1710
+ }
1711
+ catch {
1712
+ // The logger itself failed or none is resolvable — no safe channel
1713
+ // remains without violating the no-console rule; degrade silently.
1714
+ }
1715
+ }
1716
+ /**
1717
+ * Surfaces an unhandled request error through the sanctioned logger channel
1718
+ * (X11-2). The response body stays opaque — the message is not disclosed to
1719
+ * the client (M70b's `maskInternalErrors` decision applied consistently) —
1720
+ * but the operator sees the message and stack, with the request identified.
1721
+ *
1722
+ * Guarded exactly like {@linkcode Application.#reportSuppressedHookError}: a
1723
+ * missing or itself-broken logger must not turn an already-failing request
1724
+ * into a crashed one.
1725
+ *
1726
+ * @param error - The unhandled error
1727
+ * @param ctx - The request context it occurred in
1728
+ */
1729
+ #reportUnhandledError(error, ctx) {
1730
+ try {
1731
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
1732
+ return;
1733
+ }
1734
+ const logger = this.#registry.get(CAPABILITIES.LOGGER);
1735
+ logger.error('Unhandled request error', {
1736
+ ...serializeError(error),
1737
+ requestId: ctx.id,
1738
+ method: ctx.request.method,
1739
+ path: ctx.request.path,
1740
+ });
1741
+ }
1742
+ catch {
1743
+ // The logger itself failed or none is resolvable — no safe channel
1744
+ // remains without violating the no-console rule; degrade silently.
1745
+ }
1746
+ }
1747
+ /**
1748
+ * Emits a soft `warn`-level diagnostic for each capability a plugin declares
1749
+ * in `consumes` that no registered plugin provides. Unlike a missing
1750
+ * `dependencies` entry (which fails plugin resolution), an unsatisfied
1751
+ * `consumes` does not stop startup — the plugin resolves the capability
1752
+ * lazily at request time — but the deferred `services.get` would then throw
1753
+ * per request, so surfacing it once at startup, naming the consumer, is the
1754
+ * friendlier failure mode.
1755
+ *
1756
+ * Reported through {@linkcode CAPABILITIES.LOGGER} when a logger is
1757
+ * registered — the sanctioned framework logging channel (AI_GUIDELINES §11.6
1758
+ * forbids `console` here). The emission is guarded so a missing or
1759
+ * itself-broken logger degrades silently rather than turning an advisory
1760
+ * warning into a startup failure.
1761
+ */
1762
+ #warnUnsatisfiedConsumers() {
1763
+ const unsatisfied = findUnsatisfiedConsumers(this.#plugins, (token) => this.#registry.has(token));
1764
+ if (unsatisfied.length === 0) {
1765
+ return;
1766
+ }
1767
+ try {
1768
+ if (!this.#registry.has(CAPABILITIES.LOGGER)) {
1769
+ return;
1770
+ }
1771
+ const logger = this.#registry.get(CAPABILITIES.LOGGER);
1772
+ for (const { plugin, capability } of unsatisfied) {
1773
+ logger.warn(`Plugin '${plugin}' consumes capability '${capability}', but no registered plugin provides it. ` +
1774
+ `Calls to services.get('${capability}') will fail at runtime — register a plugin that provides it.`, { plugin, capability });
1775
+ }
1776
+ }
1777
+ catch {
1778
+ // The logger itself failed or none is resolvable — no safe channel
1779
+ // remains without violating the no-console rule; degrade silently.
1780
+ }
1781
+ }
1782
+ #validateEnvironment(runtime) {
1783
+ const violations = [];
1784
+ for (const { name, spec } of this.#envSpecs) {
1785
+ for (const [key, rules] of Object.entries(spec)) {
1786
+ const value = runtime.env[key];
1787
+ if (value === undefined) {
1788
+ if (rules.required && rules.default === undefined) {
1789
+ violations.push(`Required environment variable '${key}' is missing (declared by ${name}).`);
1790
+ }
1791
+ continue;
1792
+ }
1793
+ // Type coercion checks
1794
+ if (rules.type === 'number') {
1795
+ // `Number('')`, `Number(' ')`, and other blank strings coerce to
1796
+ // 0 (not NaN), so an empty/whitespace value would otherwise pass as
1797
+ // a valid number. Reject blanks explicitly, and use `isFinite` so
1798
+ // `Infinity`/`-Infinity` are rejected too.
1799
+ const num = Number(value);
1800
+ if (value.trim() === '' || !Number.isFinite(num)) {
1801
+ violations.push(`Environment variable '${key}' expected number but got '${value}' (declared by ${name}).`);
1802
+ }
1803
+ }
1804
+ else if (rules.type === 'boolean') {
1805
+ if (value !== 'true' && value !== 'false') {
1806
+ violations.push(`Environment variable '${key}' expected boolean but got '${value}' (declared by ${name}).`);
1807
+ }
1808
+ }
1809
+ }
1810
+ }
1811
+ if (violations.length > 0) {
1812
+ throw new Error(`Environment validation failed with ${violations.length} violation(s):\n` +
1813
+ violations.map((v) => ` - ${v}`).join('\n'));
1814
+ }
1815
+ }
1816
+ async #drainRequests() {
1817
+ const runtime = this.#registry.get(CAPABILITIES.RUNTIME);
1818
+ // Monotonic clock: this is a duration budget, so it must not be measured
1819
+ // with `now()` (wall clock), which an NTP step can move backwards — cutting
1820
+ // the drain short or extending it past the intended 10s.
1821
+ const deadline = runtime.hrtime() + 10_000;
1822
+ // Iteration cap: a manual test clock that never advances would otherwise
1823
+ // spin forever (the deadline never arrives). 200 polls × 50ms ≈ 10s of
1824
+ // real polling under a normal clock, and a hard ceiling regardless.
1825
+ let polls = 0;
1826
+ const MAX_POLLS = 200;
1827
+ while (this.#inFlight > 0 && runtime.hrtime() < deadline && polls < MAX_POLLS) {
1828
+ polls++;
1829
+ // Poll via runtime.setTimeout (max 10s)
1830
+ await new Promise((resolve) => {
1831
+ runtime.setTimeout(() => resolve(), 50);
1832
+ });
1833
+ }
1834
+ }
1835
+ }
1836
+ /**
1837
+ * Creates a new kernel application instance.
1838
+ *
1839
+ * @param options - Optional application configuration including pre-registered plugins
1840
+ * @returns The kernel application instance with inject() capability
1841
+ * @example
1842
+ * ```typescript
1843
+ * import { createApplication } from '@setu-ts/kernel';
1844
+ *
1845
+ * const app = createApplication({
1846
+ * plugins: [RuntimePlugin()],
1847
+ * });
1848
+ *
1849
+ * await app.start({ port: 3000 });
1850
+ * ```
1851
+ * @since 0.1.0
1852
+ */
1853
+ export function createApplication(options) {
1854
+ const app = new Application(options?.diagnostics);
1855
+ if (options?.plugins) {
1856
+ for (const plugin of options.plugins) {
1857
+ app.register(plugin);
1858
+ }
1859
+ }
1860
+ return app;
1861
+ }
1862
+ //# sourceMappingURL=application.js.map