@daloyjs/core 1.0.0-beta.3 → 1.0.0-beta.5

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
@@ -2,17 +2,17 @@ import { Router } from "./router.js";
2
2
  import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
3
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, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
5
+ import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
6
6
  import { createLogger, noopLogger } from "./logger.js";
7
7
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
8
8
  import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
9
9
  import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.js";
10
10
  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";
11
11
  import { COMPRESSION_HOOK_MARKER } from "./compression.js";
12
- import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
13
- import { loadShedding as loadSheddingMiddleware } from "./load-shedding.js";
12
+ import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
13
+ import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
14
14
  import { httpMetrics, MetricsRegistry, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
15
- import { Scheduler, } from "./scheduler.js";
15
+ import { Scheduler } from "./scheduler.js";
16
16
  import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
17
17
  import { assertBehindProxy } from "./conn-info.js";
18
18
  const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
@@ -260,10 +260,10 @@ export class App {
260
260
  inflight = 0;
261
261
  draining = false;
262
262
  /**
263
- * CORS origin allowlist predicates from the currently active group-level
264
- * hooks. Used for unmatched routes; matched routes use the snapshot stored
265
- * on their compiled route so later `app.use(cors(...))` calls do not
266
- * retroactively loosen earlier routes.
263
+ * CORS origin allowlist predicates from the currently active group-level
264
+ * hooks. Used for unmatched routes; matched routes use the snapshot stored
265
+ * on their compiled route so later `app.use(cors(...))` calls do not
266
+ * retroactively loosen earlier routes.
267
267
  */
268
268
  corsOriginAllows = [];
269
269
  /**
@@ -289,9 +289,9 @@ export class App {
289
289
  */
290
290
  responseBodySchemaAuditDone = false;
291
291
  /**
292
- * Cached merge of `options.hooks` only. Used on the cold 404/405 path
293
- * and as the baseline for cross-origin guard decisions when no route
294
- * matches.
292
+ * Cached merge of `options.hooks` only. Used on the cold 404/405 path
293
+ * and as the baseline for cross-origin guard decisions when no route
294
+ * matches.
295
295
  */
296
296
  _globalHooksCache;
297
297
  _globalCorsAllowsCache;
@@ -307,6 +307,19 @@ export class App {
307
307
  }
308
308
  return this._globalCorsAllowsCache;
309
309
  }
310
+ /**
311
+ * Cached merge of `options.hooks` plus every `app.use()` group hook, used on
312
+ * the cold dispatch path (404, 405, and OPTIONS preflight) so perimeter
313
+ * `beforeHandle` guards registered via `app.use()` still cover requests that
314
+ * match no route.
315
+ */
316
+ _coldPathHooksCache;
317
+ get coldPathHooks() {
318
+ if (this._coldPathHooksCache === undefined) {
319
+ this._coldPathHooksCache = mergeHooks([this.options.hooks ?? {}, ...this.groupHooks]);
320
+ }
321
+ return this._coldPathHooksCache;
322
+ }
310
323
  constructor(options = {}) {
311
324
  const resolved = applySecurityPreset(options);
312
325
  this.options = {
@@ -319,8 +332,7 @@ export class App {
319
332
  this.log =
320
333
  options.logger === false
321
334
  ? noopLogger
322
- : options.logger &&
323
- typeof options.logger.info === "function"
335
+ : options.logger && typeof options.logger.info === "function"
324
336
  ? options.logger
325
337
  : createLogger({ level: options.logger?.level ?? "info" });
326
338
  this.warnOnEnvMismatch();
@@ -373,7 +385,7 @@ export class App {
373
385
  "If you really need this in production, also pass " +
374
386
  "acknowledgeInsecureDefaults: true to confirm. Prefer per-feature opt-outs " +
375
387
  "(secureHeaders: false, corsCrossOriginGuard: false, crashOnUnhandledRejection: false, " +
376
- "trustProxy: false, csrf: \"off\") instead. " +
388
+ 'trustProxy: false, csrf: "off") instead. ' +
377
389
  "See https://daloyjs.dev/docs/security/secure-defaults-enforcement.");
378
390
  }
379
391
  if (!insecureDefaultsLoggedThisProcess) {
@@ -438,9 +450,7 @@ export class App {
438
450
  secureHeaders: o.secureDefaults !== false && o.secureHeaders !== false,
439
451
  corsCrossOriginGuard: o.secureDefaults !== false && o.corsCrossOriginGuard !== false,
440
452
  csrf: o.csrf === "off" ? "off" : "on",
441
- crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
442
- ? "default"
443
- : o.crashOnUnhandledRejection,
453
+ crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined ? "default" : o.crashOnUnhandledRejection,
444
454
  trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
445
455
  bodyLimitBytes: this.options.bodyLimitBytes,
446
456
  requestTimeoutMs: this.options.requestTimeoutMs,
@@ -464,8 +474,7 @@ export class App {
464
474
  if (this.options.secureDefaults === false)
465
475
  return;
466
476
  if (this.options.secureHeaders !== false) {
467
- const opts = this.options.secureHeaders &&
468
- typeof this.options.secureHeaders === "object"
477
+ const opts = this.options.secureHeaders && typeof this.options.secureHeaders === "object"
469
478
  ? this.options.secureHeaders
470
479
  : {};
471
480
  const auto = secureHeadersMiddleware(opts);
@@ -474,9 +483,7 @@ export class App {
474
483
  }
475
484
  // Opt-in load-shedding pressure monitor.
476
485
  if (this.options.loadShedding) {
477
- const lsOpts = typeof this.options.loadShedding === "object"
478
- ? this.options.loadShedding
479
- : {};
486
+ const lsOpts = typeof this.options.loadShedding === "object" ? this.options.loadShedding : {};
480
487
  this.groupHooks.push(loadSheddingMiddleware(lsOpts));
481
488
  }
482
489
  }
@@ -583,10 +590,7 @@ export class App {
583
590
  return;
584
591
  if (this.options.corsCrossOriginGuard === false)
585
592
  return;
586
- if (method !== "POST" &&
587
- method !== "PUT" &&
588
- method !== "PATCH" &&
589
- method !== "DELETE") {
593
+ if (method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") {
590
594
  return;
591
595
  }
592
596
  const origin = request.headers.get("origin");
@@ -623,6 +627,42 @@ export class App {
623
627
  * a misconfigured surface.
624
628
  */
625
629
  assertSecureHookConfig(hooks) {
630
+ // Always-on correctness guard (independent of secureDefaults / environment).
631
+ // A hook bundle must be a single Hooks object. Passing an ARRAY — or any
632
+ // object carrying none of the recognized hook keys — is a silent no-op: the
633
+ // framework reads `.beforeHandle` / `.onSend` / ... off it, finds
634
+ // `undefined`, and applies NOTHING. A route that looks guarded
635
+ // (`hooks: [ipRestriction(...), bearerAuth(...)]`) would then ship wide open.
636
+ // TypeScript already rejects an array literal here; this catches JS callers,
637
+ // spreads, and `as`-casts, where the silent runtime skip is the dangerous part.
638
+ if (hooks !== null && typeof hooks === "object") {
639
+ const HOOK_KEYS = [
640
+ "onRequest",
641
+ "beforeHandle",
642
+ "afterHandle",
643
+ "onError",
644
+ "onSend",
645
+ "onResponse",
646
+ ];
647
+ const carriesAHook = HOOK_KEYS.some((k) => typeof hooks[k] === "function");
648
+ if (!carriesAHook) {
649
+ if (Array.isArray(hooks)) {
650
+ throw new Error("Hooks must be a single Hooks object, not an array. To run multiple " +
651
+ "hook bundles (e.g. ipRestriction + bearerAuth) on one route, compose " +
652
+ "them with every(...) (all must pass) or some(...) (any may pass) from " +
653
+ "@daloyjs/core. Passing an array silently applies NO hooks, leaving the " +
654
+ "route unguarded.");
655
+ }
656
+ if (Object.keys(hooks).length > 0) {
657
+ throw new Error("Hooks object carries none of the recognized hook keys (onRequest, " +
658
+ "beforeHandle, afterHandle, onError, onSend, onResponse), so it would " +
659
+ "silently apply no hooks. To compose multiple hook bundles use " +
660
+ "every(...) / some(...) from @daloyjs/core.");
661
+ }
662
+ // An empty object `{}` carries no hook and makes no false promise of one;
663
+ // it is an explicit no-op, equivalent to omitting `hooks`, and is allowed.
664
+ }
665
+ }
626
666
  if (this.options.secureDefaults === false)
627
667
  return;
628
668
  const record = hooks;
@@ -668,8 +708,7 @@ export class App {
668
708
  * `test`, which is a known, non-production answer).
669
709
  */
670
710
  isEnvIndeterminate() {
671
- if (this.options.env !== undefined ||
672
- this.options.production !== undefined) {
711
+ if (this.options.env !== undefined || this.options.production !== undefined) {
673
712
  return false;
674
713
  }
675
714
  const nodeEnv = typeof process !== "undefined" && typeof process.env !== "undefined"
@@ -881,15 +920,11 @@ export class App {
881
920
  };
882
921
  const generate = async () => generateOpenAPI(this, {
883
922
  info: await resolveInfo(),
884
- ...(this.options.openapi?.servers
885
- ? { servers: this.options.openapi.servers }
886
- : {}),
923
+ ...(this.options.openapi?.servers ? { servers: this.options.openapi.servers } : {}),
887
924
  ...(this.options.openapi?.securitySchemes
888
925
  ? { securitySchemes: this.options.openapi.securitySchemes }
889
926
  : {}),
890
- ...(this.options.openapi?.webhooks
891
- ? { webhooks: this.options.openapi.webhooks }
892
- : {}),
927
+ ...(this.options.openapi?.webhooks ? { webhooks: this.options.openapi.webhooks } : {}),
893
928
  });
894
929
  this.route({
895
930
  method: "GET",
@@ -961,6 +996,7 @@ export class App {
961
996
  title,
962
997
  configuration: opts.swagger,
963
998
  assets: opts.assets,
999
+ auth: opts.auth,
964
1000
  })
965
1001
  : ui === "redoc"
966
1002
  ? redocHtml({
@@ -968,12 +1004,14 @@ export class App {
968
1004
  title,
969
1005
  configuration: opts.redoc,
970
1006
  assets: opts.assets,
1007
+ auth: opts.auth,
971
1008
  })
972
1009
  : scalarHtml({
973
1010
  specUrl: openapiPath,
974
1011
  title,
975
1012
  configuration: scalarConfigurationWithPreferredAuth(opts.scalar, this.options.openapi?.securitySchemes),
976
1013
  assets: opts.assets,
1014
+ auth: opts.auth,
977
1015
  });
978
1016
  return {
979
1017
  status: 200,
@@ -1023,9 +1061,7 @@ export class App {
1023
1061
  */
1024
1062
  mountAsyncAPI(opts) {
1025
1063
  const jsonPath = (opts.jsonPath ?? "/asyncapi.json");
1026
- const yamlPath = opts.yamlPath === false
1027
- ? null
1028
- : (opts.yamlPath ?? "/asyncapi.yaml");
1064
+ const yamlPath = opts.yamlPath === false ? null : (opts.yamlPath ?? "/asyncapi.yaml");
1029
1065
  const uiPath = (opts.path ?? "/asyncapi");
1030
1066
  const tags = opts.tags ?? ["AsyncAPI"];
1031
1067
  const resolveInfo = async () => {
@@ -1203,10 +1239,7 @@ export class App {
1203
1239
  ...corsOriginAllowsFromHooks([globalHookLayer]),
1204
1240
  ...corsOriginAllows,
1205
1241
  ];
1206
- const securityMarkers = securityMarkersFromHooks([
1207
- globalHookLayer,
1208
- ...sources,
1209
- ]);
1242
+ const securityMarkers = securityMarkersFromHooks([globalHookLayer, ...sources]);
1210
1243
  this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
1211
1244
  // `routes` is statically a readonly tuple so the typed client can infer
1212
1245
  // per-route methods; at runtime it is a growable array, so we push through
@@ -1262,7 +1295,7 @@ export class App {
1262
1295
  handler.acknowledgeCrossOriginUpgrade !== true) {
1263
1296
  throw new Error(`app.ws(${JSON.stringify(fullPath)}): production WebSocket routes must ` +
1264
1297
  "guard against Cross-Site WebSocket Hijacking (CSWSH). Set " +
1265
- "{ allowedOrigins: \"same-origin\" } or an explicit origin allowlist, " +
1298
+ '{ allowedOrigins: "same-origin" } or an explicit origin allowlist, ' +
1266
1299
  "or pass { acknowledgeCrossOriginUpgrade: true } for an intentionally " +
1267
1300
  "public route. See https://daloyjs.dev/docs/websocket " +
1268
1301
  "and CVE-2026-27148 (Storybook) for the attack pattern.");
@@ -1332,9 +1365,7 @@ export class App {
1332
1365
  */
1333
1366
  readinesscheck(opts = {}) {
1334
1367
  this.registerHealthRoute("readinesscheck", opts, () => {
1335
- if (this.draining ||
1336
- this.pendingPlugins.size > 0 ||
1337
- this.pluginBootError.failed) {
1368
+ if (this.draining || this.pendingPlugins.size > 0 || this.pluginBootError.failed) {
1338
1369
  return {
1339
1370
  status: 503,
1340
1371
  body: { status: "not-ready" },
@@ -1379,9 +1410,7 @@ export class App {
1379
1410
  metrics(opts = {}) {
1380
1411
  const path = (opts.path ?? "/metrics");
1381
1412
  const registry = opts.registry ?? new MetricsRegistry();
1382
- const rateLimitConfig = opts.rateLimit === false
1383
- ? null
1384
- : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1413
+ const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1385
1414
  const token = opts.token;
1386
1415
  // Refuse-to-boot: an unauthenticated metrics scrape in production is a
1387
1416
  // documented info-disclosure surface (route inventory, latency
@@ -1406,9 +1435,8 @@ export class App {
1406
1435
  buckets: opts.buckets,
1407
1436
  exclude,
1408
1437
  }));
1409
- const buckets = rateLimitConfig
1410
- ? new Map()
1411
- : null;
1438
+ this._coldPathHooksCache = undefined;
1439
+ const buckets = rateLimitConfig ? new Map() : null;
1412
1440
  this.route({
1413
1441
  method: "GET",
1414
1442
  path,
@@ -1512,9 +1540,7 @@ export class App {
1512
1540
  const isHealth = kind === "healthcheck";
1513
1541
  const defaultPath = (isHealth ? "/healthz" : "/readyz");
1514
1542
  const path = (opts.path ?? defaultPath);
1515
- const rateLimitConfig = opts.rateLimit === false
1516
- ? null
1517
- : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1543
+ const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1518
1544
  const token = opts.token;
1519
1545
  // Refuse-to-boot: unauthenticated health/ready probes in
1520
1546
  // production are a documented info-disclosure surface (process uptime,
@@ -1528,9 +1554,7 @@ export class App {
1528
1554
  `Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
1529
1555
  `to acknowledge that this probe is reachable without credentials.`);
1530
1556
  }
1531
- const buckets = rateLimitConfig
1532
- ? new Map()
1533
- : null;
1557
+ const buckets = rateLimitConfig ? new Map() : null;
1534
1558
  this.route({
1535
1559
  method: "GET",
1536
1560
  path,
@@ -1587,8 +1611,8 @@ export class App {
1587
1611
  * when omitted). Returns `204 No Content` so browsers stop retrying.
1588
1612
  *
1589
1613
  * Combine with `secureHeaders({ reportingEndpoints, reportTo })` to wire
1590
- * the browser to this endpoint. The route is registered as publicly
1591
- * reachable; that is required for the browser
1614
+ * the browser to this endpoint. The route is registered as publicly
1615
+ * reachable; that is required for the browser
1592
1616
  * Reporting API to send to it.
1593
1617
  *
1594
1618
  */
@@ -1601,12 +1625,8 @@ export class App {
1601
1625
  if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > HARD_MAX) {
1602
1626
  throw new Error(`cspReportRoute(): maxBodyBytes must be a positive integer <= ${HARD_MAX}.`);
1603
1627
  }
1604
- const rateLimitConfig = opts.rateLimit === false
1605
- ? null
1606
- : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1607
- const buckets = rateLimitConfig
1608
- ? new Map()
1609
- : null;
1628
+ const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1629
+ const buckets = rateLimitConfig ? new Map() : null;
1610
1630
  const log = this.log;
1611
1631
  // Only log report bodies when explicitly enabled. In
1612
1632
  // production this is opt-in; in development the body is included by
@@ -1723,10 +1743,7 @@ export class App {
1723
1743
  child.bootGuard = this.bootGuard;
1724
1744
  child.log = this.log;
1725
1745
  child.prefix = joinPath(this.prefix, prefix);
1726
- child.groupHooks = [
1727
- ...this.groupHooks,
1728
- ...(config.hooks ? [config.hooks] : []),
1729
- ];
1746
+ child.groupHooks = [...this.groupHooks, ...(config.hooks ? [config.hooks] : [])];
1730
1747
  child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
1731
1748
  child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
1732
1749
  child.groupAuth = config.auth ?? this.groupAuth;
@@ -1770,12 +1787,12 @@ export class App {
1770
1787
  // "set only if absent" semantics mean the second installation would be
1771
1788
  // a silent no-op).
1772
1789
  if (hooks[SECURE_HEADERS_MARKER] === true) {
1773
- const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
1774
- true);
1790
+ const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] === true);
1775
1791
  if (autoIdx >= 0)
1776
1792
  this.groupHooks.splice(autoIdx, 1);
1777
1793
  }
1778
1794
  this.groupHooks.push(hooks);
1795
+ this._coldPathHooksCache = undefined;
1779
1796
  if (hooks[CORS_HOOK_MARKER] === true) {
1780
1797
  this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
1781
1798
  }
@@ -1818,10 +1835,10 @@ export class App {
1818
1835
  const hooks = { [ext.event]: ext.handler };
1819
1836
  this.groupHooks.push(hooks);
1820
1837
  }
1838
+ this._coldPathHooksCache = undefined;
1821
1839
  }
1822
1840
  decorate(key, value, opts = {}) {
1823
- if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
1824
- opts.override !== true) {
1841
+ if (Object.prototype.hasOwnProperty.call(this.decorations, key) && opts.override !== true) {
1825
1842
  // Namespace-protected decorators. Refuse to silently
1826
1843
  // shadow an existing decoration; emit a once-per-process warn naming
1827
1844
  // both decorators on the explicit-override path.
@@ -1969,9 +1986,7 @@ export class App {
1969
1986
  this.log.error({ err, plugin: event.name }, "onPluginInstalled listener failed");
1970
1987
  }
1971
1988
  }
1972
- return promises.length > 0
1973
- ? Promise.all(promises).then(() => undefined)
1974
- : undefined;
1989
+ return promises.length > 0 ? Promise.all(promises).then(() => undefined) : undefined;
1975
1990
  }
1976
1991
  /**
1977
1992
  * Wait until every async plugin registered with {@link App.register} has
@@ -2108,7 +2123,10 @@ export class App {
2108
2123
  this.assertCrossOriginAllowed(request, requestUrl, method, match.handler.fullCorsOriginAllows);
2109
2124
  }
2110
2125
  else {
2111
- this.assertCrossOriginAllowed(request, requestUrl, method, [...this.globalCorsAllows, ...this.corsOriginAllows]);
2126
+ this.assertCrossOriginAllowed(request, requestUrl, method, [
2127
+ ...this.globalCorsAllows,
2128
+ ...this.corsOriginAllows,
2129
+ ]);
2112
2130
  }
2113
2131
  if (!match || internalHidden) {
2114
2132
  if (internalHidden) {
@@ -2132,9 +2150,10 @@ export class App {
2132
2150
  // `decorations`, iterate headers, or materialize a `Headers`
2133
2151
  // instance just to be thrown away. The 204 OPTIONS preflight branch
2134
2152
  // below uses its own `synthCtx`, so this skip is safe for it too.
2153
+ const coldGuards = method === "OPTIONS" ? undefined : this.coldPathHooks.beforeHandle;
2135
2154
  const needsCtx = allowed.length > 0 && method === "OPTIONS"
2136
2155
  ? false // OPTIONS path builds synthCtx
2137
- : activeErrorHook !== undefined;
2156
+ : activeErrorHook !== undefined || coldGuards !== undefined;
2138
2157
  if (needsCtx) {
2139
2158
  // `query` and `headers` are materialized lazily — the common
2140
2159
  // `onError` hook reads `requestId` / path and never touches them,
@@ -2158,20 +2177,36 @@ export class App {
2158
2177
  const qs = hi === -1 ? reqUrl.slice(qi + 1) : reqUrl.slice(qi + 1, hi);
2159
2178
  return (_query = Object.fromEntries(new URLSearchParams(qs)));
2160
2179
  },
2161
- set query(v) { _query = v; },
2180
+ set query(v) {
2181
+ _query = v;
2182
+ },
2162
2183
  get headers() {
2163
2184
  return (_headers ??= headersToObject(reqRef.headers));
2164
2185
  },
2165
- set headers(v) { _headers = v; },
2186
+ set headers(v) {
2187
+ _headers = v;
2188
+ },
2166
2189
  body: undefined,
2167
2190
  state: { ...this.decorations, requestId, log },
2168
- set: { headers: new Headers() },
2191
+ set: new LazyResponseSet(),
2169
2192
  // Cast through `unknown`: this is a deliberately minimal bootstrap
2170
2193
  // context for the cold 404 path (no user state populated yet), so
2171
2194
  // it must compile even when a consumer augments `AppState`.
2172
2195
  };
2173
2196
  ctx.set.headers.set("x-request-id", requestId);
2174
2197
  }
2198
+ if (coldGuards !== undefined) {
2199
+ const guardResult = coldGuards(ctx);
2200
+ const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
2201
+ if (guarded instanceof Response) {
2202
+ copyContextHeaders(ctx, guarded);
2203
+ if (!guarded.headers.has("x-request-id")) {
2204
+ guarded.headers.set("x-request-id", requestId);
2205
+ }
2206
+ const fin = finalizeResponse(guarded, ctx, this.coldPathHooks, stripFingerprint);
2207
+ return isPromiseLike(fin) ? await fin : fin;
2208
+ }
2209
+ }
2175
2210
  if (allowed.length > 0) {
2176
2211
  if (method === "OPTIONS") {
2177
2212
  // Synthesize a preflight: let global hooks (e.g. CORS) intercept;
@@ -2185,14 +2220,13 @@ export class App {
2185
2220
  headers: headersToObject(request.headers),
2186
2221
  body: undefined,
2187
2222
  state: { ...this.decorations, requestId, log },
2188
- set: { headers: new Headers() },
2223
+ set: new LazyResponseSet(),
2189
2224
  };
2190
- const preflightHooks = mergeHooks([
2191
- this.options.hooks ?? {},
2192
- ...this.groupHooks,
2193
- ]);
2225
+ const preflightHooks = this.coldPathHooks;
2194
2226
  const interceptedResult = preflightHooks.beforeHandle?.(synthCtx);
2195
- const intercepted = isPromiseLike(interceptedResult) ? await interceptedResult : interceptedResult;
2227
+ const intercepted = isPromiseLike(interceptedResult)
2228
+ ? await interceptedResult
2229
+ : interceptedResult;
2196
2230
  if (intercepted instanceof Response) {
2197
2231
  copyContextHeaders(synthCtx, intercepted);
2198
2232
  const fin = finalizeResponse(intercepted, synthCtx, preflightHooks, stripFingerprint);
@@ -2260,8 +2294,41 @@ export class App {
2260
2294
  if (afterReturn !== undefined)
2261
2295
  result = afterReturn;
2262
2296
  }
2297
+ // Escape hatch: a handler (or an `afterHandle` transform) may return a
2298
+ // raw web-standard `Response` — an AI SDK stream, a forwarded upstream
2299
+ // response, or any pre-built body that no response schema can describe.
2300
+ // It bypasses response-schema validation by design, but is finalized
2301
+ // through the exact same path as every other response (and as the
2302
+ // `beforeHandle` `Response` passthrough above), so no security control
2303
+ // is skipped: `ctx.set` headers (secureHeaders / CORS) are copied on, the
2304
+ // request id is added when absent, `onSend` / `onResponse` hooks run,
2305
+ // fingerprint headers are stripped, and `HEAD` yields an empty body.
2306
+ if (result instanceof Response) {
2307
+ copyContextHeaders(ctx, result);
2308
+ if (!result.headers.has("x-request-id")) {
2309
+ result.headers.set("x-request-id", requestId);
2310
+ }
2311
+ let finalizedRaw;
2312
+ if (hasFinalizeHook) {
2313
+ const fin = finalizeResponse(result, ctx, allHooks, stripFingerprint);
2314
+ finalizedRaw = isPromiseLike(fin) ? await fin : fin;
2315
+ }
2316
+ else {
2317
+ finalizedRaw = finalizeFast(result, stripFingerprint);
2318
+ }
2319
+ if (method === "HEAD") {
2320
+ return new Response(null, {
2321
+ status: finalizedRaw.status,
2322
+ statusText: finalizedRaw.statusText,
2323
+ headers: finalizedRaw.headers,
2324
+ });
2325
+ }
2326
+ return finalizedRaw;
2327
+ }
2263
2328
  const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
2264
- let response = isPromiseLike(serializeResultRes) ? await serializeResultRes : serializeResultRes;
2329
+ let response = isPromiseLike(serializeResultRes)
2330
+ ? await serializeResultRes
2331
+ : serializeResultRes;
2265
2332
  copyContextHeaders(ctx, response);
2266
2333
  // `serializeResult` always builds a fresh Response with no request id —
2267
2334
  // skip the `has()` probe and set directly. Saves one undici contains()
@@ -2312,8 +2379,7 @@ export class App {
2312
2379
  // problem errors short-circuit before any signal/option lookup.
2313
2380
  const isHttp = err instanceof HttpError;
2314
2381
  const disconnectCode = isHttp ? 0 : (this.options.disconnectStatusCode ?? 499);
2315
- if (disconnectCode > 0 &&
2316
- request.signal?.aborted === true) {
2382
+ if (disconnectCode > 0 && request.signal?.aborted === true) {
2317
2383
  log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
2318
2384
  const res = new Response(null, {
2319
2385
  status: disconnectCode,
@@ -2370,9 +2436,7 @@ export class App {
2370
2436
  * @returns Fulfills with the `Response` produced by the matching handler.
2371
2437
  */
2372
2438
  request(input, init) {
2373
- const url = typeof input === "string" && input.startsWith("/")
2374
- ? `http://test.local${input}`
2375
- : input;
2439
+ const url = typeof input === "string" && input.startsWith("/") ? `http://test.local${input}` : input;
2376
2440
  const req = url instanceof Request ? url : new Request(url, init);
2377
2441
  return this.fetch(req);
2378
2442
  }
@@ -2527,9 +2591,7 @@ function healthRouteKey(request) {
2527
2591
  // so even apps that trust forwarded headers should not let an attacker
2528
2592
  // bypass the per-IP cap by spoofing the header. Fall back to a constant
2529
2593
  // key when no proxy header is available (single shared bucket).
2530
- return (request.headers.get("x-real-ip") ??
2531
- request.headers.get("fly-client-ip") ??
2532
- "global");
2594
+ return request.headers.get("x-real-ip") ?? request.headers.get("fly-client-ip") ?? "global";
2533
2595
  }
2534
2596
  function corsOriginAllowsFromHooks(layers) {
2535
2597
  const allows = [];
@@ -2643,9 +2705,7 @@ export function topoSortExtensions(exts) {
2643
2705
  const bHeaders = b.responseHeaders;
2644
2706
  if (!bHeaders || bHeaders.length === 0)
2645
2707
  continue;
2646
- const overlap = bHeaders
2647
- .map((h) => h.toLowerCase())
2648
- .filter((h) => aSet.has(h));
2708
+ const overlap = bHeaders.map((h) => h.toLowerCase()).filter((h) => aSet.has(h));
2649
2709
  if (overlap.length === 0)
2650
2710
  continue;
2651
2711
  const declared = (a.before ?? []).includes(b.name) ||
@@ -2676,10 +2736,7 @@ function securityMarkersFromHooks(layers) {
2676
2736
  return { hasSession, hasCsrf };
2677
2737
  }
2678
2738
  function isStateChangingMethod(method) {
2679
- return (method === "POST" ||
2680
- method === "PUT" ||
2681
- method === "PATCH" ||
2682
- method === "DELETE");
2739
+ return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
2683
2740
  }
2684
2741
  /**
2685
2742
  * Extract the pathname from a fully-qualified request URL without
@@ -2710,9 +2767,7 @@ function getPathnameFast(url) {
2710
2767
  return url.slice(pathStart, end);
2711
2768
  }
2712
2769
  function mergeHooks(layers) {
2713
- const pick = (key) => layers
2714
- .map((h) => h[key])
2715
- .filter((f) => typeof f === "function");
2770
+ const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
2716
2771
  const requiredScopes = requiredScopesFromHooks(layers);
2717
2772
  const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
2718
2773
  const hooks = {
@@ -2813,9 +2868,7 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
2813
2868
  return {
2814
2869
  ...(configuration ?? {}),
2815
2870
  authentication: {
2816
- ...(authentication &&
2817
- typeof authentication === "object" &&
2818
- !Array.isArray(authentication)
2871
+ ...(authentication && typeof authentication === "object" && !Array.isArray(authentication)
2819
2872
  ? authentication
2820
2873
  : {}),
2821
2874
  preferredSecurityScheme,
@@ -3105,7 +3158,8 @@ function buildContext(request, getUrl, rawParams, def, opts) {
3105
3158
  }
3106
3159
  if (def.request?.body) {
3107
3160
  const ct = (request.headers.get("content-type") ?? "").toLowerCase();
3108
- const allowed = def.accepts ?? opts.allowedContentTypes ?? [
3161
+ const allowed = def.accepts ??
3162
+ opts.allowedContentTypes ?? [
3109
3163
  "application/json",
3110
3164
  "application/x-www-form-urlencoded",
3111
3165
  "multipart/form-data",
@@ -3147,7 +3201,7 @@ function toIssues(issues) {
3147
3201
  return issues.map((i) => ({
3148
3202
  message: i.message,
3149
3203
  path: (i.path ?? [])
3150
- .map((p) => typeof p === "object" && p && "key" in p ? p.key : p)
3204
+ .map((p) => (typeof p === "object" && p && "key" in p ? p.key : p))
3151
3205
  .join("."),
3152
3206
  }));
3153
3207
  }
@@ -3204,8 +3258,7 @@ async function readBody(req, ct, limit, multipart) {
3204
3258
  typeof v.arrayBuffer === "function";
3205
3259
  if (isFile) {
3206
3260
  files++;
3207
- if (multipart?.maxFileBytes !== undefined &&
3208
- v.size > multipart.maxFileBytes) {
3261
+ if (multipart?.maxFileBytes !== undefined && v.size > multipart.maxFileBytes) {
3209
3262
  throw new PayloadTooLargeError(multipart.maxFileBytes);
3210
3263
  }
3211
3264
  }
@@ -3444,9 +3497,7 @@ function readHostPackageJsonInfo() {
3444
3497
  // deno.jsonc allows // line comments and /* block */ comments. Strip
3445
3498
  // them before parsing — naively, but well enough for typical manifests.
3446
3499
  const text = allowComments
3447
- ? raw
3448
- .replace(/\/\*[\s\S]*?\*\//g, "")
3449
- .replace(/(^|[^:\\])\/\/.*$/gm, "$1")
3500
+ ? raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\])\/\/.*$/gm, "$1")
3450
3501
  : raw;
3451
3502
  return JSON.parse(text);
3452
3503
  };