@daloyjs/core 1.0.0-beta.7 → 1.0.0-rc.1

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
@@ -1,13 +1,12 @@
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";
8
7
  import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
9
8
  import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.js";
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";
9
+ import { secureHeaders as secureHeadersMiddleware, AUTH_HOOK_MARKER, 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
10
  import { COMPRESSION_HOOK_MARKER } from "./compression.js";
12
11
  import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
13
12
  import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
@@ -119,6 +118,13 @@ const CANONICAL_HTTP_METHODS = new Set([
119
118
  "HEAD",
120
119
  "OPTIONS",
121
120
  ]);
121
+ /**
122
+ * Global-registry symbol stamped by `mcpRoutes()` on the route definitions it
123
+ * produces (unless the caller opts out with `public: true`). Read here — rather
124
+ * than importing from `mcp.ts` — so the MCP module is never pulled into the core
125
+ * `App` bundle. Must match the string used in `mcpRoutes`.
126
+ */
127
+ const MCP_ROUTE_MARKER = Symbol.for("daloyjs.mcp.route");
122
128
  /**
123
129
  * Apply a topology-aware security preset on top of caller-supplied
124
130
  * options. Returns a new options object where preset defaults fill in
@@ -194,6 +200,99 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
194
200
  * opt in; userland code should not depend on it.
195
201
  */
196
202
  export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
203
+ /**
204
+ * Internal Symbol an adapter sets (once, on its request shim's prototype) to
205
+ * declare: "the object that ultimately consumes this request's `Response`
206
+ * only reads `status` / `headers` / {@link DALOY_RAW_BODY} — it never needs a
207
+ * branded WHATWG `Response`". When present on the incoming request,
208
+ * {@link serializeResult} may return a {@link LightResponse} and skip the
209
+ * ~2µs undici `Response` construction per request. Requests without the
210
+ * marker (Bun / Deno / Workers adapters, tests, direct `app.fetch()` callers)
211
+ * always get a real `Response`, so the public contract is unchanged.
212
+ */
213
+ export const DALOY_LIGHT_RESPONSE_OK = Symbol.for("daloyjs.response.lightOk");
214
+ /**
215
+ * Minimal `Response` stand-in returned on the Node-adapter hot path (gated by
216
+ * {@link DALOY_LIGHT_RESPONSE_OK}). Carries `status` + a real `Headers`
217
+ * instance + the raw body bytes via {@link DALOY_RAW_BODY}; every other
218
+ * WHATWG surface (body streams, `json()`, `clone()`, …) delegates to a
219
+ * lazily-materialized real `Response`, so hook code that inspects the
220
+ * response body still behaves exactly as before — it just pays the
221
+ * construction cost only when it actually does so. `instanceof Response`
222
+ * holds via prototype re-rooting; all overridden accessors below shadow
223
+ * undici's brand-checked ones.
224
+ */
225
+ class LightResponse {
226
+ #status;
227
+ #headers;
228
+ #rawBody;
229
+ #real;
230
+ constructor(status, headers, rawBody) {
231
+ this.#status = status;
232
+ this.#headers = headers;
233
+ this.#rawBody = rawBody;
234
+ this[DALOY_RAW_BODY] = rawBody;
235
+ }
236
+ /** Build (once) and return an equivalent real `Response` for rare surfaces. */
237
+ #materialize() {
238
+ return (this.#real ??= new Response(this.#rawBody, {
239
+ status: this.#status,
240
+ headers: this.#headers,
241
+ }));
242
+ }
243
+ get status() {
244
+ return this.#status;
245
+ }
246
+ get headers() {
247
+ return this.#headers;
248
+ }
249
+ get ok() {
250
+ return this.#status >= 200 && this.#status <= 299;
251
+ }
252
+ // Spec constants for a synthesized (non-network) Response.
253
+ get statusText() {
254
+ return "";
255
+ }
256
+ get type() {
257
+ return "default";
258
+ }
259
+ get url() {
260
+ return "";
261
+ }
262
+ get redirected() {
263
+ return false;
264
+ }
265
+ get body() {
266
+ return this.#materialize().body;
267
+ }
268
+ get bodyUsed() {
269
+ return this.#real !== undefined ? this.#real.bodyUsed : false;
270
+ }
271
+ arrayBuffer() {
272
+ return this.#materialize().arrayBuffer();
273
+ }
274
+ blob() {
275
+ return this.#materialize().blob();
276
+ }
277
+ bytes() {
278
+ return this.#materialize().bytes();
279
+ }
280
+ formData() {
281
+ return this.#materialize().formData();
282
+ }
283
+ json() {
284
+ return this.#materialize().json();
285
+ }
286
+ text() {
287
+ return this.#materialize().text();
288
+ }
289
+ clone() {
290
+ return this.#materialize().clone();
291
+ }
292
+ }
293
+ // `instanceof Response` must hold for hook code and adapter checks. Every
294
+ // own getter/method above shadows undici's brand-checked accessors.
295
+ Object.setPrototypeOf(LightResponse.prototype, Response.prototype);
197
296
  /**
198
297
  * The DaloyJS application: a contract-first router plus a web-standard
199
298
  * `fetch(Request): Promise<Response>` handler that runs unchanged on Node,
@@ -614,6 +713,26 @@ export class App {
614
713
  const origin = request.headers.get("origin");
615
714
  if (!origin || origin === "null")
616
715
  return;
716
+ // Fast path: when both the Origin header and the request URL are in the
717
+ // trivially-normalized shape (lowercase ASCII scheme://host[:port] with
718
+ // no userinfo / percent-escapes / IPv6 brackets), their origins can be
719
+ // compared as plain strings without two `new URL()` constructions per
720
+ // request. Anything unusual returns `undefined` and falls back to the
721
+ // exact WHATWG comparison below — the guard's accept/reject semantics
722
+ // are identical on both paths.
723
+ const fastHeaderOrigin = getOriginFast(origin);
724
+ if (fastHeaderOrigin !== undefined) {
725
+ const fastReqOrigin = typeof requestUrl === "string" ? getOriginFast(requestUrl) : requestUrl.origin;
726
+ if (fastReqOrigin !== undefined) {
727
+ if (fastHeaderOrigin === fastReqOrigin)
728
+ return;
729
+ if (corsOriginAllows.some((allows) => allows(origin)))
730
+ return;
731
+ throw new ForbiddenError(`Cross-origin ${method} from "${fastHeaderOrigin}" rejected: no registered cors() policy allows that origin. ` +
732
+ `Register cors({ origin: [...] }) via app.use(...) to allow it, or pass ` +
733
+ `app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
734
+ }
735
+ }
617
736
  let originUrl;
618
737
  try {
619
738
  originUrl = new URL(origin);
@@ -794,13 +913,24 @@ export class App {
794
913
  /**
795
914
  * First-request boot guard. Verifies that the assembled hook
796
915
  * chain + route table is internally consistent before any user handler
797
- * runs. Currently checks: when `session()` is installed and any route
798
- * accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
799
- * a `csrf()` hook (or third-party equivalent stamped with
800
- * {@link CSRF_HOOK_MARKER}) must also be present in that route's effective
801
- * hook chain. Opt out with `app({ csrf: "off" })` or
802
- * `app({ secureDefaults: false })`. Runs once per App between registration
803
- * changes; the result is cached so the fast path is a single boolean check.
916
+ * runs. Currently checks (production + `secureDefaults` only):
917
+ *
918
+ * 1. **Shadow auth** — a route that declares an `auth:` requirement (so it is
919
+ * advertised as protected in the OpenAPI `security` list) must have an
920
+ * authentication hook ({@link AUTH_HOOK_MARKER}) in its effective chain.
921
+ * Otherwise it accepts unauthenticated requests while claiming protection.
922
+ * 2. **Unauthenticated MCP** a route from {@link mcpRoutes} must have an
923
+ * auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
924
+ * MCP tools are model-controlled and side-effecting, so a public one is a
925
+ * high-impact default.
926
+ * 3. **Missing CSRF** — when `session()` is installed and any route accepts a
927
+ * state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
928
+ * (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
929
+ * also be present. Skipped when `app({ csrf: "off" })`.
930
+ *
931
+ * Opt out of all guards with `app({ secureDefaults: false })`. Runs once per
932
+ * App between registration changes; the result is cached so the fast path is a
933
+ * single boolean check.
804
934
  */
