@daloyjs/core 1.0.0-beta.6 → 1.0.0-rc.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.
Files changed (99) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/bun.d.ts +13 -1
  3. package/dist/adapters/bun.js +8 -1
  4. package/dist/adapters/cloudflare.d.ts +7 -1
  5. package/dist/adapters/cloudflare.js +6 -1
  6. package/dist/adapters/deno.d.ts +11 -1
  7. package/dist/adapters/deno.js +8 -1
  8. package/dist/adapters/fastly.d.ts +12 -2
  9. package/dist/adapters/fastly.js +12 -2
  10. package/dist/adapters/lambda.d.ts +37 -1
  11. package/dist/adapters/lambda.js +6 -1
  12. package/dist/adapters/node.d.ts +12 -1
  13. package/dist/adapters/node.js +223 -12
  14. package/dist/adapters/vercel.d.ts +13 -1
  15. package/dist/adapters/vercel.js +12 -1
  16. package/dist/app.d.ts +78 -17
  17. package/dist/app.js +444 -86
  18. package/dist/banner.d.ts +6 -0
  19. package/dist/banner.js +6 -0
  20. package/dist/cli.d.ts +35 -0
  21. package/dist/cli.js +23 -1
  22. package/dist/combine.d.ts +8 -0
  23. package/dist/combine.js +8 -0
  24. package/dist/compression.d.ts +3 -0
  25. package/dist/compression.js +3 -0
  26. package/dist/config.d.ts +4 -0
  27. package/dist/config.js +4 -0
  28. package/dist/conn-info.d.ts +35 -2
  29. package/dist/conn-info.js +35 -2
  30. package/dist/contract.d.ts +2 -0
  31. package/dist/contract.js +2 -0
  32. package/dist/cookie.d.ts +12 -0
  33. package/dist/cookie.js +12 -0
  34. package/dist/dependency.d.ts +4 -0
  35. package/dist/dependency.js +3 -0
  36. package/dist/discriminator.d.ts +13 -0
  37. package/dist/discriminator.js +23 -1
  38. package/dist/docs.d.ts +78 -0
  39. package/dist/docs.js +21 -0
  40. package/dist/errors.d.ts +16 -0
  41. package/dist/errors.js +14 -0
  42. package/dist/etag.d.ts +2 -0
  43. package/dist/etag.js +2 -0
  44. package/dist/fetch-guard.d.ts +7 -0
  45. package/dist/fetch-guard.js +7 -0
  46. package/dist/fetch-resilience.d.ts +4 -0
  47. package/dist/fetch-resilience.js +4 -0
  48. package/dist/http-signatures.d.ts +32 -0
  49. package/dist/http-signatures.js +30 -0
  50. package/dist/index.d.ts +1 -1
  51. package/dist/ip-restriction.d.ts +40 -3
  52. package/dist/ip-restriction.js +35 -3
  53. package/dist/jwk.d.ts +12 -1
  54. package/dist/jwk.js +6 -0
  55. package/dist/jwt.d.ts +14 -0
  56. package/dist/jwt.js +10 -0
  57. package/dist/load-shedding.d.ts +3 -0
  58. package/dist/load-shedding.js +3 -0
  59. package/dist/logger.d.ts +13 -0
  60. package/dist/logger.js +3 -0
  61. package/dist/mcp.d.ts +152 -10
  62. package/dist/mcp.js +223 -19
  63. package/dist/middleware.d.ts +68 -0
  64. package/dist/middleware.js +17 -0
  65. package/dist/mtls.d.ts +19 -2
  66. package/dist/mtls.js +12 -2
  67. package/dist/multipart.d.ts +42 -5
  68. package/dist/multipart.js +41 -5
  69. package/dist/openapi.d.ts +15 -9
  70. package/dist/openapi.js +6 -9
  71. package/dist/rate-limit-redis.d.ts +21 -2
  72. package/dist/rate-limit-redis.js +17 -2
  73. package/dist/safe-redirect.d.ts +6 -0
  74. package/dist/safe-redirect.js +6 -0
  75. package/dist/sbom.cdx.json +9 -9
  76. package/dist/sbom.spdx.json +5 -5
  77. package/dist/scheduler.d.ts +4 -0
  78. package/dist/schema.d.ts +25 -0
  79. package/dist/security-schemes.d.ts +50 -0
  80. package/dist/security-schemes.js +6 -0
  81. package/dist/security.d.ts +33 -0
  82. package/dist/security.js +28 -10
  83. package/dist/session.d.ts +34 -5
  84. package/dist/session.js +31 -5
  85. package/dist/streaming.d.ts +19 -0
  86. package/dist/streaming.js +16 -0
  87. package/dist/subdomains.d.ts +4 -0
  88. package/dist/subdomains.js +4 -0
  89. package/dist/time-claims.d.ts +22 -2
  90. package/dist/time-claims.js +6 -0
  91. package/dist/tracing.d.ts +12 -0
  92. package/dist/tracing.js +6 -0
  93. package/dist/types.d.ts +70 -1
  94. package/dist/waf.js +21 -1
  95. package/dist/webhook-delivery.d.ts +6 -0
  96. package/dist/webhook-delivery.js +5 -0
  97. package/dist/websocket.d.ts +137 -4
  98. package/dist/websocket.js +105 -4
  99. package/package.json +1 -1
