@daloyjs/core 1.1.1 → 1.2.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/README.md +2 -1
- package/dist/adapters/cloudflare.d.ts +8 -1
- package/dist/adapters/cloudflare.js +15 -1
- package/dist/adapters/lambda.js +15 -5
- package/dist/adapters/vercel.d.ts +5 -0
- package/dist/adapters/vercel.js +27 -2
- package/dist/app.d.ts +34 -8
- package/dist/app.js +257 -72
- package/dist/combine.js +14 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/metrics.d.ts +23 -9
- package/dist/metrics.js +37 -10
- package/dist/otlp.d.ts +268 -0
- package/dist/otlp.js +589 -0
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/types.d.ts +18 -2
- package/package.json +5 -1
package/dist/app.js
CHANGED
|
@@ -2,7 +2,8 @@ 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
4
|
import { readBodyLimited, safeJsonParseLimited, randomId, assertInboundHeaderGuards, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
|
|
5
|
-
import { createLogger, noopLogger, sanitizeUrlForLog } from "./logger.js";
|
|
5
|
+
import { createLogger, noopLogger, sanitizeUrlForLog, } from "./logger.js";
|
|
6
|
+
import { createAppTelemetry, } from "./otlp.js";
|
|
6
7
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
7
8
|
import { isSchemaValidatedResponse } from "./internal-response.js";
|
|
8
9
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
@@ -12,7 +13,7 @@ import { COMPRESSION_HOOK_MARKER } from "./compression.js";
|
|
|
12
13
|
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
|
|
13
14
|
import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
|
|
14
15
|
import { httpMetrics, MetricsRegistry, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
|
|
15
|
-
import { Scheduler } from "./scheduler.js";
|
|
16
|
+
import { Scheduler, } from "./scheduler.js";
|
|
16
17
|
import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
|
|
17
18
|
import { assertBehindProxy } from "./conn-info.js";
|
|
18
19
|
const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
|
|
@@ -374,6 +375,13 @@ export class App {
|
|
|
374
375
|
options;
|
|
375
376
|
/** Structured logger for the app. Defaults to a JSON-lines console logger; override via `options.logger`. */
|
|
376
377
|
log;
|
|
378
|
+
/**
|
|
379
|
+
* OTLP telemetry wiring created by the `telemetry` option; `undefined` when
|
|
380
|
+
* the option is off. Exposed for tests and advanced flushing.
|
|
381
|
+
*
|
|
382
|
+
* @since 1.2.0
|
|
383
|
+
*/
|
|
384
|
+
telemetry;
|
|
377
385
|
/**
|
|
378
386
|
* Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
|
|
379
387
|
*
|
|
@@ -477,7 +485,9 @@ export class App {
|
|
|
477
485
|
}
|
|
478
486
|
get globalCorsAllows() {
|
|
479
487
|
if (this._globalCorsAllowsCache === undefined) {
|
|
480
|
-
this._globalCorsAllowsCache = corsOriginAllowsFromHooks([
|
|
488
|
+
this._globalCorsAllowsCache = corsOriginAllowsFromHooks([
|
|
489
|
+
this.options.hooks ?? {},
|
|
490
|
+
]);
|
|
481
491
|
}
|
|
482
492
|
return this._globalCorsAllowsCache;
|
|
483
493
|
}
|
|
@@ -490,7 +500,10 @@ export class App {
|
|
|
490
500
|
_coldPathHooksCache;
|
|
491
501
|
get coldPathHooks() {
|
|
492
502
|
if (this._coldPathHooksCache === undefined) {
|
|
493
|
-
this._coldPathHooksCache = mergeHooks([
|
|
503
|
+
this._coldPathHooksCache = mergeHooks([
|
|
504
|
+
this.options.hooks ?? {},
|
|
505
|
+
...this.groupHooks,
|
|
506
|
+
]);
|
|
494
507
|
}
|
|
495
508
|
return this._coldPathHooksCache;
|
|
496
509
|
}
|
|
@@ -505,12 +518,26 @@ export class App {
|
|
|
505
518
|
jsonMaxDepth: resolved.jsonMaxDepth ?? DEFAULTS.jsonMaxDepth,
|
|
506
519
|
...resolved,
|
|
507
520
|
};
|
|
521
|
+
// Telemetry is resolved before the logger so the logger's write sink can
|
|
522
|
+
// tee into the OTLP log exporter. Inert when no endpoint is configured.
|
|
523
|
+
const telemetryOpt = options.telemetry;
|
|
524
|
+
this.telemetry =
|
|
525
|
+
telemetryOpt === undefined || telemetryOpt === false
|
|
526
|
+
? undefined
|
|
527
|
+
: createAppTelemetry(telemetryOpt === true ? {} : telemetryOpt);
|
|
528
|
+
const telemetryWrite = this.telemetry?.logWrite;
|
|
508
529
|
this.log =
|
|
509
530
|
options.logger === false
|
|
510
531
|
? noopLogger
|
|
511
|
-
: options.logger &&
|
|
532
|
+
: options.logger &&
|
|
533
|
+
typeof options.logger.info === "function"
|
|
512
534
|
? options.logger
|
|
513
|
-
: createLogger({
|
|
535
|
+
: createLogger({
|
|
536
|
+
level: options.logger?.level ?? "info",
|
|
537
|
+
...(telemetryWrite !== undefined
|
|
538
|
+
? { write: telemetryWrite }
|
|
539
|
+
: {}),
|
|
540
|
+
});
|
|
514
541
|
this.warnOnEnvMismatch();
|
|
515
542
|
this.assertDisconnectStatusCode();
|
|
516
543
|
assertBehindProxy(this.options.behindProxy);
|
|
@@ -522,6 +549,20 @@ export class App {
|
|
|
522
549
|
this.maybeInstallCrashHandlers();
|
|
523
550
|
this.maybeMountDocs();
|
|
524
551
|
this.maybeMountAsyncAPI();
|
|
552
|
+
if (this.telemetry !== undefined) {
|
|
553
|
+
if (this.telemetry.hooks !== undefined)
|
|
554
|
+
this.use(this.telemetry.hooks);
|
|
555
|
+
const telemetry = this.telemetry;
|
|
556
|
+
this.onClose(() => telemetry.flush());
|
|
557
|
+
this.log.info({
|
|
558
|
+
event: "telemetry.otlp",
|
|
559
|
+
active: telemetry.endpoint !== null,
|
|
560
|
+
// Endpoint only — OTLP headers may carry tenant credentials.
|
|
561
|
+
endpoint: telemetry.endpoint ?? undefined,
|
|
562
|
+
}, telemetry.endpoint !== null
|
|
563
|
+
? "OTLP telemetry export active"
|
|
564
|
+
: "Telemetry enabled but no OTEL_EXPORTER_OTLP_ENDPOINT configured; export disabled");
|
|
565
|
+
}
|
|
525
566
|
}
|
|
526
567
|
/**
|
|
527
568
|
* Validate {@link AppOptions.disconnectStatusCode}.
|
|
@@ -626,7 +667,9 @@ export class App {
|
|
|
626
667
|
secureHeaders: o.secureDefaults !== false && o.secureHeaders !== false,
|
|
627
668
|
corsCrossOriginGuard: o.secureDefaults !== false && o.corsCrossOriginGuard !== false,
|
|
628
669
|
csrf: o.csrf === "off" ? "off" : "on",
|
|
629
|
-
crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
|
|
670
|
+
crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
|
|
671
|
+
? "default"
|
|
672
|
+
: o.crashOnUnhandledRejection,
|
|
630
673
|
trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
|
|
631
674
|
bodyLimitBytes: this.options.bodyLimitBytes,
|
|
632
675
|
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
@@ -652,7 +695,8 @@ export class App {
|
|
|
652
695
|
if (this.options.secureDefaults === false)
|
|
653
696
|
return;
|
|
654
697
|
if (this.options.secureHeaders !== false) {
|
|
655
|
-
const opts = this.options.secureHeaders &&
|
|
698
|
+
const opts = this.options.secureHeaders &&
|
|
699
|
+
typeof this.options.secureHeaders === "object"
|
|
656
700
|
? this.options.secureHeaders
|
|
657
701
|
: {};
|
|
658
702
|
const auto = secureHeadersMiddleware(opts);
|
|
@@ -661,7 +705,9 @@ export class App {
|
|
|
661
705
|
}
|
|
662
706
|
// Opt-in load-shedding pressure monitor.
|
|
663
707
|
if (this.options.loadShedding) {
|
|
664
|
-
const lsOpts = typeof this.options.loadShedding === "object"
|
|
708
|
+
const lsOpts = typeof this.options.loadShedding === "object"
|
|
709
|
+
? this.options.loadShedding
|
|
710
|
+
: {};
|
|
665
711
|
this.groupHooks.push(loadSheddingMiddleware(lsOpts));
|
|
666
712
|
}
|
|
667
713
|
}
|
|
@@ -768,7 +814,10 @@ export class App {
|
|
|
768
814
|
return;
|
|
769
815
|
if (this.options.corsCrossOriginGuard === false)
|
|
770
816
|
return;
|
|
771
|
-
if (method !== "POST" &&
|
|
817
|
+
if (method !== "POST" &&
|
|
818
|
+
method !== "PUT" &&
|
|
819
|
+
method !== "PATCH" &&
|
|
820
|
+
method !== "DELETE") {
|
|
772
821
|
return;
|
|
773
822
|
}
|
|
774
823
|
const origin = request.headers.get("origin");
|
|
@@ -783,7 +832,9 @@ export class App {
|
|
|
783
832
|
// are identical on both paths.
|
|
784
833
|
const fastHeaderOrigin = getOriginFast(origin);
|
|
785
834
|
if (fastHeaderOrigin !== undefined) {
|
|
786
|
-
const fastReqOrigin = typeof requestUrl === "string"
|
|
835
|
+
const fastReqOrigin = typeof requestUrl === "string"
|
|
836
|
+
? getOriginFast(requestUrl)
|
|
837
|
+
: requestUrl.origin;
|
|
787
838
|
if (fastReqOrigin !== undefined) {
|
|
788
839
|
if (fastHeaderOrigin === fastReqOrigin)
|
|
789
840
|
return;
|
|
@@ -802,7 +853,9 @@ export class App {
|
|
|
802
853
|
// Malformed Origin header — refuse loudly.
|
|
803
854
|
throw new ForbiddenError("Cross-origin state-changing request rejected: malformed Origin header.");
|
|
804
855
|
}
|
|
805
|
-
const reqOrigin = typeof requestUrl === "string"
|
|
856
|
+
const reqOrigin = typeof requestUrl === "string"
|
|
857
|
+
? new URL(requestUrl).origin
|
|
858
|
+
: requestUrl.origin;
|
|
806
859
|
if (originUrl.origin === reqOrigin)
|
|
807
860
|
return;
|
|
808
861
|
if (corsOriginAllows.some((allows) => allows(origin)))
|
|
@@ -907,7 +960,8 @@ export class App {
|
|
|
907
960
|
* `test`, which is a known, non-production answer).
|
|
908
961
|
*/
|
|
909
962
|
isEnvIndeterminate() {
|
|
910
|
-
if (this.options.env !== undefined ||
|
|
963
|
+
if (this.options.env !== undefined ||
|
|
964
|
+
this.options.production !== undefined) {
|
|
911
965
|
return false;
|
|
912
966
|
}
|
|
913
967
|
const nodeEnv = typeof process !== "undefined" && typeof process.env !== "undefined"
|
|
@@ -1067,7 +1121,8 @@ export class App {
|
|
|
1067
1121
|
// follow them there because its `keyGenerator` is caller-supplied and may
|
|
1068
1122
|
// read `ctx.state`, so the unsafe order is refused instead.
|
|
1069
1123
|
const replayBeforeBudget = this.routeSecurityMarkers.find((r) => r.replayBeforeBudget !== null);
|
|
1070
|
-
if (replayBeforeBudget !== undefined &&
|
|
1124
|
+
if (replayBeforeBudget !== undefined &&
|
|
1125
|
+
this.bootGuard.error === undefined) {
|
|
1071
1126
|
this.bootGuard.error = new Error(`Route ${replayBeforeBudget.method} ${replayBeforeBudget.path} runs ` +
|
|
1072
1127
|
`${replayBeforeBudget.replayBeforeBudget} before rateLimit() / loginThrottle() in its ` +
|
|
1073
1128
|
`effective hook chain. Both act from beforeHandle, so a cache hit or an idempotent ` +
|
|
@@ -1161,7 +1216,8 @@ export class App {
|
|
|
1161
1216
|
// refusal must stay visible — but drop the stack, so a client cannot
|
|
1162
1217
|
// multiply the bytes it pushes into the error tier by replaying the header.
|
|
1163
1218
|
// The actionable message is logged once per process by the warn above.
|
|
1164
|
-
refusal[OMIT_STACK_IN_LOG] =
|
|
1219
|
+
refusal[OMIT_STACK_IN_LOG] =
|
|
1220
|
+
true;
|
|
1165
1221
|
throw refusal;
|
|
1166
1222
|
}
|
|
1167
1223
|
/**
|
|
@@ -1214,11 +1270,15 @@ export class App {
|
|
|
1214
1270
|
};
|
|
1215
1271
|
const generate = async () => generateOpenAPI(this, {
|
|
1216
1272
|
info: resolveInfo(),
|
|
1217
|
-
...(this.options.openapi?.servers
|
|
1273
|
+
...(this.options.openapi?.servers
|
|
1274
|
+
? { servers: this.options.openapi.servers }
|
|
1275
|
+
: {}),
|
|
1218
1276
|
...(this.options.openapi?.securitySchemes
|
|
1219
1277
|
? { securitySchemes: this.options.openapi.securitySchemes }
|
|
1220
1278
|
: {}),
|
|
1221
|
-
...(this.options.openapi?.webhooks
|
|
1279
|
+
...(this.options.openapi?.webhooks
|
|
1280
|
+
? { webhooks: this.options.openapi.webhooks }
|
|
1281
|
+
: {}),
|
|
1222
1282
|
});
|
|
1223
1283
|
this.route({
|
|
1224
1284
|
method: "GET",
|
|
@@ -1246,7 +1306,9 @@ export class App {
|
|
|
1246
1306
|
summary: "OpenAPI 3.1 document (YAML)",
|
|
1247
1307
|
acknowledgeNoResponseBodySchema: true,
|
|
1248
1308
|
responses: {
|
|
1249
|
-
200: {
|
|
1309
|
+
200: {
|
|
1310
|
+
description: "OpenAPI 3.1 document for this application, in YAML.",
|
|
1311
|
+
},
|
|
1250
1312
|
},
|
|
1251
1313
|
handler: async () => ({
|
|
1252
1314
|
status: 200,
|
|
@@ -1275,7 +1337,9 @@ export class App {
|
|
|
1275
1337
|
...(ui === "redoc"
|
|
1276
1338
|
? { ...opts.csp, allowBlobWorkers: opts.csp?.allowBlobWorkers ?? true }
|
|
1277
1339
|
: opts.csp),
|
|
1278
|
-
...(docsConnectOrigins.length
|
|
1340
|
+
...(docsConnectOrigins.length
|
|
1341
|
+
? { connectOrigins: docsConnectOrigins }
|
|
1342
|
+
: {}),
|
|
1279
1343
|
});
|
|
1280
1344
|
this.route({
|
|
1281
1345
|
method: "GET",
|
|
@@ -1360,7 +1424,9 @@ export class App {
|
|
|
1360
1424
|
*/
|
|
1361
1425
|
mountAsyncAPI(opts) {
|
|
1362
1426
|
const jsonPath = (opts.jsonPath ?? "/asyncapi.json");
|
|
1363
|
-
const yamlPath = opts.yamlPath === false
|
|
1427
|
+
const yamlPath = opts.yamlPath === false
|
|
1428
|
+
? null
|
|
1429
|
+
: (opts.yamlPath ?? "/asyncapi.yaml");
|
|
1364
1430
|
const uiPath = (opts.path ?? "/asyncapi");
|
|
1365
1431
|
const tags = opts.tags ?? ["AsyncAPI"];
|
|
1366
1432
|
const resolveInfo = () => {
|
|
@@ -1383,7 +1449,9 @@ export class App {
|
|
|
1383
1449
|
// Framework-owned bodies on the AsyncAPI surface, like mountDocs above.
|
|
1384
1450
|
acknowledgeNoResponseBodySchema: true,
|
|
1385
1451
|
responses: {
|
|
1386
|
-
200: {
|
|
1452
|
+
200: {
|
|
1453
|
+
description: "AsyncAPI 3.0 document for this application's WebSocket channels.",
|
|
1454
|
+
},
|
|
1387
1455
|
},
|
|
1388
1456
|
handler: async () => ({ status: 200, body: await generate() }),
|
|
1389
1457
|
});
|
|
@@ -1396,7 +1464,9 @@ export class App {
|
|
|
1396
1464
|
summary: "AsyncAPI 3.0 document (YAML)",
|
|
1397
1465
|
acknowledgeNoResponseBodySchema: true,
|
|
1398
1466
|
responses: {
|
|
1399
|
-
200: {
|
|
1467
|
+
200: {
|
|
1468
|
+
description: "AsyncAPI 3.0 document for this application, in YAML.",
|
|
1469
|
+
},
|
|
1400
1470
|
},
|
|
1401
1471
|
handler: async () => ({
|
|
1402
1472
|
status: 200,
|
|
@@ -1541,7 +1611,10 @@ export class App {
|
|
|
1541
1611
|
...corsOriginAllowsFromHooks([globalHookLayer]),
|
|
1542
1612
|
...corsOriginAllows,
|
|
1543
1613
|
];
|
|
1544
|
-
const securityMarkers = securityMarkersFromHooks([
|
|
1614
|
+
const securityMarkers = securityMarkersFromHooks([
|
|
1615
|
+
globalHookLayer,
|
|
1616
|
+
...sources,
|
|
1617
|
+
]);
|
|
1545
1618
|
// Capture the decorations of this route's scope at registration time so the
|
|
1546
1619
|
// dispatch hot path reads the scope-local bag rather than the root app's.
|
|
1547
1620
|
// `this.decorations` is this scope's own bag (the root's, or the child's
|
|
@@ -1608,7 +1681,9 @@ export class App {
|
|
|
1608
1681
|
return this.addHttpShorthand("HEAD", path, options, handler);
|
|
1609
1682
|
}
|
|
1610
1683
|
addHttpShorthand(method, path, options, possibleHandler) {
|
|
1611
|
-
if (options === null ||
|
|
1684
|
+
if (options === null ||
|
|
1685
|
+
typeof options !== "object" ||
|
|
1686
|
+
typeof possibleHandler !== "function") {
|
|
1612
1687
|
throw new TypeError(`app.${method.toLowerCase()}(): expected (path, contract, handler); opaque responses require an explicit contract with acknowledgeNoResponseBodySchema: true`);
|
|
1613
1688
|
}
|
|
1614
1689
|
const contract = options;
|
|
@@ -1734,7 +1809,9 @@ export class App {
|
|
|
1734
1809
|
*/
|
|
1735
1810
|
readinesscheck(opts = {}) {
|
|
1736
1811
|
this.registerHealthRoute("readinesscheck", opts, () => {
|
|
1737
|
-
if (this.draining ||
|
|
1812
|
+
if (this.draining ||
|
|
1813
|
+
this.pendingPlugins.size > 0 ||
|
|
1814
|
+
this.pluginBootError.failed) {
|
|
1738
1815
|
return {
|
|
1739
1816
|
status: 503,
|
|
1740
1817
|
body: { status: "not-ready" },
|
|
@@ -1769,8 +1846,13 @@ export class App {
|
|
|
1769
1846
|
*
|
|
1770
1847
|
* Call this **before** registering the routes you want measured — like any
|
|
1771
1848
|
* `app.use(...)` middleware, the instrumentation only wraps routes added
|
|
1772
|
-
* afterwards.
|
|
1773
|
-
*
|
|
1849
|
+
* afterwards. Calling it after routes already exist logs a
|
|
1850
|
+
* `metrics.late_install` warning listing the uninstrumented paths.
|
|
1851
|
+
* Pass `opts.registry` to register custom application metrics that are
|
|
1852
|
+
* rendered alongside the built-in HTTP series. Default series names are
|
|
1853
|
+
* unprefixed (`http_requests_total`, `process_resident_memory_bytes`);
|
|
1854
|
+
* construct the registry with `prefix: "daloy_"` if you want the old
|
|
1855
|
+
* names.
|
|
1774
1856
|
*
|
|
1775
1857
|
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
1776
1858
|
* @returns `this` for chaining.
|
|
@@ -1779,7 +1861,9 @@ export class App {
|
|
|
1779
1861
|
metrics(opts = {}) {
|
|
1780
1862
|
const path = (opts.path ?? "/metrics");
|
|
1781
1863
|
const registry = opts.registry ?? new MetricsRegistry();
|
|
1782
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
1864
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
1865
|
+
? null
|
|
1866
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1783
1867
|
const token = opts.token;
|
|
1784
1868
|
// Refuse-to-boot: an unauthenticated metrics scrape in production is a
|
|
1785
1869
|
// documented info-disclosure surface (route inventory, latency
|
|
@@ -1793,6 +1877,27 @@ export class App {
|
|
|
1793
1877
|
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
1794
1878
|
`to acknowledge that this scrape endpoint is reachable without credentials.`);
|
|
1795
1879
|
}
|
|
1880
|
+
// Group hooks only wrap routes registered *after* this call. Framework
|
|
1881
|
+
// auto-mounts (docs / health / metrics / reporting) are tagged and
|
|
1882
|
+
// ignored; anything else already on `this.routes` is a footgun.
|
|
1883
|
+
const skipLateTags = new Set([
|
|
1884
|
+
"Docs",
|
|
1885
|
+
"Observability",
|
|
1886
|
+
"Health",
|
|
1887
|
+
"Reporting",
|
|
1888
|
+
]);
|
|
1889
|
+
const late = this.routes.filter((r) => {
|
|
1890
|
+
const tags = r.tags ?? [];
|
|
1891
|
+
return !tags.some((t) => skipLateTags.has(t));
|
|
1892
|
+
});
|
|
1893
|
+
if (late.length > 0) {
|
|
1894
|
+
const listed = late.slice(0, 20).map((r) => `${r.method} ${r.path}`);
|
|
1895
|
+
this.log.warn({
|
|
1896
|
+
event: "metrics.late_install",
|
|
1897
|
+
count: late.length,
|
|
1898
|
+
routes: listed,
|
|
1899
|
+
}, `app.metrics() was called after ${late.length} route(s) were registered; those routes are not RED-instrumented. Call app.metrics() before app.get/post/...`);
|
|
1900
|
+
}
|
|
1796
1901
|
// Install RED instrumentation as a group hook so it wraps every route
|
|
1797
1902
|
// registered after this call. Always exclude the scrape path itself, plus
|
|
1798
1903
|
// any caller-supplied predicate.
|
|
@@ -1805,7 +1910,9 @@ export class App {
|
|
|
1805
1910
|
exclude,
|
|
1806
1911
|
}));
|
|
1807
1912
|
this._coldPathHooksCache = undefined;
|
|
1808
|
-
const buckets = rateLimitConfig
|
|
1913
|
+
const buckets = rateLimitConfig
|
|
1914
|
+
? new Map()
|
|
1915
|
+
: null;
|
|
1809
1916
|
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1810
1917
|
this.route({
|
|
1811
1918
|
method: "GET",
|
|
@@ -1821,7 +1928,10 @@ export class App {
|
|
|
1821
1928
|
const now = Date.now();
|
|
1822
1929
|
const entry = buckets.get(key);
|
|
1823
1930
|
if (!entry || entry.resetMs <= now) {
|
|
1824
|
-
buckets.set(key, {
|
|
1931
|
+
buckets.set(key, {
|
|
1932
|
+
count: 1,
|
|
1933
|
+
resetMs: now + rateLimitConfig.windowMs,
|
|
1934
|
+
});
|
|
1825
1935
|
}
|
|
1826
1936
|
else {
|
|
1827
1937
|
entry.count++;
|
|
@@ -1889,7 +1999,9 @@ export class App {
|
|
|
1889
1999
|
*/
|
|
1890
2000
|
cron(def, handler) {
|
|
1891
2001
|
if (this.scheduler === undefined) {
|
|
1892
|
-
const scheduler = new Scheduler({
|
|
2002
|
+
const scheduler = new Scheduler({
|
|
2003
|
+
logger: this.log.child({ component: "scheduler" }),
|
|
2004
|
+
});
|
|
1893
2005
|
this.scheduler = scheduler;
|
|
1894
2006
|
scheduler.start();
|
|
1895
2007
|
// Drain the scheduler during the post-drain close phase so periodic
|
|
@@ -1912,7 +2024,9 @@ export class App {
|
|
|
1912
2024
|
const isHealth = kind === "healthcheck";
|
|
1913
2025
|
const defaultPath = (isHealth ? "/healthz" : "/readyz");
|
|
1914
2026
|
const path = (opts.path ?? defaultPath);
|
|
1915
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
2027
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
2028
|
+
? null
|
|
2029
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1916
2030
|
const token = opts.token;
|
|
1917
2031
|
// Refuse-to-boot: unauthenticated health/ready probes in
|
|
1918
2032
|
// production are a documented info-disclosure surface (process uptime,
|
|
@@ -1926,7 +2040,9 @@ export class App {
|
|
|
1926
2040
|
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
1927
2041
|
`to acknowledge that this probe is reachable without credentials.`);
|
|
1928
2042
|
}
|
|
1929
|
-
const buckets = rateLimitConfig
|
|
2043
|
+
const buckets = rateLimitConfig
|
|
2044
|
+
? new Map()
|
|
2045
|
+
: null;
|
|
1930
2046
|
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1931
2047
|
this.route({
|
|
1932
2048
|
method: "GET",
|
|
@@ -1942,7 +2058,10 @@ export class App {
|
|
|
1942
2058
|
const now = Date.now();
|
|
1943
2059
|
const entry = buckets.get(key);
|
|
1944
2060
|
if (!entry || entry.resetMs <= now) {
|
|
1945
|
-
buckets.set(key, {
|
|
2061
|
+
buckets.set(key, {
|
|
2062
|
+
count: 1,
|
|
2063
|
+
resetMs: now + rateLimitConfig.windowMs,
|
|
2064
|
+
});
|
|
1946
2065
|
}
|
|
1947
2066
|
else {
|
|
1948
2067
|
entry.count++;
|
|
@@ -2000,8 +2119,12 @@ export class App {
|
|
|
2000
2119
|
if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > HARD_MAX) {
|
|
2001
2120
|
throw new Error(`cspReportRoute(): maxBodyBytes must be a positive integer <= ${HARD_MAX}.`);
|
|
2002
2121
|
}
|
|
2003
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
2004
|
-
|
|
2122
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
2123
|
+
? null
|
|
2124
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
2125
|
+
const buckets = rateLimitConfig
|
|
2126
|
+
? new Map()
|
|
2127
|
+
: null;
|
|
2005
2128
|
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
2006
2129
|
const log = this.log;
|
|
2007
2130
|
// Only log report bodies when explicitly enabled. In
|
|
@@ -2020,7 +2143,10 @@ export class App {
|
|
|
2020
2143
|
const now = Date.now();
|
|
2021
2144
|
const entry = buckets.get(key);
|
|
2022
2145
|
if (!entry || entry.resetMs <= now) {
|
|
2023
|
-
buckets.set(key, {
|
|
2146
|
+
buckets.set(key, {
|
|
2147
|
+
count: 1,
|
|
2148
|
+
resetMs: now + rateLimitConfig.windowMs,
|
|
2149
|
+
});
|
|
2024
2150
|
}
|
|
2025
2151
|
else {
|
|
2026
2152
|
entry.count++;
|
|
@@ -2110,8 +2236,11 @@ export class App {
|
|
|
2110
2236
|
this.assertSecureHookConfig(config.hooks);
|
|
2111
2237
|
// Child apps share the parent's router/routes/etc. Disable docs auto-mount
|
|
2112
2238
|
// on the child so it does not re-register the parent's `/openapi.json` and
|
|
2113
|
-
// `/docs` routes (which would throw "Duplicate route").
|
|
2114
|
-
|
|
2239
|
+
// `/docs` routes (which would throw "Duplicate route"). Disable telemetry
|
|
2240
|
+
// too: spreading `this.options` would otherwise construct a second pair of
|
|
2241
|
+
// OTLP exporters, `unref`'d flush timers, and a duplicate `telemetry.otlp`
|
|
2242
|
+
// boot line per group/plugin. The parent's wiring is copied below.
|
|
2243
|
+
const child = new App({ ...this.options, docs: false, telemetry: false });
|
|
2115
2244
|
child.router = this.router;
|
|
2116
2245
|
child.routes = this.routes;
|
|
2117
2246
|
child.webSocketRoutes = this.webSocketRoutes;
|
|
@@ -2119,7 +2248,10 @@ export class App {
|
|
|
2119
2248
|
child.bootGuard = this.bootGuard;
|
|
2120
2249
|
child.log = this.log;
|
|
2121
2250
|
child.prefix = joinPath(this.prefix, prefix);
|
|
2122
|
-
child.groupHooks = [
|
|
2251
|
+
child.groupHooks = [
|
|
2252
|
+
...this.groupHooks,
|
|
2253
|
+
...(config.hooks ? [config.hooks] : []),
|
|
2254
|
+
];
|
|
2123
2255
|
child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
|
|
2124
2256
|
child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
|
|
2125
2257
|
child.groupAuth = config.auth ?? this.groupAuth;
|
|
@@ -2139,6 +2271,7 @@ export class App {
|
|
|
2139
2271
|
child.shutdownListeners = this.shutdownListeners;
|
|
2140
2272
|
child.pendingPlugins = this.pendingPlugins;
|
|
2141
2273
|
child.pluginBootError = this.pluginBootError;
|
|
2274
|
+
child.telemetry = this.telemetry;
|
|
2142
2275
|
register(child);
|
|
2143
2276
|
return this;
|
|
2144
2277
|
}
|
|
@@ -2171,7 +2304,8 @@ export class App {
|
|
|
2171
2304
|
// "set only if absent" semantics mean the second installation would be
|
|
2172
2305
|
// a silent no-op).
|
|
2173
2306
|
if (hooks[SECURE_HEADERS_MARKER] === true) {
|
|
2174
|
-
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
|
|
2307
|
+
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
|
|
2308
|
+
true);
|
|
2175
2309
|
if (autoIdx >= 0)
|
|
2176
2310
|
this.groupHooks.splice(autoIdx, 1);
|
|
2177
2311
|
}
|
|
@@ -2243,14 +2377,16 @@ export class App {
|
|
|
2243
2377
|
* @throws Error if `key` is already decorated and `opts.override` is not `true`.
|
|
2244
2378
|
*/
|
|
2245
2379
|
decorate(key, value, opts = {}) {
|
|
2246
|
-
if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
|
|
2380
|
+
if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
|
|
2381
|
+
opts.override !== true) {
|
|
2247
2382
|
// Namespace-protected decorators. Refuse to silently
|
|
2248
2383
|
// shadow an existing decoration; emit a once-per-process warn naming
|
|
2249
2384
|
// both decorators on the explicit-override path.
|
|
2250
2385
|
throw new Error(`decorate(): key "${key}" is already decorated. ` +
|
|
2251
2386
|
`Pass { override: true } to replace, or rename to avoid the collision.`);
|
|
2252
2387
|
}
|
|
2253
|
-
if (opts.override === true &&
|
|
2388
|
+
if (opts.override === true &&
|
|
2389
|
+
Object.prototype.hasOwnProperty.call(this.decorations, key)) {
|
|
2254
2390
|
this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
|
|
2255
2391
|
}
|
|
2256
2392
|
const hadKey = Object.prototype.hasOwnProperty.call(this.decorations, key);
|
|
@@ -2322,7 +2458,10 @@ export class App {
|
|
|
2322
2458
|
const dedupKey = name ? (seed ? `${name}#${seed}` : name) : undefined;
|
|
2323
2459
|
const dependencies = descriptor?.dependencies ?? [];
|
|
2324
2460
|
const stateful = descriptor?.stateful ?? false;
|
|
2325
|
-
if (stateful &&
|
|
2461
|
+
if (stateful &&
|
|
2462
|
+
!name &&
|
|
2463
|
+
this.isProduction() &&
|
|
2464
|
+
this.options.secureDefaults !== false) {
|
|
2326
2465
|
throw new Error("register(): anonymous stateful plugin refused in production. " +
|
|
2327
2466
|
"Declare { name } (and optional { seed }) so the plugin can be deduplicated.");
|
|
2328
2467
|
}
|
|
@@ -2372,7 +2511,9 @@ export class App {
|
|
|
2372
2511
|
throw err;
|
|
2373
2512
|
});
|
|
2374
2513
|
this.pendingPlugins.add(tracked);
|
|
2375
|
-
void tracked
|
|
2514
|
+
void tracked
|
|
2515
|
+
.finally(() => this.pendingPlugins.delete(tracked))
|
|
2516
|
+
.catch(() => { });
|
|
2376
2517
|
}
|
|
2377
2518
|
firePluginInstalled(event) {
|
|
2378
2519
|
if (this.pluginInstalledListeners.length === 0)
|
|
@@ -2391,7 +2532,9 @@ export class App {
|
|
|
2391
2532
|
this.log.error({ err, plugin: event.name }, "onPluginInstalled listener failed");
|
|
2392
2533
|
}
|
|
2393
2534
|
}
|
|
2394
|
-
return promises.length > 0
|
|
2535
|
+
return promises.length > 0
|
|
2536
|
+
? Promise.all(promises).then(() => undefined)
|
|
2537
|
+
: undefined;
|
|
2395
2538
|
}
|
|
2396
2539
|
/**
|
|
2397
2540
|
* Wait until every async plugin registered with {@link App.register} has
|
|
@@ -2610,7 +2753,9 @@ export class App {
|
|
|
2610
2753
|
}
|
|
2611
2754
|
if (coldPreBody !== undefined) {
|
|
2612
2755
|
const guardResult = coldPreBody(ctx);
|
|
2613
|
-
const guarded = isPromiseLike(guardResult)
|
|
2756
|
+
const guarded = isPromiseLike(guardResult)
|
|
2757
|
+
? await guardResult
|
|
2758
|
+
: guardResult;
|
|
2614
2759
|
if (guarded instanceof Response) {
|
|
2615
2760
|
copyContextHeaders(ctx, guarded);
|
|
2616
2761
|
if (!guarded.headers.has("x-request-id")) {
|
|
@@ -2622,7 +2767,9 @@ export class App {
|
|
|
2622
2767
|
}
|
|
2623
2768
|
if (coldGuards !== undefined) {
|
|
2624
2769
|
const guardResult = coldGuards(ctx);
|
|
2625
|
-
const guarded = isPromiseLike(guardResult)
|
|
2770
|
+
const guarded = isPromiseLike(guardResult)
|
|
2771
|
+
? await guardResult
|
|
2772
|
+
: guardResult;
|
|
2626
2773
|
if (guarded instanceof Response) {
|
|
2627
2774
|
copyContextHeaders(ctx, guarded);
|
|
2628
2775
|
if (!guarded.headers.has("x-request-id")) {
|
|
@@ -2670,7 +2817,7 @@ export class App {
|
|
|
2670
2817
|
}
|
|
2671
2818
|
throw new NotFoundError(`No route for ${request.method} ${pathname}`);
|
|
2672
2819
|
}
|
|
2673
|
-
const { def, hooks, mergedHooks: allHooks, hasFinalizeHook } = match.handler;
|
|
2820
|
+
const { def, hooks, mergedHooks: allHooks, hasFinalizeHook, } = match.handler;
|
|
2674
2821
|
activeErrorHook = allHooks.onError;
|
|
2675
2822
|
activeResponseHook = allHooks.onResponse;
|
|
2676
2823
|
activeSendHook = allHooks.onSend;
|
|
@@ -2683,6 +2830,8 @@ export class App {
|
|
|
2683
2830
|
// perimeter hooks can reject unauthenticated callers without consuming
|
|
2684
2831
|
// an attacker-controlled request stream.
|
|
2685
2832
|
ctx = createPreBodyContext(request, getUrl, match.params);
|
|
2833
|
+
// Matched route template for low-cardinality labels (`http.route`).
|
|
2834
|
+
ctx.routePath = def.path;
|
|
2686
2835
|
// Stable two-field write keeps `ctx.state`'s hidden class consistent across
|
|
2687
2836
|
// requests for the common no-decorator case. The decorations spread only
|
|
2688
2837
|
// fires when `app.decorate()` was actually called.
|
|
@@ -2698,7 +2847,9 @@ export class App {
|
|
|
2698
2847
|
Object.assign(state, routeDecorations);
|
|
2699
2848
|
if (allHooks.preBody !== undefined) {
|
|
2700
2849
|
const preBodyResult = allHooks.preBody(ctx);
|
|
2701
|
-
const preBody = isPromiseLike(preBodyResult)
|
|
2850
|
+
const preBody = isPromiseLike(preBodyResult)
|
|
2851
|
+
? await preBodyResult
|
|
2852
|
+
: preBodyResult;
|
|
2702
2853
|
const overriddenId = state.requestId;
|
|
2703
2854
|
if (typeof overriddenId === "string" && overriddenId.length > 0) {
|
|
2704
2855
|
requestId = overriddenId;
|
|
@@ -2722,7 +2873,9 @@ export class App {
|
|
|
2722
2873
|
ctx = isPromiseLike(validatedCtx) ? await validatedCtx : validatedCtx;
|
|
2723
2874
|
if (allHooks.beforeHandle !== undefined) {
|
|
2724
2875
|
const beforeResult = allHooks.beforeHandle(ctx);
|
|
2725
|
-
const before = isPromiseLike(beforeResult)
|
|
2876
|
+
const before = isPromiseLike(beforeResult)
|
|
2877
|
+
? await beforeResult
|
|
2878
|
+
: beforeResult;
|
|
2726
2879
|
// Honor any request id override applied by middleware (e.g. the
|
|
2727
2880
|
// `requestId()` Hooks bundle replaces the framework-generated value
|
|
2728
2881
|
// with a trusted incoming header or a user-supplied generator).
|
|
@@ -2748,7 +2901,9 @@ export class App {
|
|
|
2748
2901
|
let result = isPromiseLike(runResult) ? await runResult : runResult;
|
|
2749
2902
|
if (allHooks.afterHandle !== undefined) {
|
|
2750
2903
|
const afterResult = allHooks.afterHandle(ctx, result);
|
|
2751
|
-
const afterReturn = isPromiseLike(afterResult)
|
|
2904
|
+
const afterReturn = isPromiseLike(afterResult)
|
|
2905
|
+
? await afterResult
|
|
2906
|
+
: afterResult;
|
|
2752
2907
|
if (afterReturn !== undefined)
|
|
2753
2908
|
result = afterReturn;
|
|
2754
2909
|
}
|
|
@@ -2842,7 +2997,9 @@ export class App {
|
|
|
2842
2997
|
// `err instanceof HttpError` first: the framework's own thrown
|
|
2843
2998
|
// problem errors short-circuit before any signal/option lookup.
|
|
2844
2999
|
const isHttp = err instanceof HttpError;
|
|
2845
|
-
const disconnectCode = isHttp
|
|
3000
|
+
const disconnectCode = isHttp
|
|
3001
|
+
? 0
|
|
3002
|
+
: (this.options.disconnectStatusCode ?? 499);
|
|
2846
3003
|
if (disconnectCode > 0 && request.signal?.aborted === true) {
|
|
2847
3004
|
log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
|
|
2848
3005
|
const res = new Response(null, {
|
|
@@ -2900,7 +3057,9 @@ export class App {
|
|
|
2900
3057
|
* @returns Fulfills with the `Response` produced by the matching handler.
|
|
2901
3058
|
*/
|
|
2902
3059
|
request(input, init) {
|
|
2903
|
-
const url = typeof input === "string" && input.startsWith("/")
|
|
3060
|
+
const url = typeof input === "string" && input.startsWith("/")
|
|
3061
|
+
? `http://test.local${input}`
|
|
3062
|
+
: input;
|
|
2904
3063
|
const req = url instanceof Request ? url : new Request(url, init);
|
|
2905
3064
|
return this.fetch(req);
|
|
2906
3065
|
}
|
|
@@ -3057,7 +3216,9 @@ function inferOperationId(method, path) {
|
|
|
3057
3216
|
const suffix = path
|
|
3058
3217
|
.slice(1)
|
|
3059
3218
|
.split("/")
|
|
3060
|
-
.map((segment) => segment.startsWith(":")
|
|
3219
|
+
.map((segment) => segment.startsWith(":")
|
|
3220
|
+
? `By${capitalizeWords(segment.slice(1))}`
|
|
3221
|
+
: capitalizeWords(segment))
|
|
3061
3222
|
.join("");
|
|
3062
3223
|
return `${method.toLowerCase()}${suffix}`;
|
|
3063
3224
|
}
|
|
@@ -3085,7 +3246,9 @@ function joinPath(a, b) {
|
|
|
3085
3246
|
function healthRouteKey(request, trustProxyHeaders) {
|
|
3086
3247
|
if (!trustProxyHeaders)
|
|
3087
3248
|
return "global";
|
|
3088
|
-
return request.headers.get("x-real-ip") ??
|
|
3249
|
+
return (request.headers.get("x-real-ip") ??
|
|
3250
|
+
request.headers.get("fly-client-ip") ??
|
|
3251
|
+
"global");
|
|
3089
3252
|
}
|
|
3090
3253
|
/** True when the app declared a trusted reverse-proxy posture. */
|
|
3091
3254
|
function appTrustsProxyHeaders(options) {
|
|
@@ -3209,7 +3372,9 @@ export function topoSortExtensions(exts) {
|
|
|
3209
3372
|
const bHeaders = b.responseHeaders;
|
|
3210
3373
|
if (!bHeaders || bHeaders.length === 0)
|
|
3211
3374
|
continue;
|
|
3212
|
-
const overlap = bHeaders
|
|
3375
|
+
const overlap = bHeaders
|
|
3376
|
+
.map((h) => h.toLowerCase())
|
|
3377
|
+
.filter((h) => aSet.has(h));
|
|
3213
3378
|
if (overlap.length === 0)
|
|
3214
3379
|
continue;
|
|
3215
3380
|
const declared = (a.before ?? []).includes(b.name) ||
|
|
@@ -3271,11 +3436,16 @@ function securityMarkersFromHooks(layers) {
|
|
|
3271
3436
|
hasCsrf,
|
|
3272
3437
|
hasAuth,
|
|
3273
3438
|
cacheBeforeTenancy: cacheIndex !== -1 && tenancyIndex !== -1 && cacheIndex < tenancyIndex,
|
|
3274
|
-
replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex
|
|
3439
|
+
replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex
|
|
3440
|
+
? replayName
|
|
3441
|
+
: null,
|
|
3275
3442
|
};
|
|
3276
3443
|
}
|
|
3277
3444
|
function isStateChangingMethod(method) {
|
|
3278
|
-
return method === "POST" ||
|
|
3445
|
+
return (method === "POST" ||
|
|
3446
|
+
method === "PUT" ||
|
|
3447
|
+
method === "PATCH" ||
|
|
3448
|
+
method === "DELETE");
|
|
3279
3449
|
}
|
|
3280
3450
|
/**
|
|
3281
3451
|
* Extract the pathname from a fully-qualified request URL without
|
|
@@ -3385,7 +3555,9 @@ function getOriginFast(url) {
|
|
|
3385
3555
|
return url.slice(0, end);
|
|
3386
3556
|
}
|
|
3387
3557
|
function mergeHooks(layers) {
|
|
3388
|
-
const pick = (key) => layers
|
|
3558
|
+
const pick = (key) => layers
|
|
3559
|
+
.map((h) => h[key])
|
|
3560
|
+
.filter((f) => typeof f === "function");
|
|
3389
3561
|
const requiredScopes = requiredScopesFromHooks(layers);
|
|
3390
3562
|
const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
|
|
3391
3563
|
const hooks = {
|
|
@@ -3415,7 +3587,9 @@ function requiredScopesFromHooks(layers) {
|
|
|
3415
3587
|
}
|
|
3416
3588
|
function stampRequiredScopes(hooks, scopes) {
|
|
3417
3589
|
if (scopes.length > 0) {
|
|
3418
|
-
hooks[REQUIRE_SCOPES_HOOK_MARKER] = [
|
|
3590
|
+
hooks[REQUIRE_SCOPES_HOOK_MARKER] = [
|
|
3591
|
+
...scopes,
|
|
3592
|
+
];
|
|
3419
3593
|
}
|
|
3420
3594
|
}
|
|
3421
3595
|
function mergeBeforeHandle(beforeHandle, requiredScopes) {
|
|
@@ -3487,7 +3661,9 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
|
|
|
3487
3661
|
return {
|
|
3488
3662
|
...(configuration ?? {}),
|
|
3489
3663
|
authentication: {
|
|
3490
|
-
...(authentication &&
|
|
3664
|
+
...(authentication &&
|
|
3665
|
+
typeof authentication === "object" &&
|
|
3666
|
+
!Array.isArray(authentication)
|
|
3491
3667
|
? authentication
|
|
3492
3668
|
: {}),
|
|
3493
3669
|
preferredSecurityScheme,
|
|
@@ -3502,21 +3678,21 @@ function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
|
|
|
3502
3678
|
return sentResult.then((sent) => {
|
|
3503
3679
|
if (sent instanceof Response)
|
|
3504
3680
|
final = sent;
|
|
3505
|
-
return finishFinalize(final, hooks, stripFingerprint);
|
|
3681
|
+
return finishFinalize(final, ctx, hooks, stripFingerprint);
|
|
3506
3682
|
});
|
|
3507
3683
|
}
|
|
3508
3684
|
if (sentResult instanceof Response)
|
|
3509
3685
|
final = sentResult;
|
|
3510
3686
|
}
|
|
3511
|
-
return finishFinalize(final, hooks, stripFingerprint);
|
|
3687
|
+
return finishFinalize(final, ctx, hooks, stripFingerprint);
|
|
3512
3688
|
}
|
|
3513
|
-
function finishFinalize(res, hooks, stripFingerprint) {
|
|
3689
|
+
function finishFinalize(res, ctx, hooks, stripFingerprint) {
|
|
3514
3690
|
if (stripFingerprint) {
|
|
3515
3691
|
res.headers.delete("server");
|
|
3516
3692
|
res.headers.delete("x-powered-by");
|
|
3517
3693
|
}
|
|
3518
3694
|
if (hooks.onResponse !== undefined) {
|
|
3519
|
-
const onResponseResult = hooks.onResponse(res);
|
|
3695
|
+
const onResponseResult = hooks.onResponse(res, ctx);
|
|
3520
3696
|
if (isPromiseLike(onResponseResult)) {
|
|
3521
3697
|
return onResponseResult.then(() => res);
|
|
3522
3698
|
}
|
|
@@ -3524,7 +3700,9 @@ function finishFinalize(res, hooks, stripFingerprint) {
|
|
|
3524
3700
|
return res;
|
|
3525
3701
|
}
|
|
3526
3702
|
function isPromiseLike(value) {
|
|
3527
|
-
return value !== null &&
|
|
3703
|
+
return (value !== null &&
|
|
3704
|
+
typeof value === "object" &&
|
|
3705
|
+
typeof value.then === "function");
|
|
3528
3706
|
}
|
|
3529
3707
|
/**
|
|
3530
3708
|
* Allocation-free finalizer for the common case (no `onSend`/`onResponse`
|
|
@@ -3703,6 +3881,7 @@ class RequestContext {
|
|
|
3703
3881
|
body = undefined;
|
|
3704
3882
|
state;
|
|
3705
3883
|
set;
|
|
3884
|
+
routePath = undefined;
|
|
3706
3885
|
_q = undefined;
|
|
3707
3886
|
_qBuilder = undefined;
|
|
3708
3887
|
_qSet = false;
|
|
@@ -3753,7 +3932,10 @@ function validateContext(ctx, def, opts) {
|
|
|
3753
3932
|
const rawParams = ctx.params;
|
|
3754
3933
|
const buildHeaders = () => ctx.headers;
|
|
3755
3934
|
const buildQuery = () => ctx.query;
|
|
3756
|
-
const hasSchema = def.request?.params ||
|
|
3935
|
+
const hasSchema = def.request?.params ||
|
|
3936
|
+
def.request?.query ||
|
|
3937
|
+
def.request?.headers ||
|
|
3938
|
+
def.request?.body;
|
|
3757
3939
|
const finishContext = () => ctx;
|
|
3758
3940
|
if (!hasSchema) {
|
|
3759
3941
|
return finishContext();
|
|
@@ -3797,7 +3979,8 @@ function validateContext(ctx, def, opts) {
|
|
|
3797
3979
|
const declared = request.headers.get("content-length");
|
|
3798
3980
|
if (declared !== null) {
|
|
3799
3981
|
const declaredBytes = Number(declared);
|
|
3800
|
-
if (Number.isFinite(declaredBytes) &&
|
|
3982
|
+
if (Number.isFinite(declaredBytes) &&
|
|
3983
|
+
declaredBytes > opts.bodyLimitBytes) {
|
|
3801
3984
|
throw new PayloadTooLargeError(opts.bodyLimitBytes);
|
|
3802
3985
|
}
|
|
3803
3986
|
}
|
|
@@ -3870,7 +4053,7 @@ function toIssues(issues) {
|
|
|
3870
4053
|
return issues.map((i) => ({
|
|
3871
4054
|
message: i.message,
|
|
3872
4055
|
path: (i.path ?? [])
|
|
3873
|
-
.map((p) =>
|
|
4056
|
+
.map((p) => typeof p === "object" && p && "key" in p ? p.key : p)
|
|
3874
4057
|
.join("."),
|
|
3875
4058
|
}));
|
|
3876
4059
|
}
|
|
@@ -4001,7 +4184,8 @@ async function readBodySlow(req, ct, limit, multipart) {
|
|
|
4001
4184
|
typeof v.arrayBuffer === "function";
|
|
4002
4185
|
if (isFile) {
|
|
4003
4186
|
files++;
|
|
4004
|
-
if (multipart?.maxFileBytes !== undefined &&
|
|
4187
|
+
if (multipart?.maxFileBytes !== undefined &&
|
|
4188
|
+
v.size > multipart.maxFileBytes) {
|
|
4005
4189
|
throw new PayloadTooLargeError(multipart.maxFileBytes);
|
|
4006
4190
|
}
|
|
4007
4191
|
}
|
|
@@ -4230,7 +4414,8 @@ function withTimeout(p, ms, request) {
|
|
|
4230
4414
|
const OMIT_STACK_IN_LOG = Symbol.for("daloyjs.error.omitStackInLog");
|
|
4231
4415
|
function serializeErr(err) {
|
|
4232
4416
|
if (err instanceof Error) {
|
|
4233
|
-
if (err[OMIT_STACK_IN_LOG] ===
|
|
4417
|
+
if (err[OMIT_STACK_IN_LOG] ===
|
|
4418
|
+
true) {
|
|
4234
4419
|
return { name: err.name, message: err.message };
|
|
4235
4420
|
}
|
|
4236
4421
|
return { name: err.name, message: err.message, stack: err.stack };
|