@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.
- package/README.md +2 -2
- package/dist/adapters/bun.d.ts +13 -1
- package/dist/adapters/bun.js +8 -1
- package/dist/adapters/cloudflare.d.ts +7 -1
- package/dist/adapters/cloudflare.js +6 -1
- package/dist/adapters/deno.d.ts +11 -1
- package/dist/adapters/deno.js +8 -1
- package/dist/adapters/fastly.d.ts +12 -2
- package/dist/adapters/fastly.js +12 -2
- package/dist/adapters/lambda.d.ts +37 -1
- package/dist/adapters/lambda.js +6 -1
- package/dist/adapters/node.d.ts +12 -1
- package/dist/adapters/node.js +223 -12
- package/dist/adapters/vercel.d.ts +13 -1
- package/dist/adapters/vercel.js +12 -1
- package/dist/app.d.ts +78 -17
- package/dist/app.js +444 -86
- package/dist/banner.d.ts +6 -0
- package/dist/banner.js +6 -0
- package/dist/cli.d.ts +35 -0
- package/dist/cli.js +23 -1
- package/dist/combine.d.ts +8 -0
- package/dist/combine.js +8 -0
- package/dist/compression.d.ts +3 -0
- package/dist/compression.js +3 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/conn-info.d.ts +35 -2
- package/dist/conn-info.js +35 -2
- package/dist/contract.d.ts +2 -0
- package/dist/contract.js +2 -0
- package/dist/cookie.d.ts +12 -0
- package/dist/cookie.js +12 -0
- package/dist/dependency.d.ts +4 -0
- package/dist/dependency.js +3 -0
- package/dist/discriminator.d.ts +13 -0
- package/dist/discriminator.js +23 -1
- package/dist/docs.d.ts +78 -0
- package/dist/docs.js +21 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +14 -0
- package/dist/etag.d.ts +2 -0
- package/dist/etag.js +2 -0
- package/dist/fetch-guard.d.ts +7 -0
- package/dist/fetch-guard.js +7 -0
- package/dist/fetch-resilience.d.ts +4 -0
- package/dist/fetch-resilience.js +4 -0
- package/dist/http-signatures.d.ts +32 -0
- package/dist/http-signatures.js +30 -0
- package/dist/index.d.ts +1 -1
- package/dist/ip-restriction.d.ts +40 -3
- package/dist/ip-restriction.js +35 -3
- package/dist/jwk.d.ts +12 -1
- package/dist/jwk.js +6 -0
- package/dist/jwt.d.ts +14 -0
- package/dist/jwt.js +10 -0
- package/dist/load-shedding.d.ts +3 -0
- package/dist/load-shedding.js +3 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +3 -0
- package/dist/mcp.d.ts +152 -10
- package/dist/mcp.js +223 -19
- package/dist/middleware.d.ts +68 -0
- package/dist/middleware.js +17 -0
- package/dist/mtls.d.ts +19 -2
- package/dist/mtls.js +12 -2
- package/dist/multipart.d.ts +42 -5
- package/dist/multipart.js +41 -5
- package/dist/openapi.d.ts +15 -9
- package/dist/openapi.js +6 -9
- package/dist/rate-limit-redis.d.ts +21 -2
- package/dist/rate-limit-redis.js +17 -2
- package/dist/safe-redirect.d.ts +6 -0
- package/dist/safe-redirect.js +6 -0
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +4 -0
- package/dist/schema.d.ts +25 -0
- package/dist/security-schemes.d.ts +50 -0
- package/dist/security-schemes.js +6 -0
- package/dist/security.d.ts +33 -0
- package/dist/security.js +28 -10
- package/dist/session.d.ts +34 -5
- package/dist/session.js +31 -5
- package/dist/streaming.d.ts +19 -0
- package/dist/streaming.js +16 -0
- package/dist/subdomains.d.ts +4 -0
- package/dist/subdomains.js +4 -0
- package/dist/time-claims.d.ts +22 -2
- package/dist/time-claims.js +6 -0
- package/dist/tracing.d.ts +12 -0
- package/dist/tracing.js +6 -0
- package/dist/types.d.ts +70 -1
- package/dist/waf.js +21 -1
- package/dist/webhook-delivery.d.ts +6 -0
- package/dist/webhook-delivery.js +5 -0
- package/dist/websocket.d.ts +137 -4
- package/dist/websocket.js +105 -4
- 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
|
-
|
|
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
|
|
2473
|
-
"
|
|
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
|
-
|
|
3141
|
-
if (
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
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
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
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
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
if (
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
"
|
|
3165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3211
|
-
if (
|
|
3212
|
-
return
|
|
3213
|
-
return
|
|
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
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
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
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
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 = {}) {
|