package/dist/app.js CHANGED
@@ -1,7 +1,6 @@
1
1
  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
- import { validate } from "./schema.js";
5
4
  import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
6
5
  import { createLogger, noopLogger } from "./logger.js";
7
6
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
@@ -195,10 +194,121 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
195
194
  */
196
195
  export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
197
196
  /**
197
+ * Internal Symbol an adapter sets (once, on its request shim's prototype) to
198
+ * declare: "the object that ultimately consumes this request's `Response`
199
+ * only reads `status` / `headers` / {@link DALOY_RAW_BODY} — it never needs a
200
+ * branded WHATWG `Response`". When present on the incoming request,
201
+ * {@link serializeResult} may return a {@link LightResponse} and skip the
202
+ * ~2µs undici `Response` construction per request. Requests without the
203
+ * marker (Bun / Deno / Workers adapters, tests, direct `app.fetch()` callers)
204
+ * always get a real `Response`, so the public contract is unchanged.
205
+ */
206
+ export const DALOY_LIGHT_RESPONSE_OK = Symbol.for("daloyjs.response.lightOk");
207
+ /**
208
+ * Minimal `Response` stand-in returned on the Node-adapter hot path (gated by
209
+ * {@link DALOY_LIGHT_RESPONSE_OK}). Carries `status` + a real `Headers`
210
+ * instance + the raw body bytes via {@link DALOY_RAW_BODY}; every other
211
+ * WHATWG surface (body streams, `json()`, `clone()`, …) delegates to a
212
+ * lazily-materialized real `Response`, so hook code that inspects the
213
+ * response body still behaves exactly as before — it just pays the
214
+ * construction cost only when it actually does so. `instanceof Response`
215
+ * holds via prototype re-rooting; all overridden accessors below shadow
216
+ * undici's brand-checked ones.
217
+ */
218
+ class LightResponse {
219
+ #status;
220
+ #headers;
221
+ #rawBody;
222
+ #real;
223
+ constructor(status, headers, rawBody) {
224
+ this.#status = status;
225
+ this.#headers = headers;
226
+ this.#rawBody = rawBody;
227
+ this[DALOY_RAW_BODY] = rawBody;
228
+ }
229
+ /** Build (once) and return an equivalent real `Response` for rare surfaces. */
230
+ #materialize() {
231
+ return (this.#real ??= new Response(this.#rawBody, {
232
+ status: this.#status,
233
+ headers: this.#headers,
234
+ }));
235
+ }
236
+ get status() {
237
+ return this.#status;
238
+ }
239
+ get headers() {
240
+ return this.#headers;
241
+ }
242
+ get ok() {
243
+ return this.#status >= 200 && this.#status <= 299;
244
+ }
245
+ // Spec constants for a synthesized (non-network) Response.
246
+ get statusText() {
247
+ return "";
248
+ }
249
+ get type() {
250
+ return "default";
251
+ }
252
+ get url() {
253
+ return "";
254
+ }
255
+ get redirected() {
256
+ return false;
257
+ }
258
+ get body() {
259
+ return this.#materialize().body;
260
+ }
261
+ get bodyUsed() {
262
+ return this.#real !== undefined ? this.#real.bodyUsed : false;
263
+ }
264
+ arrayBuffer() {
265
+ return this.#materialize().arrayBuffer();
266
+ }
267
+ blob() {
268
+ return this.#materialize().blob();
269
+ }
270
+ bytes() {
271
+ return this.#materialize().bytes();
272
+ }
273
+ formData() {
274
+ return this.#materialize().formData();
275
+ }
276
+ json() {
277
+ return this.#materialize().json();
278
+ }
279
+ text() {
280
+ return this.#materialize().text();
281
+ }
282
+ clone() {
283
+ return this.#materialize().clone();
284
+ }
285
+ }
286
+ // `instanceof Response` must hold for hook code and adapter checks. Every
287
+ // own getter/method above shadows undici's brand-checked accessors.
288
+ Object.setPrototypeOf(LightResponse.prototype, Response.prototype);
289
+ /**
290
+ * The DaloyJS application: a contract-first router plus a web-standard
291
+ * `fetch(Request): Promise<Response>` handler that runs unchanged on Node,
292
+ * Bun, Deno, Cloudflare Workers, and Vercel via the adapters.
293
+ *
294
+ * Routes registered with {@link App.route} are validated against their
295
+ * declared schemas on the way in and out, accumulate in the typed
296
+ * {@link App.routes} tuple (powering OpenAPI generation and the typed
297
+ * client), and are served with secure defaults: body limits, request
298
+ * timeouts, security headers, and prod-mode error redaction.
299
+ *
300
+ * @typeParam Routes - Compile-time tuple of registered route definitions,
301
+ * grown by each {@link App.route} call.
198
302
  * @since 0.1.0
199
303
  */
