@daloyjs/core 0.35.0 → 0.35.2

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.
Files changed (188) hide show
  1. package/README.md +145 -29
  2. package/dist/adapters/bun.d.ts +0 -1
  3. package/dist/adapters/bun.js +0 -1
  4. package/dist/adapters/cloudflare.d.ts +0 -1
  5. package/dist/adapters/cloudflare.js +0 -1
  6. package/dist/adapters/deno.d.ts +0 -1
  7. package/dist/adapters/deno.js +0 -1
  8. package/dist/adapters/fastly.d.ts +0 -1
  9. package/dist/adapters/fastly.js +0 -1
  10. package/dist/adapters/lambda.d.ts +0 -1
  11. package/dist/adapters/lambda.js +0 -1
  12. package/dist/adapters/node.d.ts +11 -1
  13. package/dist/adapters/node.js +246 -44
  14. package/dist/adapters/vercel.d.ts +0 -1
  15. package/dist/adapters/vercel.js +0 -1
  16. package/dist/app.d.ts +52 -1
  17. package/dist/app.js +557 -146
  18. package/dist/banner.d.ts +0 -1
  19. package/dist/banner.js +0 -1
  20. package/dist/cli.d.ts +0 -1
  21. package/dist/cli.js +0 -1
  22. package/dist/client.d.ts +0 -1
  23. package/dist/client.js +0 -1
  24. package/dist/combine.d.ts +0 -1
  25. package/dist/combine.js +0 -1
  26. package/dist/compression.d.ts +0 -1
  27. package/dist/compression.js +0 -1
  28. package/dist/config.d.ts +0 -1
  29. package/dist/config.js +0 -1
  30. package/dist/conn-info.d.ts +0 -1
  31. package/dist/conn-info.js +0 -1
  32. package/dist/contract.d.ts +0 -1
  33. package/dist/contract.js +0 -1
  34. package/dist/cookie.d.ts +0 -1
  35. package/dist/cookie.js +0 -1
  36. package/dist/dependency.d.ts +0 -1
  37. package/dist/dependency.js +0 -1
  38. package/dist/discriminator.d.ts +0 -1
  39. package/dist/discriminator.js +0 -1
  40. package/dist/docs.d.ts +0 -1
  41. package/dist/docs.js +0 -1
  42. package/dist/errors.d.ts +0 -1
  43. package/dist/errors.js +6 -3
  44. package/dist/etag.d.ts +0 -1
  45. package/dist/etag.js +0 -1
  46. package/dist/fetch-guard.d.ts +0 -1
  47. package/dist/fetch-guard.js +0 -1
  48. package/dist/hashing.d.ts +0 -1
  49. package/dist/hashing.js +0 -1
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.js +0 -1
  52. package/dist/ip-restriction.d.ts +0 -1
  53. package/dist/ip-restriction.js +0 -1
  54. package/dist/jwk.d.ts +0 -1
  55. package/dist/jwk.js +0 -1
  56. package/dist/jwt.d.ts +0 -1
  57. package/dist/jwt.js +0 -1
  58. package/dist/load-shedding.d.ts +0 -1
  59. package/dist/load-shedding.js +0 -1
  60. package/dist/logger.d.ts +0 -1
  61. package/dist/logger.js +0 -1
  62. package/dist/middleware.d.ts +0 -1
  63. package/dist/middleware.js +16 -13
  64. package/dist/multipart.d.ts +0 -1
  65. package/dist/multipart.js +0 -1
  66. package/dist/openapi.d.ts +0 -1
  67. package/dist/openapi.js +0 -1
  68. package/dist/rate-limit-redis.d.ts +0 -1
  69. package/dist/rate-limit-redis.js +0 -1
  70. package/dist/router.d.ts +0 -1
  71. package/dist/router.js +0 -1
  72. package/dist/safe-redirect.d.ts +2 -3
  73. package/dist/safe-redirect.js +2 -3
  74. package/dist/sbom.cdx.json +9 -9
  75. package/dist/sbom.spdx.json +5 -5
  76. package/dist/schema.d.ts +0 -1
  77. package/dist/schema.js +0 -1
  78. package/dist/security-schemes.d.ts +0 -1
  79. package/dist/security-schemes.js +0 -1
  80. package/dist/security.d.ts +0 -16
  81. package/dist/security.js +32 -4
  82. package/dist/session.d.ts +0 -1
  83. package/dist/session.js +0 -1
  84. package/dist/streaming.d.ts +0 -1
  85. package/dist/streaming.js +0 -1
  86. package/dist/subdomains.d.ts +0 -1
  87. package/dist/subdomains.js +0 -1
  88. package/dist/time-claims.d.ts +0 -1
  89. package/dist/time-claims.js +0 -1
  90. package/dist/tracing.d.ts +0 -1
  91. package/dist/tracing.js +0 -1
  92. package/dist/types.d.ts +0 -1
  93. package/dist/types.js +0 -1
  94. package/dist/websocket.d.ts +0 -1
  95. package/dist/websocket.js +0 -1
  96. package/package.json +6 -2
  97. package/dist/adapters/bun.d.ts.map +0 -1
  98. package/dist/adapters/bun.js.map +0 -1
  99. package/dist/adapters/cloudflare.d.ts.map +0 -1
  100. package/dist/adapters/cloudflare.js.map +0 -1
  101. package/dist/adapters/deno.d.ts.map +0 -1
  102. package/dist/adapters/deno.js.map +0 -1
  103. package/dist/adapters/fastly.d.ts.map +0 -1
  104. package/dist/adapters/fastly.js.map +0 -1
  105. package/dist/adapters/lambda.d.ts.map +0 -1
  106. package/dist/adapters/lambda.js.map +0 -1
  107. package/dist/adapters/node.d.ts.map +0 -1
  108. package/dist/adapters/node.js.map +0 -1
  109. package/dist/adapters/vercel.d.ts.map +0 -1
  110. package/dist/adapters/vercel.js.map +0 -1
  111. package/dist/app.d.ts.map +0 -1
  112. package/dist/app.js.map +0 -1
  113. package/dist/banner.d.ts.map +0 -1
  114. package/dist/banner.js.map +0 -1
  115. package/dist/cli.d.ts.map +0 -1
  116. package/dist/cli.js.map +0 -1
  117. package/dist/client.d.ts.map +0 -1
  118. package/dist/client.js.map +0 -1
  119. package/dist/combine.d.ts.map +0 -1
  120. package/dist/combine.js.map +0 -1
  121. package/dist/compression.d.ts.map +0 -1
  122. package/dist/compression.js.map +0 -1
  123. package/dist/config.d.ts.map +0 -1
  124. package/dist/config.js.map +0 -1
  125. package/dist/conn-info.d.ts.map +0 -1
  126. package/dist/conn-info.js.map +0 -1
  127. package/dist/contract.d.ts.map +0 -1
  128. package/dist/contract.js.map +0 -1
  129. package/dist/cookie.d.ts.map +0 -1
  130. package/dist/cookie.js.map +0 -1
  131. package/dist/dependency.d.ts.map +0 -1
  132. package/dist/dependency.js.map +0 -1
  133. package/dist/discriminator.d.ts.map +0 -1
  134. package/dist/discriminator.js.map +0 -1
  135. package/dist/docs.d.ts.map +0 -1
  136. package/dist/docs.js.map +0 -1
  137. package/dist/errors.d.ts.map +0 -1
  138. package/dist/errors.js.map +0 -1
  139. package/dist/etag.d.ts.map +0 -1
  140. package/dist/etag.js.map +0 -1
  141. package/dist/fetch-guard.d.ts.map +0 -1
  142. package/dist/fetch-guard.js.map +0 -1
  143. package/dist/hashing.d.ts.map +0 -1
  144. package/dist/hashing.js.map +0 -1
  145. package/dist/index.d.ts.map +0 -1
  146. package/dist/index.js.map +0 -1
  147. package/dist/ip-restriction.d.ts.map +0 -1
  148. package/dist/ip-restriction.js.map +0 -1
  149. package/dist/jwk.d.ts.map +0 -1
  150. package/dist/jwk.js.map +0 -1
  151. package/dist/jwt.d.ts.map +0 -1
  152. package/dist/jwt.js.map +0 -1
  153. package/dist/load-shedding.d.ts.map +0 -1
  154. package/dist/load-shedding.js.map +0 -1
  155. package/dist/logger.d.ts.map +0 -1
  156. package/dist/logger.js.map +0 -1
  157. package/dist/middleware.d.ts.map +0 -1
  158. package/dist/middleware.js.map +0 -1
  159. package/dist/multipart.d.ts.map +0 -1
  160. package/dist/multipart.js.map +0 -1
  161. package/dist/openapi.d.ts.map +0 -1
  162. package/dist/openapi.js.map +0 -1
  163. package/dist/rate-limit-redis.d.ts.map +0 -1
  164. package/dist/rate-limit-redis.js.map +0 -1
  165. package/dist/router.d.ts.map +0 -1
  166. package/dist/router.js.map +0 -1
  167. package/dist/safe-redirect.d.ts.map +0 -1
  168. package/dist/safe-redirect.js.map +0 -1
  169. package/dist/schema.d.ts.map +0 -1
  170. package/dist/schema.js.map +0 -1
  171. package/dist/security-schemes.d.ts.map +0 -1
  172. package/dist/security-schemes.js.map +0 -1
  173. package/dist/security.d.ts.map +0 -1
  174. package/dist/security.js.map +0 -1
  175. package/dist/session.d.ts.map +0 -1
  176. package/dist/session.js.map +0 -1
  177. package/dist/streaming.d.ts.map +0 -1
  178. package/dist/streaming.js.map +0 -1
  179. package/dist/subdomains.d.ts.map +0 -1
  180. package/dist/subdomains.js.map +0 -1
  181. package/dist/time-claims.d.ts.map +0 -1
  182. package/dist/time-claims.js.map +0 -1
  183. package/dist/tracing.d.ts.map +0 -1
  184. package/dist/tracing.js.map +0 -1
  185. package/dist/types.d.ts.map +0 -1
  186. package/dist/types.js.map +0 -1
  187. package/dist/websocket.d.ts.map +0 -1
  188. package/dist/websocket.js.map +0 -1
