@daloyjs/core 0.17.0 → 0.29.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/README.md +3 -3
- package/dist/adapters/bun.d.ts.map +1 -1
- package/dist/adapters/bun.js +38 -7
- package/dist/adapters/bun.js.map +1 -1
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +29 -7
- package/dist/adapters/node.js.map +1 -1
- package/dist/app.d.ts +322 -3
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +715 -29
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +218 -1
- package/dist/cli.js.map +1 -1
- package/dist/combine.d.ts +97 -0
- package/dist/combine.d.ts.map +1 -0
- package/dist/combine.js +247 -0
- package/dist/combine.js.map +1 -0
- package/dist/compression.d.ts +127 -0
- package/dist/compression.d.ts.map +1 -0
- package/dist/compression.js +368 -0
- package/dist/compression.js.map +1 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +132 -0
- package/dist/config.js.map +1 -0
- package/dist/conn-info.d.ts +121 -0
- package/dist/conn-info.d.ts.map +1 -0
- package/dist/conn-info.js +145 -0
- package/dist/conn-info.js.map +1 -0
- package/dist/cookie.d.ts +112 -0
- package/dist/cookie.d.ts.map +1 -0
- package/dist/cookie.js +185 -0
- package/dist/cookie.js.map +1 -0
- package/dist/dependency.d.ts +47 -0
- package/dist/dependency.d.ts.map +1 -0
- package/dist/dependency.js +68 -0
- package/dist/dependency.js.map +1 -0
- package/dist/etag.d.ts +48 -0
- package/dist/etag.d.ts.map +1 -0
- package/dist/etag.js +117 -0
- package/dist/etag.js.map +1 -0
- package/dist/index.d.ts +40 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -4
- package/dist/index.js.map +1 -1
- package/dist/ip-restriction.d.ts +75 -0
- package/dist/ip-restriction.d.ts.map +1 -0
- package/dist/ip-restriction.js +203 -0
- package/dist/ip-restriction.js.map +1 -0
- package/dist/jwk.d.ts +82 -0
- package/dist/jwk.d.ts.map +1 -0
- package/dist/jwk.js +269 -0
- package/dist/jwk.js.map +1 -0
- package/dist/jwt.d.ts +103 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +437 -0
- package/dist/jwt.js.map +1 -0
- package/dist/load-shedding.d.ts +73 -0
- package/dist/load-shedding.d.ts.map +1 -0
- package/dist/load-shedding.js +171 -0
- package/dist/load-shedding.js.map +1 -0
- package/dist/middleware.d.ts +181 -2
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +367 -77
- package/dist/middleware.js.map +1 -1
- package/dist/multipart.d.ts +17 -0
- package/dist/multipart.d.ts.map +1 -1
- package/dist/multipart.js +117 -1
- package/dist/multipart.js.map +1 -1
- package/dist/openapi.d.ts +8 -0
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +18 -1
- package/dist/openapi.js.map +1 -1
- package/dist/security-schemes.d.ts +21 -5
- package/dist/security-schemes.d.ts.map +1 -1
- package/dist/security-schemes.js +32 -5
- package/dist/security-schemes.js.map +1 -1
- package/dist/session.d.ts +23 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +106 -86
- package/dist/session.js.map +1 -1
- package/dist/subdomains.d.ts +97 -0
- package/dist/subdomains.d.ts.map +1 -0
- package/dist/subdomains.js +157 -0
- package/dist/subdomains.js.map +1 -0
- package/dist/time-claims.d.ts +72 -0
- package/dist/time-claims.d.ts.map +1 -0
- package/dist/time-claims.js +88 -0
- package/dist/time-claims.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket.d.ts +51 -34
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +162 -2
- package/dist/websocket.js.map +1 -1
- package/package.json +31 -3
package/dist/app.js
CHANGED
|
@@ -1,14 +1,59 @@
|
|
|
1
1
|
import { Router } from "./router.js";
|
|
2
|
-
import { WebSocketRegistry } from "./websocket.js";
|
|
3
|
-
import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
2
|
+
import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
|
|
3
|
+
import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
4
4
|
import { validate } from "./schema.js";
|
|
5
|
-
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertStrongSecret } from "./security.js";
|
|
5
|
+
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertStrongSecret, timingSafeEqual } from "./security.js";
|
|
6
6
|
import { createLogger, noopLogger } from "./logger.js";
|
|
7
7
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
8
8
|
import { docsContentSecurityPolicy, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
9
|
-
import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
|
|
9
|
+
import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
|
|
10
10
|
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
|
|
11
|
+
import { loadShedding as loadSheddingMiddleware } from "./load-shedding.js";
|
|
12
|
+
import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
|
|
13
|
+
import { assertBehindProxy } from "./conn-info.js";
|
|
11
14
|
const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
|
|
15
|
+
/**
|
|
16
|
+
* Module-level latch shared across every {@link App} constructed in the same
|
|
17
|
+
* process. Ensures `unhandledRejection` / `uncaughtException` handlers are
|
|
18
|
+
* registered at most once — installing them twice would log the same crash
|
|
19
|
+
* twice (and once per listener thereafter), polluting the final exit signal.
|
|
20
|
+
*/
|
|
21
|
+
let crashHandlersInstalled = false;
|
|
22
|
+
let activeCrashLogger;
|
|
23
|
+
function setActiveCrashLogger(log) {
|
|
24
|
+
activeCrashLogger = log;
|
|
25
|
+
}
|
|
26
|
+
/** @internal Test-only helper to reset the latch between tests. */
|
|
27
|
+
export function _resetCrashHandlersForTests() {
|
|
28
|
+
crashHandlersInstalled = false;
|
|
29
|
+
activeCrashLogger = undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Wave 8 — once-per-process latch for the `secureDefaults: false` warning
|
|
33
|
+
* log. Multiple `new App({ secureDefaults: false })` instances in the same
|
|
34
|
+
* process share one log entry instead of flooding the access log with
|
|
35
|
+
* duplicate warnings on every construction.
|
|
36
|
+
*/
|
|
37
|
+
let insecureDefaultsLoggedThisProcess = false;
|
|
38
|
+
/** @internal Test-only helper to reset the latch between tests. */
|
|
39
|
+
export function _resetInsecureDefaultsLogForTests() {
|
|
40
|
+
insecureDefaultsLoggedThisProcess = false;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Wave 8 — list of secure-by-default surfaces disabled when
|
|
44
|
+
* `secureDefaults: false` is set. Surfaced through the once-per-process
|
|
45
|
+
* `error` log so the operator sees exactly which guards are off.
|
|
46
|
+
*/
|
|
47
|
+
const DISABLED_BY_INSECURE_DEFAULTS = Object.freeze([
|
|
48
|
+
"secureHeaders auto-install",
|
|
49
|
+
"cross-origin guard for state-changing requests",
|
|
50
|
+
"crash-on-unhandled-rejection (production)",
|
|
51
|
+
"first-request X-Forwarded-* / trustProxy guard",
|
|
52
|
+
"session() + state-changing route requires csrf() boot guard",
|
|
53
|
+
"weak session secret refuse-to-boot",
|
|
54
|
+
"cors({ origin: '*' }) refuse-to-boot",
|
|
55
|
+
"anonymous stateful plugin refuse-to-boot",
|
|
56
|
+
]);
|
|
12
57
|
const DEFAULTS = {
|
|
13
58
|
bodyLimitBytes: 1024 * 1024,
|
|
14
59
|
requestTimeoutMs: 30_000,
|
|
@@ -82,10 +127,16 @@ export class App {
|
|
|
82
127
|
installedPlugins = new Set();
|
|
83
128
|
closeHooks = [];
|
|
84
129
|
closeHooksRun = false;
|
|
130
|
+
/** Wave 4 idle-connection close hooks (adapter-registered, sync). */
|
|
131
|
+
idleConnectionCloseHooks = [];
|
|
85
132
|
pluginInstalledListeners = [];
|
|
86
133
|
shutdownListeners = [];
|
|
87
134
|
shutdownListenersRun = false;
|
|
88
|
-
pendingPlugins =
|
|
135
|
+
pendingPlugins = new Set();
|
|
136
|
+
pluginBootError = {
|
|
137
|
+
failed: false,
|
|
138
|
+
error: undefined,
|
|
139
|
+
};
|
|
89
140
|
/** In-flight request count for graceful shutdown. */
|
|
90
141
|
inflight = 0;
|
|
91
142
|
draining = false;
|
|
@@ -125,11 +176,65 @@ export class App {
|
|
|
125
176
|
? options.logger
|
|
126
177
|
: createLogger({ level: options.logger?.level ?? "info" });
|
|
127
178
|
this.warnOnEnvMismatch();
|
|
179
|
+
this.assertDisconnectStatusCode();
|
|
180
|
+
assertBehindProxy(this.options.behindProxy);
|
|
128
181
|
if (this.options.hooks)
|
|
129
182
|
this.assertSecureHookConfig(this.options.hooks);
|
|
183
|
+
this.assertInsecureDefaultsAcknowledged();
|
|
130
184
|
this.installSecureDefaults();
|
|
185
|
+
this.maybeInstallCrashHandlers();
|
|
131
186
|
this.maybeMountDocs();
|
|
132
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Wave 4 leftover: validate {@link AppOptions.disconnectStatusCode}.
|
|
190
|
+
* Refuses anything outside `[400, 499]` (except `0`, which disables the
|
|
191
|
+
* rewrite). Throws at construction time so the misconfiguration cannot
|
|
192
|
+
* survive past boot.
|
|
193
|
+
*/
|
|
194
|
+
assertDisconnectStatusCode() {
|
|
195
|
+
const v = this.options.disconnectStatusCode;
|
|
196
|
+
if (v === undefined || v === 0)
|
|
197
|
+
return;
|
|
198
|
+
if (!Number.isInteger(v) || v < 400 || v > 499) {
|
|
199
|
+
throw new Error(`disconnectStatusCode must be an integer in [400, 499] or 0; got ${String(v)}.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Wave 8 — master-flag escape-hatch enforcement. When
|
|
204
|
+
* `secureDefaults: false` is set, the framework:
|
|
205
|
+
* - refuses-to-construct in production unless
|
|
206
|
+
* `acknowledgeInsecureDefaults: true` is also set; closes the
|
|
207
|
+
* "developer flipped the flag off while debugging and shipped to
|
|
208
|
+
* production" footgun.
|
|
209
|
+
* - emits a once-per-process `error` log naming every default the flag
|
|
210
|
+
* disables, so operators see the blast radius even if the option was
|
|
211
|
+
* set deep in shared configuration.
|
|
212
|
+
*/
|
|
213
|
+
assertInsecureDefaultsAcknowledged() {
|
|
214
|
+
if (this.options.secureDefaults !== false)
|
|
215
|
+
return;
|
|
216
|
+
const inProduction = this.isProduction();
|
|
217
|
+
if (inProduction && this.options.acknowledgeInsecureDefaults !== true) {
|
|
218
|
+
throw new Error("app({ secureDefaults: false }) is refused in production. " +
|
|
219
|
+
"secureDefaults turns off the entire Wave 1–7 secure-by-default " +
|
|
220
|
+
"surface (auto secureHeaders, cross-origin guard, crash-on-unhandled-rejection, " +
|
|
221
|
+
"trustProxy guard, csrf/session boot guard, weak-secret refuse-to-boot, " +
|
|
222
|
+
"cors({ origin: '*' }) refuse-to-boot, anonymous-stateful-plugin refuse-to-boot). " +
|
|
223
|
+
"If you really need this in production, also pass " +
|
|
224
|
+
"acknowledgeInsecureDefaults: true to confirm. Prefer per-feature opt-outs " +
|
|
225
|
+
"(secureHeaders: false, corsCrossOriginGuard: false, crashOnUnhandledRejection: false, " +
|
|
226
|
+
"trustProxy: false, csrf: \"off\") instead.");
|
|
227
|
+
}
|
|
228
|
+
if (!insecureDefaultsLoggedThisProcess) {
|
|
229
|
+
insecureDefaultsLoggedThisProcess = true;
|
|
230
|
+
this.log.error({
|
|
231
|
+
event: "secure_defaults.disabled",
|
|
232
|
+
production: inProduction,
|
|
233
|
+
acknowledged: this.options.acknowledgeInsecureDefaults === true,
|
|
234
|
+
disabled: DISABLED_BY_INSECURE_DEFAULTS,
|
|
235
|
+
}, `app({ secureDefaults: false }) disables: ${DISABLED_BY_INSECURE_DEFAULTS.join(", ")}.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
133
238
|
/**
|
|
134
239
|
* Install the Wave 2 secure-by-default global hooks. Currently:
|
|
135
240
|
* - {@link secureHeaders} as a group-level hook so every response carries
|
|
@@ -153,6 +258,58 @@ export class App {
|
|
|
153
258
|
auto[AUTO_SECURE_HEADERS_MARKER] = true;
|
|
154
259
|
this.groupHooks.push(auto);
|
|
155
260
|
}
|
|
261
|
+
// Wave 4 leftover: opt-in load-shedding pressure monitor.
|
|
262
|
+
if (this.options.loadShedding) {
|
|
263
|
+
const lsOpts = typeof this.options.loadShedding === "object"
|
|
264
|
+
? this.options.loadShedding
|
|
265
|
+
: {};
|
|
266
|
+
this.groupHooks.push(loadSheddingMiddleware(lsOpts));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Wave 4 crash-on-unrecoverable-error guard. Installs Node-process-level
|
|
271
|
+
* listeners for `unhandledRejection` and `uncaughtException` that log
|
|
272
|
+
* through the pluggable logger and call `process.exit(1)`. Idempotent via
|
|
273
|
+
* a module-level latch so multiple `new App()` instantiations in the same
|
|
274
|
+
* process do not double-register; the most recently constructed App's
|
|
275
|
+
* logger is used. No-op on runtimes without `process.on` (Workers / Edge
|
|
276
|
+
* / Fastly), in non-production by default, and when
|
|
277
|
+
* `crashOnUnhandledRejection: false` or `secureDefaults: false`.
|
|
278
|
+
*/
|
|
279
|
+
maybeInstallCrashHandlers() {
|
|
280
|
+
if (this.options.crashOnUnhandledRejection === false)
|
|
281
|
+
return;
|
|
282
|
+
if (this.options.crashOnUnhandledRejection === undefined &&
|
|
283
|
+
(this.options.secureDefaults === false || !this.isProduction())) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (typeof process === "undefined" || typeof process.on !== "function") {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
setActiveCrashLogger(this.log);
|
|
290
|
+
if (crashHandlersInstalled)
|
|
291
|
+
return;
|
|
292
|
+
crashHandlersInstalled = true;
|
|
293
|
+
process.on("unhandledRejection", (reason) => {
|
|
294
|
+
const log = activeCrashLogger ?? this.log;
|
|
295
|
+
try {
|
|
296
|
+
log.fatal({ event: "process.unhandledRejection", err: serializeErr(reason) }, "Unhandled promise rejection — exiting (crashOnUnhandledRejection)");
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
/* swallow logger failure so we still exit */
|
|
300
|
+
}
|
|
301
|
+
process.exit(1);
|
|
302
|
+
});
|
|
303
|
+
process.on("uncaughtException", (err) => {
|
|
304
|
+
const log = activeCrashLogger ?? this.log;
|
|
305
|
+
try {
|
|
306
|
+
log.fatal({ event: "process.uncaughtException", err: serializeErr(err) }, "Uncaught exception — exiting (crashOnUnhandledRejection)");
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* swallow logger failure so we still exit */
|
|
310
|
+
}
|
|
311
|
+
process.exit(1);
|
|
312
|
+
});
|
|
156
313
|
}
|
|
157
314
|
/**
|
|
158
315
|
* Emit a one-time `warn` when the explicit {@link AppOptions.env} option
|
|
@@ -263,6 +420,17 @@ export class App {
|
|
|
263
420
|
}
|
|
264
421
|
}
|
|
265
422
|
}
|
|
423
|
+
assertRouteAuthPayloadConfig(route) {
|
|
424
|
+
const auth = route.auth;
|
|
425
|
+
if (!auth || auth.payload !== false)
|
|
426
|
+
return;
|
|
427
|
+
const scheme = this.options.openapi?.securitySchemes?.[auth.scheme];
|
|
428
|
+
if (!securitySchemeRequiresPayloadAuth(scheme))
|
|
429
|
+
return;
|
|
430
|
+
throw new Error(`Route ${route.method} ${route.path} declares auth.payload: false, ` +
|
|
431
|
+
`but security scheme "${auth.scheme}" requires payload authentication. ` +
|
|
432
|
+
`Remove the route-level opt-out or use a scheme without requirePayloadAuth: true.`);
|
|
433
|
+
}
|
|
266
434
|
resetWave3BootGuardCache() {
|
|
267
435
|
this.wave3BootGuard.checked = false;
|
|
268
436
|
this.wave3BootGuard.error = undefined;
|
|
@@ -319,6 +487,11 @@ export class App {
|
|
|
319
487
|
return;
|
|
320
488
|
if (this.options.trustProxy !== undefined)
|
|
321
489
|
return;
|
|
490
|
+
// Wave 6: `behindProxy` is the declarative successor — when supplied,
|
|
491
|
+
// the framework already knows how to interpret forwarded headers and
|
|
492
|
+
// the legacy unconfigured-proxy guard is satisfied.
|
|
493
|
+
if (this.options.behindProxy !== undefined)
|
|
494
|
+
return;
|
|
322
495
|
// Same risk-register clause as the session/CSRF guard: only enforce
|
|
323
496
|
// in production. Dev/CI surfaces routinely test forwarded headers
|
|
324
497
|
// without configuring a reverse-proxy posture.
|
|
@@ -517,6 +690,7 @@ export class App {
|
|
|
517
690
|
tags: [...(this.groupTags ?? []), ...(def.tags ?? [])],
|
|
518
691
|
auth: def.auth ?? this.groupAuth,
|
|
519
692
|
};
|
|
693
|
+
this.assertRouteAuthPayloadConfig(merged);
|
|
520
694
|
const sources = [...this.groupHooks, def.hooks ?? {}];
|
|
521
695
|
const hooks = mergeHooks(sources);
|
|
522
696
|
const corsOriginAllows = corsOriginAllowsFromHooks(sources);
|
|
@@ -543,7 +717,235 @@ export class App {
|
|
|
543
717
|
*/
|
|
544
718
|
ws(path, handler) {
|
|
545
719
|
const fullPath = joinPath(this.prefix, path);
|
|
546
|
-
|
|
720
|
+
const options = normalizeWebSocketOptions(handler, {
|
|
721
|
+
production: this.isProduction(),
|
|
722
|
+
secureDefaults: this.options.secureDefaults !== false,
|
|
723
|
+
});
|
|
724
|
+
this.webSocketRoutes.add(fullPath, handler, () => ({ ...this.decorations }), options);
|
|
725
|
+
return this;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Register a liveness probe route. Returns `200 {"status":"ok"}` while
|
|
729
|
+
* the process is alive, regardless of plugin readiness. Use this for
|
|
730
|
+
* container orchestrator `livenessProbe` configuration — a failing
|
|
731
|
+
* liveness probe restarts the container.
|
|
732
|
+
*
|
|
733
|
+
* Defaults (Wave 4 secure-by-default):
|
|
734
|
+
* - path: `/healthz`
|
|
735
|
+
* - rate-limit: 60 req/min per remote IP, in-memory (per-process)
|
|
736
|
+
* - auth: opt-in via `token`. In production with `secureDefaults: true`,
|
|
737
|
+
* registration refuses to add the route without a `token` unless
|
|
738
|
+
* `acknowledgeUnauthenticated: true` is set, so an unguarded
|
|
739
|
+
* healthcheck cannot ship to production by accident.
|
|
740
|
+
*
|
|
741
|
+
* @example
|
|
742
|
+
* ```ts
|
|
743
|
+
* app.healthcheck({ token: process.env.HEALTH_TOKEN! });
|
|
744
|
+
* ```
|
|
745
|
+
*
|
|
746
|
+
* @since 0.18.0
|
|
747
|
+
*/
|
|
748
|
+
healthcheck(opts = {}) {
|
|
749
|
+
this.registerHealthRoute("healthcheck", opts, () => ({
|
|
750
|
+
status: 200,
|
|
751
|
+
body: { status: "ok" },
|
|
752
|
+
}));
|
|
753
|
+
return this;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Register a readiness probe route. Returns `200 {"status":"ready"}`
|
|
757
|
+
* once every async plugin has resolved AND the app is not draining.
|
|
758
|
+
* Returns `503` otherwise. Use this for container orchestrator
|
|
759
|
+
* `readinessProbe` configuration — a failing readiness probe removes
|
|
760
|
+
* the pod from load-balancer rotation without restarting it.
|
|
761
|
+
*
|
|
762
|
+
* Defaults match {@link App.healthcheck} (path defaults to `/readyz`).
|
|
763
|
+
*
|
|
764
|
+
* @since 0.18.0
|
|
765
|
+
*/
|
|
766
|
+
readinesscheck(opts = {}) {
|
|
767
|
+
this.registerHealthRoute("readinesscheck", opts, () => {
|
|
768
|
+
if (this.draining ||
|
|
769
|
+
this.pendingPlugins.size > 0 ||
|
|
770
|
+
this.pluginBootError.failed) {
|
|
771
|
+
return {
|
|
772
|
+
status: 503,
|
|
773
|
+
body: { status: "not-ready" },
|
|
774
|
+
headers: { "retry-after": "5" },
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
status: 200,
|
|
779
|
+
body: { status: "ready" },
|
|
780
|
+
};
|
|
781
|
+
});
|
|
782
|
+
return this;
|
|
783
|
+
}
|
|
784
|
+
registerHealthRoute(kind, opts, handler) {
|
|
785
|
+
const isHealth = kind === "healthcheck";
|
|
786
|
+
const defaultPath = (isHealth ? "/healthz" : "/readyz");
|
|
787
|
+
const path = (opts.path ?? defaultPath);
|
|
788
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
789
|
+
? null
|
|
790
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
791
|
+
const token = opts.token;
|
|
792
|
+
// Wave 4 refuse-to-boot: unauthenticated health/ready probes in
|
|
793
|
+
// production are a documented info-disclosure surface (process uptime,
|
|
794
|
+
// plugin-ready transitions, internal hostnames in some shops). Force
|
|
795
|
+
// an explicit acknowledgement.
|
|
796
|
+
if (this.options.secureDefaults !== false &&
|
|
797
|
+
this.isProduction() &&
|
|
798
|
+
token === undefined &&
|
|
799
|
+
opts.acknowledgeUnauthenticated !== true) {
|
|
800
|
+
throw new Error(`app.${kind}() refused in production: provide opts.token to require ` +
|
|
801
|
+
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
802
|
+
`to acknowledge that this probe is reachable without credentials.`);
|
|
803
|
+
}
|
|
804
|
+
const buckets = rateLimitConfig
|
|
805
|
+
? new Map()
|
|
806
|
+
: null;
|
|
807
|
+
this.route({
|
|
808
|
+
method: "GET",
|
|
809
|
+
path,
|
|
810
|
+
operationId: isHealth ? "healthcheck" : "readinesscheck",
|
|
811
|
+
tags: ["Health"],
|
|
812
|
+
summary: isHealth ? "Liveness probe" : "Readiness probe",
|
|
813
|
+
handler: async ({ request }) => {
|
|
814
|
+
if (buckets && rateLimitConfig) {
|
|
815
|
+
const key = healthRouteKey(request);
|
|
816
|
+
const now = Date.now();
|
|
817
|
+
const entry = buckets.get(key);
|
|
818
|
+
if (!entry || entry.resetMs <= now) {
|
|
819
|
+
buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
entry.count++;
|
|
823
|
+
if (entry.count > rateLimitConfig.limit) {
|
|
824
|
+
throw new TooManyRequestsError(Math.ceil((entry.resetMs - now) / 1000));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (token !== undefined) {
|
|
829
|
+
const h = request.headers.get("authorization") ?? "";
|
|
830
|
+
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
831
|
+
if (!m) {
|
|
832
|
+
throw new HttpError(401, {
|
|
833
|
+
type: "https://daloyjs.dev/errors/unauthorized",
|
|
834
|
+
title: "Unauthorized",
|
|
835
|
+
detail: "Health probe requires a bearer token.",
|
|
836
|
+
}, { "www-authenticate": 'Bearer realm="health"' });
|
|
837
|
+
}
|
|
838
|
+
if (!timingSafeEqual(m[1], token)) {
|
|
839
|
+
throw new ForbiddenError("Invalid health probe token.");
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return handler();
|
|
843
|
+
},
|
|
844
|
+
responses: {
|
|
845
|
+
200: {
|
|
846
|
+
description: isHealth ? "Service is alive." : "Service is ready.",
|
|
847
|
+
},
|
|
848
|
+
503: {
|
|
849
|
+
description: "Service is not ready.",
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Wave 4 leftover: register a built-in receiver for CSP / Reporting API
|
|
856
|
+
* violation reports. Accepts `application/csp-report` and
|
|
857
|
+
* `application/reports+json` payloads, rate-limits per IP (defaults: 60
|
|
858
|
+
* req/min), caps body size (default 8 KiB), and forwards parsed reports
|
|
859
|
+
* to {@link CspReportRouteOptions.onReport} (or the structured logger
|
|
860
|
+
* when omitted). Returns `204 No Content` so browsers stop retrying.
|
|
861
|
+
*
|
|
862
|
+
* Combine with `secureHeaders({ reportingEndpoints, reportTo })` to wire
|
|
863
|
+
* the browser to this endpoint. The route is registered as `internal:
|
|
864
|
+
* false` (i.e. publicly reachable); that is required for the browser
|
|
865
|
+
* Reporting API to send to it.
|
|
866
|
+
*
|
|
867
|
+
*/
|
|
868
|
+
cspReportRoute(opts = {}) {
|
|
869
|
+
const path = (opts.path ?? "/__csp-report");
|
|
870
|
+
const maxBytes = opts.maxBodyBytes ?? 8192;
|
|
871
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
872
|
+
? null
|
|
873
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
874
|
+
const buckets = rateLimitConfig
|
|
875
|
+
? new Map()
|
|
876
|
+
: null;
|
|
877
|
+
const log = this.log;
|
|
878
|
+
this.route({
|
|
879
|
+
method: "POST",
|
|
880
|
+
path,
|
|
881
|
+
operationId: "cspReport",
|
|
882
|
+
tags: ["Reporting"],
|
|
883
|
+
summary: "CSP / Reporting API violation receiver",
|
|
884
|
+
handler: async ({ request }) => {
|
|
885
|
+
if (buckets && rateLimitConfig) {
|
|
886
|
+
const key = healthRouteKey(request);
|
|
887
|
+
const now = Date.now();
|
|
888
|
+
const entry = buckets.get(key);
|
|
889
|
+
if (!entry || entry.resetMs <= now) {
|
|
890
|
+
buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
entry.count++;
|
|
894
|
+
if (entry.count > rateLimitConfig.limit) {
|
|
895
|
+
throw new TooManyRequestsError(Math.ceil((entry.resetMs - now) / 1000));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const contentType = (request.headers.get("content-type") ?? "")
|
|
900
|
+
.split(";")[0]
|
|
901
|
+
.trim()
|
|
902
|
+
.toLowerCase();
|
|
903
|
+
if (contentType !== "application/csp-report" &&
|
|
904
|
+
contentType !== "application/reports+json" &&
|
|
905
|
+
contentType !== "application/json") {
|
|
906
|
+
throw new UnsupportedMediaTypeError(contentType || "<none>", [
|
|
907
|
+
"application/csp-report",
|
|
908
|
+
"application/reports+json",
|
|
909
|
+
"application/json",
|
|
910
|
+
]);
|
|
911
|
+
}
|
|
912
|
+
const rawBytes = await readBodyLimited(request, maxBytes);
|
|
913
|
+
const rawText = new TextDecoder().decode(rawBytes);
|
|
914
|
+
let parsed;
|
|
915
|
+
try {
|
|
916
|
+
parsed = safeJsonParse(rawText);
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
throw new BadRequestError("Invalid JSON report body");
|
|
920
|
+
}
|
|
921
|
+
if (parsed === undefined) {
|
|
922
|
+
throw new BadRequestError("Invalid JSON report body");
|
|
923
|
+
}
|
|
924
|
+
const ip = healthRouteKey(request);
|
|
925
|
+
const userAgent = request.headers.get("user-agent");
|
|
926
|
+
try {
|
|
927
|
+
if (opts.onReport) {
|
|
928
|
+
await opts.onReport(parsed, {
|
|
929
|
+
ip: ip === "global" ? null : ip,
|
|
930
|
+
userAgent,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
log.warn({ event: "csp.report", ip, userAgent, report: parsed }, "CSP violation report received");
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
log.error({ err: serializeErr(err), event: "csp.report.sinkFailed" }, "cspReportRoute onReport sink failed");
|
|
939
|
+
}
|
|
940
|
+
return { status: 204, body: undefined };
|
|
941
|
+
},
|
|
942
|
+
responses: {
|
|
943
|
+
204: { description: "Report accepted." },
|
|
944
|
+
413: { description: "Report body too large." },
|
|
945
|
+
415: { description: "Unsupported content-type." },
|
|
946
|
+
429: { description: "Rate limit exceeded." },
|
|
947
|
+
},
|
|
948
|
+
});
|
|
547
949
|
return this;
|
|
548
950
|
}
|
|
549
951
|
/**
|
|
@@ -594,9 +996,11 @@ export class App {
|
|
|
594
996
|
child.decorations = this.decorations;
|
|
595
997
|
child.installedPlugins = this.installedPlugins;
|
|
596
998
|
child.closeHooks = this.closeHooks;
|
|
999
|
+
child.idleConnectionCloseHooks = this.idleConnectionCloseHooks;
|
|
597
1000
|
child.pluginInstalledListeners = this.pluginInstalledListeners;
|
|
598
1001
|
child.shutdownListeners = this.shutdownListeners;
|
|
599
1002
|
child.pendingPlugins = this.pendingPlugins;
|
|
1003
|
+
child.pluginBootError = this.pluginBootError;
|
|
600
1004
|
register(child);
|
|
601
1005
|
return this;
|
|
602
1006
|
}
|
|
@@ -665,7 +1069,31 @@ export class App {
|
|
|
665
1069
|
* @param value - Value bound to that property on every request.
|
|
666
1070
|
* @returns This `App` instance for chaining.
|
|
667
1071
|
*/
|
|
668
|
-
|
|
1072
|
+
/**
|
|
1073
|
+
* Apply an ordered list of plugin extensions (Wave 6 item 10) to the
|
|
1074
|
+
* group-level hook chain. Each extension's `handler` is wrapped into a
|
|
1075
|
+
* single-event {@link Hooks} bundle so subsequent route registrations
|
|
1076
|
+
* pick it up via the normal hook composition path.
|
|
1077
|
+
* @internal
|
|
1078
|
+
*/
|
|
1079
|
+
applyExtensions(ordered) {
|
|
1080
|
+
for (const ext of ordered) {
|
|
1081
|
+
const hooks = { [ext.event]: ext.handler };
|
|
1082
|
+
this.groupHooks.push(hooks);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
decorate(key, value, opts = {}) {
|
|
1086
|
+
if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
|
|
1087
|
+
opts.override !== true) {
|
|
1088
|
+
// Wave 6 item 9: namespace-protected decorators. Refuse to silently
|
|
1089
|
+
// shadow an existing decoration; emit a once-per-process warn naming
|
|
1090
|
+
// both decorators on the explicit-override path.
|
|
1091
|
+
throw new Error(`decorate(): key "${key}" is already decorated. ` +
|
|
1092
|
+
`Pass { override: true } to replace, or rename to avoid the collision.`);
|
|
1093
|
+
}
|
|
1094
|
+
if (opts.override === true && Object.prototype.hasOwnProperty.call(this.decorations, key)) {
|
|
1095
|
+
this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
|
|
1096
|
+
}
|
|
669
1097
|
this.decorations[key] = value;
|
|
670
1098
|
return this;
|
|
671
1099
|
}
|
|
@@ -705,16 +1133,53 @@ export class App {
|
|
|
705
1133
|
return this;
|
|
706
1134
|
}
|
|
707
1135
|
/**
|
|
708
|
-
* Encapsulated plugin registration (Fastify-style). Receives a child App;
|
|
1136
|
+
* Encapsulated plugin registration (Fastify-style). Receives a child App;
|
|
1137
|
+
* routes/hooks declared on the child are scoped to the plugin by default
|
|
1138
|
+
* (Wave 6 item 15 \u2014 encapsulation default `local`).
|
|
1139
|
+
*
|
|
1140
|
+
* Wave 6 (`0.24.0`) additions on the plugin descriptor object:
|
|
1141
|
+
*
|
|
1142
|
+
* - `dependencies: string[]` \u2014 prerequisite plugin names; the framework
|
|
1143
|
+
* refuses-to-boot at registration time when any declared dependency has
|
|
1144
|
+
* not been installed first (item 8).
|
|
1145
|
+
* - `seed: string` \u2014 differentiator for parameterized instances of the
|
|
1146
|
+
* same plugin (item 16). Dedup key becomes `${name}#${seed}`.
|
|
1147
|
+
* - `stateful: boolean` \u2014 when `true` AND `name` is absent AND the app is
|
|
1148
|
+
* in production with `secureDefaults` on, registration refuses-to-boot
|
|
1149
|
+
* so silent double-installs of global-state-mutating plugins are caught
|
|
1150
|
+
* loud (item 16).
|
|
1151
|
+
* - `extensions: [{ event, handler, before?, after? }]` \u2014 declarative
|
|
1152
|
+
* lifecycle-hook ordering with topological-sort + cycle detection
|
|
1153
|
+
* (item 10). Refuses-at-registration on cycles.
|
|
709
1154
|
*/
|
|
710
1155
|
register(plugin, config = {}) {
|
|
711
|
-
const fn = typeof plugin === "function" ? plugin : plugin.register;
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1156
|
+
const fn = typeof plugin === "function" ? plugin : (plugin.register ?? (() => { }));
|
|
1157
|
+
const descriptor = typeof plugin === "function" ? undefined : plugin;
|
|
1158
|
+
const name = descriptor?.name;
|
|
1159
|
+
const seed = descriptor?.seed;
|
|
1160
|
+
const dedupKey = name ? (seed ? `${name}#${seed}` : name) : undefined;
|
|
1161
|
+
const dependencies = descriptor?.dependencies ?? [];
|
|
1162
|
+
const stateful = descriptor?.stateful ?? false;
|
|
1163
|
+
if (stateful && !name && this.isProduction() && this.options.secureDefaults !== false) {
|
|
1164
|
+
throw new Error("register(): anonymous stateful plugin refused in production. " +
|
|
1165
|
+
"Declare { name } (and optional { seed }) so the plugin can be deduplicated.");
|
|
1166
|
+
}
|
|
1167
|
+
for (const dep of dependencies) {
|
|
1168
|
+
if (!this.installedPlugins.has(dep)) {
|
|
1169
|
+
throw new Error(`register(): plugin ${JSON.stringify(name ?? "<anonymous>")} declares ` +
|
|
1170
|
+
`dependency on "${dep}" but no plugin with that name has been registered yet.`);
|
|
716
1171
|
}
|
|
717
|
-
|
|
1172
|
+
}
|
|
1173
|
+
if (dedupKey) {
|
|
1174
|
+
if (this.installedPlugins.has(dedupKey)) {
|
|
1175
|
+
throw new Error(`Plugin "${dedupKey}" already registered`);
|
|
1176
|
+
}
|
|
1177
|
+
this.installedPlugins.add(dedupKey);
|
|
1178
|
+
}
|
|
1179
|
+
if (descriptor?.extensions && descriptor.extensions.length > 0) {
|
|
1180
|
+
// Wave 6 item 10: topological sort with cycle detection.
|
|
1181
|
+
const ordered = topoSortExtensions(descriptor.extensions);
|
|
1182
|
+
this.applyExtensions(ordered);
|
|
718
1183
|
}
|
|
719
1184
|
const prefix = config.prefix ?? "/";
|
|
720
1185
|
const event = {
|
|
@@ -724,20 +1189,29 @@ export class App {
|
|
|
724
1189
|
this.group(prefix, config, (child) => {
|
|
725
1190
|
const r = fn(child);
|
|
726
1191
|
if (r && typeof r.then === "function") {
|
|
727
|
-
// Plugin is async
|
|
728
|
-
this.
|
|
1192
|
+
// Plugin is async - caller should await app.ready().
|
|
1193
|
+
this.trackPendingPlugin(r.then(() => this.firePluginInstalled(event)));
|
|
729
1194
|
}
|
|
730
1195
|
else {
|
|
731
1196
|
// Sync plugin: fire listeners immediately. Any returned promise from a
|
|
732
1197
|
// listener is collected so `app.ready()` can await observers too.
|
|
733
1198
|
const pending = this.firePluginInstalled(event);
|
|
734
1199
|
if (pending) {
|
|
735
|
-
this.
|
|
1200
|
+
this.trackPendingPlugin(pending);
|
|
736
1201
|
}
|
|
737
1202
|
}
|
|
738
1203
|
});
|
|
739
1204
|
return this;
|
|
740
1205
|
}
|
|
1206
|
+
trackPendingPlugin(promise) {
|
|
1207
|
+
const tracked = promise.catch((err) => {
|
|
1208
|
+
this.pluginBootError.failed = true;
|
|
1209
|
+
this.pluginBootError.error = err;
|
|
1210
|
+
throw err;
|
|
1211
|
+
});
|
|
1212
|
+
this.pendingPlugins.add(tracked);
|
|
1213
|
+
void tracked.finally(() => this.pendingPlugins.delete(tracked)).catch(() => { });
|
|
1214
|
+
}
|
|
741
1215
|
firePluginInstalled(event) {
|
|
742
1216
|
if (this.pluginInstalledListeners.length === 0)
|
|
743
1217
|
return undefined;
|
|
@@ -777,9 +1251,12 @@ export class App {
|
|
|
777
1251
|
* @returns Promise that resolves once all pending plugins have settled.
|
|
778
1252
|
*/
|
|
779
1253
|
ready() {
|
|
780
|
-
if (this.
|
|
1254
|
+
if (this.pluginBootError.failed) {
|
|
1255
|
+
return Promise.reject(this.pluginBootError.error);
|
|
1256
|
+
}
|
|
1257
|
+
if (this.pendingPlugins.size === 0)
|
|
781
1258
|
return Promise.resolve();
|
|
782
|
-
const pending = this.pendingPlugins
|
|
1259
|
+
const pending = Array.from(this.pendingPlugins);
|
|
783
1260
|
return Promise.all(pending).then(() => undefined);
|
|
784
1261
|
}
|
|
785
1262
|
/**
|
|
@@ -802,6 +1279,28 @@ export class App {
|
|
|
802
1279
|
* to RFC 9457 `application/problem+json` automatically.
|
|
803
1280
|
*/
|
|
804
1281
|
fetch = async (request) => {
|
|
1282
|
+
const response = await this.dispatch(request);
|
|
1283
|
+
// In-flight responses that finish during draining advertise
|
|
1284
|
+
// `Connection: close` so HTTP/1.1 load balancers stop re-using the
|
|
1285
|
+
// socket for new requests. Wave 4 connection-draining.
|
|
1286
|
+
if (this.draining && !response.headers.has("connection")) {
|
|
1287
|
+
response.headers.set("connection", "close");
|
|
1288
|
+
}
|
|
1289
|
+
return response;
|
|
1290
|
+
};
|
|
1291
|
+
/**
|
|
1292
|
+
* In-process entry point that bypasses the public `404` shield for
|
|
1293
|
+
* routes declared with `internal: true`. Use it for cron jobs, admin
|
|
1294
|
+
* scripts, and integration tests that need to exercise privileged
|
|
1295
|
+
* handlers without exposing them to the network. All other security
|
|
1296
|
+
* middleware (CORS, CSRF, rate limit, etc.) still runs normally.
|
|
1297
|
+
*
|
|
1298
|
+
* @since 0.19.0
|
|
1299
|
+
*/
|
|
1300
|
+
inject = async (request) => {
|
|
1301
|
+
return this.dispatch(request, { allowInternal: true });
|
|
1302
|
+
};
|
|
1303
|
+
dispatch = async (request, opts = {}) => {
|
|
805
1304
|
if (this.draining) {
|
|
806
1305
|
return new Response(JSON.stringify({
|
|
807
1306
|
type: "https://daloyjs.dev/errors/shutting-down",
|
|
@@ -812,6 +1311,10 @@ export class App {
|
|
|
812
1311
|
headers: {
|
|
813
1312
|
"content-type": "application/problem+json",
|
|
814
1313
|
"retry-after": "5",
|
|
1314
|
+
// Tell HTTP/1.1 load balancers to close the keep-alive socket
|
|
1315
|
+
// immediately so the next request lands on a healthy instance
|
|
1316
|
+
// rather than coming back to a dying one. Wave 4.
|
|
1317
|
+
connection: "close",
|
|
815
1318
|
},
|
|
816
1319
|
});
|
|
817
1320
|
}
|
|
@@ -838,12 +1341,30 @@ export class App {
|
|
|
838
1341
|
const headFallback = method === "HEAD";
|
|
839
1342
|
const match = this.router.find(method, url.pathname) ??
|
|
840
1343
|
(headFallback ? this.router.find("GET", url.pathname) : undefined);
|
|
1344
|
+
// Hide internal routes from the public adapter surface. The router
|
|
1345
|
+
// still finds them so app.inject() can dispatch normally, but
|
|
1346
|
+
// app.fetch() responds 404 to avoid leaking existence.
|
|
1347
|
+
const internalHidden = match?.handler.def.internal === true && opts.allowInternal !== true;
|
|
841
1348
|
this.assertCrossOriginAllowed(request, url, method, [
|
|
842
1349
|
...corsOriginAllowsFromHooks([this.options.hooks ?? {}]),
|
|
843
|
-
...(match ? match.handler.corsOriginAllows : this.corsOriginAllows),
|
|
1350
|
+
...(match && !internalHidden ? match.handler.corsOriginAllows : this.corsOriginAllows),
|
|
844
1351
|
]);
|
|
845
|
-
if (!match) {
|
|
846
|
-
|
|
1352
|
+
if (!match || internalHidden) {
|
|
1353
|
+
if (internalHidden) {
|
|
1354
|
+
// Don't leak existence via 405/Allow header. Always 404.
|
|
1355
|
+
throw new NotFoundError(`No route for ${request.method} ${url.pathname}`);
|
|
1356
|
+
}
|
|
1357
|
+
const rawAllowed = this.router.allowedMethods(url.pathname);
|
|
1358
|
+
// Filter out methods whose route definitions are marked
|
|
1359
|
+
// `internal: true` unless the caller explicitly opted in via
|
|
1360
|
+
// app.inject(). This prevents 405/Allow from leaking the
|
|
1361
|
+
// existence of hidden admin/cron endpoints.
|
|
1362
|
+
const allowed = opts.allowInternal
|
|
1363
|
+
? rawAllowed
|
|
1364
|
+
: rawAllowed.filter((m) => {
|
|
1365
|
+
const candidate = this.router.find(m, url.pathname);
|
|
1366
|
+
return candidate?.handler.def.internal !== true;
|
|
1367
|
+
});
|
|
847
1368
|
ctx = {
|
|
848
1369
|
request,
|
|
849
1370
|
params: {},
|
|
@@ -909,14 +1430,16 @@ export class App {
|
|
|
909
1430
|
if (afterReturn !== undefined)
|
|
910
1431
|
result = afterReturn;
|
|
911
1432
|
let response = await serializeResult(result, def, this.options.validateResponses ?? true);
|
|
1433
|
+
copyContextHeaders(ctx, response);
|
|
1434
|
+
const finalized = await finalizeResponse(response, ctx, allHooks, stripFingerprint);
|
|
912
1435
|
if (method === "HEAD") {
|
|
913
|
-
|
|
914
|
-
status:
|
|
915
|
-
|
|
1436
|
+
return new Response(null, {
|
|
1437
|
+
status: finalized.status,
|
|
1438
|
+
statusText: finalized.statusText,
|
|
1439
|
+
headers: finalized.headers,
|
|
916
1440
|
});
|
|
917
1441
|
}
|
|
918
|
-
|
|
919
|
-
return await finalizeResponse(response, ctx, allHooks, stripFingerprint);
|
|
1442
|
+
return finalized;
|
|
920
1443
|
}
|
|
921
1444
|
catch (err) {
|
|
922
1445
|
const handled = await activeErrorHook?.(err, ctx);
|
|
@@ -930,6 +1453,29 @@ export class App {
|
|
|
930
1453
|
onResponse: activeResponseHook,
|
|
931
1454
|
}, stripFingerprint);
|
|
932
1455
|
}
|
|
1456
|
+
// Wave 4 leftover: when the client has already disconnected, classify
|
|
1457
|
+
// the request at `disconnectStatusCode` (default 499) instead of
|
|
1458
|
+
// letting an AbortError bubble up as a generic 5xx. Logged at `info`
|
|
1459
|
+
// so disconnect storms do not look like service incidents.
|
|
1460
|
+
const disconnectCode = this.options.disconnectStatusCode ?? 499;
|
|
1461
|
+
if (disconnectCode > 0 &&
|
|
1462
|
+
request.signal?.aborted === true &&
|
|
1463
|
+
!(err instanceof HttpError)) {
|
|
1464
|
+
log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
|
|
1465
|
+
const res = new Response(null, {
|
|
1466
|
+
status: disconnectCode,
|
|
1467
|
+
headers: {
|
|
1468
|
+
"content-type": "application/problem+json",
|
|
1469
|
+
"x-request-id": requestId,
|
|
1470
|
+
},
|
|
1471
|
+
});
|
|
1472
|
+
if (ctx)
|
|
1473
|
+
copyContextHeaders(ctx, res);
|
|
1474
|
+
return finalizeResponse(res, ctx, {
|
|
1475
|
+
onSend: activeSendHook,
|
|
1476
|
+
onResponse: activeResponseHook,
|
|
1477
|
+
}, stripFingerprint);
|
|
1478
|
+
}
|
|
933
1479
|
const httpErr = err instanceof HttpError
|
|
934
1480
|
? err
|
|
935
1481
|
: new InternalError(err instanceof Error ? err.message : "Unexpected error");
|
|
@@ -1042,6 +1588,17 @@ export class App {
|
|
|
1042
1588
|
}
|
|
1043
1589
|
}
|
|
1044
1590
|
}
|
|
1591
|
+
// Wave 4: kill idle keep-alive connections immediately so they cannot
|
|
1592
|
+
// be re-used for a new request that would race with the drain. Adapters
|
|
1593
|
+
// (Node) register a hook here. In-flight requests are unaffected.
|
|
1594
|
+
for (const hook of this.idleConnectionCloseHooks) {
|
|
1595
|
+
try {
|
|
1596
|
+
hook();
|
|
1597
|
+
}
|
|
1598
|
+
catch (err) {
|
|
1599
|
+
this.log.error({ err }, "idleConnectionCloseHook failed");
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1045
1602
|
const start = Date.now();
|
|
1046
1603
|
while (this.inflight > 0 && Date.now() - start < timeoutMs) {
|
|
1047
1604
|
await new Promise((r) => setTimeout(r, 25));
|
|
@@ -1054,6 +1611,30 @@ export class App {
|
|
|
1054
1611
|
}
|
|
1055
1612
|
this.log.info({ inflight: this.inflight }, "DaloyJS shutdown complete");
|
|
1056
1613
|
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Alias for {@link App.shutdown}. Matches the Node `Server.close()` shape
|
|
1616
|
+
* and reads more naturally from adapters that want a single "stop"
|
|
1617
|
+
* method.
|
|
1618
|
+
*
|
|
1619
|
+
* @since 0.18.0
|
|
1620
|
+
*/
|
|
1621
|
+
async close(timeoutMs = 10_000, reason) {
|
|
1622
|
+
return this.shutdown(timeoutMs, reason);
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Adapter-private hook to register a callback that runs synchronously
|
|
1626
|
+
* when {@link App.shutdown} begins draining. The Node adapter uses this
|
|
1627
|
+
* to invoke `server.closeIdleConnections()` so keep-alive sockets without
|
|
1628
|
+
* an in-flight request are killed immediately instead of being held open
|
|
1629
|
+
* until the OS / load balancer notices.
|
|
1630
|
+
*
|
|
1631
|
+
* Not part of the documented public API surface: subject to change.
|
|
1632
|
+
*
|
|
1633
|
+
* @internal
|
|
1634
|
+
*/
|
|
1635
|
+
_registerIdleConnectionCloseHook(hook) {
|
|
1636
|
+
this.idleConnectionCloseHooks.push(hook);
|
|
1637
|
+
}
|
|
1057
1638
|
}
|
|
1058
1639
|
// ---------- helpers ----------
|
|
1059
1640
|
function joinPath(a, b) {
|
|
@@ -1062,6 +1643,16 @@ function joinPath(a, b) {
|
|
|
1062
1643
|
const joined = `${left}${right}`;
|
|
1063
1644
|
return joined === "" ? "/" : joined;
|
|
1064
1645
|
}
|
|
1646
|
+
function healthRouteKey(request) {
|
|
1647
|
+
// The probe rate limit deliberately does NOT honour `X-Forwarded-For` —
|
|
1648
|
+
// health probes typically arrive directly from a sidecar / orchestrator,
|
|
1649
|
+
// so even apps that trust forwarded headers should not let an attacker
|
|
1650
|
+
// bypass the per-IP cap by spoofing the header. Fall back to a constant
|
|
1651
|
+
// key when no proxy header is available (single shared bucket).
|
|
1652
|
+
return (request.headers.get("x-real-ip") ??
|
|
1653
|
+
request.headers.get("fly-client-ip") ??
|
|
1654
|
+
"global");
|
|
1655
|
+
}
|
|
1065
1656
|
function corsOriginAllowsFromHooks(layers) {
|
|
1066
1657
|
const allows = [];
|
|
1067
1658
|
for (const hooks of layers) {
|
|
@@ -1079,6 +1670,62 @@ function corsOriginAllowsFromHooks(layers) {
|
|
|
1079
1670
|
}
|
|
1080
1671
|
return allows;
|
|
1081
1672
|
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Topological sort of plugin extensions (Wave 6 item 10). Refuses-at-call
|
|
1675
|
+
* on cyclic ordering with a structured error naming the cycle.
|
|
1676
|
+
* @internal
|
|
1677
|
+
*/
|
|
1678
|
+
export function topoSortExtensions(exts) {
|
|
1679
|
+
const byName = new Map();
|
|
1680
|
+
for (const e of exts) {
|
|
1681
|
+
if (byName.has(e.name)) {
|
|
1682
|
+
throw new Error(`Duplicate plugin extension name: ${JSON.stringify(e.name)}.`);
|
|
1683
|
+
}
|
|
1684
|
+
byName.set(e.name, e);
|
|
1685
|
+
}
|
|
1686
|
+
// Build adjacency: edge from A -> B means A must run before B.
|
|
1687
|
+
const edges = new Map();
|
|
1688
|
+
for (const e of exts)
|
|
1689
|
+
edges.set(e.name, new Set());
|
|
1690
|
+
for (const e of exts) {
|
|
1691
|
+
for (const b of e.before ?? []) {
|
|
1692
|
+
if (byName.has(b))
|
|
1693
|
+
edges.get(e.name).add(b);
|
|
1694
|
+
}
|
|
1695
|
+
for (const a of e.after ?? []) {
|
|
1696
|
+
if (byName.has(a))
|
|
1697
|
+
edges.get(a).add(e.name);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
// Kahn's algorithm.
|
|
1701
|
+
const indeg = new Map();
|
|
1702
|
+
for (const name of edges.keys())
|
|
1703
|
+
indeg.set(name, 0);
|
|
1704
|
+
for (const [, outs] of edges) {
|
|
1705
|
+
for (const v of outs)
|
|
1706
|
+
indeg.set(v, (indeg.get(v) ?? 0) + 1);
|
|
1707
|
+
}
|
|
1708
|
+
const queue = [];
|
|
1709
|
+
for (const [name, d] of indeg)
|
|
1710
|
+
if (d === 0)
|
|
1711
|
+
queue.push(name);
|
|
1712
|
+
const out = [];
|
|
1713
|
+
while (queue.length > 0) {
|
|
1714
|
+
const name = queue.shift();
|
|
1715
|
+
out.push(byName.get(name));
|
|
1716
|
+
for (const v of edges.get(name)) {
|
|
1717
|
+
const d = (indeg.get(v) ?? 0) - 1;
|
|
1718
|
+
indeg.set(v, d);
|
|
1719
|
+
if (d === 0)
|
|
1720
|
+
queue.push(v);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
if (out.length !== exts.length) {
|
|
1724
|
+
const remaining = Array.from(byName.keys()).filter((n) => !out.some((e) => e.name === n));
|
|
1725
|
+
throw new Error(`Plugin extension cycle detected among: ${remaining.map((n) => JSON.stringify(n)).join(", ")}.`);
|
|
1726
|
+
}
|
|
1727
|
+
return out;
|
|
1728
|
+
}
|
|
1082
1729
|
function securityMarkersFromHooks(layers) {
|
|
1083
1730
|
let hasSession = false;
|
|
1084
1731
|
let hasCsrf = false;
|
|
@@ -1101,14 +1748,53 @@ function mergeHooks(layers) {
|
|
|
1101
1748
|
const pick = (key) => layers
|
|
1102
1749
|
.map((h) => h[key])
|
|
1103
1750
|
.filter((f) => typeof f === "function");
|
|
1104
|
-
|
|
1751
|
+
const requiredScopes = requiredScopesFromHooks(layers);
|
|
1752
|
+
const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
|
|
1753
|
+
const hooks = {
|
|
1105
1754
|
onRequest: chain(pick("onRequest")),
|
|
1106
|
-
beforeHandle
|
|
1755
|
+
beforeHandle,
|
|
1107
1756
|
afterHandle: pipeline(pick("afterHandle")),
|
|
1108
1757
|
onError: firstResponse(pick("onError")),
|
|
1109
1758
|
onSend: responsePipeline(pick("onSend")),
|
|
1110
1759
|
onResponse: chain(pick("onResponse")),
|
|
1111
1760
|
};
|
|
1761
|
+
stampRequiredScopes(hooks, requiredScopes);
|
|
1762
|
+
return hooks;
|
|
1763
|
+
}
|
|
1764
|
+
function requiredScopesFromHooks(layers) {
|
|
1765
|
+
const out = [];
|
|
1766
|
+
for (const hooks of layers) {
|
|
1767
|
+
const scopes = hooks[REQUIRE_SCOPES_HOOK_MARKER];
|
|
1768
|
+
if (!Array.isArray(scopes))
|
|
1769
|
+
continue;
|
|
1770
|
+
for (const scope of scopes) {
|
|
1771
|
+
if (typeof scope === "string" && !out.includes(scope))
|
|
1772
|
+
out.push(scope);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
return out;
|
|
1776
|
+
}
|
|
1777
|
+
function stampRequiredScopes(hooks, scopes) {
|
|
1778
|
+
if (scopes.length > 0) {
|
|
1779
|
+
hooks[REQUIRE_SCOPES_HOOK_MARKER] = [...scopes];
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
function mergeBeforeHandle(beforeHandle, requiredScopes) {
|
|
1783
|
+
if (!beforeHandle && requiredScopes.length === 0)
|
|
1784
|
+
return undefined;
|
|
1785
|
+
return async (ctx) => {
|
|
1786
|
+
if (requiredScopes.length > 0) {
|
|
1787
|
+
const state = ctx.state;
|
|
1788
|
+
const prior = state[REQUIRE_SCOPES_AGGREGATE_KEY];
|
|
1789
|
+
const aggregate = Array.isArray(prior) ? [...prior] : [];
|
|
1790
|
+
for (const scope of requiredScopes) {
|
|
1791
|
+
if (!aggregate.includes(scope))
|
|
1792
|
+
aggregate.push(scope);
|
|
1793
|
+
}
|
|
1794
|
+
state[REQUIRE_SCOPES_AGGREGATE_KEY] = aggregate;
|
|
1795
|
+
}
|
|
1796
|
+
return beforeHandle?.(ctx);
|
|
1797
|
+
};
|
|
1112
1798
|
}
|
|
1113
1799
|
function responsePipeline(fns) {
|
|
1114
1800
|
if (fns.length === 0)
|