200
304
  export class App {
305
+ /**
306
+ * Resolved constructor options. The security-relevant trio is always
307
+ * populated: `validateResponses` (default `true`), `bodyLimitBytes`
308
+ * (default 1 MiB), and `requestTimeoutMs` (default 30_000 ms).
309
+ */
201
310
  options;
311
+ /** Structured logger for the app. Defaults to a JSON-lines console logger; override via `options.logger`. */
202
312
  log;
203
313
  /**
204
314
  * Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
@@ -596,6 +706,26 @@ export class App {
596
706
  const origin = request.headers.get("origin");
597
707
  if (!origin || origin === "null")
598
708
  return;
709
+ // Fast path: when both the Origin header and the request URL are in the
710
+ // trivially-normalized shape (lowercase ASCII scheme://host[:port] with
711
+ // no userinfo / percent-escapes / IPv6 brackets), their origins can be
712
+ // compared as plain strings without two `new URL()` constructions per
713
+ // request. Anything unusual returns `undefined` and falls back to the
714
+ // exact WHATWG comparison below — the guard's accept/reject semantics
715
+ // are identical on both paths.
716
+ const fastHeaderOrigin = getOriginFast(origin);
717
+ if (fastHeaderOrigin !== undefined) {
718
+ const fastReqOrigin = typeof requestUrl === "string" ? getOriginFast(requestUrl) : requestUrl.origin;
719
+ if (fastReqOrigin !== undefined) {
720
+ if (fastHeaderOrigin === fastReqOrigin)
721
+ return;
722
+ if (corsOriginAllows.some((allows) => allows(origin)))
723
+ return;
724
+ throw new ForbiddenError(`Cross-origin ${method} from "${fastHeaderOrigin}" rejected: no registered cors() policy allows that origin. ` +
725
+ `Register cors({ origin: [...] }) via app.use(...) to allow it, or pass ` +
726
+ `app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
727
+ }
728
+ }
599
729
  let originUrl;
600
730
  try {
601
731
  originUrl = new URL(origin);
@@ -932,6 +1062,9 @@ export class App {
932
1062
  operationId: "getOpenAPIDocument",
933
1063
  ...(tags.length ? { tags } : {}),
934
1064
  summary: "OpenAPI 3.1 document",
1065
+ // Framework-owned body (the generated spec document); the missing
1066
+ // schema is intentional, not an OWASP API3 leak risk.
1067
+ acknowledgeNoResponseBodySchema: true,
935
1068
  responses: {
936
1069
  200: { description: "OpenAPI 3.1 document for this application." },
937
1070
  },
@@ -947,6 +1080,7 @@ export class App {
947
1080
  operationId: "getOpenAPIDocumentYaml",
948
1081
  ...(tags.length ? { tags } : {}),
949
1082
  summary: "OpenAPI 3.1 document (YAML)",
1083
+ acknowledgeNoResponseBodySchema: true,
950
1084
  responses: {
951
1085
  200: { description: "OpenAPI 3.1 document for this application, in YAML." },
952
1086
  },
@@ -985,6 +1119,7 @@ export class App {
985
1119
  operationId: "getDocsUI",
986
1120
  ...(tags.length ? { tags } : {}),
987
1121
  summary: "Interactive API reference",
1122
+ acknowledgeNoResponseBodySchema: true,
988
1123
  responses: {
989
1124
  200: { description: "Interactive API documentation UI." },
990
1125
  },
@@ -1082,6 +1217,8 @@ export class App {
1082
1217
  operationId: "getAsyncAPIDocument",
1083
1218
  ...(tags.length ? { tags } : {}),
1084
1219
  summary: "AsyncAPI 3.0 document",
1220
+ // Framework-owned bodies on the AsyncAPI surface, like mountDocs above.
1221
+ acknowledgeNoResponseBodySchema: true,
1085
1222
  responses: {
1086
1223
  200: { description: "AsyncAPI 3.0 document for this application's WebSocket channels." },
1087
1224
  },
@@ -1094,6 +1231,7 @@ export class App {
1094
1231
  operationId: "getAsyncAPIDocumentYaml",
1095
1232
  ...(tags.length ? { tags } : {}),
1096
1233
  summary: "AsyncAPI 3.0 document (YAML)",
1234
+ acknowledgeNoResponseBodySchema: true,
1097
1235
  responses: {
1098
1236
  200: { description: "AsyncAPI 3.0 document for this application, in YAML." },
1099
1237
  },
@@ -1115,6 +1253,7 @@ export class App {
1115
1253
  operationId: "getAsyncAPIUI",
1116
1254
  ...(tags.length ? { tags } : {}),
1117
1255
  summary: "Interactive AsyncAPI reference",
1256
+ acknowledgeNoResponseBodySchema: true,
1118
1257
  responses: {
1119
1258
  200: { description: "Interactive AsyncAPI documentation UI." },
1120
1259
  },
@@ -1443,6 +1582,8 @@ export class App {
1443
1582
  operationId: "metrics",
1444
1583
  tags: ["Observability"],
1445
1584
  summary: "Prometheus metrics scrape endpoint",
1585
+ // Prometheus text exposition rendered by the framework registry.
1586
+ acknowledgeNoResponseBodySchema: true,
1446
1587
  handler: async ({ request }) => {
1447
1588
  if (buckets && rateLimitConfig) {
1448
1589
  const key = healthRouteKey(request);
@@ -1561,6 +1702,8 @@ export class App {
1561
1702
  operationId: isHealth ? "healthcheck" : "readinesscheck",
1562
1703
  tags: ["Health"],
1563
1704
  summary: isHealth ? "Liveness probe" : "Readiness probe",
1705
+ // Framework-serialized probe payload; missing schema is intentional.
1706
+ acknowledgeNoResponseBodySchema: true,
1564
1707
  handler: async ({ request }) => {
1565
1708
  if (buckets && rateLimitConfig) {
1566
1709
  const key = healthRouteKey(request);
@@ -1799,6 +1942,20 @@ export class App {
1799
1942
  this.resetBootGuardCache();
1800
1943
  return this;
1801
1944
  }
1945
+ /**
1946
+ * Apply an ordered list of plugin extensions to the
1947
+ * group-level hook chain. Each extension's `handler` is wrapped into a
1948
+ * single-event {@link Hooks} bundle so subsequent route registrations
1949
+ * pick it up via the normal hook composition path.
1950
+ * @internal
1951
+ */
1952
+ applyExtensions(ordered) {
1953
+ for (const ext of ordered) {
1954
+ const hooks = { [ext.event]: ext.handler };
1955
+ this.groupHooks.push(hooks);
1956
+ }
1957
+ this._coldPathHooksCache = undefined;
1958
+ }
1802
1959
  /**
1803
1960
  * Decorate `ctx.state` with a value available inside every handler and hook.
1804
1961
  *
@@ -1821,22 +1978,10 @@ export class App {
1821
1978
  *
1822
1979
  * @param key - Property name on `ctx.state`.
1823
1980
  * @param value - Value bound to that property on every request.
1981
+ * @param opts - Pass `{ override: true }` to replace an existing decoration (logged as a warning).
1824
1982
  * @returns This `App` instance for chaining.
1983
+ * @throws Error if `key` is already decorated and `opts.override` is not `true`.
1825
1984
  */
1826
- /**
1827
- * Apply an ordered list of plugin extensions to the
1828
- * group-level hook chain. Each extension's `handler` is wrapped into a
1829
- * single-event {@link Hooks} bundle so subsequent route registrations
1830
- * pick it up via the normal hook composition path.
1831
- * @internal
1832
- */
1833
- applyExtensions(ordered) {
1834
- for (const ext of ordered) {
1835
- const hooks = { [ext.event]: ext.handler };
1836
- this.groupHooks.push(hooks);
1837
- }
1838
- this._coldPathHooksCache = undefined;
1839
- }
1840
1985
  decorate(key, value, opts = {}) {
1841
1986
  if (Object.prototype.hasOwnProperty.call(this.decorations, key) && opts.override !== true) {
1842
1987
  // Namespace-protected decorators. Refuse to silently
@@ -2254,7 +2399,11 @@ export class App {
2254
2399
  if (isPromiseLike(routeOnRequestResult))
2255
2400
  await routeOnRequestResult;
2256
2401
  }
2257
- ctx = await buildContext(request, getUrl, match.params, def, this.options);
2402
+ // buildContext is sync unless a schema validator or body read actually
2403
+ // suspends — branch on the promise so the fully-sync case never
2404
+ // schedules a microtask.
2405
+ const builtCtx = buildContext(request, getUrl, match.params, def, this.options);
2406
+ ctx = isPromiseLike(builtCtx) ? await builtCtx : builtCtx;
2258
2407
  // Stable two-field write keeps `ctx.state`'s hidden class consistent across
2259
2408
  // requests for the common no-decorator case. The decorations spread only
2260
2409
  // fires when `app.decorate()` was actually called.
@@ -2325,7 +2474,11 @@ export class App {
2325
2474
  }
2326
2475
  return finalizedRaw;
2327
2476
  }
2328
- const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
2477
+ const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true,
2478
+ // Adapter shims set this marker on their request prototype to declare
2479
+ // that the response consumer only reads status/headers/raw-body — see
2480
+ // DALOY_LIGHT_RESPONSE_OK. Everyone else gets a real Response.
2481
+ request[DALOY_LIGHT_RESPONSE_OK] === true);
2329
2482
  let response = isPromiseLike(serializeResultRes)
2330
2483
  ? await serializeResultRes
2331
2484
  : serializeResultRes;
@@ -2440,13 +2593,6 @@ export class App {
2440
2593
  const req = url instanceof Request ? url : new Request(url, init);
2441
2594
  return this.fetch(req);
2442
2595
  }
2443
- /**
2444
- * Return a JSON-serializable summary of every registered route. Useful for
2445
- * dead-route detection, dashboards, and tests that want to assert against
2446
- * the route table without parsing the OpenAPI document.
2447
- *
2448
- * @returns Array of one {@link IntrospectedRoute} per registered route.
2449
- */
2450
2596
  /**
2451
2597
  * Emit a one-time development warning when any route declares a `2xx`
2452
2598
  * response without a body schema, because response-field stripping
@@ -2469,9 +2615,18 @@ export class App {
2469
2615
  routes: offending.slice(0, 20),
2470
2616
  }, `${offending.length} route(s) declare a 2xx response with no body schema; ` +
2471
2617
  "response field-level stripping (OWASP API3) is not applied there, so a handler that " +
2472
- "returns undeclared fields will leak them. Declare a response body schema, or ignore if " +
2473
- "the route intentionally returns no body. Run `daloy doctor` to list them.");
2618
+ "returns undeclared fields will leak them. Declare a response body schema, or set " +
2619
+ "`acknowledgeNoResponseBodySchema: true` on routes that intentionally return an opaque " +
2620
+ "or body-less response. Run `daloy doctor` to list them. " +
2621
+ "See https://daloyjs.dev/docs/security/owasp-api-top-10#api3");
2474
2622
  }
2623
+ /**
2624
+ * Return a JSON-serializable summary of every registered route. Useful for
2625
+ * dead-route detection, dashboards, and tests that want to assert against
2626
+ * the route table without parsing the OpenAPI document.
2627
+ *
2628
+ * @returns Array of one {@link IntrospectedRoute} per registered route.
2629
+ */
2475
2630
  introspect() {
2476
2631
  return this.routes.map((r) => {
2477
2632
  const route = {
@@ -2639,6 +2794,12 @@ function detectHeaderMutatingMiddleware(layers) {
2639
2794
  /**
2640
2795
  * Topological sort of plugin extensions. Refuses-at-call
2641
2796
  * on cyclic ordering with a structured error naming the cycle.
2797
+ *
2798
+ * @param exts - Extensions to order by their `before` / `after` constraints (Kahn's algorithm).
2799
+ * @returns The extensions in a valid execution order.
2800
+ * @throws Error on duplicate extension names, on a `before`/`after` cycle
2801
+ * (naming the members), or when two extensions mutate the same response
2802
+ * header without declaring an ordering between each other.
2642
2803
  * @internal
2643
2804
  */
2644
2805
  export function topoSortExtensions(exts) {
@@ -2766,6 +2927,85 @@ function getPathnameFast(url) {
2766
2927
  end = h;
2767
2928
  return url.slice(pathStart, end);
2768
2929
  }
2930
+ /**
2931
+ * Extract the WHATWG origin (`scheme://host[:port]`) from an absolute
2932
+ * `http`/`https` URL or Origin-header value without constructing a `URL`.
2933
+ * Companion to {@link getPathnameFast}, used by the cross-origin guard on
2934
+ * state-changing requests.
2935
+ *
2936
+ * Returns `undefined` — signalling "fall back to `new URL(...).origin`" —
2937
+ * whenever the input is not in the trivially-normalized shape a `URL` parse
2938
+ * would return unchanged: non-`http(s)` schemes, uppercase characters
2939
+ * (scheme/host case-folding), userinfo (`user@host`, which `URL.origin`
2940
+ * strips), percent-escapes, non-ASCII hosts (IDNA/punycode), IPv6 literals
2941
+ * (zero-compression normalization), empty hosts, backslashes (URL treats
2942
+ * `\` as `/`), and explicit default ports (`:80` / `:443`, which
2943
+ * `URL.origin` elides). The fast path therefore never *disagrees* with the
2944
+ * WHATWG origin — it only answers when the answer is unambiguous.
2945
+ */
2946
+ function getOriginFast(url) {
2947
+ let hostStart;
2948
+ if (url.startsWith("http://"))
2949
+ hostStart = 7;
2950
+ else if (url.startsWith("https://"))
2951
+ hostStart = 8;
2952
+ else
2953
+ return undefined;
2954
+ // Find the end of the authority: first "/", "?", or "#" after the scheme.
2955
+ let end = url.length;
2956
+ for (let i = hostStart; i < url.length; i++) {
2957
+ const c = url.charCodeAt(i);
2958
+ if (c === 47 /* / */ || c === 63 /* ? */ || c === 35 /* # */) {
2959
+ end = i;
2960
+ break;
2961
+ }
2962
+ }
2963
+ if (end === hostStart)
2964
+ return undefined; // empty host
2965
+ for (let i = hostStart; i < end; i++) {
2966
+ const c = url.charCodeAt(i);
2967
+ // Reject anything that could normalize differently under a real URL
2968
+ // parse: uppercase A-Z, userinfo "@", percent "%", IPv6 "[", backslash
2969
+ // "\", raw whitespace/controls, and all non-ASCII.
2970
+ if ((c >= 65 && c <= 90) /* A-Z */ ||
2971
+ c === 64 /* @ */ ||
2972
+ c === 37 /* % */ ||
2973
+ c === 91 /* [ */ ||
2974
+ c === 92 /* \ */ ||
2975
+ c <= 32 /* controls + space */ ||
2976
+ c >= 127 /* DEL + non-ASCII */) {
2977
+ return undefined;
2978
+ }
2979
+ }
2980
+ const authority = url.slice(hostStart, end);
2981
+ const colon = authority.indexOf(":");
2982
+ if (colon !== -1) {
2983
+ const port = authority.slice(colon + 1);
2984
+ // Trailing ":" alone, an empty port, or a default port all normalize to
2985
+ // no port under URL — fall back rather than replicate that here. A
2986
+ // second ":" (malformed / IPv6-ish) also falls back.
2987
+ if (port.length === 0 || port.indexOf(":") !== -1)
2988
+ return undefined;
2989
+ if ((hostStart === 7 && port === "80") /* http default */ ||
2990
+ (hostStart === 8 && port === "443") /* https default */) {
2991
+ return undefined;
2992
+ }
2993
+ // Port must be all digits; anything else is not trivially normalized.
2994
+ for (let i = 0; i < port.length; i++) {
2995
+ const c = port.charCodeAt(i);
2996
+ if (c < 48 || c > 57)
2997
+ return undefined;
2998
+ }
2999
+ // Leading zeros normalize away under URL ("0080" -> "80"), and ports
3000
+ // above 65535 make the URL constructor *throw* (the guard's malformed-
3001
+ // origin rejection) — both must take the exact WHATWG path.
3002
+ if (port.length > 1 && port.charCodeAt(0) === 48 /* 0 */)
3003
+ return undefined;
3004
+ if (port.length > 5 || (port.length === 5 && Number(port) > 65535))
3005
+ return undefined;
3006
+ }
3007
+ return url.slice(0, end);
3008
+ }
2769
3009
  function mergeHooks(layers) {
2770
3010
  const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
2771
3011
  const requiredScopes = requiredScopesFromHooks(layers);
@@ -3018,7 +3258,10 @@ function hasRequestSchema(request, key) {
3018
3258
  * It powers both the `daloy doctor` `audit.response.bodySchema` finding and
3019
3259
  * the development-mode boot warning emitted on the first request. The result
3020
3260
  * is advisory — a route may legitimately return no body — so callers treat
3021
- * it as a `warn`, never a hard error.
3261
+ * it as a `warn`, never a hard error. Routes that set
3262
+ * `acknowledgeNoResponseBodySchema: true` (including the framework-mounted
3263
+ * docs, AsyncAPI, health, and metrics routes, whose bodies DaloyJS itself
3264
+ * serializes) are skipped: the missing schema is declared intentional.
3022
3265
  *
3023
3266
  * @param routes - Route definitions to inspect (typically `app.routes`).
3024
3267
  * @returns One entry per offending route with the affected `2xx` status codes.
@@ -3027,6 +3270,8 @@ function hasRequestSchema(request, key) {
3027
3270
  export function findRoutesMissingResponseBodySchema(routes) {
3028
3271
  const offending = [];
3029
3272
  for (const route of routes) {
3273
+ if (route.acknowledgeNoResponseBodySchema === true)
3274
+ continue;
3030
3275
  const statuses = [];
3031
3276
  const responses = route.responses;
3032
3277
  for (const key of Object.keys(responses)) {
@@ -3137,44 +3382,77 @@ function buildContext(request, getUrl, rawParams, def, opts) {
3137
3382
  if (!hasSchema) {
3138
3383
  return finishContext();
3139
3384
  }
3140
- return (async () => {
3141
- if (def.request?.params) {
3142
- const r = await validate(def.request.params, rawParams);
3143
- if (r.issues)
3144
- throw new ValidationError("params", toIssues(r.issues));
3145
- params = r.value;
3385
+ const applyChecked = (r, part) => {
3386
+ if (r.issues)
3387
+ throw new ValidationError(part, toIssues(r.issues));
3388
+ return r.value;
3389
+ };
3390
+ const validateBodyAndFinish = (raw) => {
3391
+ const r = def.request.body["~standard"].validate(raw);
3392
+ if (isPromiseLike(r)) {
3393
+ return r.then((resolved) => {
3394
+ body = applyChecked(resolved, "body");
3395
+ return finishContext();
3396
+ });
3146
3397
  }
3147
- if (hasQuerySchema) {
3148
- const r = await validate(def.request.query, buildQuery());
3149
- if (r.issues)
3150
- throw new ValidationError("query", toIssues(r.issues));
3151
- query = r.value;
3398
+ body = applyChecked(r, "body");
3399
+ return finishContext();
3400
+ };
3401
+ const stepBody = () => {
3402
+ if (!def.request?.body)
3403
+ return finishContext();
3404
+ const ct = (request.headers.get("content-type") ?? "").toLowerCase();
3405
+ const allowed = def.accepts ??
3406
+ opts.allowedContentTypes ?? [
3407
+ "application/json",
3408
+ "application/x-www-form-urlencoded",
3409
+ "multipart/form-data",
3410
+ ];
3411
+ if (!allowed.some((a) => ct.includes(a))) {
3412
+ throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
3152
3413
  }
3153
- if (hasHeadersSchema) {
3154
- const r = await validate(def.request.headers, buildHeaders());
3155
- if (r.issues)
3156
- throw new ValidationError("headers", toIssues(r.issues));
3157
- headers = r.value;
3158
- }
3159
- if (def.request?.body) {
3160
- const ct = (request.headers.get("content-type") ?? "").toLowerCase();
3161
- const allowed = def.accepts ??
3162
- opts.allowedContentTypes ?? [
3163
- "application/json",
3164
- "application/x-www-form-urlencoded",
3165
- "multipart/form-data",
3166
- ];
3167
- if (!allowed.some((a) => ct.includes(a))) {
3168
- throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
3169
- }
3170
- const raw = await readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
3171
- const r = await validate(def.request.body, raw);
3172
- if (r.issues)
3173
- throw new ValidationError("body", toIssues(r.issues));
3174
- body = r.value;
3414
+ const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
3415
+ if (isPromiseLike(raw))
3416
+ return raw.then(validateBodyAndFinish);
3417
+ return validateBodyAndFinish(raw);
3418
+ };
3419
+ const stepHeaders = () => {
3420
+ if (!hasHeadersSchema)
3421
+ return stepBody();
3422
+ const r = def.request.headers["~standard"].validate(buildHeaders());
3423
+ if (isPromiseLike(r)) {
3424
+ return r.then((resolved) => {
3425
+ headers = applyChecked(resolved, "headers");
3426
+ return stepBody();
3427
+ });
3175
3428
  }
3176
- return finishContext();
3177
- })();
3429
+ headers = applyChecked(r, "headers");
3430
+ return stepBody();
3431
+ };
3432
+ const stepQuery = () => {
3433
+ if (!hasQuerySchema)
3434
+ return stepHeaders();
3435
+ const r = def.request.query["~standard"].validate(buildQuery());
3436
+ if (isPromiseLike(r)) {
3437
+ return r.then((resolved) => {
3438
+ query = applyChecked(resolved, "query");
3439
+ return stepHeaders();
3440
+ });
3441
+ }
3442
+ query = applyChecked(r, "query");
3443
+ return stepHeaders();
3444
+ };
3445
+ if (def.request?.params) {
3446
+ const r = def.request.params["~standard"].validate(rawParams);
3447
+ if (isPromiseLike(r)) {
3448
+ return r.then((resolved) => {
3449
+ params = applyChecked(resolved, "params");
3450
+ return stepQuery();
3451
+ });
3452
+ }
3453
+ params = applyChecked(r, "params");
3454
+ }
3455
+ return stepQuery();
3178
3456
  }
3179
3457
  function headersToObject(h) {
3180
3458
  const o = {};
@@ -3205,26 +3483,79 @@ function toIssues(issues) {
3205
3483
  .join("."),
3206
3484
  }));
3207
3485
  }
3208
- async function readBody(req, ct, limit, multipart) {
3486
+ /** Shared decoder for request-body text. Allocating one per request is wasted work. */
3487
+ const TEXT_DECODER = new TextDecoder();
3488
+ /**
3489
+ * Synchronous fast path for {@link readBodyLimited}: returns the adapter's
3490
+ * pre-buffered body bytes when they are available on the request via
3491
+ * {@link DALOY_REQUEST_RAW_BODY}, or `undefined` when the caller must fall
3492
+ * back to the async streaming read. Runs the exact same Content-Length
3493
+ * validation and size-limit checks (in the same order, throwing the same
3494
+ * errors) as `readBodyLimited`, so the security posture is identical — the
3495
+ * only difference is that a symbol-cache hit never touches the microtask
3496
+ * queue.
3497
+ *
3498
+ * @throws {BadRequestError} When `Content-Length` is present but invalid.
3499
+ * @throws {PayloadTooLargeError} When the declared or actual size exceeds `limit`.
3500
+ */
3501
+ function readBodyBytesFast(req, limit) {
3502
+ const cl = req.headers.get("content-length");
3503
+ if (cl) {
3504
+ const n = Number(cl);
3505
+ if (!Number.isFinite(n) || n < 0)
3506
+ throw new BadRequestError("Invalid Content-Length");
3507
+ if (n > limit)
3508
+ throw new PayloadTooLargeError(limit);
3509
+ }
3510
+ const cached = req[DALOY_REQUEST_RAW_BODY];
3511
+ if (cached instanceof Uint8Array) {
3512
+ if (cached.byteLength > limit)
3513
+ throw new PayloadTooLargeError(limit);
3514
+ return cached;
3515
+ }
3516
+ return undefined;
3517
+ }
3518
+ function parseJsonBodyBytes(bytes) {
3519
+ if (bytes.byteLength === 0)
3520
+ return undefined;
3521
+ return safeJsonParse(TEXT_DECODER.decode(bytes));
3522
+ }
3523
+ function parseUrlencodedBodyBytes(bytes) {
3524
+ const params = new URLSearchParams(TEXT_DECODER.decode(bytes));
3525
+ // Same Spring4Shell-class defense as queryToObject: Object.fromEntries
3526
+ // would set __proto__ / constructor / prototype as own properties.
3527
+ const out = {};
3528
+ for (const [k, v] of params) {
3529
+ if (isForbiddenObjectKey(k))
3530
+ continue;
3531
+ out[k] = v;
3532
+ }
3533
+ return out;
3534
+ }
3535
+ /**
3536
+ * Read and parse a request body according to its content type. Plain
3537
+ * (non-`async`) on purpose: when the adapter pre-buffered the body bytes
3538
+ * (the common JSON POST case on Node), the parse completes synchronously and
3539
+ * the caller stays on the sync dispatch fast path. Falls back to the
3540
+ * streaming `readBodyLimited` promise otherwise. All parsing keeps the
3541
+ * prototype-pollution-safe semantics of the previous implementation.
3542
+ */
3543
+ function readBody(req, ct, limit, multipart) {
3209
3544
  if (ct.includes("application/json")) {
3210
- const bytes = await readBodyLimited(req, limit);
3211
- if (bytes.byteLength === 0)
3212
- return undefined;
3213
- return safeJsonParse(new TextDecoder().decode(bytes));
3545
+ const fast = readBodyBytesFast(req, limit);
3546
+ if (fast !== undefined)
3547
+ return parseJsonBodyBytes(fast);
3548
+ return readBodyLimited(req, limit).then(parseJsonBodyBytes);
3214
3549
  }
3215
3550
  if (ct.includes("application/x-www-form-urlencoded")) {
3216
- const bytes = await readBodyLimited(req, limit);
3217
- const params = new URLSearchParams(new TextDecoder().decode(bytes));
3218
- // Same Spring4Shell-class defense as queryToObject: Object.fromEntries
3219
- // would set __proto__ / constructor / prototype as own properties.
3220
- const out = {};
3221
- for (const [k, v] of params) {
3222
- if (isForbiddenObjectKey(k))
3223
- continue;
3224
- out[k] = v;
3225
- }
3226
- return out;
3551
+ const fast = readBodyBytesFast(req, limit);
3552
+ if (fast !== undefined)
3553
+ return parseUrlencodedBodyBytes(fast);
3554
+ return readBodyLimited(req, limit).then(parseUrlencodedBodyBytes);
3227
3555
  }
3556
+ return readBodySlow(req, ct, limit, multipart);
3557
+ }
3558
+ async function readBodySlow(req, ct, limit, multipart) {
3228
3559
  if (ct.includes("multipart/form-data")) {
3229
3560
  // Fast-fail on an honestly-declared oversize body.
3230
3561
  const cl = req.headers.get("content-length");
@@ -3238,15 +3569,33 @@ async function readBody(req, ct, limit, multipart) {
3238
3569
  // in memory on runtimes whose adapter does not cap at the socket layer
3239
3570
  // (Workers / Deno / Vercel). `readBodyLimited` streams the body and
3240
3571
  // throws `PayloadTooLargeError` the instant it exceeds `limit`; we then
3241
- // re-parse the bounded bytes with the standard `formData()` parser,
3242
- // preserving the multipart boundary via the original Content-Type. This is
3572
+ // re-parse the bounded bytes with the standard `formData()` parser. This is
3243
3573
  // Web-standard only (`Request` + `formData`), so it stays runtime-portable.
3244
3574
  const bytes = await readBodyLimited(req, limit);
3245
- const fd = await new Request(req.url, {
3246
- method: "POST",
3247
- headers: { "content-type": ct },
3248
- body: bytes,
3249
- }).formData();
3575
+ // The `ct` argument is lower-cased by the caller for case-insensitive
3576
+ // media-type matching, but multipart boundaries are case-SENSITIVE (RFC
3577
+ // 2046 §5.1.1). Reconstructing the Request with the lower-cased boundary
3578
+ // would stop it matching the delimiter in the raw body, so the platform
3579
+ // parser throws and every upload from a client that uses a mixed-case
3580
+ // boundary — Chromium/WebKit browsers (`----WebKitFormBoundary…`) and curl
3581
+ // (`------------------------…`) — fails. Re-read the ORIGINAL-case
3582
+ // Content-Type header to preserve the boundary verbatim.
3583
+ const rawContentType = req.headers.get("content-type") ?? ct;
3584
+ let fd;
3585
+ try {
3586
+ fd = await new Request(req.url, {
3587
+ method: "POST",
3588
+ headers: { "content-type": rawContentType },
3589
+ body: bytes,
3590
+ }).formData();
3591
+ }
3592
+ catch {
3593
+ // A body the platform parser cannot read is a malformed client request,
3594
+ // not a server fault: surface it as an RFC 9457 400 rather than letting
3595
+ // the raw parser TypeError bubble up as a generic 500 (which would also
3596
+ // pollute server-error monitoring with client-controlled input).
3597
+ throw new BadRequestError("Malformed multipart/form-data body");
3598
+ }
3250
3599
  const out = {};
3251
3600
  let fields = 0;
3252
3601
  let files = 0;
@@ -3298,7 +3647,7 @@ function normalizeSunset(value, method, path) {
3298
3647
  }
3299
3648
  return date.toUTCString();
3300
3649
  }
3301
- function serializeResult(result, def, validateResponses) {
3650
+ function serializeResult(result, def, validateResponses, lightOk = false) {
3302
3651
  const spec = def.responses[result.status];
3303
3652
  if (!spec) {
3304
3653
  throw new InternalError(`Handler returned status ${result.status} which is not declared in responses for ${def.method} ${def.path}`);
@@ -3360,6 +3709,13 @@ function serializeResult(result, def, validateResponses) {
3360
3709
  body = bytes;
3361
3710
  rawBody = bytes;
3362
3711
  }
3712
+ // Node-adapter hot path (opt-in via DALOY_LIGHT_RESPONSE_OK on the
3713
+ // incoming request): skip the ~2µs undici Response construction. Only
3714
+ // buffer-backed bodies qualify — streams keep the real Response so the
3715
+ // adapter's stream plumbing is untouched.
3716
+ if (lightOk && !isStream) {
3717
+ return new LightResponse(result.status, headers, rawBody);
3718
+ }
3363
3719
  const response = new Response(body, { status: result.status, headers });
3364
3720
  if (!isStream) {
3365
3721
  response[DALOY_RAW_BODY] = rawBody;
@@ -3449,6 +3805,8 @@ function serializeErr(err) {
3449
3805
  * for ergonomics and matches the factory pattern used by Express, Fastify,
3450
3806
  * and Hono adapters.
3451
3807
  *
3808
+ * @param options - Same {@link AppOptions} accepted by the `App` constructor. Default: `{}`.
3809
+ * @returns A new {@link App} instance.
3452
3810
  * @since 0.3.0
3453
3811
  */
3454
3812
  export function createApp(options = {}) {