@daloyjs/core 1.0.0-rc.3 → 1.0.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -41
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +41 -5
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +104 -19
- package/dist/app.d.ts +131 -11
- package/dist/app.js +305 -217
- package/dist/bot-guard.js +30 -3
- package/dist/cli.js +41 -1
- package/dist/client.d.ts +64 -18
- package/dist/client.js +36 -6
- package/dist/combine.d.ts +11 -11
- package/dist/combine.js +90 -47
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/docs.d.ts +5 -9
- package/dist/docs.js +36 -14
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +12 -3
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +13 -1
- package/dist/idempotency.js +2 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/internal-response.d.ts +15 -0
- package/dist/internal-response.js +27 -0
- package/dist/jwk.d.ts +11 -7
- package/dist/jwk.js +11 -7
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +137 -0
- package/dist/mcp.js +21 -15
- package/dist/middleware.d.ts +48 -7
- package/dist/middleware.js +129 -43
- package/dist/mtls.d.ts +6 -5
- package/dist/mtls.js +8 -9
- package/dist/openapi.js +1 -1
- package/dist/pagination.js +4 -1
- package/dist/response-cache.js +2 -1
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +9 -2
- package/dist/safe-redirect.js +29 -4
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +62 -0
- package/dist/security.js +220 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/tenancy.d.ts +2 -2
- package/dist/time-claims.js +3 -1
- package/dist/types.d.ts +85 -20
- package/dist/types.js +16 -1
- package/dist/waf.js +86 -26
- package/package.json +11 -4
package/dist/app.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
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 { readBodyLimited,
|
|
5
|
-
import { createLogger, noopLogger } from "./logger.js";
|
|
4
|
+
import { readBodyLimited, safeJsonParseLimited, randomId, assertInboundHeaderGuards, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
|
|
5
|
+
import { createLogger, noopLogger, sanitizeUrlForLog } from "./logger.js";
|
|
6
6
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
7
|
+
import { isSchemaValidatedResponse } from "./internal-response.js";
|
|
7
8
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
8
9
|
import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.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";
|
|
10
|
+
import { secureHeaders as secureHeadersMiddleware, AUTH_HOOK_MARKER, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, _mergePreBodyWithEarlyRejections, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
|
|
10
11
|
import { COMPRESSION_HOOK_MARKER } from "./compression.js";
|
|
11
12
|
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
|
|
12
13
|
import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
|
|
@@ -72,6 +73,7 @@ const INTERNAL_SERVICE_PRESET_DISABLED = Object.freeze([
|
|
|
72
73
|
*/
|
|
73
74
|
const INTERNAL_SERVICE_PRESET_KEPT = Object.freeze([
|
|
74
75
|
"bodyLimitBytes (1 MiB default)",
|
|
76
|
+
"jsonMaxKeys (10k) + jsonMaxDepth (50) structural limits",
|
|
75
77
|
"requestTimeoutMs (30 s default)",
|
|
76
78
|
"crashOnUnhandledRejection (production)",
|
|
77
79
|
"weak session secret refuse-to-boot",
|
|
@@ -169,6 +171,8 @@ const DEFAULTS = {
|
|
|
169
171
|
bodyLimitBytes: 1024 * 1024,
|
|
170
172
|
requestTimeoutMs: 30_000,
|
|
171
173
|
maxHeaderCount: DEFAULT_MAX_HEADER_COUNT,
|
|
174
|
+
jsonMaxKeys: 10_000,
|
|
175
|
+
jsonMaxDepth: 50,
|
|
172
176
|
validateResponses: true,
|
|
173
177
|
};
|
|
174
178
|
const TEXT_ENCODER = new TextEncoder();
|
|
@@ -192,6 +196,23 @@ export const DALOY_RAW_BODY = Symbol.for("daloyjs.response.rawBody");
|
|
|
192
196
|
* so first-party adapters can opt in; not part of the userland API surface.
|
|
193
197
|
*/
|
|
194
198
|
export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
199
|
+
/**
|
|
200
|
+
* Internal Symbol an adapter sets (on its request shim) to expose the request's
|
|
201
|
+
* abort hook: a `(reason: unknown) => void` that aborts the `AbortController`
|
|
202
|
+
* backing `request.signal`. The core invokes it when a request exceeds
|
|
203
|
+
* {@link AppOptions.requestTimeoutMs} so a handler that forwarded
|
|
204
|
+
* `ctx.request.signal` to downstream I/O (`fetch`, a DB driver) sees those
|
|
205
|
+
* calls cancel — cooperative teardown, since single-threaded JS cannot preempt
|
|
206
|
+
* a running handler.
|
|
207
|
+
*
|
|
208
|
+
* The hook is invoked as a method on the request (`this` stays bound to the
|
|
209
|
+
* shim) so it can reach the shim's private controller. Absent on runtimes
|
|
210
|
+
* whose `Request.signal` is managed by the platform (Bun / Deno / Workers) and
|
|
211
|
+
* on direct `app.fetch()` callers, where {@link abortRequest} is a safe no-op
|
|
212
|
+
* and the timeout still resolves as a `408`. Module-public so first-party
|
|
213
|
+
* adapters can opt in; not part of the userland API surface.
|
|
214
|
+
*/
|
|
215
|
+
export const DALOY_REQUEST_ABORT = Symbol.for("daloyjs.request.abort");
|
|
195
216
|
/**
|
|
196
217
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
197
218
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
@@ -444,6 +465,8 @@ export class App {
|
|
|
444
465
|
bodyLimitBytes: resolved.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
|
|
445
466
|
requestTimeoutMs: resolved.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
|
|
446
467
|
maxHeaderCount: resolved.maxHeaderCount ?? DEFAULTS.maxHeaderCount,
|
|
468
|
+
jsonMaxKeys: resolved.jsonMaxKeys ?? DEFAULTS.jsonMaxKeys,
|
|
469
|
+
jsonMaxDepth: resolved.jsonMaxDepth ?? DEFAULTS.jsonMaxDepth,
|
|
447
470
|
...resolved,
|
|
448
471
|
};
|
|
449
472
|
this.log =
|
|
@@ -572,6 +595,8 @@ export class App {
|
|
|
572
595
|
bodyLimitBytes: this.options.bodyLimitBytes,
|
|
573
596
|
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
574
597
|
maxHeaderCount: this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT,
|
|
598
|
+
jsonMaxKeys: this.options.jsonMaxKeys ?? DEFAULTS.jsonMaxKeys,
|
|
599
|
+
jsonMaxDepth: this.options.jsonMaxDepth ?? DEFAULTS.jsonMaxDepth,
|
|
575
600
|
stripServerHeaders: o.stripServerHeaders !== false,
|
|
576
601
|
production: this.isProduction(),
|
|
577
602
|
});
|
|
@@ -775,6 +800,7 @@ export class App {
|
|
|
775
800
|
if (hooks !== null && typeof hooks === "object") {
|
|
776
801
|
const HOOK_KEYS = [
|
|
777
802
|
"onRequest",
|
|
803
|
+
"preBody",
|
|
778
804
|
"beforeHandle",
|
|
779
805
|
"afterHandle",
|
|
780
806
|
"onError",
|
|
@@ -791,7 +817,7 @@ export class App {
|
|
|
791
817
|
"route unguarded.");
|
|
792
818
|
}
|
|
793
819
|
if (Object.keys(hooks).length > 0) {
|
|
794
|
-
throw new Error("Hooks object carries none of the recognized hook keys (onRequest, " +
|
|
820
|
+
throw new Error("Hooks object carries none of the recognized hook keys (onRequest, preBody, " +
|
|
795
821
|
"beforeHandle, afterHandle, onError, onSend, onResponse), so it would " +
|
|
796
822
|
"silently apply no hooks. To compose multiple hook bundles use " +
|
|
797
823
|
"every(...) / some(...) from @daloyjs/core.");
|
|
@@ -1089,21 +1115,17 @@ export class App {
|
|
|
1089
1115
|
const docsPath = (opts.path ?? "/docs");
|
|
1090
1116
|
const ui = opts.ui ?? "scalar";
|
|
1091
1117
|
const tags = opts.tags ?? ["Docs"];
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
|
|
1095
|
-
// `version` → version, `description` → description). Silently skipped on
|
|
1096
|
-
// edge runtimes that lack `node:fs`.
|
|
1097
|
-
const resolveInfo = async () => {
|
|
1118
|
+
// Keep docs generation purely web-standard. Host metadata is explicit so
|
|
1119
|
+
// edge bundlers never need to resolve or stub `node:fs` from the core.
|
|
1120
|
+
const resolveInfo = () => {
|
|
1098
1121
|
const fromOpenapi = this.options.openapi?.info ?? {};
|
|
1099
|
-
const
|
|
1100
|
-
const
|
|
1101
|
-
const
|
|
1102
|
-
const description = fromOpenapi.description ?? this.options.description ?? fromPkg.description;
|
|
1122
|
+
const title = fromOpenapi.title ?? this.options.title ?? "DaloyJS API";
|
|
1123
|
+
const version = fromOpenapi.version ?? this.options.version ?? "0.0.0";
|
|
1124
|
+
const description = fromOpenapi.description ?? this.options.description;
|
|
1103
1125
|
return description ? { title, version, description } : { title, version };
|
|
1104
1126
|
};
|
|
1105
1127
|
const generate = async () => generateOpenAPI(this, {
|
|
1106
|
-
info:
|
|
1128
|
+
info: resolveInfo(),
|
|
1107
1129
|
...(this.options.openapi?.servers ? { servers: this.options.openapi.servers } : {}),
|
|
1108
1130
|
...(this.options.openapi?.securitySchemes
|
|
1109
1131
|
? { securitySchemes: this.options.openapi.securitySchemes }
|
|
@@ -1178,7 +1200,7 @@ export class App {
|
|
|
1178
1200
|
200: { description: "Interactive API documentation UI." },
|
|
1179
1201
|
},
|
|
1180
1202
|
handler: async () => {
|
|
1181
|
-
const title = opts.title ??
|
|
1203
|
+
const title = opts.title ?? resolveInfo().title;
|
|
1182
1204
|
const html = ui === "swagger"
|
|
1183
1205
|
? swaggerUiHtml({
|
|
1184
1206
|
specUrl: openapiPath,
|
|
@@ -1253,16 +1275,15 @@ export class App {
|
|
|
1253
1275
|
const yamlPath = opts.yamlPath === false ? null : (opts.yamlPath ?? "/asyncapi.yaml");
|
|
1254
1276
|
const uiPath = (opts.path ?? "/asyncapi");
|
|
1255
1277
|
const tags = opts.tags ?? ["AsyncAPI"];
|
|
1256
|
-
const resolveInfo =
|
|
1278
|
+
const resolveInfo = () => {
|
|
1257
1279
|
const fromOpenapi = this.options.openapi?.info ?? {};
|
|
1258
|
-
const
|
|
1259
|
-
const
|
|
1260
|
-
const version = fromOpenapi.version ?? this.options.version ?? fromPkg.version ?? "0.0.0";
|
|
1280
|
+
const title = fromOpenapi.title ?? this.options.title ?? "DaloyJS API";
|
|
1281
|
+
const version = fromOpenapi.version ?? this.options.version ?? "0.0.0";
|
|
1261
1282
|
return { title, version };
|
|
1262
1283
|
};
|
|
1263
1284
|
const servers = opts.servers ?? this.asyncapiServersFromOpenAPI();
|
|
1264
1285
|
const generate = async () => generateAsyncAPI(this, {
|
|
1265
|
-
info:
|
|
1286
|
+
info: resolveInfo(),
|
|
1266
1287
|
...(servers ? { servers } : {}),
|
|
1267
1288
|
});
|
|
1268
1289
|
this.route({
|
|
@@ -1463,6 +1484,56 @@ export class App {
|
|
|
1463
1484
|
this.resetBootGuardCache();
|
|
1464
1485
|
return this;
|
|
1465
1486
|
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Register a literal tuple of independently defined route contracts.
|
|
1489
|
+
*
|
|
1490
|
+
* Unlike repeated statements against an already-declared `App` variable,
|
|
1491
|
+
* this method returns an App whose route tuple includes every supplied
|
|
1492
|
+
* contract. That preserves the exact no-codegen client surface across route
|
|
1493
|
+
* files and feature modules.
|
|
1494
|
+
*
|
|
1495
|
+
* @param definitions - Readonly literal tuple of route definitions.
|
|
1496
|
+
* @returns This App instance widened with every supplied route contract.
|
|
1497
|
+
* @since 1.0.0
|
|
1498
|
+
*/
|
|
1499
|
+
registerRoutes(definitions) {
|
|
1500
|
+
for (const definition of definitions)
|
|
1501
|
+
this.route(definition);
|
|
1502
|
+
return this;
|
|
1503
|
+
}
|
|
1504
|
+
get(path, options, handler) {
|
|
1505
|
+
return this.addHttpShorthand("GET", path, options, handler);
|
|
1506
|
+
}
|
|
1507
|
+
post(path, options, handler) {
|
|
1508
|
+
return this.addHttpShorthand("POST", path, options, handler);
|
|
1509
|
+
}
|
|
1510
|
+
put(path, options, handler) {
|
|
1511
|
+
return this.addHttpShorthand("PUT", path, options, handler);
|
|
1512
|
+
}
|
|
1513
|
+
patch(path, options, handler) {
|
|
1514
|
+
return this.addHttpShorthand("PATCH", path, options, handler);
|
|
1515
|
+
}
|
|
1516
|
+
delete(path, options, handler) {
|
|
1517
|
+
return this.addHttpShorthand("DELETE", path, options, handler);
|
|
1518
|
+
}
|
|
1519
|
+
head(path, options, handler) {
|
|
1520
|
+
return this.addHttpShorthand("HEAD", path, options, handler);
|
|
1521
|
+
}
|
|
1522
|
+
addHttpShorthand(method, path, options, possibleHandler) {
|
|
1523
|
+
if (options === null || typeof options !== "object" || typeof possibleHandler !== "function") {
|
|
1524
|
+
throw new TypeError(`app.${method.toLowerCase()}(): expected (path, contract, handler); opaque responses require an explicit contract with acknowledgeNoResponseBodySchema: true`);
|
|
1525
|
+
}
|
|
1526
|
+
const contract = options;
|
|
1527
|
+
return this.route({
|
|
1528
|
+
...contract,
|
|
1529
|
+
method,
|
|
1530
|
+
path,
|
|
1531
|
+
operationId: typeof contract.operationId === "string"
|
|
1532
|
+
? contract.operationId
|
|
1533
|
+
: inferOperationId(method, path),
|
|
1534
|
+
handler: possibleHandler,
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1466
1537
|
/**
|
|
1467
1538
|
* Register a WebSocket route. The handler runs when an HTTP client sends an
|
|
1468
1539
|
* `Upgrade: websocket` request to `path`; the adapter performs the RFC 6455
|
|
@@ -1647,6 +1718,7 @@ export class App {
|
|
|
1647
1718
|
}));
|
|
1648
1719
|
this._coldPathHooksCache = undefined;
|
|
1649
1720
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1721
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1650
1722
|
this.route({
|
|
1651
1723
|
method: "GET",
|
|
1652
1724
|
path,
|
|
@@ -1657,7 +1729,7 @@ export class App {
|
|
|
1657
1729
|
acknowledgeNoResponseBodySchema: true,
|
|
1658
1730
|
handler: async ({ request }) => {
|
|
1659
1731
|
if (buckets && rateLimitConfig) {
|
|
1660
|
-
const key = healthRouteKey(request);
|
|
1732
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1661
1733
|
const now = Date.now();
|
|
1662
1734
|
const entry = buckets.get(key);
|
|
1663
1735
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1767,6 +1839,7 @@ export class App {
|
|
|
1767
1839
|
`to acknowledge that this probe is reachable without credentials.`);
|
|
1768
1840
|
}
|
|
1769
1841
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1842
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1770
1843
|
this.route({
|
|
1771
1844
|
method: "GET",
|
|
1772
1845
|
path,
|
|
@@ -1777,7 +1850,7 @@ export class App {
|
|
|
1777
1850
|
acknowledgeNoResponseBodySchema: true,
|
|
1778
1851
|
handler: async ({ request }) => {
|
|
1779
1852
|
if (buckets && rateLimitConfig) {
|
|
1780
|
-
const key = healthRouteKey(request);
|
|
1853
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1781
1854
|
const now = Date.now();
|
|
1782
1855
|
const entry = buckets.get(key);
|
|
1783
1856
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1841,6 +1914,7 @@ export class App {
|
|
|
1841
1914
|
}
|
|
1842
1915
|
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1843
1916
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1917
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1844
1918
|
const log = this.log;
|
|
1845
1919
|
// Only log report bodies when explicitly enabled. In
|
|
1846
1920
|
// production this is opt-in; in development the body is included by
|
|
@@ -1854,7 +1928,7 @@ export class App {
|
|
|
1854
1928
|
summary: "CSP / Reporting API violation receiver",
|
|
1855
1929
|
handler: async ({ request }) => {
|
|
1856
1930
|
if (buckets && rateLimitConfig) {
|
|
1857
|
-
const key = healthRouteKey(request);
|
|
1931
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1858
1932
|
const now = Date.now();
|
|
1859
1933
|
const entry = buckets.get(key);
|
|
1860
1934
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1882,7 +1956,7 @@ export class App {
|
|
|
1882
1956
|
const rawText = new TextDecoder().decode(rawBytes);
|
|
1883
1957
|
let parsed;
|
|
1884
1958
|
try {
|
|
1885
|
-
parsed =
|
|
1959
|
+
parsed = safeJsonParseLimited(rawText, 1000, 20); // CSP reports are small
|
|
1886
1960
|
}
|
|
1887
1961
|
catch {
|
|
1888
1962
|
throw new BadRequestError("Invalid JSON report body");
|
|
@@ -1890,7 +1964,7 @@ export class App {
|
|
|
1890
1964
|
if (parsed === undefined) {
|
|
1891
1965
|
throw new BadRequestError("Invalid JSON report body");
|
|
1892
1966
|
}
|
|
1893
|
-
const ip = healthRouteKey(request);
|
|
1967
|
+
const ip = healthRouteKey(request, trustProxyHeaders);
|
|
1894
1968
|
const userAgent = request.headers.get("user-agent");
|
|
1895
1969
|
try {
|
|
1896
1970
|
if (opts.onReport) {
|
|
@@ -2331,7 +2405,11 @@ export class App {
|
|
|
2331
2405
|
: baseLog.child({
|
|
2332
2406
|
requestId,
|
|
2333
2407
|
method: request.method,
|
|
2334
|
-
|
|
2408
|
+
// Never bind the raw request URL: query strings commonly carry
|
|
2409
|
+
// OAuth codes, API keys, and signed-URL tokens. sanitizeUrlForLog
|
|
2410
|
+
// keeps origin+path and redacts sensitive query values so 4xx/5xx
|
|
2411
|
+
// lines cannot become a credential sink under the field name `url`.
|
|
2412
|
+
url: sanitizeUrlForLog(request.url),
|
|
2335
2413
|
});
|
|
2336
2414
|
const stripFingerprint = this.options.stripServerHeaders !== false;
|
|
2337
2415
|
let ctx;
|
|
@@ -2340,9 +2418,10 @@ export class App {
|
|
|
2340
2418
|
let activeResponseHook = globalHooks.onResponse;
|
|
2341
2419
|
let activeSendHook = globalHooks.onSend;
|
|
2342
2420
|
try {
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2421
|
+
// Singleton-duplicate + reserved-prefix + header-count cap share ONE
|
|
2422
|
+
// Headers.forEach walk (assertInboundHeaderGuards) instead of a
|
|
2423
|
+
// three-Headers.get() pass plus a separate walk.
|
|
2424
|
+
assertInboundHeaderGuards(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
|
|
2346
2425
|
this.assertTrustProxyConfigured(request);
|
|
2347
2426
|
this.assertBootGuards();
|
|
2348
2427
|
if (globalHooks.onRequest !== undefined) {
|
|
@@ -2393,10 +2472,13 @@ export class App {
|
|
|
2393
2472
|
// `decorations`, iterate headers, or materialize a `Headers`
|
|
2394
2473
|
// instance just to be thrown away. The 204 OPTIONS preflight branch
|
|
2395
2474
|
// below uses its own `synthCtx`, so this skip is safe for it too.
|
|
2475
|
+
const coldPreBody = method === "OPTIONS" ? undefined : this.coldPathHooks.preBody;
|
|
2396
2476
|
const coldGuards = method === "OPTIONS" ? undefined : this.coldPathHooks.beforeHandle;
|
|
2397
2477
|
const needsCtx = allowed.length > 0 && method === "OPTIONS"
|
|
2398
2478
|
? false // OPTIONS path builds synthCtx
|
|
2399
|
-
: activeErrorHook !== undefined ||
|
|
2479
|
+
: activeErrorHook !== undefined ||
|
|
2480
|
+
coldPreBody !== undefined ||
|
|
2481
|
+
coldGuards !== undefined;
|
|
2400
2482
|
if (needsCtx) {
|
|
2401
2483
|
// `query` and `headers` are materialized lazily — the common
|
|
2402
2484
|
// `onError` hook reads `requestId` / path and never touches them,
|
|
@@ -2438,6 +2520,18 @@ export class App {
|
|
|
2438
2520
|
};
|
|
2439
2521
|
ctx.set.headers.set("x-request-id", requestId);
|
|
2440
2522
|
}
|
|
2523
|
+
if (coldPreBody !== undefined) {
|
|
2524
|
+
const guardResult = coldPreBody(ctx);
|
|
2525
|
+
const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
|
|
2526
|
+
if (guarded instanceof Response) {
|
|
2527
|
+
copyContextHeaders(ctx, guarded);
|
|
2528
|
+
if (!guarded.headers.has("x-request-id")) {
|
|
2529
|
+
guarded.headers.set("x-request-id", requestId);
|
|
2530
|
+
}
|
|
2531
|
+
const fin = finalizeResponse(guarded, ctx, this.coldPathHooks, stripFingerprint);
|
|
2532
|
+
return isPromiseLike(fin) ? await fin : fin;
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2441
2535
|
if (coldGuards !== undefined) {
|
|
2442
2536
|
const guardResult = coldGuards(ctx);
|
|
2443
2537
|
const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
|
|
@@ -2497,11 +2591,10 @@ export class App {
|
|
|
2497
2591
|
if (isPromiseLike(routeOnRequestResult))
|
|
2498
2592
|
await routeOnRequestResult;
|
|
2499
2593
|
}
|
|
2500
|
-
//
|
|
2501
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
|
|
2504
|
-
ctx = isPromiseLike(builtCtx) ? await builtCtx : builtCtx;
|
|
2594
|
+
// Build a minimal route context before validation or body I/O so cheap
|
|
2595
|
+
// perimeter hooks can reject unauthenticated callers without consuming
|
|
2596
|
+
// an attacker-controlled request stream.
|
|
2597
|
+
ctx = createPreBodyContext(request, getUrl, match.params);
|
|
2505
2598
|
// Stable two-field write keeps `ctx.state`'s hidden class consistent across
|
|
2506
2599
|
// requests for the common no-decorator case. The decorations spread only
|
|
2507
2600
|
// fires when `app.decorate()` was actually called.
|
|
@@ -2515,6 +2608,30 @@ export class App {
|
|
|
2515
2608
|
const routeDecorations = match.handler.decorations;
|
|
2516
2609
|
if (routeDecorations !== undefined)
|
|
2517
2610
|
Object.assign(state, routeDecorations);
|
|
2611
|
+
if (allHooks.preBody !== undefined) {
|
|
2612
|
+
const preBodyResult = allHooks.preBody(ctx);
|
|
2613
|
+
const preBody = isPromiseLike(preBodyResult) ? await preBodyResult : preBodyResult;
|
|
2614
|
+
const overriddenId = state.requestId;
|
|
2615
|
+
if (typeof overriddenId === "string" && overriddenId.length > 0) {
|
|
2616
|
+
requestId = overriddenId;
|
|
2617
|
+
}
|
|
2618
|
+
if (preBody instanceof Response) {
|
|
2619
|
+
assertAcknowledgedSuccessfulHookResponse(preBody, def, "preBody");
|
|
2620
|
+
copyContextHeaders(ctx, preBody);
|
|
2621
|
+
if (!preBody.headers.has("x-request-id"))
|
|
2622
|
+
preBody.headers.set("x-request-id", requestId);
|
|
2623
|
+
if (hasFinalizeHook) {
|
|
2624
|
+
const fin = finalizeResponse(preBody, ctx, allHooks, stripFingerprint);
|
|
2625
|
+
return isPromiseLike(fin) ? await fin : fin;
|
|
2626
|
+
}
|
|
2627
|
+
return finalizeFast(preBody, stripFingerprint);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
// Validation remains sync-first; only an async schema or an actual body
|
|
2631
|
+
// stream read suspends. `beforeHandle` still receives the fully validated
|
|
2632
|
+
// context for compatibility with body-aware middleware.
|
|
2633
|
+
const validatedCtx = validateContext(ctx, def, this.options);
|
|
2634
|
+
ctx = isPromiseLike(validatedCtx) ? await validatedCtx : validatedCtx;
|
|
2518
2635
|
if (allHooks.beforeHandle !== undefined) {
|
|
2519
2636
|
const beforeResult = allHooks.beforeHandle(ctx);
|
|
2520
2637
|
const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
|
|
@@ -2526,6 +2643,7 @@ export class App {
|
|
|
2526
2643
|
requestId = overriddenId;
|
|
2527
2644
|
}
|
|
2528
2645
|
if (before instanceof Response) {
|
|
2646
|
+
assertAcknowledgedSuccessfulHookResponse(before, def, "beforeHandle");
|
|
2529
2647
|
copyContextHeaders(ctx, before);
|
|
2530
2648
|
if (!before.headers.has("x-request-id"))
|
|
2531
2649
|
before.headers.set("x-request-id", requestId);
|
|
@@ -2546,16 +2664,18 @@ export class App {
|
|
|
2546
2664
|
if (afterReturn !== undefined)
|
|
2547
2665
|
result = afterReturn;
|
|
2548
2666
|
}
|
|
2549
|
-
//
|
|
2550
|
-
// raw web-standard `Response`
|
|
2551
|
-
//
|
|
2552
|
-
//
|
|
2553
|
-
//
|
|
2554
|
-
//
|
|
2555
|
-
//
|
|
2556
|
-
// request id is added when absent, `onSend` / `onResponse` hooks run,
|
|
2557
|
-
// fingerprint headers are stripped, and `HEAD` yields an empty body.
|
|
2667
|
+
// Explicit escape hatch: a handler (or an `afterHandle` transform) may
|
|
2668
|
+
// return a raw web-standard `Response` only when the route acknowledges
|
|
2669
|
+
// that its body is opaque and will not be schema-validated. Without the
|
|
2670
|
+
// acknowledgement, fail closed instead of silently bypassing response
|
|
2671
|
+
// field stripping. Acknowledged responses still use the normal finalizer
|
|
2672
|
+
// so headers, request ids, hooks, fingerprint stripping, and HEAD
|
|
2673
|
+
// semantics remain intact.
|
|
2558
2674
|
if (result instanceof Response) {
|
|
2675
|
+
if (def.acknowledgeNoResponseBodySchema !== true) {
|
|
2676
|
+
throw new InternalError("Raw Response refused: set acknowledgeNoResponseBodySchema: true on the route " +
|
|
2677
|
+
"to explicitly accept that its response body bypasses schema validation.");
|
|
2678
|
+
}
|
|
2559
2679
|
copyContextHeaders(ctx, result);
|
|
2560
2680
|
if (!result.headers.has("x-request-id")) {
|
|
2561
2681
|
result.headers.set("x-request-id", requestId);
|
|
@@ -2767,8 +2887,8 @@ export class App {
|
|
|
2767
2887
|
* "draining" signal); then the app waits up to `timeoutMs` for in-flight
|
|
2768
2888
|
* requests to settle; finally, {@link App.onClose} cleanups run.
|
|
2769
2889
|
*
|
|
2770
|
-
*
|
|
2771
|
-
* Call it manually from custom runtimes or integration tests.
|
|
2890
|
+
* The Node, Bun, and Deno adapters call this automatically on `SIGINT` /
|
|
2891
|
+
* `SIGTERM`. Call it manually from custom runtimes or integration tests.
|
|
2772
2892
|
*
|
|
2773
2893
|
* @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
|
|
2774
2894
|
* @param reason - Optional human-readable reason forwarded to listeners.
|
|
@@ -2837,20 +2957,52 @@ export class App {
|
|
|
2837
2957
|
}
|
|
2838
2958
|
}
|
|
2839
2959
|
// ---------- helpers ----------
|
|
2960
|
+
/** Derive a stable camel-case operation id from an HTTP method and route path. */
|
|
2961
|
+
function inferOperationId(method, path) {
|
|
2962
|
+
if (path === "/")
|
|
2963
|
+
return `${method.toLowerCase()}Root`;
|
|
2964
|
+
const capitalizeWords = (value) => value
|
|
2965
|
+
.split(/[-_]/)
|
|
2966
|
+
.filter(Boolean)
|
|
2967
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
2968
|
+
.join("");
|
|
2969
|
+
const suffix = path
|
|
2970
|
+
.slice(1)
|
|
2971
|
+
.split("/")
|
|
2972
|
+
.map((segment) => segment.startsWith(":") ? `By${capitalizeWords(segment.slice(1))}` : capitalizeWords(segment))
|
|
2973
|
+
.join("");
|
|
2974
|
+
return `${method.toLowerCase()}${suffix}`;
|
|
2975
|
+
}
|
|
2840
2976
|
function joinPath(a, b) {
|
|
2841
2977
|
const left = a.replace(/\/+$/, "");
|
|
2842
2978
|
const right = b.startsWith("/") ? b : `/${b}`;
|
|
2843
2979
|
const joined = `${left}${right}`;
|
|
2844
2980
|
return joined === "" ? "/" : joined;
|
|
2845
2981
|
}
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2982
|
+
/**
|
|
2983
|
+
* Rate-limit / attribution key for built-in observability routes
|
|
2984
|
+
* (`/healthz`, `/readyz`, `/metrics`, CSP report).
|
|
2985
|
+
*
|
|
2986
|
+
* Secure default: a single shared `"global"` bucket. Spoofable platform
|
|
2987
|
+
* headers (`X-Real-IP`, `Fly-Client-IP`) are only read when the app has an
|
|
2988
|
+
* explicit trusted-proxy posture (`trustProxy: true` or `behindProxy` set).
|
|
2989
|
+
* `X-Forwarded-For` is never used here — probes and scrapers often hit the
|
|
2990
|
+
* process directly, and a free-form XFF chain would let an attacker rotate
|
|
2991
|
+
* identities to bypass the cap.
|
|
2992
|
+
*
|
|
2993
|
+
* @param request - Inbound request.
|
|
2994
|
+
* @param trustProxyHeaders - When true, platform client-IP headers may be used.
|
|
2995
|
+
* @returns A stable string key for the in-memory rate-limit map.
|
|
2996
|
+
*/
|
|
2997
|
+
function healthRouteKey(request, trustProxyHeaders) {
|
|
2998
|
+
if (!trustProxyHeaders)
|
|
2999
|
+
return "global";
|
|
2852
3000
|
return request.headers.get("x-real-ip") ?? request.headers.get("fly-client-ip") ?? "global";
|
|
2853
3001
|
}
|
|
3002
|
+
/** True when the app declared a trusted reverse-proxy posture. */
|
|
3003
|
+
function appTrustsProxyHeaders(options) {
|
|
3004
|
+
return options.trustProxy === true || options.behindProxy !== undefined;
|
|
3005
|
+
}
|
|
2854
3006
|
function corsOriginAllowsFromHooks(layers) {
|
|
2855
3007
|
const allows = [];
|
|
2856
3008
|
for (const hooks of layers) {
|
|
@@ -3118,6 +3270,7 @@ function mergeHooks(layers) {
|
|
|
3118
3270
|
const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
|
|
3119
3271
|
const hooks = {
|
|
3120
3272
|
onRequest: chain(pick("onRequest")),
|
|
3273
|
+
preBody: _mergePreBodyWithEarlyRejections(layers),
|
|
3121
3274
|
beforeHandle,
|
|
3122
3275
|
afterHandle: pipeline(pick("afterHandle")),
|
|
3123
3276
|
onError: firstResponse(pick("onError")),
|
|
@@ -3223,34 +3376,32 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
|
|
|
3223
3376
|
}
|
|
3224
3377
|
function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
|
|
3225
3378
|
let final = res;
|
|
3226
|
-
const finish = (f) => {
|
|
3227
|
-
if (stripFingerprint) {
|
|
3228
|
-
f.headers.delete("server");
|
|
3229
|
-
f.headers.delete("x-powered-by");
|
|
3230
|
-
}
|
|
3231
|
-
if (hooks.onResponse !== undefined) {
|
|
3232
|
-
const onResponseResult = hooks.onResponse(f);
|
|
3233
|
-
if (isPromiseLike(onResponseResult)) {
|
|
3234
|
-
return onResponseResult.then(() => f);
|
|
3235
|
-
}
|
|
3236
|
-
}
|
|
3237
|
-
return f;
|
|
3238
|
-
};
|
|
3239
3379
|
if (hooks.onSend !== undefined) {
|
|
3240
3380
|
const sentResult = hooks.onSend(res, ctx);
|
|
3241
3381
|
if (isPromiseLike(sentResult)) {
|
|
3242
3382
|
return sentResult.then((sent) => {
|
|
3243
3383
|
if (sent instanceof Response)
|
|
3244
3384
|
final = sent;
|
|
3245
|
-
return
|
|
3385
|
+
return finishFinalize(final, hooks, stripFingerprint);
|
|
3246
3386
|
});
|
|
3247
3387
|
}
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3388
|
+
if (sentResult instanceof Response)
|
|
3389
|
+
final = sentResult;
|
|
3390
|
+
}
|
|
3391
|
+
return finishFinalize(final, hooks, stripFingerprint);
|
|
3392
|
+
}
|
|
3393
|
+
function finishFinalize(res, hooks, stripFingerprint) {
|
|
3394
|
+
if (stripFingerprint) {
|
|
3395
|
+
res.headers.delete("server");
|
|
3396
|
+
res.headers.delete("x-powered-by");
|
|
3397
|
+
}
|
|
3398
|
+
if (hooks.onResponse !== undefined) {
|
|
3399
|
+
const onResponseResult = hooks.onResponse(res);
|
|
3400
|
+
if (isPromiseLike(onResponseResult)) {
|
|
3401
|
+
return onResponseResult.then(() => res);
|
|
3251
3402
|
}
|
|
3252
3403
|
}
|
|
3253
|
-
return
|
|
3404
|
+
return res;
|
|
3254
3405
|
}
|
|
3255
3406
|
function isPromiseLike(value) {
|
|
3256
3407
|
return value !== null && typeof value === "object" && typeof value.then === "function";
|
|
@@ -3346,6 +3497,20 @@ function copyContextHeaders(ctx, res) {
|
|
|
3346
3497
|
res.headers.set(k, v);
|
|
3347
3498
|
});
|
|
3348
3499
|
}
|
|
3500
|
+
/**
|
|
3501
|
+
* Refuse a successful opaque hook response unless the route explicitly opts
|
|
3502
|
+
* out of response-body schema protection. Error/denial responses remain
|
|
3503
|
+
* available to authentication and authorization hooks without an opt-out.
|
|
3504
|
+
*/
|
|
3505
|
+
function assertAcknowledgedSuccessfulHookResponse(response, def, phase) {
|
|
3506
|
+
if (response.status >= 400 ||
|
|
3507
|
+
def.acknowledgeNoResponseBodySchema === true ||
|
|
3508
|
+
isSchemaValidatedResponse(response)) {
|
|
3509
|
+
return;
|
|
3510
|
+
}
|
|
3511
|
+
throw new InternalError(`Raw ${phase} Response refused: set acknowledgeNoResponseBodySchema: true on the route ` +
|
|
3512
|
+
"to explicitly accept that its successful response body bypasses schema validation.");
|
|
3513
|
+
}
|
|
3349
3514
|
function hasRequestSchema(request, key) {
|
|
3350
3515
|
return !!request && !!request[key];
|
|
3351
3516
|
}
|
|
@@ -3453,38 +3618,23 @@ class RequestContext {
|
|
|
3453
3618
|
this._hSet = true;
|
|
3454
3619
|
}
|
|
3455
3620
|
}
|
|
3456
|
-
function
|
|
3457
|
-
const set = new LazyResponseSet();
|
|
3458
|
-
const hasHeadersSchema = !!def.request?.headers;
|
|
3459
|
-
const hasQuerySchema = !!def.request?.query;
|
|
3621
|
+
function createPreBodyContext(request, getUrl, rawParams) {
|
|
3460
3622
|
let headersObj;
|
|
3461
3623
|
let queryObj;
|
|
3462
|
-
const
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3624
|
+
const ctx = new RequestContext(request, rawParams, {}, new LazyResponseSet());
|
|
3625
|
+
ctx._hBuilder = () => (headersObj ??= headersToObject(request.headers));
|
|
3626
|
+
ctx._qBuilder = () => (queryObj ??= queryToObject(getUrl().searchParams));
|
|
3627
|
+
return ctx;
|
|
3628
|
+
}
|
|
3629
|
+
function validateContext(ctx, def, opts) {
|
|
3630
|
+
const hasHeadersSchema = !!def.request?.headers;
|
|
3631
|
+
const hasQuerySchema = !!def.request?.query;
|
|
3632
|
+
const request = ctx.request;
|
|
3633
|
+
const rawParams = ctx.params;
|
|
3634
|
+
const buildHeaders = () => ctx.headers;
|
|
3635
|
+
const buildQuery = () => ctx.query;
|
|
3468
3636
|
const hasSchema = def.request?.params || def.request?.query || def.request?.headers || def.request?.body;
|
|
3469
|
-
const finishContext = () =>
|
|
3470
|
-
const ctx = new RequestContext(request, params, {}, set);
|
|
3471
|
-
ctx.body = body;
|
|
3472
|
-
if (hasQuerySchema) {
|
|
3473
|
-
ctx._q = query;
|
|
3474
|
-
ctx._qSet = true;
|
|
3475
|
-
}
|
|
3476
|
-
else {
|
|
3477
|
-
ctx._qBuilder = buildQuery;
|
|
3478
|
-
}
|
|
3479
|
-
if (hasHeadersSchema) {
|
|
3480
|
-
ctx._h = headers;
|
|
3481
|
-
ctx._hSet = true;
|
|
3482
|
-
}
|
|
3483
|
-
else {
|
|
3484
|
-
ctx._hBuilder = buildHeaders;
|
|
3485
|
-
}
|
|
3486
|
-
return ctx;
|
|
3487
|
-
};
|
|
3637
|
+
const finishContext = () => ctx;
|
|
3488
3638
|
if (!hasSchema) {
|
|
3489
3639
|
return finishContext();
|
|
3490
3640
|
}
|
|
@@ -3497,11 +3647,11 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3497
3647
|
const r = def.request.body["~standard"].validate(raw);
|
|
3498
3648
|
if (isPromiseLike(r)) {
|
|
3499
3649
|
return r.then((resolved) => {
|
|
3500
|
-
body = applyChecked(resolved, "body");
|
|
3650
|
+
ctx.body = applyChecked(resolved, "body");
|
|
3501
3651
|
return finishContext();
|
|
3502
3652
|
});
|
|
3503
3653
|
}
|
|
3504
|
-
body = applyChecked(r, "body");
|
|
3654
|
+
ctx.body = applyChecked(r, "body");
|
|
3505
3655
|
return finishContext();
|
|
3506
3656
|
};
|
|
3507
3657
|
const stepBody = () => {
|
|
@@ -3517,7 +3667,7 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3517
3667
|
if (!allowed.some((a) => ct.includes(a))) {
|
|
3518
3668
|
throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
|
|
3519
3669
|
}
|
|
3520
|
-
const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
|
|
3670
|
+
const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart, opts.jsonMaxKeys, opts.jsonMaxDepth);
|
|
3521
3671
|
if (isPromiseLike(raw))
|
|
3522
3672
|
return raw.then(validateBodyAndFinish);
|
|
3523
3673
|
return validateBodyAndFinish(raw);
|
|
@@ -3528,11 +3678,11 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3528
3678
|
const r = def.request.headers["~standard"].validate(buildHeaders());
|
|
3529
3679
|
if (isPromiseLike(r)) {
|
|
3530
3680
|
return r.then((resolved) => {
|
|
3531
|
-
headers = applyChecked(resolved, "headers");
|
|
3681
|
+
ctx.headers = applyChecked(resolved, "headers");
|
|
3532
3682
|
return stepBody();
|
|
3533
3683
|
});
|
|
3534
3684
|
}
|
|
3535
|
-
headers = applyChecked(r, "headers");
|
|
3685
|
+
ctx.headers = applyChecked(r, "headers");
|
|
3536
3686
|
return stepBody();
|
|
3537
3687
|
};
|
|
3538
3688
|
const stepQuery = () => {
|
|
@@ -3541,22 +3691,22 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3541
3691
|
const r = def.request.query["~standard"].validate(buildQuery());
|
|
3542
3692
|
if (isPromiseLike(r)) {
|
|
3543
3693
|
return r.then((resolved) => {
|
|
3544
|
-
query = applyChecked(resolved, "query");
|
|
3694
|
+
ctx.query = applyChecked(resolved, "query");
|
|
3545
3695
|
return stepHeaders();
|
|
3546
3696
|
});
|
|
3547
3697
|
}
|
|
3548
|
-
query = applyChecked(r, "query");
|
|
3698
|
+
ctx.query = applyChecked(r, "query");
|
|
3549
3699
|
return stepHeaders();
|
|
3550
3700
|
};
|
|
3551
3701
|
if (def.request?.params) {
|
|
3552
3702
|
const r = def.request.params["~standard"].validate(rawParams);
|
|
3553
3703
|
if (isPromiseLike(r)) {
|
|
3554
3704
|
return r.then((resolved) => {
|
|
3555
|
-
params = applyChecked(resolved, "params");
|
|
3705
|
+
ctx.params = applyChecked(resolved, "params");
|
|
3556
3706
|
return stepQuery();
|
|
3557
3707
|
});
|
|
3558
3708
|
}
|
|
3559
|
-
params = applyChecked(r, "params");
|
|
3709
|
+
ctx.params = applyChecked(r, "params");
|
|
3560
3710
|
}
|
|
3561
3711
|
return stepQuery();
|
|
3562
3712
|
}
|
|
@@ -3621,10 +3771,13 @@ function readBodyBytesFast(req, limit) {
|
|
|
3621
3771
|
}
|
|
3622
3772
|
return undefined;
|
|
3623
3773
|
}
|
|
3624
|
-
function parseJsonBodyBytes(bytes) {
|
|
3774
|
+
function parseJsonBodyBytes(bytes, maxKeys, maxDepth) {
|
|
3625
3775
|
if (bytes.byteLength === 0)
|
|
3626
3776
|
return undefined;
|
|
3627
|
-
|
|
3777
|
+
const text = TEXT_DECODER.decode(bytes);
|
|
3778
|
+
// Use the limited parser when limits are enabled; falls back to safe behavior
|
|
3779
|
+
// when limits are disabled (0 / negative).
|
|
3780
|
+
return safeJsonParseLimited(text, maxKeys, maxDepth);
|
|
3628
3781
|
}
|
|
3629
3782
|
function parseUrlencodedBodyBytes(bytes) {
|
|
3630
3783
|
const params = new URLSearchParams(TEXT_DECODER.decode(bytes));
|
|
@@ -3646,12 +3799,12 @@ function parseUrlencodedBodyBytes(bytes) {
|
|
|
3646
3799
|
* streaming `readBodyLimited` promise otherwise. All parsing keeps the
|
|
3647
3800
|
* prototype-pollution-safe semantics of the previous implementation.
|
|
3648
3801
|
*/
|
|
3649
|
-
function readBody(req, ct, limit, multipart) {
|
|
3802
|
+
function readBody(req, ct, limit, multipart, jsonMaxKeys = 10_000, jsonMaxDepth = 50) {
|
|
3650
3803
|
if (ct.includes("application/json")) {
|
|
3651
3804
|
const fast = readBodyBytesFast(req, limit);
|
|
3652
3805
|
if (fast !== undefined)
|
|
3653
|
-
return parseJsonBodyBytes(fast);
|
|
3654
|
-
return readBodyLimited(req, limit).then(parseJsonBodyBytes);
|
|
3806
|
+
return parseJsonBodyBytes(fast, jsonMaxKeys, jsonMaxDepth);
|
|
3807
|
+
return readBodyLimited(req, limit).then((b) => parseJsonBodyBytes(b, jsonMaxKeys, jsonMaxDepth));
|
|
3655
3808
|
}
|
|
3656
3809
|
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
3657
3810
|
const fast = readBodyBytesFast(req, limit);
|
|
@@ -3877,11 +4030,46 @@ function runHandler(def, ctx, requestTimeoutMs) {
|
|
|
3877
4030
|
if (requestTimeoutMs === 0 || !isPromiseLike(result)) {
|
|
3878
4031
|
return result;
|
|
3879
4032
|
}
|
|
3880
|
-
return withTimeout(result, requestTimeoutMs);
|
|
4033
|
+
return withTimeout(result, requestTimeoutMs, ctx.request);
|
|
4034
|
+
}
|
|
4035
|
+
/**
|
|
4036
|
+
* Fire an adapter's request abort hook (see {@link DALOY_REQUEST_ABORT}) if the
|
|
4037
|
+
* request shim exposes one. Called as a method so `this` stays bound to the
|
|
4038
|
+
* request. A no-op when the hook is absent (platform-managed `Request.signal`
|
|
4039
|
+
* or a direct `app.fetch()` caller), so the caller must not depend on the
|
|
4040
|
+
* signal actually firing.
|
|
4041
|
+
*
|
|
4042
|
+
* @param request - The in-flight request whose signal should be aborted.
|
|
4043
|
+
* @param reason - Abort reason surfaced on `request.signal.reason`.
|
|
4044
|
+
*/
|
|
4045
|
+
function abortRequest(request, reason) {
|
|
4046
|
+
const hooked = request;
|
|
4047
|
+
hooked[DALOY_REQUEST_ABORT]?.(reason);
|
|
3881
4048
|
}
|
|
3882
|
-
|
|
4049
|
+
/**
|
|
4050
|
+
* Race a handler promise against the per-request timeout.
|
|
4051
|
+
*
|
|
4052
|
+
* On timeout the request's {@link DALOY_REQUEST_ABORT} hook is fired first —
|
|
4053
|
+
* aborting `request.signal` with a `TimeoutError` `DOMException` (the same
|
|
4054
|
+
* reason shape as `AbortSignal.timeout()`) so cooperative downstream I/O the
|
|
4055
|
+
* handler forwarded the signal to unwinds — and then the returned promise
|
|
4056
|
+
* rejects with a {@link RequestTimeoutError} so the client receives a `408`.
|
|
4057
|
+
* The handler promise itself keeps a rejection handler attached, so a late
|
|
4058
|
+
* settle (including the `AbortError` from the work it just cancelled) never
|
|
4059
|
+
* surfaces as an unhandled rejection.
|
|
4060
|
+
*
|
|
4061
|
+
* @typeParam T - The handler's resolved value type.
|
|
4062
|
+
* @param p - The handler (or hook chain) promise to bound.
|
|
4063
|
+
* @param ms - Timeout in milliseconds; assumed non-zero by the caller.
|
|
4064
|
+
* @param request - The in-flight request, used to fire the abort hook.
|
|
4065
|
+
* @returns A promise that settles with the handler result or a 408 timeout.
|
|
4066
|
+
*/
|
|
4067
|
+
function withTimeout(p, ms, request) {
|
|
3883
4068
|
return new Promise((resolve, reject) => {
|
|
3884
|
-
const t = setTimeout(() =>
|
|
4069
|
+
const t = setTimeout(() => {
|
|
4070
|
+
abortRequest(request, new DOMException(`Request exceeded ${ms}ms`, "TimeoutError"));
|
|
4071
|
+
reject(new RequestTimeoutError(ms));
|
|
4072
|
+
}, ms);
|
|
3885
4073
|
p.then((v) => {
|
|
3886
4074
|
clearTimeout(t);
|
|
3887
4075
|
resolve(v);
|
|
@@ -3918,103 +4106,3 @@ function serializeErr(err) {
|
|
|
3918
4106
|
export function createApp(options = {}) {
|
|
3919
4107
|
return new App(options);
|
|
3920
4108
|
}
|
|
3921
|
-
const PACKAGE_JSON_CACHE = {};
|
|
3922
|
-
/**
|
|
3923
|
-
* Best-effort lazy read of the host project's `package.json` so that
|
|
3924
|
-
* `new App({ docs: true })` with no explicit `openapi.info` still produces
|
|
3925
|
-
* a spec titled after the user's package. Reads `package.json` first; if
|
|
3926
|
-
* none is found while walking up from `process.cwd()`, falls back to
|
|
3927
|
-
* `deno.json` / `deno.jsonc` so Deno projects get the same DX without a
|
|
3928
|
-
* `package.json`. Returns an empty object on edge runtimes (Cloudflare
|
|
3929
|
-
* Workers) where `node:fs` is absent, on any I/O or parse
|
|
3930
|
-
* error, and when nothing is found.
|
|
3931
|
-
*
|
|
3932
|
-
* The result is memoized at module scope: manifests do not change
|
|
3933
|
-
* during a process lifetime and we never want this to add latency to
|
|
3934
|
-
* subsequent docs requests.
|
|
3935
|
-
*/
|
|
3936
|
-
function readHostPackageJsonInfo() {
|
|
3937
|
-
if (PACKAGE_JSON_CACHE.value !== undefined)
|
|
3938
|
-
return PACKAGE_JSON_CACHE.value;
|
|
3939
|
-
const promise = (async () => {
|
|
3940
|
-
const empty = {};
|
|
3941
|
-
const proc = globalThis.process;
|
|
3942
|
-
if (!proc || typeof proc.cwd !== "function")
|
|
3943
|
-
return empty;
|
|
3944
|
-
let fs;
|
|
3945
|
-
let path;
|
|
3946
|
-
try {
|
|
3947
|
-
fs = await import("node:fs");
|
|
3948
|
-
path = await import("node:path");
|
|
3949
|
-
}
|
|
3950
|
-
catch {
|
|
3951
|
-
return empty;
|
|
3952
|
-
}
|
|
3953
|
-
let dir;
|
|
3954
|
-
try {
|
|
3955
|
-
dir = proc.cwd();
|
|
3956
|
-
}
|
|
3957
|
-
catch {
|
|
3958
|
-
return empty;
|
|
3959
|
-
}
|
|
3960
|
-
const parseManifest = (raw, allowComments) => {
|
|
3961
|
-
// deno.jsonc allows // line comments and /* block */ comments. Strip
|
|
3962
|
-
// them before parsing — naively, but well enough for typical manifests.
|
|
3963
|
-
const text = allowComments
|
|
3964
|
-
? raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\])\/\/.*$/gm, "$1")
|
|
3965
|
-
: raw;
|
|
3966
|
-
return JSON.parse(text);
|
|
3967
|
-
};
|
|
3968
|
-
const extractInfo = (json) => {
|
|
3969
|
-
const result = {};
|
|
3970
|
-
if (typeof json.name === "string" && json.name.length > 0) {
|
|
3971
|
-
result.title = json.name;
|
|
3972
|
-
}
|
|
3973
|
-
if (typeof json.version === "string" && json.version.length > 0) {
|
|
3974
|
-
result.version = json.version;
|
|
3975
|
-
}
|
|
3976
|
-
if (typeof json.description === "string" && json.description.length > 0) {
|
|
3977
|
-
result.description = json.description;
|
|
3978
|
-
}
|
|
3979
|
-
return result;
|
|
3980
|
-
};
|
|
3981
|
-
// Walk up to the filesystem root looking for a manifest. Cap depth so
|
|
3982
|
-
// a deeply-nested cwd can't cause excessive stat calls. At each level,
|
|
3983
|
-
// prefer package.json, then deno.json, then deno.jsonc.
|
|
3984
|
-
for (let i = 0; i < 12; i++) {
|
|
3985
|
-
const pkg = path.join(dir, "package.json");
|
|
3986
|
-
const denoJson = path.join(dir, "deno.json");
|
|
3987
|
-
const denoJsonc = path.join(dir, "deno.jsonc");
|
|
3988
|
-
try {
|
|
3989
|
-
if (fs.existsSync(pkg)) {
|
|
3990
|
-
return extractInfo(parseManifest(fs.readFileSync(pkg, "utf8"), false));
|
|
3991
|
-
}
|
|
3992
|
-
if (fs.existsSync(denoJson)) {
|
|
3993
|
-
return extractInfo(parseManifest(fs.readFileSync(denoJson, "utf8"), false));
|
|
3994
|
-
}
|
|
3995
|
-
if (fs.existsSync(denoJsonc)) {
|
|
3996
|
-
return extractInfo(parseManifest(fs.readFileSync(denoJsonc, "utf8"), true));
|
|
3997
|
-
}
|
|
3998
|
-
}
|
|
3999
|
-
catch {
|
|
4000
|
-
return empty;
|
|
4001
|
-
}
|
|
4002
|
-
const parent = path.dirname(dir);
|
|
4003
|
-
if (parent === dir)
|
|
4004
|
-
break;
|
|
4005
|
-
dir = parent;
|
|
4006
|
-
}
|
|
4007
|
-
return empty;
|
|
4008
|
-
})();
|
|
4009
|
-
PACKAGE_JSON_CACHE.value = promise;
|
|
4010
|
-
return promise;
|
|
4011
|
-
}
|
|
4012
|
-
/**
|
|
4013
|
-
* Test helper: clear the cached package.json read so each test starts
|
|
4014
|
-
* from a fresh lookup. Not part of the public API.
|
|
4015
|
-
*
|
|
4016
|
-
* @internal
|
|
4017
|
-
*/
|
|
4018
|
-
export function _resetPackageJsonCacheForTests() {
|
|
4019
|
-
PACKAGE_JSON_CACHE.value = undefined;
|
|
4020
|
-
}
|