package/dist/app.js CHANGED
@@ -79,6 +79,35 @@ const DEFAULTS = {
79
79
  requestTimeoutMs: 30_000,
80
80
  validateResponses: true,
81
81
  };
82
+ const TEXT_ENCODER = new TextEncoder();
83
+ /**
84
+ * Internal Symbol used to stash the raw Uint8Array (or null) body on a
85
+ * `Response` produced by {@link serializeResult}. Adapters that want to
86
+ * avoid the cost of `await response.arrayBuffer()` for buffer-backed
87
+ * responses can read this property and write the bytes directly. The
88
+ * Symbol is intentionally module-private (not exported) to keep this an
89
+ * implementation detail — userland code should never depend on it.
90
+ */
91
+ export const DALOY_RAW_BODY = Symbol.for("daloyjs.response.rawBody");
92
+ /**
93
+ * Internal Symbol used by adapters to stash a pre-buffered request body on
94
+ * the `Request` instance. When set, {@link readBodyLimited} skips the
95
+ * `ReadableStream` reader loop and returns the cached bytes directly after
96
+ * re-checking them against the caller-supplied limit. Adapters MUST only
97
+ * attach bytes they have already validated against the configured
98
+ * {@link AppOptions.bodyLimitBytes}; the limit re-check in
99
+ * `readBodyLimited` is defense-in-depth, not the primary cap. Module-public
100
+ * so first-party adapters can opt in; not part of the userland API surface.
101
+ */
102
+ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
103
+ /**
104
+ * Internal Symbol set by handlers/serializers to attach a raw stream
105
+ * (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
106
+ * adapter pipes the stream straight to the socket, skipping the
107
+ * Web-stream reader bridge. Module-public so first-party adapters can
108
+ * opt in; userland code should not depend on it.
109
+ */
110
+ export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
82
111
  /**
83
112
  * Contract-first HTTP application.
84
113
  *
@@ -134,6 +163,14 @@ export class App {
134
163
  /** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
135
164
  routes = [];
136
165
  router = new Router();
166
+ /**
167
+ * Memoized result of `isProduction()`. The inputs (`options.env`,
168
+ * `options.production`, `process.env.NODE_ENV`) cannot change between
169
+ * the moment a route is dispatched and the moment its error is rendered,
170
+ * so reading `process.env.NODE_ENV` on every error response is wasted
171
+ * work in the hot path. Computed lazily on first read.
172
+ */
173
+ _productionCache;
137
174
  /** WebSocket route registry. Adapters look up handlers via `app.webSocketRoutes.find()`. */
138
175
  webSocketRoutes = new WebSocketRegistry();
139
176
  prefix = "";
