@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/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: options.validateResponses ?? DEFAULTS.validateResponses,
216
- bodyLimitBytes: options.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
217
- requestTimeoutMs: options.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
218
- ...options,
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
- return this.options.env === "production";
388
- if (this.options.production !== undefined)
389
- return this.options.production;
390
- return (typeof process !== "undefined" &&
391
- typeof process.env !== "undefined" &&
392
- process.env.NODE_ENV === "production");
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
- const requestId = randomId();
1459
- const log = this.log.child({
1460
- requestId,
1461
- method: request.method,
1462
- url: request.url,
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} ${url404.pathname}`);
1685
+ throw new NotFoundError(`No route for ${request.method} ${pathname}`);
1503
1686
  }
1504
- const rawAllowed = this.router.allowedMethods(url404.pathname);
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, url404.pathname);
1695
+ const candidate = this.router.find(m, pathname);
1513
1696
  return candidate?.handler.def.internal !== true;
1514
1697
  });
1515
- ctx = {
1516
- request,
1517
- params: {},
1518
- query: Object.fromEntries(url404.searchParams.entries()),
1519
- headers: headersToObject(request.headers),
1520
- body: undefined,
1521
- state: { ...this.decorations, requestId, log },
1522
- set: { headers: new Headers() },
1523
- };
1524
- ctx.set.headers.set("x-request-id", requestId);
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} ${url404.pathname}`);
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
- Object.assign(ctx.state, this.decorations, { requestId, log });
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
- if (!response.headers.has("x-request-id"))
1601
- response.headers.set("x-request-id", requestId);
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
- const handled = await activeErrorHook?.(err, ctx);
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
- const disconnectCode = this.options.disconnectStatusCode ?? 499;
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 = err instanceof HttpError
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
- * Marker stamped on `ctx.set` once the `headers` getter has been
2170
- * materialised. {@link copyContextHeaders} consults this to skip the
2171
- * forEach loop entirely on requests where no middleware or handler ever
2172
- * touched `ctx.set.headers` the default case for the no-middleware
2173
- * benchmark.
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
- const SET_HEADERS_TOUCHED = Symbol.for("daloyjs.app.setHeadersTouched");
2176
- function makeLazySet() {
2177
- let h;
2178
- const set = {};
2179
- Object.defineProperty(set, "headers", {
2180
- get() {
2181
- if (h === undefined) {
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
- set(v) {
2188
- h = v;
2189
- set[SET_HEADERS_TOUCHED] = true;
2190
- },
2191
- enumerable: true,
2192
- configurable: true,
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
- if (!ctx.set[SET_HEADERS_TOUCHED])
2440
+ const set = ctx.set;
2441
+ if (set.touched !== true)
2198
2442
  return;
2199
- ctx.set.headers.forEach((v, k) => {
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 = makeLazySet();
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
- request,
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.query = query;
2522
+ ctx._q = query;
2523
+ ctx._qSet = true;
2230
2524
  }
2231
2525
  else {
2232
- defineLazyContextProperty(ctx, "query", buildQuery);
2526
+ ctx._qBuilder = buildQuery;
2233
2527
  }
2234
2528
  if (hasHeadersSchema) {
2235
- ctx.headers = headers;
2529
+ ctx._h = headers;
2530
+ ctx._hSet = true;
2236
2531
  }
2237
2532
  else {
2238
- defineLazyContextProperty(ctx, "headers", buildHeaders);
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
- for (const [name, value] of Object.entries(this.headers ?? {})) {
206
- headers.set(name, value);
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";