@daloyjs/core 0.35.1 → 0.36.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 +1 -0
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +90 -28
- package/dist/app.d.ts +113 -0
- package/dist/app.js +372 -96
- package/dist/errors.js +6 -2
- package/dist/index.d.ts +1 -1
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +2 -2
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +4 -19
- package/dist/security.js +36 -7
- package/package.json +1 -1
package/dist/app.js
CHANGED
|
@@ -40,6 +40,36 @@ let insecureDefaultsLoggedThisProcess = false;
|
|
|
40
40
|
export function _resetInsecureDefaultsLogForTests() {
|
|
41
41
|
insecureDefaultsLoggedThisProcess = false;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The exact set of fields the `"internal-service"` preset flips off when
|
|
45
|
+
* the caller has not set them explicitly. Surfaced through the boot
|
|
46
|
+
* audit log entry so operators can see which guards the preset turned
|
|
47
|
+
* off without re-reading the framework source.
|
|
48
|
+
*/
|
|
49
|
+
const INTERNAL_SERVICE_PRESET_DISABLED = Object.freeze([
|
|
50
|
+
"secureHeaders auto-install",
|
|
51
|
+
"corsCrossOriginGuard (state-changing cross-origin write rejection)",
|
|
52
|
+
"csrf boot guard (session() + state-changing route)",
|
|
53
|
+
"unconfigured X-Forwarded-* / trustProxy guard",
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* Defaults that the `"internal-service"` preset keeps on. Logged at boot
|
|
57
|
+
* alongside the disabled list so the audit entry shows the full posture.
|
|
58
|
+
*/
|
|
59
|
+
const INTERNAL_SERVICE_PRESET_KEPT = Object.freeze([
|
|
60
|
+
"bodyLimitBytes (1 MiB default)",
|
|
61
|
+
"requestTimeoutMs (30 s default)",
|
|
62
|
+
"crashOnUnhandledRejection (production)",
|
|
63
|
+
"weak session secret refuse-to-boot",
|
|
64
|
+
"cors({ origin: '*' }) refuse-to-boot",
|
|
65
|
+
"anonymous stateful plugin refuse-to-boot",
|
|
66
|
+
"stripServerHeaders",
|
|
67
|
+
"RFC 9457 problem+json prod redaction",
|
|
68
|
+
"JWT algorithm allowlist + timingSafeEqual credential comparison",
|
|
69
|
+
"prototype-pollution-safe parsers + isForbiddenObjectKey",
|
|
70
|
+
"fetchGuard() SSRF defaults",
|
|
71
|
+
"schema .strict() + response validation when enabled",
|
|
72
|
+
]);
|
|
43
73
|
/**
|
|
44
74
|
* List of secure-by-default surfaces disabled when `secureDefaults: false`
|
|
45
75
|
* is set. Surfaced through the once-per-process `error` log so the operator
|
|
@@ -74,6 +104,46 @@ const CANONICAL_HTTP_METHODS = new Set([
|
|
|
74
104
|
"HEAD",
|
|
75
105
|
"OPTIONS",
|
|
76
106
|
]);
|
|
107
|
+
/**
|
|
108
|
+
* Apply a topology-aware security preset on top of caller-supplied
|
|
109
|
+
* options. Returns a new options object where preset defaults fill in
|
|
110
|
+
* any field the caller left `undefined`; explicit caller values always
|
|
111
|
+
* win. Pure / no side effects — the boot audit log is emitted
|
|
112
|
+
* separately by {@link App.logSecurityPresetIfApplied} so this helper is
|
|
113
|
+
* safe to call from `new App({ preset: ... })` in test setups.
|
|
114
|
+
*
|
|
115
|
+
* The `"internal-service"` preset turns off:
|
|
116
|
+
* - `secureHeaders` auto-install (browser-only headers)
|
|
117
|
+
* - `corsCrossOriginGuard` (no browser Origin to guard against)
|
|
118
|
+
* - `csrf` (set to `"off"` — service-to-service callers aren't browsers)
|
|
119
|
+
* - `trustProxy` (set to `false` — explicitly ignore `X-Forwarded-*`
|
|
120
|
+
* and silence the unconfigured-proxy 500 guard; the immediate peer
|
|
121
|
+
* inside the mesh *is* the caller)
|
|
122
|
+
*
|
|
123
|
+
* Everything else (body limits, request timeouts, JWT allowlist,
|
|
124
|
+
* `crashOnUnhandledRejection`, weak-secret refuse-to-boot, cors-wildcard
|
|
125
|
+
* refuse-to-boot, anonymous stateful plugin refuse-to-boot,
|
|
126
|
+
* `stripServerHeaders`, RFC 9457 prod redaction, schema strictness,
|
|
127
|
+
* `fetchGuard`, parser safety) stays at its standard secure-by-default
|
|
128
|
+
* value.
|
|
129
|
+
*
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
function applySecurityPreset(options) {
|
|
133
|
+
if (options.preset !== "internal-service")
|
|
134
|
+
return options;
|
|
135
|
+
const out = { ...options };
|
|
136
|
+
if (out.secureHeaders === undefined)
|
|
137
|
+
out.secureHeaders = false;
|
|
138
|
+
if (out.corsCrossOriginGuard === undefined)
|
|
139
|
+
out.corsCrossOriginGuard = false;
|
|
140
|
+
if (out.csrf === undefined)
|
|
141
|
+
out.csrf = "off";
|
|
142
|
+
if (out.trustProxy === undefined && out.behindProxy === undefined) {
|
|
143
|
+
out.trustProxy = false;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
77
147
|
const DEFAULTS = {
|
|
78
148
|
bodyLimitBytes: 1024 * 1024,
|
|
79
149
|
requestTimeoutMs: 30_000,
|
|
@@ -89,6 +159,25 @@ const TEXT_ENCODER = new TextEncoder();
|
|
|
89
159
|
* implementation detail — userland code should never depend on it.
|
|
90
160
|
*/
|
|
91
161
|
export const DALOY_RAW_BODY = Symbol.for("daloyjs.response.rawBody");
|
|
162
|
+
/**
|
|
163
|
+
* Internal Symbol used by adapters to stash a pre-buffered request body on
|
|
164
|
+
* the `Request` instance. When set, {@link readBodyLimited} skips the
|
|
165
|
+
* `ReadableStream` reader loop and returns the cached bytes directly after
|
|
166
|
+
* re-checking them against the caller-supplied limit. Adapters MUST only
|
|
167
|
+
* attach bytes they have already validated against the configured
|
|
168
|
+
* {@link AppOptions.bodyLimitBytes}; the limit re-check in
|
|
169
|
+
* `readBodyLimited` is defense-in-depth, not the primary cap. Module-public
|
|
170
|
+
* so first-party adapters can opt in; not part of the userland API surface.
|
|
171
|
+
*/
|
|
172
|
+
export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
173
|
+
/**
|
|
174
|
+
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
175
|
+
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
176
|
+
* adapter pipes the stream straight to the socket, skipping the
|
|
177
|
+
* Web-stream reader bridge. Module-public so first-party adapters can
|
|
178
|
+
* opt in; userland code should not depend on it.
|
|
179
|
+
*/
|
|
180
|
+
export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
|
|
92
181
|
/**
|
|
93
182
|
* Contract-first HTTP application.
|
|
94
183
|
*
|
|
@@ -144,6 +233,14 @@ export class App {
|
|
|
144
233
|
/** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
|
|
145
234
|
routes = [];
|
|
146
235
|
router = new Router();
|
|
236
|
+
/**
|
|
237
|
+
* Memoized result of `isProduction()`. The inputs (`options.env`,
|
|
238
|
+
* `options.production`, `process.env.NODE_ENV`) cannot change between
|
|
239
|
+
* the moment a route is dispatched and the moment its error is rendered,
|
|
240
|
+
* so reading `process.env.NODE_ENV` on every error response is wasted
|
|
241
|
+
* work in the hot path. Computed lazily on first read.
|
|
242
|
+
*/
|
|
243
|
+
_productionCache;
|
|
147
244
|
/** WebSocket route registry. Adapters look up handlers via `app.webSocketRoutes.find()`. */
|
|
148
245
|
webSocketRoutes = new WebSocketRegistry();
|
|
149
246
|
prefix = "";
|
|
@@ -154,6 +251,13 @@ export class App {
|
|
|
154
251
|
routeSecurityMarkers = [];
|
|
155
252
|
/** Decorator bag merged into ctx.state on every request. */
|
|
156
253
|
decorations = {};
|
|
254
|
+
/**
|
|
255
|
+
* Count of own keys on {@link decorations}. Tracked alongside the bag so the
|
|
256
|
+
* dispatch hot path can take a `count === 0` fast path and skip the
|
|
257
|
+
* `Object.assign` spread on the common case (no `app.decorate()` calls).
|
|
258
|
+
* Updated only when {@link decorate} mutates the bag.
|
|
259
|
+
*/
|
|
260
|
+
decorationsCount = 0;
|
|
157
261
|
installedPlugins = new Set();
|
|
158
262
|
closeHooks = [];
|
|
159
263
|
closeHooksRun = false;
|
|
@@ -211,11 +315,12 @@ export class App {
|
|
|
211
315
|
return this._globalCorsAllowsCache;
|
|
212
316
|
}
|
|
213
317
|
constructor(options = {}) {
|
|
318
|
+
const resolved = applySecurityPreset(options);
|
|
214
319
|
this.options = {
|
|
215
|
-
validateResponses:
|
|
216
|
-
bodyLimitBytes:
|
|
217
|
-
requestTimeoutMs:
|
|
218
|
-
...
|
|
320
|
+
validateResponses: resolved.validateResponses ?? DEFAULTS.validateResponses,
|
|
321
|
+
bodyLimitBytes: resolved.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
|
|
322
|
+
requestTimeoutMs: resolved.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
|
|
323
|
+
...resolved,
|
|
219
324
|
};
|
|
220
325
|
this.log =
|
|
221
326
|
options.logger === false
|
|
@@ -230,6 +335,7 @@ export class App {
|
|
|
230
335
|
if (this.options.hooks)
|
|
231
336
|
this.assertSecureHookConfig(this.options.hooks);
|
|
232
337
|
this.assertInsecureDefaultsAcknowledged();
|
|
338
|
+
this.logSecurityPresetIfApplied(options);
|
|
233
339
|
this.installSecureDefaults();
|
|
234
340
|
this.maybeInstallCrashHandlers();
|
|
235
341
|
this.maybeMountDocs();
|
|
@@ -284,6 +390,68 @@ export class App {
|
|
|
284
390
|
}, `app({ secureDefaults: false }) disables: ${DISABLED_BY_INSECURE_DEFAULTS.join(", ")}.`);
|
|
285
391
|
}
|
|
286
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Emit the one-time boot audit entry for an applied security preset.
|
|
395
|
+
* Called from the constructor with the *original* (pre-preset) options
|
|
396
|
+
* so the log captures which fields the preset filled in vs. which the
|
|
397
|
+
* caller set explicitly. Logged at `info` so the line shows up in
|
|
398
|
+
* standard production log shipping without being noisy.
|
|
399
|
+
*
|
|
400
|
+
* Operators can audit the live posture at any time through
|
|
401
|
+
* {@link App.getSecurityPosture}.
|
|
402
|
+
*
|
|
403
|
+
* @since 0.34.0
|
|
404
|
+
*/
|
|
405
|
+
logSecurityPresetIfApplied(originalOptions) {
|
|
406
|
+
if (originalOptions.preset !== "internal-service")
|
|
407
|
+
return;
|
|
408
|
+
const userOverrode = [];
|
|
409
|
+
if (originalOptions.secureHeaders !== undefined)
|
|
410
|
+
userOverrode.push("secureHeaders");
|
|
411
|
+
if (originalOptions.corsCrossOriginGuard !== undefined) {
|
|
412
|
+
userOverrode.push("corsCrossOriginGuard");
|
|
413
|
+
}
|
|
414
|
+
if (originalOptions.csrf !== undefined)
|
|
415
|
+
userOverrode.push("csrf");
|
|
416
|
+
if (originalOptions.trustProxy !== undefined)
|
|
417
|
+
userOverrode.push("trustProxy");
|
|
418
|
+
if (originalOptions.behindProxy !== undefined)
|
|
419
|
+
userOverrode.push("behindProxy");
|
|
420
|
+
this.log.info({
|
|
421
|
+
event: "security.preset.applied",
|
|
422
|
+
preset: "internal-service",
|
|
423
|
+
disabled: INTERNAL_SERVICE_PRESET_DISABLED,
|
|
424
|
+
kept: INTERNAL_SERVICE_PRESET_KEPT,
|
|
425
|
+
userOverrode,
|
|
426
|
+
}, `Applied security preset "internal-service": disabled ${INTERNAL_SERVICE_PRESET_DISABLED.length} topology-dependent guards; kept ${INTERNAL_SERVICE_PRESET_KEPT.length} input/credential/SSRF guards on. See app.getSecurityPosture() for the live snapshot.`);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Structured snapshot of the live security posture. Returns the same
|
|
430
|
+
* data the constructor logs under the `security.preset.applied` audit
|
|
431
|
+
* event plus the resolved values of every secure-by-default knob, so
|
|
432
|
+
* operators can build a `/__security` introspection route or a CI
|
|
433
|
+
* audit without parsing the framework source.
|
|
434
|
+
*
|
|
435
|
+
* @since 0.34.0
|
|
436
|
+
*/
|
|
437
|
+
getSecurityPosture() {
|
|
438
|
+
const o = this.options;
|
|
439
|
+
return Object.freeze({
|
|
440
|
+
preset: o.preset,
|
|
441
|
+
secureDefaults: o.secureDefaults !== false,
|
|
442
|
+
secureHeaders: o.secureDefaults !== false && o.secureHeaders !== false,
|
|
443
|
+
corsCrossOriginGuard: o.secureDefaults !== false && o.corsCrossOriginGuard !== false,
|
|
444
|
+
csrf: o.csrf === "off" ? "off" : "on",
|
|
445
|
+
crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
|
|
446
|
+
? "default"
|
|
447
|
+
: o.crashOnUnhandledRejection,
|
|
448
|
+
trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
|
|
449
|
+
bodyLimitBytes: this.options.bodyLimitBytes,
|
|
450
|
+
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
451
|
+
stripServerHeaders: o.stripServerHeaders !== false,
|
|
452
|
+
production: this.isProduction(),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
287
455
|
/**
|
|
288
456
|
* Install the secure-by-default global hooks. Currently:
|
|
289
457
|
* - {@link secureHeaders} as a group-level hook so every response carries
|
|
@@ -383,13 +551,20 @@ export class App {
|
|
|
383
551
|
* auto-mount and error response detail stripping.
|
|
384
552
|
*/
|
|
385
553
|
isProduction() {
|
|
554
|
+
if (this._productionCache !== undefined)
|
|
555
|
+
return this._productionCache;
|
|
556
|
+
let v;
|
|
386
557
|
if (this.options.env !== undefined)
|
|
387
|
-
|
|
388
|
-
if (this.options.production !== undefined)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
558
|
+
v = this.options.env === "production";
|
|
559
|
+
else if (this.options.production !== undefined)
|
|
560
|
+
v = this.options.production;
|
|
561
|
+
else
|
|
562
|
+
v =
|
|
563
|
+
typeof process !== "undefined" &&
|
|
564
|
+
typeof process.env !== "undefined" &&
|
|
565
|
+
process.env.NODE_ENV === "production";
|
|
566
|
+
this._productionCache = v;
|
|
567
|
+
return v;
|
|
393
568
|
}
|
|
394
569
|
/**
|
|
395
570
|
* Cross-origin guard. Rejects state-changing requests (`POST` /
|
|
@@ -1230,7 +1405,10 @@ export class App {
|
|
|
1230
1405
|
if (opts.override === true && Object.prototype.hasOwnProperty.call(this.decorations, key)) {
|
|
1231
1406
|
this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
|
|
1232
1407
|
}
|
|
1408
|
+
const hadKey = Object.prototype.hasOwnProperty.call(this.decorations, key);
|
|
1233
1409
|
this.decorations[key] = value;
|
|
1410
|
+
if (!hadKey)
|
|
1411
|
+
this.decorationsCount++;
|
|
1234
1412
|
return this;
|
|
1235
1413
|
}
|
|
1236
1414
|
/**
|
|
@@ -1455,12 +1633,18 @@ export class App {
|
|
|
1455
1633
|
});
|
|
1456
1634
|
}
|
|
1457
1635
|
this.inflight++;
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1636
|
+
let requestId = randomId();
|
|
1637
|
+
// Skip the per-request child-logger allocation when the app was
|
|
1638
|
+
// constructed with `{ logger: false }`. noopLogger.child() returns
|
|
1639
|
+
// itself, so the binding is wasted work on every request.
|
|
1640
|
+
const baseLog = this.log;
|
|
1641
|
+
const log = baseLog === noopLogger
|
|
1642
|
+
? noopLogger
|
|
1643
|
+
: baseLog.child({
|
|
1644
|
+
requestId,
|
|
1645
|
+
method: request.method,
|
|
1646
|
+
url: request.url,
|
|
1647
|
+
});
|
|
1464
1648
|
const stripFingerprint = this.options.stripServerHeaders !== false;
|
|
1465
1649
|
let ctx;
|
|
1466
1650
|
const globalHooks = this.globalHooks;
|
|
@@ -1496,12 +1680,11 @@ export class App {
|
|
|
1496
1680
|
this.assertCrossOriginAllowed(request, requestUrl, method, [...this.globalCorsAllows, ...this.corsOriginAllows]);
|
|
1497
1681
|
}
|
|
1498
1682
|
if (!match || internalHidden) {
|
|
1499
|
-
const url404 = getUrl();
|
|
1500
1683
|
if (internalHidden) {
|
|
1501
1684
|
// Don't leak existence via 405/Allow header. Always 404.
|
|
1502
|
-
throw new NotFoundError(`No route for ${request.method} ${
|
|
1685
|
+
throw new NotFoundError(`No route for ${request.method} ${pathname}`);
|
|
1503
1686
|
}
|
|
1504
|
-
const rawAllowed = this.router.allowedMethods(
|
|
1687
|
+
const rawAllowed = this.router.allowedMethods(pathname);
|
|
1505
1688
|
// Filter out methods whose route definitions are marked
|
|
1506
1689
|
// `internal: true` unless the caller explicitly opted in via
|
|
1507
1690
|
// app.inject(). This prevents 405/Allow from leaking the
|
|
@@ -1509,19 +1692,52 @@ export class App {
|
|
|
1509
1692
|
const allowed = opts.allowInternal
|
|
1510
1693
|
? rawAllowed
|
|
1511
1694
|
: rawAllowed.filter((m) => {
|
|
1512
|
-
const candidate = this.router.find(m,
|
|
1695
|
+
const candidate = this.router.find(m, pathname);
|
|
1513
1696
|
return candidate?.handler.def.internal !== true;
|
|
1514
1697
|
});
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1698
|
+
// On the throw paths (405 -> MethodNotAllowedError, 404 -> NotFoundError)
|
|
1699
|
+
// ctx is only read by a registered onError hook. Build it lazily so
|
|
1700
|
+
// the common no-hook 404 doesn't allocate a context object, spread
|
|
1701
|
+
// `decorations`, iterate headers, or materialize a `Headers`
|
|
1702
|
+
// instance just to be thrown away. The 204 OPTIONS preflight branch
|
|
1703
|
+
// below uses its own `synthCtx`, so this skip is safe for it too.
|
|
1704
|
+
const needsCtx = allowed.length > 0 && method === "OPTIONS"
|
|
1705
|
+
? false // OPTIONS path builds synthCtx
|
|
1706
|
+
: activeErrorHook !== undefined;
|
|
1707
|
+
if (needsCtx) {
|
|
1708
|
+
// `query` and `headers` are materialized lazily — the common
|
|
1709
|
+
// `onError` hook reads `requestId` / path and never touches them,
|
|
1710
|
+
// so we skip `new URL(...)` + `Object.fromEntries` + the
|
|
1711
|
+
// `Headers.forEach` on 404 GETs entirely. Setters preserve write
|
|
1712
|
+
// semantics for hooks that reassign these fields.
|
|
1713
|
+
let _query;
|
|
1714
|
+
let _headers;
|
|
1715
|
+
const reqRef = request;
|
|
1716
|
+
const reqUrl = requestUrl;
|
|
1717
|
+
ctx = {
|
|
1718
|
+
request,
|
|
1719
|
+
params: {},
|
|
1720
|
+
get query() {
|
|
1721
|
+
if (_query !== undefined)
|
|
1722
|
+
return _query;
|
|
1723
|
+
const qi = reqUrl.indexOf("?");
|
|
1724
|
+
if (qi === -1)
|
|
1725
|
+
return (_query = {});
|
|
1726
|
+
const hi = reqUrl.indexOf("#", qi + 1);
|
|
1727
|
+
const qs = hi === -1 ? reqUrl.slice(qi + 1) : reqUrl.slice(qi + 1, hi);
|
|
1728
|
+
return (_query = Object.fromEntries(new URLSearchParams(qs)));
|
|
1729
|
+
},
|
|
1730
|
+
set query(v) { _query = v; },
|
|
1731
|
+
get headers() {
|
|
1732
|
+
return (_headers ??= headersToObject(reqRef.headers));
|
|
1733
|
+
},
|
|
1734
|
+
set headers(v) { _headers = v; },
|
|
1735
|
+
body: undefined,
|
|
1736
|
+
state: { ...this.decorations, requestId, log },
|
|
1737
|
+
set: { headers: new Headers() },
|
|
1738
|
+
};
|
|
1739
|
+
ctx.set.headers.set("x-request-id", requestId);
|
|
1740
|
+
}
|
|
1525
1741
|
if (allowed.length > 0) {
|
|
1526
1742
|
if (method === "OPTIONS") {
|
|
1527
1743
|
// Synthesize a preflight: let global hooks (e.g. CORS) intercept;
|
|
@@ -1557,7 +1773,7 @@ export class App {
|
|
|
1557
1773
|
}
|
|
1558
1774
|
throw new MethodNotAllowedError(allowed);
|
|
1559
1775
|
}
|
|
1560
|
-
throw new NotFoundError(`No route for ${request.method} ${
|
|
1776
|
+
throw new NotFoundError(`No route for ${request.method} ${pathname}`);
|
|
1561
1777
|
}
|
|
1562
1778
|
const { def, hooks, mergedHooks: allHooks, hasFinalizeHook } = match.handler;
|
|
1563
1779
|
activeErrorHook = allHooks.onError;
|
|
@@ -1569,10 +1785,24 @@ export class App {
|
|
|
1569
1785
|
await routeOnRequestResult;
|
|
1570
1786
|
}
|
|
1571
1787
|
ctx = await buildContext(request, getUrl, match.params, def, this.options);
|
|
1572
|
-
|
|
1788
|
+
// Stable two-field write keeps `ctx.state`'s hidden class consistent across
|
|
1789
|
+
// requests for the common no-decorator case. The decorations spread only
|
|
1790
|
+
// fires when `app.decorate()` was actually called.
|
|
1791
|
+
const state = ctx.state;
|
|
1792
|
+
state.requestId = requestId;
|
|
1793
|
+
state.log = log;
|
|
1794
|
+
if (this.decorationsCount !== 0)
|
|
1795
|
+
Object.assign(state, this.decorations);
|
|
1573
1796
|
if (allHooks.beforeHandle !== undefined) {
|
|
1574
1797
|
const beforeResult = allHooks.beforeHandle(ctx);
|
|
1575
1798
|
const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
|
|
1799
|
+
// Honor any request id override applied by middleware (e.g. the
|
|
1800
|
+
// `requestId()` Hooks bundle replaces the framework-generated value
|
|
1801
|
+
// with a trusted incoming header or a user-supplied generator).
|
|
1802
|
+
const overriddenId = state.requestId;
|
|
1803
|
+
if (typeof overriddenId === "string" && overriddenId.length > 0) {
|
|
1804
|
+
requestId = overriddenId;
|
|
1805
|
+
}
|
|
1576
1806
|
if (before instanceof Response) {
|
|
1577
1807
|
copyContextHeaders(ctx, before);
|
|
1578
1808
|
if (!before.headers.has("x-request-id"))
|
|
@@ -1597,8 +1827,10 @@ export class App {
|
|
|
1597
1827
|
const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
|
|
1598
1828
|
let response = isPromiseLike(serializeResultRes) ? await serializeResultRes : serializeResultRes;
|
|
1599
1829
|
copyContextHeaders(ctx, response);
|
|
1600
|
-
|
|
1601
|
-
|
|
1830
|
+
// `serializeResult` always builds a fresh Response with no request id —
|
|
1831
|
+
// skip the `has()` probe and set directly. Saves one undici contains()
|
|
1832
|
+
// call per request on the hot path.
|
|
1833
|
+
response.headers.set("x-request-id", requestId);
|
|
1602
1834
|
let finalized;
|
|
1603
1835
|
if (hasFinalizeHook) {
|
|
1604
1836
|
const fin = finalizeResponse(response, ctx, allHooks, stripFingerprint);
|
|
@@ -1617,7 +1849,15 @@ export class App {
|
|
|
1617
1849
|
return finalized;
|
|
1618
1850
|
}
|
|
1619
1851
|
catch (err) {
|
|
1620
|
-
|
|
1852
|
+
// Skip the unconditional `await activeErrorHook?.(...)`: when no
|
|
1853
|
+
// error hook is registered (the common case), `await undefined`
|
|
1854
|
+
// still schedules a microtask. Branching first lets the hot error
|
|
1855
|
+
// path stay synchronous.
|
|
1856
|
+
let handled;
|
|
1857
|
+
if (activeErrorHook !== undefined) {
|
|
1858
|
+
const r = activeErrorHook(err, ctx);
|
|
1859
|
+
handled = isPromiseLike(r) ? await r : r;
|
|
1860
|
+
}
|
|
1621
1861
|
if (handled instanceof Response) {
|
|
1622
1862
|
if (ctx)
|
|
1623
1863
|
copyContextHeaders(ctx, handled);
|
|
@@ -1632,10 +1872,12 @@ export class App {
|
|
|
1632
1872
|
// the request at `disconnectStatusCode` (default 499) instead of
|
|
1633
1873
|
// letting an AbortError bubble up as a generic 5xx. Logged at `info`
|
|
1634
1874
|
// so disconnect storms do not look like service incidents.
|
|
1635
|
-
|
|
1875
|
+
// `err instanceof HttpError` first: the framework's own thrown
|
|
1876
|
+
// problem errors short-circuit before any signal/option lookup.
|
|
1877
|
+
const isHttp = err instanceof HttpError;
|
|
1878
|
+
const disconnectCode = isHttp ? 0 : (this.options.disconnectStatusCode ?? 499);
|
|
1636
1879
|
if (disconnectCode > 0 &&
|
|
1637
|
-
request.signal?.aborted === true
|
|
1638
|
-
!(err instanceof HttpError)) {
|
|
1880
|
+
request.signal?.aborted === true) {
|
|
1639
1881
|
log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
|
|
1640
1882
|
const res = new Response(null, {
|
|
1641
1883
|
status: disconnectCode,
|
|
@@ -1651,7 +1893,7 @@ export class App {
|
|
|
1651
1893
|
onResponse: activeResponseHook,
|
|
1652
1894
|
}, stripFingerprint);
|
|
1653
1895
|
}
|
|
1654
|
-
const httpErr =
|
|
1896
|
+
const httpErr = isHttp
|
|
1655
1897
|
? err
|
|
1656
1898
|
: new InternalError(err instanceof Error ? err.message : "Unexpected error");
|
|
1657
1899
|
if (httpErr.status >= 500)
|
|
@@ -2166,37 +2408,39 @@ function pipeline(fns) {
|
|
|
2166
2408
|
});
|
|
2167
2409
|
}
|
|
2168
2410
|
/**
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2411
|
+
* Per-request response-side `set` object. Implemented as a class so every
|
|
2412
|
+
* instance shares one V8 hidden class — the previous {@link Object.defineProperty}
|
|
2413
|
+
* based factory installed fresh accessor descriptors on every request, which
|
|
2414
|
+
* forced V8 to treat each `ctx.set` as a unique shape and tanked inline-cache
|
|
2415
|
+
* sharing in `copyContextHeaders` and downstream hooks.
|
|
2416
|
+
*
|
|
2417
|
+
* `_h` is a public-but-underscored slot rather than a `#`-private field so
|
|
2418
|
+
* the compiled output stays target-agnostic; consumer code that reads
|
|
2419
|
+
* `ctx.set.headers` flows through the prototype getter and never sees it.
|
|
2420
|
+
* `touched` replaces the prior `SET_HEADERS_TOUCHED` symbol — same intent,
|
|
2421
|
+
* stable field offset.
|
|
2174
2422
|
*/
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
h = new Headers();
|
|
2183
|
-
set[SET_HEADERS_TOUCHED] = true;
|
|
2184
|
-
}
|
|
2423
|
+
class LazyResponseSet {
|
|
2424
|
+
status = undefined;
|
|
2425
|
+
_h = undefined;
|
|
2426
|
+
touched = false;
|
|
2427
|
+
get headers() {
|
|
2428
|
+
const h = this._h;
|
|
2429
|
+
if (h !== undefined)
|
|
2185
2430
|
return h;
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
});
|
|
2194
|
-
return set;
|
|
2431
|
+
this.touched = true;
|
|
2432
|
+
return (this._h = new Headers());
|
|
2433
|
+
}
|
|
2434
|
+
set headers(v) {
|
|
2435
|
+
this._h = v;
|
|
2436
|
+
this.touched = true;
|
|
2437
|
+
}
|
|
2195
2438
|
}
|
|
2196
2439
|
function copyContextHeaders(ctx, res) {
|
|
2197
|
-
|
|
2440
|
+
const set = ctx.set;
|
|
2441
|
+
if (set.touched !== true)
|
|
2198
2442
|
return;
|
|
2199
|
-
|
|
2443
|
+
set._h.forEach((v, k) => {
|
|
2200
2444
|
if (!res.headers.has(k))
|
|
2201
2445
|
res.headers.set(k, v);
|
|
2202
2446
|
});
|
|
@@ -2204,8 +2448,62 @@ function copyContextHeaders(ctx, res) {
|
|
|
2204
2448
|
function hasRequestSchema(request, key) {
|
|
2205
2449
|
return !!request && !!request[key];
|
|
2206
2450
|
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Stable-shape per-request context. All fields are initialised in fixed
|
|
2453
|
+
* order in the constructor so every dispatched request produces an instance
|
|
2454
|
+
* with the same V8 hidden class — replacing the prior object-literal +
|
|
2455
|
+
* {@link Object.defineProperty} pattern, which gave each request a unique
|
|
2456
|
+
* shape and forced inline-cache misses through every downstream hook.
|
|
2457
|
+
*
|
|
2458
|
+
* `query` / `headers` are prototype getters that either return the value
|
|
2459
|
+
* already stored on `_q` / `_h` (set eagerly by schema validation) or
|
|
2460
|
+
* materialise it lazily from the captured builder closure on first read.
|
|
2461
|
+
* The `_qSet` / `_hSet` flags distinguish "validated, value cached" from
|
|
2462
|
+
* "not yet read" so setters from user hooks remain observable.
|
|
2463
|
+
*/
|
|
2464
|
+
class RequestContext {
|
|
2465
|
+
request;
|
|
2466
|
+
params;
|
|
2467
|
+
body = undefined;
|
|
2468
|
+
state;
|
|
2469
|
+
set;
|
|
2470
|
+
_q = undefined;
|
|
2471
|
+
_qBuilder = undefined;
|
|
2472
|
+
_qSet = false;
|
|
2473
|
+
_h = undefined;
|
|
2474
|
+
_hBuilder = undefined;
|
|
2475
|
+
_hSet = false;
|
|
2476
|
+
constructor(request, params, state, set) {
|
|
2477
|
+
this.request = request;
|
|
2478
|
+
this.params = params;
|
|
2479
|
+
this.state = state;
|
|
2480
|
+
this.set = set;
|
|
2481
|
+
}
|
|
2482
|
+
get query() {
|
|
2483
|
+
if (this._qSet)
|
|
2484
|
+
return this._q;
|
|
2485
|
+
const b = this._qBuilder;
|
|
2486
|
+
this._qSet = true;
|
|
2487
|
+
return (this._q = b !== undefined ? b() : undefined);
|
|
2488
|
+
}
|
|
2489
|
+
set query(v) {
|
|
2490
|
+
this._q = v;
|
|
2491
|
+
this._qSet = true;
|
|
2492
|
+
}
|
|
2493
|
+
get headers() {
|
|
2494
|
+
if (this._hSet)
|
|
2495
|
+
return this._h;
|
|
2496
|
+
const b = this._hBuilder;
|
|
2497
|
+
this._hSet = true;
|
|
2498
|
+
return (this._h = b !== undefined ? b() : undefined);
|
|
2499
|
+
}
|
|
2500
|
+
set headers(v) {
|
|
2501
|
+
this._h = v;
|
|
2502
|
+
this._hSet = true;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2207
2505
|
function buildContext(request, getUrl, rawParams, def, opts) {
|
|
2208
|
-
const set =
|
|
2506
|
+
const set = new LazyResponseSet();
|
|
2209
2507
|
const hasHeadersSchema = !!def.request?.headers;
|
|
2210
2508
|
const hasQuerySchema = !!def.request?.query;
|
|
2211
2509
|
let headersObj;
|
|
@@ -2218,24 +2516,21 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
2218
2516
|
let body = undefined;
|
|
2219
2517
|
const hasSchema = def.request?.params || def.request?.query || def.request?.headers || def.request?.body;
|
|
2220
2518
|
const finishContext = () => {
|
|
2221
|
-
const ctx = {
|
|
2222
|
-
|
|
2223
|
-
params,
|
|
2224
|
-
body,
|
|
2225
|
-
state: {},
|
|
2226
|
-
set,
|
|
2227
|
-
};
|
|
2519
|
+
const ctx = new RequestContext(request, params, {}, set);
|
|
2520
|
+
ctx.body = body;
|
|
2228
2521
|
if (hasQuerySchema) {
|
|
2229
|
-
ctx.
|
|
2522
|
+
ctx._q = query;
|
|
2523
|
+
ctx._qSet = true;
|
|
2230
2524
|
}
|
|
2231
2525
|
else {
|
|
2232
|
-
|
|
2526
|
+
ctx._qBuilder = buildQuery;
|
|
2233
2527
|
}
|
|
2234
2528
|
if (hasHeadersSchema) {
|
|
2235
|
-
ctx.
|
|
2529
|
+
ctx._h = headers;
|
|
2530
|
+
ctx._hSet = true;
|
|
2236
2531
|
}
|
|
2237
2532
|
else {
|
|
2238
|
-
|
|
2533
|
+
ctx._hBuilder = buildHeaders;
|
|
2239
2534
|
}
|
|
2240
2535
|
return ctx;
|
|
2241
2536
|
};
|
|
@@ -2280,25 +2575,6 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
2280
2575
|
return finishContext();
|
|
2281
2576
|
})();
|
|
2282
2577
|
}
|
|
2283
|
-
function defineLazyContextProperty(ctx, key, build) {
|
|
2284
|
-
let initialized = false;
|
|
2285
|
-
let value;
|
|
2286
|
-
Object.defineProperty(ctx, key, {
|
|
2287
|
-
get() {
|
|
2288
|
-
if (!initialized) {
|
|
2289
|
-
value = build();
|
|
2290
|
-
initialized = true;
|
|
2291
|
-
}
|
|
2292
|
-
return value;
|
|
2293
|
-
},
|
|
2294
|
-
set(next) {
|
|
2295
|
-
value = next;
|
|
2296
|
-
initialized = true;
|
|
2297
|
-
},
|
|
2298
|
-
enumerable: true,
|
|
2299
|
-
configurable: true,
|
|
2300
|
-
});
|
|
2301
|
-
}
|
|
2302
2578
|
function headersToObject(h) {
|
|
2303
2579
|
const o = {};
|
|
2304
2580
|
h.forEach((v, k) => {
|
package/dist/errors.js
CHANGED
|
@@ -202,8 +202,12 @@ export class HttpError extends Error {
|
|
|
202
202
|
if (opts.requestId)
|
|
203
203
|
out.instance = `urn:request:${opts.requestId}`;
|
|
204
204
|
const headers = new Headers({ "content-type": "application/problem+json" });
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
// Skip the Object.entries({}) churn on the common case where the
|
|
206
|
+
// error was constructed without extra response headers.
|
|
207
|
+
if (this.headers !== undefined) {
|
|
208
|
+
for (const [name, value] of Object.entries(this.headers)) {
|
|
209
|
+
headers.set(name, value);
|
|
210
|
+
}
|
|
207
211
|
}
|
|
208
212
|
// Merge Context.set.headers (CSRF rotation, session renewal,
|
|
209
213
|
// request-id, secureHeaders output) without overriding headers the
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { createApp } from "./app.js";
|
|
|
3
3
|
export { _resetPackageJsonCacheForTests } from "./app.js";
|
|
4
4
|
export { _resetCrashHandlersForTests } from "./app.js";
|
|
5
5
|
export { _resetInsecureDefaultsLogForTests } from "./app.js";
|
|
6
|
-
export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, HealthRouteOptions, CspReportRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, } from "./app.js";
|
|
6
|
+
export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, HealthRouteOptions, CspReportRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
|
|
7
7
|
export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
|
|
8
8
|
export type { BehindProxyConfig, ConnInfo } from "./conn-info.js";
|
|
9
9
|
export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
|