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