@@ -144,6 +181,13 @@ export class App {
144
181
  routeSecurityMarkers = [];
145
182
  /** Decorator bag merged into ctx.state on every request. */
146
183
  decorations = {};
184
+ /**
185
+ * Count of own keys on {@link decorations}. Tracked alongside the bag so the
186
+ * dispatch hot path can take a `count === 0` fast path and skip the
187
+ * `Object.assign` spread on the common case (no `app.decorate()` calls).
188
+ * Updated only when {@link decorate} mutates the bag.
189
+ */
190
+ decorationsCount = 0;
147
191
  installedPlugins = new Set();
148
192
  closeHooks = [];
149
193
  closeHooksRun = false;
@@ -181,6 +225,25 @@ export class App {
181
225
  * dashboards see the misconfiguration without flooding on every retry.
182
226
  */
183
227
  trustProxyWarned = false;
228
+ /**
229
+ * Cached merge of `options.hooks` only. Used on the cold 404/405 path
230
+ * and as the baseline for cross-origin guard decisions when no route
231
+ * matches.
232
+ */
233
+ _globalHooksCache;
234
+ _globalCorsAllowsCache;
235
+ get globalHooks() {
236
+ if (this._globalHooksCache === undefined) {
237
+ this._globalHooksCache = mergeHooks([this.options.hooks ?? {}]);
238
+ }
239
+ return this._globalHooksCache;
240
+ }
241
+ get globalCorsAllows() {
242
+ if (this._globalCorsAllowsCache === undefined) {
243
+ this._globalCorsAllowsCache = corsOriginAllowsFromHooks([this.options.hooks ?? {}]);
244
+ }
245
+ return this._globalCorsAllowsCache;
246
+ }
184
247
  constructor(options = {}) {
185
248
  this.options = {
186
249
  validateResponses: options.validateResponses ?? DEFAULTS.validateResponses,
@@ -354,13 +417,20 @@ export class App {
354
417
  * auto-mount and error response detail stripping.
355
418
  */
356
419
  isProduction() {
420
+ if (this._productionCache !== undefined)
421
+ return this._productionCache;
422
+ let v;
357
423
  if (this.options.env !== undefined)
358
- return this.options.env === "production";
359
- if (this.options.production !== undefined)
360
- return this.options.production;
361
- return (typeof process !== "undefined" &&
362
- typeof process.env !== "undefined" &&
363
- process.env.NODE_ENV === "production");
424
+ v = this.options.env === "production";
425
+ else if (this.options.production !== undefined)
426
+ v = this.options.production;
427
+ else
428
+ v =
429
+ typeof process !== "undefined" &&
430
+ typeof process.env !== "undefined" &&
431
+ process.env.NODE_ENV === "production";
432
+ this._productionCache = v;
433
+ return v;
364
434
  }
365
435
  /**
366
436
  * Cross-origin guard. Rejects state-changing requests (`POST` /
@@ -377,7 +447,7 @@ export class App {
377
447
  * routes whose hook chain includes a `cors()` policy allowing the origin
378
448
  * are unaffected.
379
449
  */
380
- assertCrossOriginAllowed(request, url, method, corsOriginAllows) {
450
+ assertCrossOriginAllowed(request, requestUrl, method, corsOriginAllows) {
381
451
  if (this.options.secureDefaults === false)
382
452
  return;
383
453
  if (this.options.corsCrossOriginGuard === false)
@@ -399,7 +469,8 @@ export class App {
399
469
  // Malformed Origin header — refuse loudly.
400
470
  throw new ForbiddenError("Cross-origin state-changing request rejected: malformed Origin header.");
401
471
  }
402
- if (originUrl.origin === url.origin)
472
+ const reqOrigin = typeof requestUrl === "string" ? new URL(requestUrl).origin : requestUrl.origin;
473
+ if (originUrl.origin === reqOrigin)
403
474
  return;
404
475
  if (corsOriginAllows.some((allows) => allows(origin)))
405
476
  return;
@@ -728,11 +799,18 @@ export class App {
728
799
  const sources = [...this.groupHooks, def.hooks ?? {}];
729
800
  const hooks = mergeHooks(sources);
730
801
  const corsOriginAllows = corsOriginAllowsFromHooks(sources);
802
+ const globalHookLayer = this.options.hooks ?? {};
803
+ const mergedHooks = mergeHooks([globalHookLayer, ...sources]);
804
+ const hasFinalizeHook = mergedHooks.onSend !== undefined || mergedHooks.onResponse !== undefined;
805
+ const fullCorsOriginAllows = [
806
+ ...corsOriginAllowsFromHooks([globalHookLayer]),
807
+ ...corsOriginAllows,
808
+ ];
731
809
  const securityMarkers = securityMarkersFromHooks([
732
- this.options.hooks ?? {},
810
+ globalHookLayer,
733
811
  ...sources,
734
812
  ]);
735
- this.router.add(def.method, fullPath, { def: merged, hooks, corsOriginAllows }, def.operationId);
813
+ this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
736
814
  this.routes.push(merged);
737
815
  this.routeSecurityMarkers.push({
738
816
  method: merged.method,
@@ -1193,7 +1271,10 @@ export class App {
1193
1271
  if (opts.override === true && Object.prototype.hasOwnProperty.call(this.decorations, key)) {
1194
1272
  this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
1195
1273
  }
1274
+ const hadKey = Object.prototype.hasOwnProperty.call(this.decorations, key);
1196
1275
  this.decorations[key] = value;
1276
+ if (!hadKey)
1277
+ this.decorationsCount++;
1197
1278
  return this;
1198
1279
  }
1199
1280
  /**
@@ -1419,14 +1500,20 @@ export class App {
1419
1500
  }
1420
1501
  this.inflight++;
1421
1502
  const requestId = randomId();
1422
- const log = this.log.child({
1423
- requestId,
1424
- method: request.method,
1425
- url: request.url,
1426
- });
1503
+ // Skip the per-request child-logger allocation when the app was
1504
+ // constructed with `{ logger: false }`. noopLogger.child() returns
1505
+ // itself, so the binding is wasted work on every request.
1506
+ const baseLog = this.log;
1507
+ const log = baseLog === noopLogger
1508
+ ? noopLogger
1509
+ : baseLog.child({
1510
+ requestId,
1511
+ method: request.method,
1512
+ url: request.url,
1513
+ });
1427
1514
  const stripFingerprint = this.options.stripServerHeaders !== false;
1428
1515
  let ctx;
1429
- const globalHooks = mergeHooks([this.options.hooks ?? {}]);
1516
+ const globalHooks = this.globalHooks;
1430
1517
  let activeErrorHook = globalHooks.onError;
1431
1518
  let activeResponseHook = globalHooks.onResponse;
1432
1519
  let activeSendHook = globalHooks.onSend;
@@ -1435,26 +1522,35 @@ export class App {
1435
1522
  assertNoReservedInternalHeaders(request.headers);
1436
1523
  this.assertTrustProxyConfigured(request);
1437
1524
  this.assertBootGuards();
1438
- await globalHooks.onRequest?.(request);
1439
- const url = new URL(request.url);
1525
+ if (globalHooks.onRequest !== undefined) {
1526
+ const onRequestResult = globalHooks.onRequest(request);
1527
+ if (isPromiseLike(onRequestResult))
1528
+ await onRequestResult;
1529
+ }
1440
1530
  const method = request.method;
1531
+ const requestUrl = request.url;
1532
+ const pathname = getPathnameFast(requestUrl);
1533
+ let url;
1534
+ const getUrl = () => (url ??= new URL(requestUrl));
1441
1535
  const headFallback = method === "HEAD";
1442
- const match = this.router.find(method, url.pathname) ??
1443
- (headFallback ? this.router.find("GET", url.pathname) : undefined);
1536
+ const match = this.router.find(method, pathname) ??
1537
+ (headFallback ? this.router.find("GET", pathname) : undefined);
1444
1538
  // Hide internal routes from the public adapter surface. The router
1445
1539
  // still finds them so app.inject() can dispatch normally, but
1446
1540
  // app.fetch() responds 404 to avoid leaking existence.
1447
1541
  const internalHidden = match?.handler.def.internal === true && opts.allowInternal !== true;
1448
- this.assertCrossOriginAllowed(request, url, method, [
1449
- ...corsOriginAllowsFromHooks([this.options.hooks ?? {}]),
1450
- ...(match && !internalHidden ? match.handler.corsOriginAllows : this.corsOriginAllows),
1451
- ]);
1542
+ if (match && !internalHidden) {
1543
+ this.assertCrossOriginAllowed(request, requestUrl, method, match.handler.fullCorsOriginAllows);
1544
+ }
1545
+ else {
1546
+ this.assertCrossOriginAllowed(request, requestUrl, method, [...this.globalCorsAllows, ...this.corsOriginAllows]);
1547
+ }
1452
1548
  if (!match || internalHidden) {
1453
1549
  if (internalHidden) {
1454
1550
  // Don't leak existence via 405/Allow header. Always 404.
1455
- throw new NotFoundError(`No route for ${request.method} ${url.pathname}`);
1551
+ throw new NotFoundError(`No route for ${request.method} ${pathname}`);
1456
1552
  }
1457
- const rawAllowed = this.router.allowedMethods(url.pathname);
1553
+ const rawAllowed = this.router.allowedMethods(pathname);
1458
1554
  // Filter out methods whose route definitions are marked
1459
1555
  // `internal: true` unless the caller explicitly opted in via
1460
1556
  // app.inject(). This prevents 405/Allow from leaking the
@@ -1462,19 +1558,52 @@ export class App {
1462
1558
  const allowed = opts.allowInternal
1463
1559
  ? rawAllowed
1464
1560
  : rawAllowed.filter((m) => {
1465
- const candidate = this.router.find(m, url.pathname);
1561
+ const candidate = this.router.find(m, pathname);
1466
1562
  return candidate?.handler.def.internal !== true;
1467
1563
  });
1468
- ctx = {
1469
- request,
1470
- params: {},
1471
- query: Object.fromEntries(url.searchParams.entries()),
1472
- headers: headersToObject(request.headers),
1473
- body: undefined,
1474
- state: { ...this.decorations, requestId, log },
1475
- set: { headers: new Headers() },
1476
- };
1477
- ctx.set.headers.set("x-request-id", requestId);
1564
+ // On the throw paths (405 -> MethodNotAllowedError, 404 -> NotFoundError)
1565
+ // ctx is only read by a registered onError hook. Build it lazily so
1566
+ // the common no-hook 404 doesn't allocate a context object, spread
1567
+ // `decorations`, iterate headers, or materialize a `Headers`
1568
+ // instance just to be thrown away. The 204 OPTIONS preflight branch
1569
+ // below uses its own `synthCtx`, so this skip is safe for it too.
1570
+ const needsCtx = allowed.length > 0 && method === "OPTIONS"
1571
+ ? false // OPTIONS path builds synthCtx
1572
+ : activeErrorHook !== undefined;
1573
+ if (needsCtx) {
1574
+ // `query` and `headers` are materialized lazily — the common
1575
+ // `onError` hook reads `requestId` / path and never touches them,
1576
+ // so we skip `new URL(...)` + `Object.fromEntries` + the
1577
+ // `Headers.forEach` on 404 GETs entirely. Setters preserve write
1578
+ // semantics for hooks that reassign these fields.
1579
+ let _query;
1580
+ let _headers;
1581
+ const reqRef = request;
1582
+ const reqUrl = requestUrl;
1583
+ ctx = {
1584
+ request,
1585
+ params: {},
1586
+ get query() {
1587
+ if (_query !== undefined)
1588
+ return _query;
1589
+ const qi = reqUrl.indexOf("?");
1590
+ if (qi === -1)
1591
+ return (_query = {});
1592
+ const hi = reqUrl.indexOf("#", qi + 1);
1593
+ const qs = hi === -1 ? reqUrl.slice(qi + 1) : reqUrl.slice(qi + 1, hi);
1594
+ return (_query = Object.fromEntries(new URLSearchParams(qs)));
1595
+ },
1596
+ set query(v) { _query = v; },
1597
+ get headers() {
1598
+ return (_headers ??= headersToObject(reqRef.headers));
1599
+ },
1600
+ set headers(v) { _headers = v; },
1601
+ body: undefined,
1602
+ state: { ...this.decorations, requestId, log },
1603
+ set: { headers: new Headers() },
1604
+ };
1605
+ ctx.set.headers.set("x-request-id", requestId);
1606
+ }
1478
1607
  if (allowed.length > 0) {
1479
1608
  if (method === "OPTIONS") {
1480
1609
  // Synthesize a preflight: let global hooks (e.g. CORS) intercept;
@@ -1492,10 +1621,12 @@ export class App {
1492
1621
  this.options.hooks ?? {},
1493
1622
  ...this.groupHooks,
1494
1623
  ]);
1495
- const intercepted = await preflightHooks.beforeHandle?.(synthCtx);
1624
+ const interceptedResult = preflightHooks.beforeHandle?.(synthCtx);
1625
+ const intercepted = isPromiseLike(interceptedResult) ? await interceptedResult : interceptedResult;
1496
1626
  if (intercepted instanceof Response) {
1497
1627
  copyContextHeaders(synthCtx, intercepted);
1498
- return await finalizeResponse(intercepted, synthCtx, preflightHooks, stripFingerprint);
1628
+ const fin = finalizeResponse(intercepted, synthCtx, preflightHooks, stripFingerprint);
1629
+ return isPromiseLike(fin) ? await fin : fin;
1499
1630
  }
1500
1631
  const res = new Response(null, {
1501
1632
  status: 204,
@@ -1503,35 +1634,68 @@ export class App {
1503
1634
  });
1504
1635
  copyContextHeaders(synthCtx, res);
1505
1636
  res.headers.set("x-request-id", requestId);
1506
- return await finalizeResponse(res, synthCtx, preflightHooks, stripFingerprint);
1637
+ const fin2 = finalizeResponse(res, synthCtx, preflightHooks, stripFingerprint);
1638
+ return isPromiseLike(fin2) ? await fin2 : fin2;
1507
1639
  }
1508
1640
  throw new MethodNotAllowedError(allowed);
1509
1641
  }
1510
- throw new NotFoundError(`No route for ${request.method} ${url.pathname}`);
1642
+ throw new NotFoundError(`No route for ${request.method} ${pathname}`);
1511
1643
  }
1512
- const { def, hooks } = match.handler;
1513
- const allHooks = mergeHooks([globalHooks, hooks]);
1644
+ const { def, hooks, mergedHooks: allHooks, hasFinalizeHook } = match.handler;
1514
1645
  activeErrorHook = allHooks.onError;
1515
1646
  activeResponseHook = allHooks.onResponse;
1516
1647
  activeSendHook = allHooks.onSend;
1517
- await hooks.onRequest?.(request);
1518
- ctx = await buildContext(request, url, match.params, def, this.options);
1519
- Object.assign(ctx.state, this.decorations, { requestId, log });
1520
- ctx.set.headers.set("x-request-id", requestId);
1521
- const before = await allHooks.beforeHandle?.(ctx);
1522
- if (before instanceof Response) {
1523
- copyContextHeaders(ctx, before);
1524
- return await finalizeResponse(before, ctx, allHooks, stripFingerprint);
1648
+ if (hooks.onRequest !== undefined) {
1649
+ const routeOnRequestResult = hooks.onRequest(request);
1650
+ if (isPromiseLike(routeOnRequestResult))
1651
+ await routeOnRequestResult;
1525
1652
  }
1526
- let result = this.options.mockMode
1653
+ ctx = await buildContext(request, getUrl, match.params, def, this.options);
1654
+ // Stable two-field write keeps `ctx.state`'s hidden class consistent across
1655
+ // requests for the common no-decorator case. The decorations spread only
1656
+ // fires when `app.decorate()` was actually called.
1657
+ const state = ctx.state;
1658
+ state.requestId = requestId;
1659
+ state.log = log;
1660
+ if (this.decorationsCount !== 0)
1661
+ Object.assign(state, this.decorations);
1662
+ if (allHooks.beforeHandle !== undefined) {
1663
+ const beforeResult = allHooks.beforeHandle(ctx);
1664
+ const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
1665
+ if (before instanceof Response) {
1666
+ copyContextHeaders(ctx, before);
1667
+ if (!before.headers.has("x-request-id"))
1668
+ before.headers.set("x-request-id", requestId);
1669
+ if (hasFinalizeHook) {
1670
+ const fin = finalizeResponse(before, ctx, allHooks, stripFingerprint);
1671
+ return isPromiseLike(fin) ? await fin : fin;
1672
+ }
1673
+ return finalizeFast(before, stripFingerprint);
1674
+ }
1675
+ }
1676
+ const runResult = this.options.mockMode
1527
1677
  ? mockResponseFor(def)
1528
- : await runHandler(def, ctx, this.options.requestTimeoutMs);
1529
- const afterReturn = await allHooks.afterHandle?.(ctx, result);
1530
- if (afterReturn !== undefined)
1531
- result = afterReturn;
1532
- let response = await serializeResult(result, def, this.options.validateResponses ?? true);
1678
+ : runHandler(def, ctx, this.options.requestTimeoutMs);
1679
+ let result = isPromiseLike(runResult) ? await runResult : runResult;
1680
+ if (allHooks.afterHandle !== undefined) {
1681
+ const afterResult = allHooks.afterHandle(ctx, result);
1682
+ const afterReturn = isPromiseLike(afterResult) ? await afterResult : afterResult;
1683
+ if (afterReturn !== undefined)
1684
+ result = afterReturn;
1685
+ }
1686
+ const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
1687
+ let response = isPromiseLike(serializeResultRes) ? await serializeResultRes : serializeResultRes;
1533
1688
  copyContextHeaders(ctx, response);
1534
- const finalized = await finalizeResponse(response, ctx, allHooks, stripFingerprint);
1689
+ if (!response.headers.has("x-request-id"))
1690
+ response.headers.set("x-request-id", requestId);
1691
+ let finalized;
1692
+ if (hasFinalizeHook) {
1693
+ const fin = finalizeResponse(response, ctx, allHooks, stripFingerprint);
1694
+ finalized = isPromiseLike(fin) ? await fin : fin;
1695
+ }
1696
+ else {
1697
+ finalized = finalizeFast(response, stripFingerprint);
1698
+ }
1535
1699
  if (method === "HEAD") {
1536
1700
  return new Response(null, {
1537
1701
  status: finalized.status,
@@ -1542,7 +1706,15 @@ export class App {
1542
1706
  return finalized;
1543
1707
  }
1544
1708
  catch (err) {
1545
- const handled = await activeErrorHook?.(err, ctx);
1709
+ // Skip the unconditional `await activeErrorHook?.(...)`: when no
1710
+ // error hook is registered (the common case), `await undefined`
1711
+ // still schedules a microtask. Branching first lets the hot error
1712
+ // path stay synchronous.
1713
+ let handled;
1714
+ if (activeErrorHook !== undefined) {
1715
+ const r = activeErrorHook(err, ctx);
1716
+ handled = isPromiseLike(r) ? await r : r;
1717
+ }
1546
1718
  if (handled instanceof Response) {
1547
1719
  if (ctx)
1548
1720
  copyContextHeaders(ctx, handled);
@@ -1557,10 +1729,12 @@ export class App {
1557
1729
  // the request at `disconnectStatusCode` (default 499) instead of
1558
1730
  // letting an AbortError bubble up as a generic 5xx. Logged at `info`
1559
1731
  // so disconnect storms do not look like service incidents.
1560
- const disconnectCode = this.options.disconnectStatusCode ?? 499;
1732
+ // `err instanceof HttpError` first: the framework's own thrown
1733
+ // problem errors short-circuit before any signal/option lookup.
1734
+ const isHttp = err instanceof HttpError;
1735
+ const disconnectCode = isHttp ? 0 : (this.options.disconnectStatusCode ?? 499);
1561
1736
  if (disconnectCode > 0 &&
1562
- request.signal?.aborted === true &&
1563
- !(err instanceof HttpError)) {
1737
+ request.signal?.aborted === true) {
1564
1738
  log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
1565
1739
  const res = new Response(null, {
1566
1740
  status: disconnectCode,
@@ -1576,7 +1750,7 @@ export class App {
1576
1750
  onResponse: activeResponseHook,
1577
1751
  }, stripFingerprint);
1578
1752
  }
1579
- const httpErr = err instanceof HttpError
1753
+ const httpErr = isHttp
1580
1754
  ? err
1581
1755
  : new InternalError(err instanceof Error ? err.message : "Unexpected error");
1582
1756
  if (httpErr.status >= 500)
@@ -1903,6 +2077,34 @@ function isStateChangingMethod(method) {
1903
2077
  method === "PATCH" ||
1904
2078
  method === "DELETE");
1905
2079
  }
2080
+ /**
2081
+ * Extract the pathname from a fully-qualified request URL without
2082
+ * constructing a `URL` object. ~10x cheaper than `new URL(...).pathname`
2083
+ * and used on the dispatch hot path; full `URL` parsing is deferred until
2084
+ * something actually reads `searchParams`. Mirrors Hono's `getPath`
2085
+ * approach.
2086
+ *
2087
+ * Handles the standard `http(s)://host[:port]/path[?query][#hash]` shape
2088
+ * produced by the Node adapter and the Fetch standard. Falls back to a
2089
+ * `URL` parse for inputs that don't match (e.g. opaque schemes), so
2090
+ * correctness is preserved even when the fast path doesn't apply.
2091
+ */
2092
+ function getPathnameFast(url) {
2093
+ const schemeEnd = url.indexOf("://");
2094
+ if (schemeEnd === -1)
2095
+ return new URL(url).pathname;
2096
+ const pathStart = url.indexOf("/", schemeEnd + 3);
2097
+ if (pathStart === -1)
2098
+ return "/";
2099
+ let end = url.length;
2100
+ const q = url.indexOf("?", pathStart);
2101
+ if (q !== -1)
2102
+ end = q;
2103
+ const h = url.indexOf("#", pathStart);
2104
+ if (h !== -1 && h < end)
2105
+ end = h;
2106
+ return url.slice(pathStart, end);
2107
+ }
1906
2108
  function mergeHooks(layers) {
1907
2109
  const pick = (key) => layers
1908
2110
  .map((h) => h[key])
@@ -1941,6 +2143,11 @@ function stampRequiredScopes(hooks, scopes) {
1941
2143
  function mergeBeforeHandle(beforeHandle, requiredScopes) {
1942
2144
  if (!beforeHandle && requiredScopes.length === 0)
1943
2145
  return undefined;
2146
+ // No scope aggregation needed → return the inner fn as-is so the dispatch
2147
+ // hot path can use the single-fn fast path and avoid an extra closure +
2148
+ // microtask wrapper around a possibly-sync beforeHandle.
2149
+ if (requiredScopes.length === 0)
2150
+ return beforeHandle;
1944
2151
  return async (ctx) => {
1945
2152
  if (requiredScopes.length > 0) {
1946
2153
  const state = ctx.state;
@@ -1958,6 +2165,8 @@ function mergeBeforeHandle(beforeHandle, requiredScopes) {
1958
2165
  function responsePipeline(fns) {
1959
2166
  if (fns.length === 0)
1960
2167
  return undefined;
2168
+ if (fns.length === 1)
2169
+ return fns[0];
1961
2170
  return async (res, ctx) => {
1962
2171
  let current = res;
1963
2172
  for (const fn of fns) {
@@ -1968,19 +2177,59 @@ function responsePipeline(fns) {
1968
2177
  return current;
1969
2178
  };
1970
2179
  }
1971
- async function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
1972
- const sent = await hooks.onSend?.(res, ctx);
1973
- const final = sent instanceof Response ? sent : res;
2180
+ function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
2181
+ let final = res;
2182
+ const finish = (f) => {
2183
+ if (stripFingerprint) {
2184
+ f.headers.delete("server");
2185
+ f.headers.delete("x-powered-by");
2186
+ }
2187
+ if (hooks.onResponse !== undefined) {
2188
+ const onResponseResult = hooks.onResponse(f);
2189
+ if (isPromiseLike(onResponseResult)) {
2190
+ return onResponseResult.then(() => f);
2191
+ }
2192
+ }
2193
+ return f;
2194
+ };
2195
+ if (hooks.onSend !== undefined) {
2196
+ const sentResult = hooks.onSend(res, ctx);
2197
+ if (isPromiseLike(sentResult)) {
2198
+ return sentResult.then((sent) => {
2199
+ if (sent instanceof Response)
2200
+ final = sent;
2201
+ return finish(final);
2202
+ });
2203
+ }
2204
+ else {
2205
+ if (sentResult instanceof Response)
2206
+ final = sentResult;
2207
+ }
2208
+ }
2209
+ return finish(final);
2210
+ }
2211
+ function isPromiseLike(value) {
2212
+ return value !== null && typeof value === "object" && typeof value.then === "function";
2213
+ }
2214
+ /**
2215
+ * Allocation-free finalizer for the common case (no `onSend`/`onResponse`
2216
+ * hooks). Avoids the two `await undefined` microtasks per request that
2217
+ * dominate sub-millisecond dispatch overhead in the no-middleware
2218
+ * benchmark. Behaviour parity with {@link finalizeResponse}: only the
2219
+ * server-fingerprint headers are stripped.
2220
+ */
2221
+ function finalizeFast(res, stripFingerprint) {
1974
2222
  if (stripFingerprint) {
1975
- final.headers.delete("server");
1976
- final.headers.delete("x-powered-by");
2223
+ res.headers.delete("server");
2224
+ res.headers.delete("x-powered-by");
1977
2225
  }
1978
- await hooks.onResponse?.(final);
1979
- return final;
2226
+ return res;
1980
2227
  }
1981
2228
  function chain(fns) {
1982
2229
  if (fns.length === 0)
1983
2230
  return undefined;
2231
+ if (fns.length === 1)
2232
+ return fns[0];
1984
2233
  return (async (...args) => {
1985
2234
  for (const fn of fns)
1986
2235
  await fn(...args);
@@ -1989,6 +2238,8 @@ function chain(fns) {
1989
2238
  function firstResponse(fns) {
1990
2239
  if (fns.length === 0)
1991
2240
  return undefined;
2241
+ if (fns.length === 1)
2242
+ return fns[0];
1992
2243
  return (async (...args) => {
1993
2244
  for (const fn of fns) {
1994
2245
  const r = await fn(...args);
@@ -2001,6 +2252,8 @@ function firstResponse(fns) {
2001
2252
  function pipeline(fns) {
2002
2253
  if (fns.length === 0)
2003
2254
  return undefined;
2255
+ if (fns.length === 1)
2256
+ return fns[0];
2004
2257
  return (async (ctx, value) => {
2005
2258
  let v = value;
2006
2259
  for (const fn of fns) {
@@ -2011,8 +2264,40 @@ function pipeline(fns) {
2011
2264
  return v;
2012
2265
  });
2013
2266
  }
2267
+ /**
2268
+ * Per-request response-side `set` object. Implemented as a class so every
2269
+ * instance shares one V8 hidden class — the previous {@link Object.defineProperty}
2270
+ * based factory installed fresh accessor descriptors on every request, which
2271
+ * forced V8 to treat each `ctx.set` as a unique shape and tanked inline-cache
2272
+ * sharing in `copyContextHeaders` and downstream hooks.
2273
+ *
2274
+ * `_h` is a public-but-underscored slot rather than a `#`-private field so
2275
+ * the compiled output stays target-agnostic; consumer code that reads
2276
+ * `ctx.set.headers` flows through the prototype getter and never sees it.
2277
+ * `touched` replaces the prior `SET_HEADERS_TOUCHED` symbol — same intent,
2278
+ * stable field offset.
2279
+ */
2280
+ class LazyResponseSet {
2281
+ status = undefined;
2282
+ _h = undefined;
2283
+ touched = false;
2284
+ get headers() {
2285
+ const h = this._h;
2286
+ if (h !== undefined)
2287
+ return h;
2288
+ this.touched = true;
2289
+ return (this._h = new Headers());
2290
+ }
2291
+ set headers(v) {
2292
+ this._h = v;
2293
+ this.touched = true;
2294
+ }
2295
+ }
2014
2296
  function copyContextHeaders(ctx, res) {
2015
- ctx.set.headers.forEach((v, k) => {
2297
+ const set = ctx.set;
2298
+ if (set.touched !== true)
2299
+ return;
2300
+ set._h.forEach((v, k) => {
2016
2301
  if (!res.headers.has(k))
2017
2302
  res.headers.set(k, v);
2018
2303
  });
@@ -2020,49 +2305,132 @@ function copyContextHeaders(ctx, res) {
2020
2305
  function hasRequestSchema(request, key) {
2021
2306
  return !!request && !!request[key];
2022
2307
  }
2023
- async function buildContext(request, url, rawParams, def, opts) {
2024
- const set = { headers: new Headers() };
2025
- const headersObj = headersToObject(request.headers);
2026
- const queryObj = queryToObject(url.searchParams);
2308
+ /**
2309
+ * Stable-shape per-request context. All fields are initialised in fixed
2310
+ * order in the constructor so every dispatched request produces an instance
2311
+ * with the same V8 hidden class — replacing the prior object-literal +
2312
+ * {@link Object.defineProperty} pattern, which gave each request a unique
2313
+ * shape and forced inline-cache misses through every downstream hook.
2314
+ *
2315
+ * `query` / `headers` are prototype getters that either return the value
2316
+ * already stored on `_q` / `_h` (set eagerly by schema validation) or
2317
+ * materialise it lazily from the captured builder closure on first read.
2318
+ * The `_qSet` / `_hSet` flags distinguish "validated, value cached" from
2319
+ * "not yet read" so setters from user hooks remain observable.
2320
+ */
2321
+ class RequestContext {
2322
+ request;
2323
+ params;
2324
+ body = undefined;
2325
+ state;
2326
+ set;
2327
+ _q = undefined;
2328
+ _qBuilder = undefined;
2329
+ _qSet = false;
2330
+ _h = undefined;
2331
+ _hBuilder = undefined;
2332
+ _hSet = false;
2333
+ constructor(request, params, state, set) {
2334
+ this.request = request;
2335
+ this.params = params;
2336
+ this.state = state;
2337
+ this.set = set;
2338
+ }
2339
+ get query() {
2340
+ if (this._qSet)
2341
+ return this._q;
2342
+ const b = this._qBuilder;
2343
+ this._qSet = true;
2344
+ return (this._q = b !== undefined ? b() : undefined);
2345
+ }
2346
+ set query(v) {
2347
+ this._q = v;
2348
+ this._qSet = true;
2349
+ }
2350
+ get headers() {
2351
+ if (this._hSet)
2352
+ return this._h;
2353
+ const b = this._hBuilder;
2354
+ this._hSet = true;
2355
+ return (this._h = b !== undefined ? b() : undefined);
2356
+ }
2357
+ set headers(v) {
2358
+ this._h = v;
2359
+ this._hSet = true;
2360
+ }
2361
+ }
2362
+ function buildContext(request, getUrl, rawParams, def, opts) {
2363
+ const set = new LazyResponseSet();
2364
+ const hasHeadersSchema = !!def.request?.headers;
2365
+ const hasQuerySchema = !!def.request?.query;
2366
+ let headersObj;
2367
+ let queryObj;
2368
+ const buildHeaders = () => (headersObj ??= headersToObject(request.headers));
2369
+ const buildQuery = () => (queryObj ??= queryToObject(getUrl().searchParams));
2027
2370
  let params = rawParams;
2028
- let query = queryObj;
2029
- let headers = headersObj;
2371
+ let query;
2372
+ let headers;
2030
2373
  let body = undefined;
2031
- if (def.request?.params) {
2032
- const r = await validate(def.request.params, rawParams);
2033
- if (r.issues)
2034
- throw new ValidationError("params", toIssues(r.issues));
2035
- params = r.value;
2036
- }
2037
- if (def.request?.query) {
2038
- const r = await validate(def.request.query, queryObj);
2039
- if (r.issues)
2040
- throw new ValidationError("query", toIssues(r.issues));
2041
- query = r.value;
2042
- }
2043
- if (def.request?.headers) {
2044
- const r = await validate(def.request.headers, headersObj);
2045
- if (r.issues)
2046
- throw new ValidationError("headers", toIssues(r.issues));
2047
- headers = r.value;
2048
- }
2049
- if (def.request?.body) {
2050
- const ct = (request.headers.get("content-type") ?? "").toLowerCase();
2051
- const allowed = def.accepts ?? opts.allowedContentTypes ?? [
2052
- "application/json",
2053
- "application/x-www-form-urlencoded",
2054
- "multipart/form-data",
2055
- ];
2056
- if (!allowed.some((a) => ct.includes(a))) {
2057
- throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
2374
+ const hasSchema = def.request?.params || def.request?.query || def.request?.headers || def.request?.body;
2375
+ const finishContext = () => {
2376
+ const ctx = new RequestContext(request, params, {}, set);
2377
+ ctx.body = body;
2378
+ if (hasQuerySchema) {
2379
+ ctx._q = query;
2380
+ ctx._qSet = true;
2058
2381
  }
2059
- const raw = await readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
2060
- const r = await validate(def.request.body, raw);
2061
- if (r.issues)
2062
- throw new ValidationError("body", toIssues(r.issues));
2063
- body = r.value;
2064
- }
2065
- return { request, params, query, headers, body, state: {}, set };
2382
+ else {
2383
+ ctx._qBuilder = buildQuery;
2384
+ }
2385
+ if (hasHeadersSchema) {
2386
+ ctx._h = headers;
2387
+ ctx._hSet = true;
2388
+ }
2389
+ else {
2390
+ ctx._hBuilder = buildHeaders;
2391
+ }
2392
+ return ctx;
2393
+ };
2394
+ if (!hasSchema) {
2395
+ return finishContext();
2396
+ }
2397
+ return (async () => {
2398
+ if (def.request?.params) {
2399
+ const r = await validate(def.request.params, rawParams);
2400
+ if (r.issues)
2401
+ throw new ValidationError("params", toIssues(r.issues));
2402
+ params = r.value;
2403
+ }
2404
+ if (hasQuerySchema) {
2405
+ const r = await validate(def.request.query, buildQuery());
2406
+ if (r.issues)
2407
+ throw new ValidationError("query", toIssues(r.issues));
2408
+ query = r.value;
2409
+ }
2410
+ if (hasHeadersSchema) {
2411
+ const r = await validate(def.request.headers, buildHeaders());
2412
+ if (r.issues)
2413
+ throw new ValidationError("headers", toIssues(r.issues));
2414
+ headers = r.value;
2415
+ }
2416
+ if (def.request?.body) {
2417
+ const ct = (request.headers.get("content-type") ?? "").toLowerCase();
2418
+ const allowed = def.accepts ?? opts.allowedContentTypes ?? [
2419
+ "application/json",
2420
+ "application/x-www-form-urlencoded",
2421
+ "multipart/form-data",
2422
+ ];
2423
+ if (!allowed.some((a) => ct.includes(a))) {
2424
+ throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
2425
+ }
2426
+ const raw = await readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
2427
+ const r = await validate(def.request.body, raw);
2428
+ if (r.issues)
2429
+ throw new ValidationError("body", toIssues(r.issues));
2430
+ body = r.value;
2431
+ }
2432
+ return finishContext();
2433
+ })();
2066
2434
  }
2067
2435
  function headersToObject(h) {
2068
2436
  const o = {};
@@ -2155,41 +2523,84 @@ async function readBody(req, ct, limit, multipart) {
2155
2523
  const bytes = await readBodyLimited(req, limit);
2156
2524
  return new TextDecoder().decode(bytes);
2157
2525
  }
2158
- async function serializeResult(result, def, validateResponses) {
2526
+ function serializeResult(result, def, validateResponses) {
2159
2527
  const spec = def.responses[result.status];
2160
2528
  if (!spec) {
2161
2529
  throw new InternalError(`Handler returned status ${result.status} which is not declared in responses for ${def.method} ${def.path}`);
2162
2530
  }
2531
+ const finish = () => {
2532
+ const headers = new Headers(result.headers);
2533
+ const explicitCt = headers.get("content-type");
2534
+ const treatAsJson = !explicitCt || explicitCt.includes("application/json");
2535
+ if (!explicitCt)
2536
+ headers.set("content-type", "application/json");
2537
+ let body;
2538
+ let rawBody = null;
2539
+ let isStream = false;
2540
+ if (result.body === undefined || result.body === null) {
2541
+ body = null;
2542
+ }
2543
+ else if (!treatAsJson && typeof result.body === "string") {
2544
+ const bytes = TEXT_ENCODER.encode(result.body);
2545
+ setContentLength(headers, bytes.byteLength);
2546
+ body = bytes;
2547
+ rawBody = bytes;
2548
+ }
2549
+ else if (!treatAsJson && result.body instanceof Uint8Array) {
2550
+ setContentLength(headers, result.body.byteLength);
2551
+ body = result.body;
2552
+ rawBody = result.body;
2553
+ }
2554
+ else if (!treatAsJson && result.body instanceof ArrayBuffer) {
2555
+ setContentLength(headers, result.body.byteLength);
2556
+ body = result.body;
2557
+ rawBody = new Uint8Array(result.body);
2558
+ }
2559
+ else if (!treatAsJson && result.body instanceof ReadableStream) {
2560
+ body = result.body;
2561
+ isStream = true;
2562
+ }
2563
+ else {
2564
+ const bytes = TEXT_ENCODER.encode(JSON.stringify(result.body));
2565
+ setContentLength(headers, bytes.byteLength);
2566
+ body = bytes;
2567
+ rawBody = bytes;
2568
+ }
2569
+ const response = new Response(body, { status: result.status, headers });
2570
+ if (!isStream) {
2571
+ response[DALOY_RAW_BODY] = rawBody;
2572
+ }
2573
+ return response;
2574
+ };
2163
2575
  if (validateResponses && spec.body) {
2164
- const r = await validate(spec.body, result.body);
2165
- if (r.issues) {
2166
- throw new InternalError(`Response body for ${def.method} ${def.path} failed schema validation: ${r.issues
2167
- .map((i) => i.message)
2168
- .join("; ")}`);
2169
- }
2170
- }
2171
- const headers = new Headers(result.headers);
2172
- const explicitCt = headers.get("content-type");
2173
- const treatAsJson = !explicitCt || explicitCt.includes("application/json");
2174
- if (!explicitCt)
2175
- headers.set("content-type", "application/json");
2176
- let body;
2177
- if (result.body === undefined || result.body === null) {
2178
- body = null;
2179
- }
2180
- else if (!treatAsJson &&
2181
- (typeof result.body === "string" ||
2182
- result.body instanceof Uint8Array ||
2183
- result.body instanceof ArrayBuffer)) {
2184
- body = result.body;
2185
- }
2186
- else if (!treatAsJson && result.body instanceof ReadableStream) {
2187
- body = result.body;
2188
- }
2189
- else {
2190
- body = JSON.stringify(result.body);
2191
- }
2192
- return new Response(body, { status: result.status, headers });
2576
+ // Call the Standard Schema validator directly so a sync validator
2577
+ // stays on the sync fast path. The public `validate()` helper always
2578
+ // returns a Promise (stable API surface), but here we own the schema
2579
+ // and can branch on whether validation actually needed to suspend.
2580
+ const r = spec.body["~standard"].validate(result.body);
2581
+ if (isPromiseLike(r)) {
2582
+ return r.then((resolved) => {
2583
+ if (resolved.issues) {
2584
+ throw new InternalError(`Response body for ${def.method} ${def.path} failed schema validation: ${resolved.issues
2585
+ .map((i) => i.message)
2586
+ .join("; ")}`);
2587
+ }
2588
+ return finish();
2589
+ });
2590
+ }
2591
+ else {
2592
+ if (r.issues) {
2593
+ throw new InternalError(`Response body for ${def.method} ${def.path} failed schema validation: ${r.issues
2594
+ .map((i) => i.message)
2595
+ .join("; ")}`);
2596
+ }
2597
+ }
2598
+ }
2599
+ return finish();
2600
+ }
2601
+ function setContentLength(headers, byteLength) {
2602
+ if (!headers.has("content-length"))
2603
+ headers.set("content-length", String(byteLength));
2193
2604
  }
2194
2605
  function mockResponseFor(def) {
2195
2606
  const statuses = Object.keys(def.responses).map(Number).sort();
@@ -2203,10 +2614,11 @@ function mockResponseFor(def) {
2203
2614
  return { status, body: example };
2204
2615
  }
2205
2616
  function runHandler(def, ctx, requestTimeoutMs) {
2206
- const handlerPromise = Promise.resolve(def.handler(ctx));
2207
- return requestTimeoutMs > 0
2208
- ? withTimeout(handlerPromise, requestTimeoutMs)
2209
- : handlerPromise;
2617
+ const result = def.handler(ctx);
2618
+ if (requestTimeoutMs === 0 || !isPromiseLike(result)) {
2619
+ return result;
2620
+ }
2621
+ return withTimeout(result, requestTimeoutMs);
2210
2622
  }
2211
2623
  function withTimeout(p, ms) {
2212
2624
  return new Promise((resolve, reject) => {
@@ -2347,4 +2759,3 @@ function readHostPackageJsonInfo() {
2347
2759
  export function _resetPackageJsonCacheForTests() {
2348
2760
  PACKAGE_JSON_CACHE.value = undefined;
2349
2761
  }
2350
- //# sourceMappingURL=app.js.map