805
935
  assertBootGuards() {
806
936
  if (this.bootGuard.checked) {
@@ -811,13 +941,40 @@ export class App {
811
941
  this.bootGuard.checked = true;
812
942
  if (this.options.secureDefaults === false)
813
943
  return;
814
- if (this.options.csrf === "off")
815
- return;
816
944
  // Per the risk register: boot guards only fire in production so CI /
817
945
  // staging surfaces that ship sample secrets / no CSRF token while
818
946
  // iterating do not pay the refuse-to-boot cost.
819
947
  if (!this.isProduction())
820
948
  return;
949
+ // Guard 1: shadow auth — declared `auth:` with nothing enforcing it.
950
+ const shadowAuth = this.routeSecurityMarkers.find((r) => r.declaresAuth && !r.hasAuth);
951
+ if (shadowAuth) {
952
+ const err = new Error(`Route ${shadowAuth.method} ${shadowAuth.path} declares an auth requirement (auth: ...) ` +
953
+ `but no authentication hook is installed in its effective hook chain, so it is advertised ` +
954
+ `as protected while accepting unauthenticated requests. ` +
955
+ `Install an auth middleware (bearerAuth/basicAuth/jwk/httpSignatureAuth/clientCertAuth), ` +
956
+ `wrap a custom auth hook with markAuthHook(...), remove the route's auth: declaration, ` +
957
+ `or pass app({ secureDefaults: false }) to disable this guard. ` +
958
+ `See https://daloyjs.dev/docs/security/boot-guards.`);
959
+ this.bootGuard.error = err;
960
+ throw err;
961
+ }
962
+ // Guard 2: unauthenticated MCP tool endpoint.
963
+ const mcpNoAuth = this.routeSecurityMarkers.find((r) => r.isMcp && !r.hasAuth);
964
+ if (mcpNoAuth) {
965
+ const err = new Error(`MCP route ${mcpNoAuth.method} ${mcpNoAuth.path} (from mcpRoutes()) has no authentication ` +
966
+ `hook in its effective hook chain. MCP tools are model-controlled and can trigger side ` +
967
+ `effects, so an unauthenticated endpoint is a high-impact default. ` +
968
+ `Install an auth middleware covering the MCP route (e.g. app.use(bearerAuth({ ... }))), ` +
969
+ `wrap a custom auth hook with markAuthHook(...), pass mcpRoutes(path, handler, { public: true }) ` +
970
+ `to intentionally expose it, or pass app({ secureDefaults: false }) to disable this guard. ` +
971
+ `See https://daloyjs.dev/docs/security/boot-guards.`);
972
+ this.bootGuard.error = err;
973
+ throw err;
974
+ }
975
+ // Guard 3: session() + state-changing route without csrf().
976
+ if (this.options.csrf === "off")
977
+ return;
821
978
  const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
822
979
  if (!stateChanging)
823
980
  return;
@@ -834,7 +991,9 @@ export class App {
834
991
  /**
835
992
  * Per-request guard for spoofed proxy headers. When the App was
836
993
  * constructed without an explicit {@link AppOptions.trustProxy} value
837
- * and a request arrives carrying an `X-Forwarded-*` header, refuse to
994
+ * and a request arrives carrying a spoofable forwarded / client-IP header
995
+ * (`X-Forwarded-*`, `X-Real-IP`, or a vendor header like `CF-Connecting-IP`,
996
+ * `Fly-Client-IP`, `True-Client-IP`), refuse to
838
997
  * dispatch it: the rate limiter, audit log, and request-id propagation
839
998
  * would otherwise honour the attacker-supplied IP. Returns a structured
840
999
  * `500 problem+json` so the failure is loud at the network boundary.
@@ -864,6 +1023,13 @@ export class App {
864
1023
  "x-forwarded-proto",
865
1024
  "x-forwarded-port",
866
1025
  "x-real-ip",
1026
+ // Platform-specific client-IP headers are just as spoofable as
1027
+ // X-Forwarded-* when the app is not actually behind that platform's
1028
+ // proxy. Refuse them too so a client cannot forge its source IP via a
1029
+ // vendor header the operator never configured trust for.
1030
+ "cf-connecting-ip",
1031
+ "fly-client-ip",
1032
+ "true-client-ip",
867
1033
  ]) {
868
1034
  if (headers.has(name)) {
869
1035
  found = name;
@@ -1276,6 +1442,8 @@ export class App {
1276
1442
  method: merged.method,
1277
1443
  path: merged.path,
1278
1444
  ...securityMarkers,
1445
+ declaresAuth: merged.auth !== undefined && merged.auth !== null,
1446
+ isMcp: merged[MCP_ROUTE_MARKER] === true,
1279
1447
  });
1280
1448
  this.resetBootGuardCache();
1281
1449
  return this;
@@ -2287,7 +2455,11 @@ export class App {
2287
2455
  if (isPromiseLike(routeOnRequestResult))
2288
2456
  await routeOnRequestResult;
2289
2457
  }
2290
- ctx = await buildContext(request, getUrl, match.params, def, this.options);
2458
+ // buildContext is sync unless a schema validator or body read actually
2459
+ // suspends — branch on the promise so the fully-sync case never
2460
+ // schedules a microtask.
2461
+ const builtCtx = buildContext(request, getUrl, match.params, def, this.options);
2462
+ ctx = isPromiseLike(builtCtx) ? await builtCtx : builtCtx;
2291
2463
  // Stable two-field write keeps `ctx.state`'s hidden class consistent across
2292
2464
  // requests for the common no-decorator case. The decorations spread only
2293
2465
  // fires when `app.decorate()` was actually called.
@@ -2358,7 +2530,11 @@ export class App {
2358
2530
  }
2359
2531
  return finalizedRaw;
2360
2532
  }
2361
- const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
2533
+ const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true,
2534
+ // Adapter shims set this marker on their request prototype to declare
2535
+ // that the response consumer only reads status/headers/raw-body — see
2536
+ // DALOY_LIGHT_RESPONSE_OK. Everyone else gets a real Response.
2537
+ request[DALOY_LIGHT_RESPONSE_OK] === true);
2362
2538
  let response = isPromiseLike(serializeResultRes)
2363
2539
  ? await serializeResultRes
2364
2540
  : serializeResultRes;
@@ -2767,14 +2943,17 @@ export function topoSortExtensions(exts) {
2767
2943
  function securityMarkersFromHooks(layers) {
2768
2944
  let hasSession = false;
2769
2945
  let hasCsrf = false;
2946
+ let hasAuth = false;
2770
2947
  for (const hooks of layers) {
2771
2948
  const record = hooks;
2772
2949
  if (record[SESSION_HOOK_MARKER] === true)
2773
2950
  hasSession = true;
2774
2951
  if (record[CSRF_HOOK_MARKER] === true)
2775
2952
  hasCsrf = true;
2953
+ if (record[AUTH_HOOK_MARKER] === true)
2954
+ hasAuth = true;
2776
2955
  }
2777
- return { hasSession, hasCsrf };
2956
+ return { hasSession, hasCsrf, hasAuth };
2778
2957
  }
2779
2958
  function isStateChangingMethod(method) {
2780
2959
  return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
@@ -2807,6 +2986,85 @@ function getPathnameFast(url) {
2807
2986
  end = h;
2808
2987
  return url.slice(pathStart, end);
2809
2988
  }
2989
+ /**
2990
+ * Extract the WHATWG origin (`scheme://host[:port]`) from an absolute
2991
+ * `http`/`https` URL or Origin-header value without constructing a `URL`.
2992
+ * Companion to {@link getPathnameFast}, used by the cross-origin guard on
2993
+ * state-changing requests.
2994
+ *
2995
+ * Returns `undefined` — signalling "fall back to `new URL(...).origin`" —
2996
+ * whenever the input is not in the trivially-normalized shape a `URL` parse
2997
+ * would return unchanged: non-`http(s)` schemes, uppercase characters
2998
+ * (scheme/host case-folding), userinfo (`user@host`, which `URL.origin`
2999
+ * strips), percent-escapes, non-ASCII hosts (IDNA/punycode), IPv6 literals
3000
+ * (zero-compression normalization), empty hosts, backslashes (URL treats
3001
+ * `\` as `/`), and explicit default ports (`:80` / `:443`, which
3002
+ * `URL.origin` elides). The fast path therefore never *disagrees* with the
3003
+ * WHATWG origin — it only answers when the answer is unambiguous.
3004
+ */
3005
+ function getOriginFast(url) {
3006
+ let hostStart;
3007
+ if (url.startsWith("http://"))
3008
+ hostStart = 7;
3009
+ else if (url.startsWith("https://"))
3010
+ hostStart = 8;
3011
+ else
3012
+ return undefined;
3013
+ // Find the end of the authority: first "/", "?", or "#" after the scheme.
3014
+ let end = url.length;
3015
+ for (let i = hostStart; i < url.length; i++) {
3016
+ const c = url.charCodeAt(i);
3017
+ if (c === 47 /* / */ || c === 63 /* ? */ || c === 35 /* # */) {
3018
+ end = i;
3019
+ break;
3020
+ }
3021
+ }
3022
+ if (end === hostStart)
3023
+ return undefined; // empty host
3024
+ for (let i = hostStart; i < end; i++) {
3025
+ const c = url.charCodeAt(i);
3026
+ // Reject anything that could normalize differently under a real URL
3027
+ // parse: uppercase A-Z, userinfo "@", percent "%", IPv6 "[", backslash
3028
+ // "\", raw whitespace/controls, and all non-ASCII.
3029
+ if ((c >= 65 && c <= 90) /* A-Z */ ||
3030
+ c === 64 /* @ */ ||
3031
+ c === 37 /* % */ ||
3032
+ c === 91 /* [ */ ||
3033
+ c === 92 /* \ */ ||
3034
+ c <= 32 /* controls + space */ ||
3035
+ c >= 127 /* DEL + non-ASCII */) {
3036
+ return undefined;
3037
+ }
3038
+ }
3039
+ const authority = url.slice(hostStart, end);
3040
+ const colon = authority.indexOf(":");
3041
+ if (colon !== -1) {
3042
+ const port = authority.slice(colon + 1);
3043
+ // Trailing ":" alone, an empty port, or a default port all normalize to
3044
+ // no port under URL — fall back rather than replicate that here. A
3045
+ // second ":" (malformed / IPv6-ish) also falls back.
3046
+ if (port.length === 0 || port.indexOf(":") !== -1)
3047
+ return undefined;
3048
+ if ((hostStart === 7 && port === "80") /* http default */ ||
3049
+ (hostStart === 8 && port === "443") /* https default */) {
3050
+ return undefined;
3051
+ }
3052
+ // Port must be all digits; anything else is not trivially normalized.
3053
+ for (let i = 0; i < port.length; i++) {
3054
+ const c = port.charCodeAt(i);
3055
+ if (c < 48 || c > 57)
3056
+ return undefined;
3057
+ }
3058
+ // Leading zeros normalize away under URL ("0080" -> "80"), and ports
3059
+ // above 65535 make the URL constructor *throw* (the guard's malformed-
3060
+ // origin rejection) — both must take the exact WHATWG path.
3061
+ if (port.length > 1 && port.charCodeAt(0) === 48 /* 0 */)
3062
+ return undefined;
3063
+ if (port.length > 5 || (port.length === 5 && Number(port) > 65535))
3064
+ return undefined;
3065
+ }
3066
+ return url.slice(0, end);
3067
+ }
2810
3068
  function mergeHooks(layers) {
2811
3069
  const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
2812
3070
  const requiredScopes = requiredScopesFromHooks(layers);
@@ -3183,44 +3441,77 @@ function buildContext(request, getUrl, rawParams, def, opts) {
3183
3441
  if (!hasSchema) {
3184
3442
  return finishContext();
3185
3443
  }
3186
- return (async () => {
3187
- if (def.request?.params) {
3188
- const r = await validate(def.request.params, rawParams);
3189
- if (r.issues)
3190
- throw new ValidationError("params", toIssues(r.issues));
3191
- params = r.value;
3444
+ const applyChecked = (r, part) => {
3445
+ if (r.issues)
3446
+ throw new ValidationError(part, toIssues(r.issues));
3447
+ return r.value;
3448
+ };
3449
+ const validateBodyAndFinish = (raw) => {
3450
+ const r = def.request.body["~standard"].validate(raw);
3451
+ if (isPromiseLike(r)) {
3452
+ return r.then((resolved) => {
3453
+ body = applyChecked(resolved, "body");
3454
+ return finishContext();
3455
+ });
3192
3456
  }
3193
- if (hasQuerySchema) {
3194
- const r = await validate(def.request.query, buildQuery());
3195
- if (r.issues)
3196
- throw new ValidationError("query", toIssues(r.issues));
3197
- query = r.value;
3457
+ body = applyChecked(r, "body");
3458
+ return finishContext();
3459
+ };
3460
+ const stepBody = () => {
3461
+ if (!def.request?.body)
3462
+ return finishContext();
3463
+ const ct = (request.headers.get("content-type") ?? "").toLowerCase();
3464
+ const allowed = def.accepts ??
3465
+ opts.allowedContentTypes ?? [
3466
+ "application/json",
3467
+ "application/x-www-form-urlencoded",
3468
+ "multipart/form-data",
3469
+ ];
3470
+ if (!allowed.some((a) => ct.includes(a))) {
3471
+ throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
3198
3472
  }
3199
- if (hasHeadersSchema) {
3200
- const r = await validate(def.request.headers, buildHeaders());
3201
- if (r.issues)
3202
- throw new ValidationError("headers", toIssues(r.issues));
3203
- headers = r.value;
3204
- }
3205
- if (def.request?.body) {
3206
- const ct = (request.headers.get("content-type") ?? "").toLowerCase();
3207
- const allowed = def.accepts ??
3208
- opts.allowedContentTypes ?? [
3209
- "application/json",
3210
- "application/x-www-form-urlencoded",
3211
- "multipart/form-data",
3212
- ];
3213
- if (!allowed.some((a) => ct.includes(a))) {
3214
- throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
3215
- }
3216
- const raw = await readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
3217
- const r = await validate(def.request.body, raw);
3218
- if (r.issues)
3219
- throw new ValidationError("body", toIssues(r.issues));
3220
- body = r.value;
3473
+ const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
3474
+ if (isPromiseLike(raw))
3475
+ return raw.then(validateBodyAndFinish);
3476
+ return validateBodyAndFinish(raw);
3477
+ };
3478
+ const stepHeaders = () => {
3479
+ if (!hasHeadersSchema)
3480
+ return stepBody();
3481
+ const r = def.request.headers["~standard"].validate(buildHeaders());
3482
+ if (isPromiseLike(r)) {
3483
+ return r.then((resolved) => {
3484
+ headers = applyChecked(resolved, "headers");
3485
+ return stepBody();
3486
+ });
3221
3487
  }
3222
- return finishContext();
3223
- })();
3488
+ headers = applyChecked(r, "headers");
3489
+ return stepBody();
3490
+ };
3491
+ const stepQuery = () => {
3492
+ if (!hasQuerySchema)
3493
+ return stepHeaders();
3494
+ const r = def.request.query["~standard"].validate(buildQuery());
3495
+ if (isPromiseLike(r)) {
3496
+ return r.then((resolved) => {
3497
+ query = applyChecked(resolved, "query");
3498
+ return stepHeaders();
3499
+ });
3500
+ }
3501
+ query = applyChecked(r, "query");
3502
+ return stepHeaders();
3503
+ };
3504
+ if (def.request?.params) {
3505
+ const r = def.request.params["~standard"].validate(rawParams);
3506
+ if (isPromiseLike(r)) {
3507
+ return r.then((resolved) => {
3508
+ params = applyChecked(resolved, "params");
3509
+ return stepQuery();
3510
+ });
3511
+ }
3512
+ params = applyChecked(r, "params");
3513
+ }
3514
+ return stepQuery();
3224
3515
  }
3225
3516
  function headersToObject(h) {
3226
3517
  const o = {};
@@ -3251,26 +3542,79 @@ function toIssues(issues) {
3251
3542
  .join("."),
3252
3543
  }));
3253
3544
  }
3254
- async function readBody(req, ct, limit, multipart) {
3545
+ /** Shared decoder for request-body text. Allocating one per request is wasted work. */
3546
+ const TEXT_DECODER = new TextDecoder();
3547
+ /**
3548
+ * Synchronous fast path for {@link readBodyLimited}: returns the adapter's
3549
+ * pre-buffered body bytes when they are available on the request via
3550
+ * {@link DALOY_REQUEST_RAW_BODY}, or `undefined` when the caller must fall
3551
+ * back to the async streaming read. Runs the exact same Content-Length
3552
+ * validation and size-limit checks (in the same order, throwing the same
3553
+ * errors) as `readBodyLimited`, so the security posture is identical — the
3554
+ * only difference is that a symbol-cache hit never touches the microtask
3555
+ * queue.
3556
+ *
3557
+ * @throws {BadRequestError} When `Content-Length` is present but invalid.
3558
+ * @throws {PayloadTooLargeError} When the declared or actual size exceeds `limit`.
3559
+ */
3560
+ function readBodyBytesFast(req, limit) {
3561
+ const cl = req.headers.get("content-length");
3562
+ if (cl) {
3563
+ const n = Number(cl);
3564
+ if (!Number.isFinite(n) || n < 0)
3565
+ throw new BadRequestError("Invalid Content-Length");
3566
+ if (n > limit)
3567
+ throw new PayloadTooLargeError(limit);
3568
+ }
3569
+ const cached = req[DALOY_REQUEST_RAW_BODY];
3570
+ if (cached instanceof Uint8Array) {
3571
+ if (cached.byteLength > limit)
3572
+ throw new PayloadTooLargeError(limit);
3573
+ return cached;
3574
+ }
3575
+ return undefined;
3576
+ }
3577
+ function parseJsonBodyBytes(bytes) {
3578
+ if (bytes.byteLength === 0)
3579
+ return undefined;
3580
+ return safeJsonParse(TEXT_DECODER.decode(bytes));
3581
+ }
3582
+ function parseUrlencodedBodyBytes(bytes) {
3583
+ const params = new URLSearchParams(TEXT_DECODER.decode(bytes));
3584
+ // Same Spring4Shell-class defense as queryToObject: Object.fromEntries
3585
+ // would set __proto__ / constructor / prototype as own properties.
3586
+ const out = {};
3587
+ for (const [k, v] of params) {
3588
+ if (isForbiddenObjectKey(k))
3589
+ continue;
3590
+ out[k] = v;
3591
+ }
3592
+ return out;
3593
+ }
3594
+ /**
3595
+ * Read and parse a request body according to its content type. Plain
3596
+ * (non-`async`) on purpose: when the adapter pre-buffered the body bytes
3597
+ * (the common JSON POST case on Node), the parse completes synchronously and
3598
+ * the caller stays on the sync dispatch fast path. Falls back to the
3599
+ * streaming `readBodyLimited` promise otherwise. All parsing keeps the
3600
+ * prototype-pollution-safe semantics of the previous implementation.
3601
+ */
3602
+ function readBody(req, ct, limit, multipart) {
3255
3603
  if (ct.includes("application/json")) {
3256
- const bytes = await readBodyLimited(req, limit);
3257
- if (bytes.byteLength === 0)
3258
- return undefined;
3259
- return safeJsonParse(new TextDecoder().decode(bytes));
3604
+ const fast = readBodyBytesFast(req, limit);
3605
+ if (fast !== undefined)
3606
+ return parseJsonBodyBytes(fast);
3607
+ return readBodyLimited(req, limit).then(parseJsonBodyBytes);
3260
3608
  }
3261
3609
  if (ct.includes("application/x-www-form-urlencoded")) {
3262
- const bytes = await readBodyLimited(req, limit);
3263
- const params = new URLSearchParams(new TextDecoder().decode(bytes));
3264
- // Same Spring4Shell-class defense as queryToObject: Object.fromEntries
3265
- // would set __proto__ / constructor / prototype as own properties.
3266
- const out = {};
3267
- for (const [k, v] of params) {
3268
- if (isForbiddenObjectKey(k))
3269
- continue;
3270
- out[k] = v;
3271
- }
3272
- return out;
3610
+ const fast = readBodyBytesFast(req, limit);
3611
+ if (fast !== undefined)
3612
+ return parseUrlencodedBodyBytes(fast);
3613
+ return readBodyLimited(req, limit).then(parseUrlencodedBodyBytes);
3273
3614
  }
3615
+ return readBodySlow(req, ct, limit, multipart);
3616
+ }
3617
+ async function readBodySlow(req, ct, limit, multipart) {
3274
3618
  if (ct.includes("multipart/form-data")) {
3275
3619
  // Fast-fail on an honestly-declared oversize body.
3276
3620
  const cl = req.headers.get("content-length");
@@ -3362,7 +3706,7 @@ function normalizeSunset(value, method, path) {
3362
3706
  }
3363
3707
  return date.toUTCString();
3364
3708
  }
3365
- function serializeResult(result, def, validateResponses) {
3709
+ function serializeResult(result, def, validateResponses, lightOk = false) {
3366
3710
  const spec = def.responses[result.status];
3367
3711
  if (!spec) {
3368
3712
  throw new InternalError(`Handler returned status ${result.status} which is not declared in responses for ${def.method} ${def.path}`);
@@ -3424,6 +3768,13 @@ function serializeResult(result, def, validateResponses) {
3424
3768
  body = bytes;
3425
3769
  rawBody = bytes;
3426
3770
  }
3771
+ // Node-adapter hot path (opt-in via DALOY_LIGHT_RESPONSE_OK on the
3772
+ // incoming request): skip the ~2µs undici Response construction. Only
3773
+ // buffer-backed bodies qualify — streams keep the real Response so the
3774
+ // adapter's stream plumbing is untouched.
3775
+ if (lightOk && !isStream) {
3776
+ return new LightResponse(result.status, headers, rawBody);
3777
+ }
3427
3778
  const response = new Response(body, { status: result.status, headers });
3428
3779
  if (!isStream) {
3429
3780
  response[DALOY_RAW_BODY] = rawBody;