@daloyjs/core 1.2.0 → 1.3.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 +3 -2
- 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 +136 -10
- package/dist/app.js +382 -69
- package/dist/combine.js +14 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/jobs.d.ts +665 -0
- package/dist/jobs.js +1102 -0
- package/dist/metrics.d.ts +23 -9
- package/dist/metrics.js +33 -10
- package/dist/otlp.d.ts +31 -5
- package/dist/otlp.js +99 -37
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +7 -1
package/dist/app.js
CHANGED
|
@@ -2,8 +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";
|
|
6
|
-
import { createAppTelemetry } from "./otlp.js";
|
|
5
|
+
import { createLogger, noopLogger, sanitizeUrlForLog, } from "./logger.js";
|
|
6
|
+
import { createAppTelemetry, } from "./otlp.js";
|
|
7
7
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
8
8
|
import { isSchemaValidatedResponse } from "./internal-response.js";
|
|
9
9
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
@@ -13,7 +13,8 @@ import { COMPRESSION_HOOK_MARKER } from "./compression.js";
|
|
|
13
13
|
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
|
|
14
14
|
import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
|
|
15
15
|
import { httpMetrics, MetricsRegistry, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
|
|
16
|
-
import { Scheduler } from "./scheduler.js";
|
|
16
|
+
import { Scheduler, } from "./scheduler.js";
|
|
17
|
+
import { createJobQueue, createJobWorker, JobConfigError, MemoryJobStore, } from "./jobs.js";
|
|
17
18
|
import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
|
|
18
19
|
import { assertBehindProxy } from "./conn-info.js";
|
|
19
20
|
const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
|
|
@@ -428,6 +429,13 @@ export class App {
|
|
|
428
429
|
* is tied to graceful shutdown.
|
|
429
430
|
*/
|
|
430
431
|
scheduler;
|
|
432
|
+
/**
|
|
433
|
+
* Job queue / worker attached by {@link App.useJobs}. Both stay `undefined`
|
|
434
|
+
* unless the app opts in — serverless isolates must never start a poll
|
|
435
|
+
* loop implicitly.
|
|
436
|
+
*/
|
|
437
|
+
jobQueue;
|
|
438
|
+
jobWorkerRef;
|
|
431
439
|
/** Idle-connection close hooks (adapter-registered, sync). */
|
|
432
440
|
idleConnectionCloseHooks = [];
|
|
433
441
|
pluginInstalledListeners = [];
|
|
@@ -485,7 +493,9 @@ export class App {
|
|
|
485
493
|
}
|
|
486
494
|
get globalCorsAllows() {
|
|
487
495
|
if (this._globalCorsAllowsCache === undefined) {
|
|
488
|
-
this._globalCorsAllowsCache = corsOriginAllowsFromHooks([
|
|
496
|
+
this._globalCorsAllowsCache = corsOriginAllowsFromHooks([
|
|
497
|
+
this.options.hooks ?? {},
|
|
498
|
+
]);
|
|
489
499
|
}
|
|
490
500
|
return this._globalCorsAllowsCache;
|
|
491
501
|
}
|
|
@@ -498,7 +508,10 @@ export class App {
|
|
|
498
508
|
_coldPathHooksCache;
|
|
499
509
|
get coldPathHooks() {
|
|
500
510
|
if (this._coldPathHooksCache === undefined) {
|
|
501
|
-
this._coldPathHooksCache = mergeHooks([
|
|
511
|
+
this._coldPathHooksCache = mergeHooks([
|
|
512
|
+
this.options.hooks ?? {},
|
|
513
|
+
...this.groupHooks,
|
|
514
|
+
]);
|
|
502
515
|
}
|
|
503
516
|
return this._coldPathHooksCache;
|
|
504
517
|
}
|
|
@@ -524,11 +537,14 @@ export class App {
|
|
|
524
537
|
this.log =
|
|
525
538
|
options.logger === false
|
|
526
539
|
? noopLogger
|
|
527
|
-
: options.logger &&
|
|
540
|
+
: options.logger &&
|
|
541
|
+
typeof options.logger.info === "function"
|
|
528
542
|
? options.logger
|
|
529
543
|
: createLogger({
|
|
530
544
|
level: options.logger?.level ?? "info",
|
|
531
|
-
...(telemetryWrite !== undefined
|
|
545
|
+
...(telemetryWrite !== undefined
|
|
546
|
+
? { write: telemetryWrite }
|
|
547
|
+
: {}),
|
|
532
548
|
});
|
|
533
549
|
this.warnOnEnvMismatch();
|
|
534
550
|
this.assertDisconnectStatusCode();
|
|
@@ -659,7 +675,9 @@ export class App {
|
|
|
659
675
|
secureHeaders: o.secureDefaults !== false && o.secureHeaders !== false,
|
|
660
676
|
corsCrossOriginGuard: o.secureDefaults !== false && o.corsCrossOriginGuard !== false,
|
|
661
677
|
csrf: o.csrf === "off" ? "off" : "on",
|
|
662
|
-
crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
|
|
678
|
+
crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
|
|
679
|
+
? "default"
|
|
680
|
+
: o.crashOnUnhandledRejection,
|
|
663
681
|
trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
|
|
664
682
|
bodyLimitBytes: this.options.bodyLimitBytes,
|
|
665
683
|
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
@@ -685,7 +703,8 @@ export class App {
|
|
|
685
703
|
if (this.options.secureDefaults === false)
|
|
686
704
|
return;
|
|
687
705
|
if (this.options.secureHeaders !== false) {
|
|
688
|
-
const opts = this.options.secureHeaders &&
|
|
706
|
+
const opts = this.options.secureHeaders &&
|
|
707
|
+
typeof this.options.secureHeaders === "object"
|
|
689
708
|
? this.options.secureHeaders
|
|
690
709
|
: {};
|
|
691
710
|
const auto = secureHeadersMiddleware(opts);
|
|
@@ -694,7 +713,9 @@ export class App {
|
|
|
694
713
|
}
|
|
695
714
|
// Opt-in load-shedding pressure monitor.
|
|
696
715
|
if (this.options.loadShedding) {
|
|
697
|
-
const lsOpts = typeof this.options.loadShedding === "object"
|
|
716
|
+
const lsOpts = typeof this.options.loadShedding === "object"
|
|
717
|
+
? this.options.loadShedding
|
|
718
|
+
: {};
|
|
698
719
|
this.groupHooks.push(loadSheddingMiddleware(lsOpts));
|
|
699
720
|
}
|
|
700
721
|
}
|
|
@@ -801,7 +822,10 @@ export class App {
|
|
|
801
822
|
return;
|
|
802
823
|
if (this.options.corsCrossOriginGuard === false)
|
|
803
824
|
return;
|
|
804
|
-
if (method !== "POST" &&
|
|
825
|
+
if (method !== "POST" &&
|
|
826
|
+
method !== "PUT" &&
|
|
827
|
+
method !== "PATCH" &&
|
|
828
|
+
method !== "DELETE") {
|
|
805
829
|
return;
|
|
806
830
|
}
|
|
807
831
|
const origin = request.headers.get("origin");
|
|
@@ -816,7 +840,9 @@ export class App {
|
|
|
816
840
|
// are identical on both paths.
|
|
817
841
|
const fastHeaderOrigin = getOriginFast(origin);
|
|
818
842
|
if (fastHeaderOrigin !== undefined) {
|
|
819
|
-
const fastReqOrigin = typeof requestUrl === "string"
|
|
843
|
+
const fastReqOrigin = typeof requestUrl === "string"
|
|
844
|
+
? getOriginFast(requestUrl)
|
|
845
|
+
: requestUrl.origin;
|
|
820
846
|
if (fastReqOrigin !== undefined) {
|
|
821
847
|
if (fastHeaderOrigin === fastReqOrigin)
|
|
822
848
|
return;
|
|
@@ -835,7 +861,9 @@ export class App {
|
|
|
835
861
|
// Malformed Origin header — refuse loudly.
|
|
836
862
|
throw new ForbiddenError("Cross-origin state-changing request rejected: malformed Origin header.");
|
|
837
863
|
}
|
|
838
|
-
const reqOrigin = typeof requestUrl === "string"
|
|
864
|
+
const reqOrigin = typeof requestUrl === "string"
|
|
865
|
+
? new URL(requestUrl).origin
|
|
866
|
+
: requestUrl.origin;
|
|
839
867
|
if (originUrl.origin === reqOrigin)
|
|
840
868
|
return;
|
|
841
869
|
if (corsOriginAllows.some((allows) => allows(origin)))
|
|
@@ -940,7 +968,8 @@ export class App {
|
|
|
940
968
|
* `test`, which is a known, non-production answer).
|
|
941
969
|
*/
|
|
942
970
|
isEnvIndeterminate() {
|
|
943
|
-
if (this.options.env !== undefined ||
|
|
971
|
+
if (this.options.env !== undefined ||
|
|
972
|
+
this.options.production !== undefined) {
|
|
944
973
|
return false;
|
|
945
974
|
}
|
|
946
975
|
const nodeEnv = typeof process !== "undefined" && typeof process.env !== "undefined"
|
|
@@ -1100,7 +1129,8 @@ export class App {
|
|
|
1100
1129
|
// follow them there because its `keyGenerator` is caller-supplied and may
|
|
1101
1130
|
// read `ctx.state`, so the unsafe order is refused instead.
|
|
1102
1131
|
const replayBeforeBudget = this.routeSecurityMarkers.find((r) => r.replayBeforeBudget !== null);
|
|
1103
|
-
if (replayBeforeBudget !== undefined &&
|
|
1132
|
+
if (replayBeforeBudget !== undefined &&
|
|
1133
|
+
this.bootGuard.error === undefined) {
|
|
1104
1134
|
this.bootGuard.error = new Error(`Route ${replayBeforeBudget.method} ${replayBeforeBudget.path} runs ` +
|
|
1105
1135
|
`${replayBeforeBudget.replayBeforeBudget} before rateLimit() / loginThrottle() in its ` +
|
|
1106
1136
|
`effective hook chain. Both act from beforeHandle, so a cache hit or an idempotent ` +
|
|
@@ -1194,7 +1224,8 @@ export class App {
|
|
|
1194
1224
|
// refusal must stay visible — but drop the stack, so a client cannot
|
|
1195
1225
|
// multiply the bytes it pushes into the error tier by replaying the header.
|
|
1196
1226
|
// The actionable message is logged once per process by the warn above.
|
|
1197
|
-
refusal[OMIT_STACK_IN_LOG] =
|
|
1227
|
+
refusal[OMIT_STACK_IN_LOG] =
|
|
1228
|
+
true;
|
|
1198
1229
|
throw refusal;
|
|
1199
1230
|
}
|
|
1200
1231
|
/**
|
|
@@ -1247,11 +1278,15 @@ export class App {
|
|
|
1247
1278
|
};
|
|
1248
1279
|
const generate = async () => generateOpenAPI(this, {
|
|
1249
1280
|
info: resolveInfo(),
|
|
1250
|
-
...(this.options.openapi?.servers
|
|
1281
|
+
...(this.options.openapi?.servers
|
|
1282
|
+
? { servers: this.options.openapi.servers }
|
|
1283
|
+
: {}),
|
|
1251
1284
|
...(this.options.openapi?.securitySchemes
|
|
1252
1285
|
? { securitySchemes: this.options.openapi.securitySchemes }
|
|
1253
1286
|
: {}),
|
|
1254
|
-
...(this.options.openapi?.webhooks
|
|
1287
|
+
...(this.options.openapi?.webhooks
|
|
1288
|
+
? { webhooks: this.options.openapi.webhooks }
|
|
1289
|
+
: {}),
|
|
1255
1290
|
});
|
|
1256
1291
|
this.route({
|
|
1257
1292
|
method: "GET",
|
|
@@ -1279,7 +1314,9 @@ export class App {
|
|
|
1279
1314
|
summary: "OpenAPI 3.1 document (YAML)",
|
|
1280
1315
|
acknowledgeNoResponseBodySchema: true,
|
|
1281
1316
|
responses: {
|
|
1282
|
-
200: {
|
|
1317
|
+
200: {
|
|
1318
|
+
description: "OpenAPI 3.1 document for this application, in YAML.",
|
|
1319
|
+
},
|
|
1283
1320
|
},
|
|
1284
1321
|
handler: async () => ({
|
|
1285
1322
|
status: 200,
|
|
@@ -1308,7 +1345,9 @@ export class App {
|
|
|
1308
1345
|
...(ui === "redoc"
|
|
1309
1346
|
? { ...opts.csp, allowBlobWorkers: opts.csp?.allowBlobWorkers ?? true }
|
|
1310
1347
|
: opts.csp),
|
|
1311
|
-
...(docsConnectOrigins.length
|
|
1348
|
+
...(docsConnectOrigins.length
|
|
1349
|
+
? { connectOrigins: docsConnectOrigins }
|
|
1350
|
+
: {}),
|
|
1312
1351
|
});
|
|
1313
1352
|
this.route({
|
|
1314
1353
|
method: "GET",
|
|
@@ -1393,7 +1432,9 @@ export class App {
|
|
|
1393
1432
|
*/
|
|
1394
1433
|
mountAsyncAPI(opts) {
|
|
1395
1434
|
const jsonPath = (opts.jsonPath ?? "/asyncapi.json");
|
|
1396
|
-
const yamlPath = opts.yamlPath === false
|
|
1435
|
+
const yamlPath = opts.yamlPath === false
|
|
1436
|
+
? null
|
|
1437
|
+
: (opts.yamlPath ?? "/asyncapi.yaml");
|
|
1397
1438
|
const uiPath = (opts.path ?? "/asyncapi");
|
|
1398
1439
|
const tags = opts.tags ?? ["AsyncAPI"];
|
|
1399
1440
|
const resolveInfo = () => {
|
|
@@ -1416,7 +1457,9 @@ export class App {
|
|
|
1416
1457
|
// Framework-owned bodies on the AsyncAPI surface, like mountDocs above.
|
|
1417
1458
|
acknowledgeNoResponseBodySchema: true,
|
|
1418
1459
|
responses: {
|
|
1419
|
-
200: {
|
|
1460
|
+
200: {
|
|
1461
|
+
description: "AsyncAPI 3.0 document for this application's WebSocket channels.",
|
|
1462
|
+
},
|
|
1420
1463
|
},
|
|
1421
1464
|
handler: async () => ({ status: 200, body: await generate() }),
|
|
1422
1465
|
});
|
|
@@ -1429,7 +1472,9 @@ export class App {
|
|
|
1429
1472
|
summary: "AsyncAPI 3.0 document (YAML)",
|
|
1430
1473
|
acknowledgeNoResponseBodySchema: true,
|
|
1431
1474
|
responses: {
|
|
1432
|
-
200: {
|
|
1475
|
+
200: {
|
|
1476
|
+
description: "AsyncAPI 3.0 document for this application, in YAML.",
|
|
1477
|
+
},
|
|
1433
1478
|
},
|
|
1434
1479
|
handler: async () => ({
|
|
1435
1480
|
status: 200,
|
|
@@ -1574,7 +1619,10 @@ export class App {
|
|
|
1574
1619
|
...corsOriginAllowsFromHooks([globalHookLayer]),
|
|
1575
1620
|
...corsOriginAllows,
|
|
1576
1621
|
];
|
|
1577
|
-
const securityMarkers = securityMarkersFromHooks([
|
|
1622
|
+
const securityMarkers = securityMarkersFromHooks([
|
|
1623
|
+
globalHookLayer,
|
|
1624
|
+
...sources,
|
|
1625
|
+
]);
|
|
1578
1626
|
// Capture the decorations of this route's scope at registration time so the
|
|
1579
1627
|
// dispatch hot path reads the scope-local bag rather than the root app's.
|
|
1580
1628
|
// `this.decorations` is this scope's own bag (the root's, or the child's
|
|
@@ -1641,7 +1689,9 @@ export class App {
|
|
|
1641
1689
|
return this.addHttpShorthand("HEAD", path, options, handler);
|
|
1642
1690
|
}
|
|
1643
1691
|
addHttpShorthand(method, path, options, possibleHandler) {
|
|
1644
|
-
if (options === null ||
|
|
1692
|
+
if (options === null ||
|
|
1693
|
+
typeof options !== "object" ||
|
|
1694
|
+
typeof possibleHandler !== "function") {
|
|
1645
1695
|
throw new TypeError(`app.${method.toLowerCase()}(): expected (path, contract, handler); opaque responses require an explicit contract with acknowledgeNoResponseBodySchema: true`);
|
|
1646
1696
|
}
|
|
1647
1697
|
const contract = options;
|
|
@@ -1767,7 +1817,9 @@ export class App {
|
|
|
1767
1817
|
*/
|
|
1768
1818
|
readinesscheck(opts = {}) {
|
|
1769
1819
|
this.registerHealthRoute("readinesscheck", opts, () => {
|
|
1770
|
-
if (this.draining ||
|
|
1820
|
+
if (this.draining ||
|
|
1821
|
+
this.pendingPlugins.size > 0 ||
|
|
1822
|
+
this.pluginBootError.failed) {
|
|
1771
1823
|
return {
|
|
1772
1824
|
status: 503,
|
|
1773
1825
|
body: { status: "not-ready" },
|
|
@@ -1802,8 +1854,13 @@ export class App {
|
|
|
1802
1854
|
*
|
|
1803
1855
|
* Call this **before** registering the routes you want measured — like any
|
|
1804
1856
|
* `app.use(...)` middleware, the instrumentation only wraps routes added
|
|
1805
|
-
* afterwards.
|
|
1806
|
-
*
|
|
1857
|
+
* afterwards. Calling it after routes already exist logs a
|
|
1858
|
+
* `metrics.late_install` warning listing the uninstrumented paths.
|
|
1859
|
+
* Pass `opts.registry` to register custom application metrics that are
|
|
1860
|
+
* rendered alongside the built-in HTTP series. Default series names are
|
|
1861
|
+
* unprefixed (`http_requests_total`, `process_resident_memory_bytes`);
|
|
1862
|
+
* construct the registry with `prefix: "daloy_"` if you want the old
|
|
1863
|
+
* names.
|
|
1807
1864
|
*
|
|
1808
1865
|
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
1809
1866
|
* @returns `this` for chaining.
|
|
@@ -1812,7 +1869,9 @@ export class App {
|
|
|
1812
1869
|
metrics(opts = {}) {
|
|
1813
1870
|
const path = (opts.path ?? "/metrics");
|
|
1814
1871
|
const registry = opts.registry ?? new MetricsRegistry();
|
|
1815
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
1872
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
1873
|
+
? null
|
|
1874
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1816
1875
|
const token = opts.token;
|
|
1817
1876
|
// Refuse-to-boot: an unauthenticated metrics scrape in production is a
|
|
1818
1877
|
// documented info-disclosure surface (route inventory, latency
|
|
@@ -1826,6 +1885,27 @@ export class App {
|
|
|
1826
1885
|
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
1827
1886
|
`to acknowledge that this scrape endpoint is reachable without credentials.`);
|
|
1828
1887
|
}
|
|
1888
|
+
// Group hooks only wrap routes registered *after* this call. Framework
|
|
1889
|
+
// auto-mounts (docs / health / metrics / reporting) are tagged and
|
|
1890
|
+
// ignored; anything else already on `this.routes` is a footgun.
|
|
1891
|
+
const skipLateTags = new Set([
|
|
1892
|
+
"Docs",
|
|
1893
|
+
"Observability",
|
|
1894
|
+
"Health",
|
|
1895
|
+
"Reporting",
|
|
1896
|
+
]);
|
|
1897
|
+
const late = this.routes.filter((r) => {
|
|
1898
|
+
const tags = r.tags ?? [];
|
|
1899
|
+
return !tags.some((t) => skipLateTags.has(t));
|
|
1900
|
+
});
|
|
1901
|
+
if (late.length > 0) {
|
|
1902
|
+
const listed = late.slice(0, 20).map((r) => `${r.method} ${r.path}`);
|
|
1903
|
+
this.log.warn({
|
|
1904
|
+
event: "metrics.late_install",
|
|
1905
|
+
count: late.length,
|
|
1906
|
+
routes: listed,
|
|
1907
|
+
}, `app.metrics() was called after ${late.length} route(s) were registered; those routes are not RED-instrumented. Call app.metrics() before app.get/post/...`);
|
|
1908
|
+
}
|
|
1829
1909
|
// Install RED instrumentation as a group hook so it wraps every route
|
|
1830
1910
|
// registered after this call. Always exclude the scrape path itself, plus
|
|
1831
1911
|
// any caller-supplied predicate.
|
|
@@ -1838,7 +1918,9 @@ export class App {
|
|
|
1838
1918
|
exclude,
|
|
1839
1919
|
}));
|
|
1840
1920
|
this._coldPathHooksCache = undefined;
|
|
1841
|
-
const buckets = rateLimitConfig
|
|
1921
|
+
const buckets = rateLimitConfig
|
|
1922
|
+
? new Map()
|
|
1923
|
+
: null;
|
|
1842
1924
|
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1843
1925
|
this.route({
|
|
1844
1926
|
method: "GET",
|
|
@@ -1854,7 +1936,10 @@ export class App {
|
|
|
1854
1936
|
const now = Date.now();
|
|
1855
1937
|
const entry = buckets.get(key);
|
|
1856
1938
|
if (!entry || entry.resetMs <= now) {
|
|
1857
|
-
buckets.set(key, {
|
|
1939
|
+
buckets.set(key, {
|
|
1940
|
+
count: 1,
|
|
1941
|
+
resetMs: now + rateLimitConfig.windowMs,
|
|
1942
|
+
});
|
|
1858
1943
|
}
|
|
1859
1944
|
else {
|
|
1860
1945
|
entry.count++;
|
|
@@ -1922,7 +2007,9 @@ export class App {
|
|
|
1922
2007
|
*/
|
|
1923
2008
|
cron(def, handler) {
|
|
1924
2009
|
if (this.scheduler === undefined) {
|
|
1925
|
-
const scheduler = new Scheduler({
|
|
2010
|
+
const scheduler = new Scheduler({
|
|
2011
|
+
logger: this.log.child({ component: "scheduler" }),
|
|
2012
|
+
});
|
|
1926
2013
|
this.scheduler = scheduler;
|
|
1927
2014
|
scheduler.start();
|
|
1928
2015
|
// Drain the scheduler during the post-drain close phase so periodic
|
|
@@ -1941,11 +2028,169 @@ export class App {
|
|
|
1941
2028
|
get scheduledTasks() {
|
|
1942
2029
|
return this.scheduler;
|
|
1943
2030
|
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Attach a queue-agnostic background-job queue (and optionally a worker)
|
|
2033
|
+
* to this app. Opt-in by design: nothing in `new App()` starts a poll
|
|
2034
|
+
* loop, so serverless isolates that only ever *enqueue* never pay for (or
|
|
2035
|
+
* accidentally run) a worker.
|
|
2036
|
+
*
|
|
2037
|
+
* The queue answers *“run this work somewhere, eventually”* —
|
|
2038
|
+
* durable, retried, at-least-once units of `{ name, payload }` that
|
|
2039
|
+
* outlive the HTTP request and, with a durable {@link JobStore} adapter,
|
|
2040
|
+
* the process itself. This is not a workflow engine: there is no replay,
|
|
2041
|
+
* no durable function, no `await sleep("7 days")`.
|
|
2042
|
+
*
|
|
2043
|
+
* - `startWorker: true` creates a {@link JobWorker} over the same store,
|
|
2044
|
+
* starts it, and registers an `onClose` hook so in-flight jobs are
|
|
2045
|
+
* drained (then aborted past the grace period) on graceful shutdown.
|
|
2046
|
+
* Use it on long-lived Node/Bun/Deno processes only — never on Lambda /
|
|
2047
|
+
* Cloudflare Workers isolates.
|
|
2048
|
+
* - {@link MemoryJobStore} under production config logs a high-severity
|
|
2049
|
+
* warning (it is process-local and loses every job on restart); pass
|
|
2050
|
+
* `strictProduction: true` to refuse to boot instead.
|
|
2051
|
+
*
|
|
2052
|
+
* @example
|
|
2053
|
+
* ```ts
|
|
2054
|
+
* app.useJobs({
|
|
2055
|
+
* store: new MemoryJobStore(), // production: your Redis/Postgres JobStore
|
|
2056
|
+
* handlers: {
|
|
2057
|
+
* "email.welcome": async ({ job, signal }) => {
|
|
2058
|
+
* await sendEmail(job.payload, { signal });
|
|
2059
|
+
* },
|
|
2060
|
+
* },
|
|
2061
|
+
* startWorker: true,
|
|
2062
|
+
* });
|
|
2063
|
+
*
|
|
2064
|
+
* app.post("/users", contract, async (ctx) => {
|
|
2065
|
+
* const user = await db.insertUser(ctx.body);
|
|
2066
|
+
* await app.jobs!.enqueue({
|
|
2067
|
+
* name: "email.welcome",
|
|
2068
|
+
* payload: { userId: user.id },
|
|
2069
|
+
* idempotencyKey: jobIdempotencyKey({ tenant: ctx.state.tenant, name: "email.welcome", key: user.id }),
|
|
2070
|
+
* });
|
|
2071
|
+
* return { status: 201 as const, body: user };
|
|
2072
|
+
* });
|
|
2073
|
+
* ```
|
|
2074
|
+
*
|
|
2075
|
+
* @param opts - Store, optional handlers, worker and queue tuning.
|
|
2076
|
+
* @returns This `App` instance for chaining.
|
|
2077
|
+
* @throws {@link JobConfigError} when jobs are already configured, when
|
|
2078
|
+
* `startWorker` lacks handlers, or when `strictProduction` rejects a
|
|
2079
|
+
* {@link MemoryJobStore} under production config.
|
|
2080
|
+
* @since 1.3.0
|
|
2081
|
+
*/
|
|
2082
|
+
useJobs(opts) {
|
|
2083
|
+
if (this.jobQueue !== undefined) {
|
|
2084
|
+
throw new JobConfigError("invalid_option", "app.useJobs() was called twice; jobs are already configured on this app.");
|
|
2085
|
+
}
|
|
2086
|
+
if (opts.store instanceof MemoryJobStore && this.isProduction()) {
|
|
2087
|
+
const message = "app.useJobs(): MemoryJobStore is not durable — jobs are lost on process restart " +
|
|
2088
|
+
"and invisible to other replicas. Supply a shared JobStore (Redis/Postgres/SQS adapter) " +
|
|
2089
|
+
"in production, or pass strictProduction: false to keep this warning-only posture.";
|
|
2090
|
+
if (opts.strictProduction === true) {
|
|
2091
|
+
throw new JobConfigError("invalid_option", message);
|
|
2092
|
+
}
|
|
2093
|
+
this.log.warn({ event: "jobs.memory_store_production", component: "jobs" }, message);
|
|
2094
|
+
}
|
|
2095
|
+
const logger = this.log.child({ component: "jobs" });
|
|
2096
|
+
const queue = createJobQueue({ store: opts.store, logger, ...opts.queue });
|
|
2097
|
+
let worker;
|
|
2098
|
+
if (opts.startWorker === true) {
|
|
2099
|
+
const handlers = opts.handlers;
|
|
2100
|
+
if (handlers === undefined || Object.keys(handlers).length === 0) {
|
|
2101
|
+
throw new JobConfigError("invalid_option", "app.useJobs(): startWorker: true requires a non-empty handlers map.");
|
|
2102
|
+
}
|
|
2103
|
+
worker = createJobWorker({
|
|
2104
|
+
...opts.worker,
|
|
2105
|
+
queue,
|
|
2106
|
+
handlers,
|
|
2107
|
+
logger: opts.worker?.logger ?? logger,
|
|
2108
|
+
});
|
|
2109
|
+
worker.start();
|
|
2110
|
+
// Drain the worker during the post-drain close phase so an in-flight
|
|
2111
|
+
// job settles (or fails back to the queue) alongside other resources.
|
|
2112
|
+
const startedWorker = worker;
|
|
2113
|
+
this.onClose(() => startedWorker.stop());
|
|
2114
|
+
}
|
|
2115
|
+
this.jobQueue = queue;
|
|
2116
|
+
this.jobWorkerRef = worker;
|
|
2117
|
+
return this;
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* The {@link JobQueue} attached by {@link App.useJobs}, or `undefined`
|
|
2121
|
+
* when jobs are not configured. Route handlers enqueue through this;
|
|
2122
|
+
* delivery is at-least-once, so handlers must be idempotent.
|
|
2123
|
+
*
|
|
2124
|
+
* @since 1.3.0
|
|
2125
|
+
*/
|
|
2126
|
+
get jobs() {
|
|
2127
|
+
return this.jobQueue;
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* The {@link JobWorker} created by {@link App.useJobs} with
|
|
2131
|
+
* `startWorker: true`, or `undefined`. Exposed for inspection
|
|
2132
|
+
* (`getState()`) and tests (`runOnce()`); the lifecycle is owned by the app.
|
|
2133
|
+
*
|
|
2134
|
+
* @since 1.3.0
|
|
2135
|
+
*/
|
|
2136
|
+
get jobWorker() {
|
|
2137
|
+
return this.jobWorkerRef;
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Register a cron task whose tick enqueues a job instead of running the
|
|
2141
|
+
* side effect in-process. This is the production posture for scheduled
|
|
2142
|
+
* work with global side effects (nightly reconciliation, invoice runs):
|
|
2143
|
+
* every replica may tick, but the deterministic idempotency key
|
|
2144
|
+
* `cron:{taskName}:{floor(scheduledFor / tickGranularity)}` collapses the
|
|
2145
|
+
* duplicate enqueues into one job, and exactly one worker claims it.
|
|
2146
|
+
*
|
|
2147
|
+
* `tickGranularity` is the task's `intervalMs` for interval schedules and
|
|
2148
|
+
* one minute for cron expressions (the finest cadence a cron expression
|
|
2149
|
+
* can fire), so two replicas ticking the same slot always derive the same
|
|
2150
|
+
* key. Use plain {@link App.cron} for process-local maintenance (cache
|
|
2151
|
+
* sweeps that must happen in *this* process); use `cronEnqueue` for work
|
|
2152
|
+
* that must happen once, cluster-wide, and survive a restart.
|
|
2153
|
+
*
|
|
2154
|
+
* @example
|
|
2155
|
+
* ```ts
|
|
2156
|
+
* app.cronEnqueue(
|
|
2157
|
+
* { name: "nightly-reconcile", cron: "0 2 * * *" },
|
|
2158
|
+
* { name: "ops.reconcile", payload: {} },
|
|
2159
|
+
* );
|
|
2160
|
+
* ```
|
|
2161
|
+
*
|
|
2162
|
+
* @param def - The task definition (schedule), same shape as {@link App.cron}.
|
|
2163
|
+
* @param job - The job to enqueue on each tick. `payload` defaults to
|
|
2164
|
+
* `{ scheduledFor: <ISO time of the tick> }`.
|
|
2165
|
+
* @returns This `App` instance for chaining.
|
|
2166
|
+
* @throws {@link JobConfigError} (`store_required`) when called before
|
|
2167
|
+
* {@link App.useJobs} — fail fast at registration, not at the first tick.
|
|
2168
|
+
* @since 1.3.0
|
|
2169
|
+
*/
|
|
2170
|
+
cronEnqueue(def, job) {
|
|
2171
|
+
const queue = this.jobQueue;
|
|
2172
|
+
if (queue === undefined) {
|
|
2173
|
+
throw new JobConfigError("store_required", "app.cronEnqueue() requires app.useJobs() first: attach a JobStore before scheduling job-producing ticks.");
|
|
2174
|
+
}
|
|
2175
|
+
const granularityMs = def.intervalMs !== undefined && def.intervalMs > 0 ? def.intervalMs : 60_000;
|
|
2176
|
+
return this.cron(def, async ({ name, scheduledFor }) => {
|
|
2177
|
+
const slot = Math.floor(scheduledFor.getTime() / granularityMs);
|
|
2178
|
+
await queue.enqueue({
|
|
2179
|
+
name: job.name,
|
|
2180
|
+
payload: job.payload ?? { scheduledFor: scheduledFor.toISOString() },
|
|
2181
|
+
...(job.queue !== undefined ? { queue: job.queue } : {}),
|
|
2182
|
+
...(job.priority !== undefined ? { priority: job.priority } : {}),
|
|
2183
|
+
idempotencyKey: `cron:${encodeURIComponent(name)}:${slot}`,
|
|
2184
|
+
});
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
1944
2187
|
registerHealthRoute(kind, opts, handler) {
|
|
1945
2188
|
const isHealth = kind === "healthcheck";
|
|
1946
2189
|
const defaultPath = (isHealth ? "/healthz" : "/readyz");
|
|
1947
2190
|
const path = (opts.path ?? defaultPath);
|
|
1948
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
2191
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
2192
|
+
? null
|
|
2193
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1949
2194
|
const token = opts.token;
|
|
1950
2195
|
// Refuse-to-boot: unauthenticated health/ready probes in
|
|
1951
2196
|
// production are a documented info-disclosure surface (process uptime,
|
|
@@ -1959,7 +2204,9 @@ export class App {
|
|
|
1959
2204
|
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
1960
2205
|
`to acknowledge that this probe is reachable without credentials.`);
|
|
1961
2206
|
}
|
|
1962
|
-
const buckets = rateLimitConfig
|
|
2207
|
+
const buckets = rateLimitConfig
|
|
2208
|
+
? new Map()
|
|
2209
|
+
: null;
|
|
1963
2210
|
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1964
2211
|
this.route({
|
|
1965
2212
|
method: "GET",
|
|
@@ -1975,7 +2222,10 @@ export class App {
|
|
|
1975
2222
|
const now = Date.now();
|
|
1976
2223
|
const entry = buckets.get(key);
|
|
1977
2224
|
if (!entry || entry.resetMs <= now) {
|
|
1978
|
-
buckets.set(key, {
|
|
2225
|
+
buckets.set(key, {
|
|
2226
|
+
count: 1,
|
|
2227
|
+
resetMs: now + rateLimitConfig.windowMs,
|
|
2228
|
+
});
|
|
1979
2229
|
}
|
|
1980
2230
|
else {
|
|
1981
2231
|
entry.count++;
|
|
@@ -2033,8 +2283,12 @@ export class App {
|
|
|
2033
2283
|
if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > HARD_MAX) {
|
|
2034
2284
|
throw new Error(`cspReportRoute(): maxBodyBytes must be a positive integer <= ${HARD_MAX}.`);
|
|
2035
2285
|
}
|
|
2036
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
2037
|
-
|
|
2286
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
2287
|
+
? null
|
|
2288
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
2289
|
+
const buckets = rateLimitConfig
|
|
2290
|
+
? new Map()
|
|
2291
|
+
: null;
|
|
2038
2292
|
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
2039
2293
|
const log = this.log;
|
|
2040
2294
|
// Only log report bodies when explicitly enabled. In
|
|
@@ -2053,7 +2307,10 @@ export class App {
|
|
|
2053
2307
|
const now = Date.now();
|
|
2054
2308
|
const entry = buckets.get(key);
|
|
2055
2309
|
if (!entry || entry.resetMs <= now) {
|
|
2056
|
-
buckets.set(key, {
|
|
2310
|
+
buckets.set(key, {
|
|
2311
|
+
count: 1,
|
|
2312
|
+
resetMs: now + rateLimitConfig.windowMs,
|
|
2313
|
+
});
|
|
2057
2314
|
}
|
|
2058
2315
|
else {
|
|
2059
2316
|
entry.count++;
|
|
@@ -2143,8 +2400,11 @@ export class App {
|
|
|
2143
2400
|
this.assertSecureHookConfig(config.hooks);
|
|
2144
2401
|
// Child apps share the parent's router/routes/etc. Disable docs auto-mount
|
|
2145
2402
|
// on the child so it does not re-register the parent's `/openapi.json` and
|
|
2146
|
-
// `/docs` routes (which would throw "Duplicate route").
|
|
2147
|
-
|
|
2403
|
+
// `/docs` routes (which would throw "Duplicate route"). Disable telemetry
|
|
2404
|
+
// too: spreading `this.options` would otherwise construct a second pair of
|
|
2405
|
+
// OTLP exporters, `unref`'d flush timers, and a duplicate `telemetry.otlp`
|
|
2406
|
+
// boot line per group/plugin. The parent's wiring is copied below.
|
|
2407
|
+
const child = new App({ ...this.options, docs: false, telemetry: false });
|
|
2148
2408
|
child.router = this.router;
|
|
2149
2409
|
child.routes = this.routes;
|
|
2150
2410
|
child.webSocketRoutes = this.webSocketRoutes;
|
|
@@ -2152,7 +2412,10 @@ export class App {
|
|
|
2152
2412
|
child.bootGuard = this.bootGuard;
|
|
2153
2413
|
child.log = this.log;
|
|
2154
2414
|
child.prefix = joinPath(this.prefix, prefix);
|
|
2155
|
-
child.groupHooks = [
|
|
2415
|
+
child.groupHooks = [
|
|
2416
|
+
...this.groupHooks,
|
|
2417
|
+
...(config.hooks ? [config.hooks] : []),
|
|
2418
|
+
];
|
|
2156
2419
|
child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
|
|
2157
2420
|
child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
|
|
2158
2421
|
child.groupAuth = config.auth ?? this.groupAuth;
|
|
@@ -2172,6 +2435,7 @@ export class App {
|
|
|
2172
2435
|
child.shutdownListeners = this.shutdownListeners;
|
|
2173
2436
|
child.pendingPlugins = this.pendingPlugins;
|
|
2174
2437
|
child.pluginBootError = this.pluginBootError;
|
|
2438
|
+
child.telemetry = this.telemetry;
|
|
2175
2439
|
register(child);
|
|
2176
2440
|
return this;
|
|
2177
2441
|
}
|
|
@@ -2204,7 +2468,8 @@ export class App {
|
|
|
2204
2468
|
// "set only if absent" semantics mean the second installation would be
|
|
2205
2469
|
// a silent no-op).
|
|
2206
2470
|
if (hooks[SECURE_HEADERS_MARKER] === true) {
|
|
2207
|
-
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
|
|
2471
|
+
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
|
|
2472
|
+
true);
|
|
2208
2473
|
if (autoIdx >= 0)
|
|
2209
2474
|
this.groupHooks.splice(autoIdx, 1);
|
|
2210
2475
|
}
|
|
@@ -2276,14 +2541,16 @@ export class App {
|
|
|
2276
2541
|
* @throws Error if `key` is already decorated and `opts.override` is not `true`.
|
|
2277
2542
|
*/
|
|
2278
2543
|
decorate(key, value, opts = {}) {
|
|
2279
|
-
if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
|
|
2544
|
+
if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
|
|
2545
|
+
opts.override !== true) {
|
|
2280
2546
|
// Namespace-protected decorators. Refuse to silently
|
|
2281
2547
|
// shadow an existing decoration; emit a once-per-process warn naming
|
|
2282
2548
|
// both decorators on the explicit-override path.
|
|
2283
2549
|
throw new Error(`decorate(): key "${key}" is already decorated. ` +
|
|
2284
2550
|
`Pass { override: true } to replace, or rename to avoid the collision.`);
|
|
2285
2551
|
}
|
|
2286
|
-
if (opts.override === true &&
|
|
2552
|
+
if (opts.override === true &&
|
|
2553
|
+
Object.prototype.hasOwnProperty.call(this.decorations, key)) {
|
|
2287
2554
|
this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
|
|
2288
2555
|
}
|
|
2289
2556
|
const hadKey = Object.prototype.hasOwnProperty.call(this.decorations, key);
|
|
@@ -2355,7 +2622,10 @@ export class App {
|
|
|
2355
2622
|
const dedupKey = name ? (seed ? `${name}#${seed}` : name) : undefined;
|
|
2356
2623
|
const dependencies = descriptor?.dependencies ?? [];
|
|
2357
2624
|
const stateful = descriptor?.stateful ?? false;
|
|
2358
|
-
if (stateful &&
|
|
2625
|
+
if (stateful &&
|
|
2626
|
+
!name &&
|
|
2627
|
+
this.isProduction() &&
|
|
2628
|
+
this.options.secureDefaults !== false) {
|
|
2359
2629
|
throw new Error("register(): anonymous stateful plugin refused in production. " +
|
|
2360
2630
|
"Declare { name } (and optional { seed }) so the plugin can be deduplicated.");
|
|
2361
2631
|
}
|
|
@@ -2405,7 +2675,9 @@ export class App {
|
|
|
2405
2675
|
throw err;
|
|
2406
2676
|
});
|
|
2407
2677
|
this.pendingPlugins.add(tracked);
|
|
2408
|
-
void tracked
|
|
2678
|
+
void tracked
|
|
2679
|
+
.finally(() => this.pendingPlugins.delete(tracked))
|
|
2680
|
+
.catch(() => { });
|
|
2409
2681
|
}
|
|
2410
2682
|
firePluginInstalled(event) {
|
|
2411
2683
|
if (this.pluginInstalledListeners.length === 0)
|
|
@@ -2424,7 +2696,9 @@ export class App {
|
|
|
2424
2696
|
this.log.error({ err, plugin: event.name }, "onPluginInstalled listener failed");
|
|
2425
2697
|
}
|
|
2426
2698
|
}
|
|
2427
|
-
return promises.length > 0
|
|
2699
|
+
return promises.length > 0
|
|
2700
|
+
? Promise.all(promises).then(() => undefined)
|
|
2701
|
+
: undefined;
|
|
2428
2702
|
}
|
|
2429
2703
|
/**
|
|
2430
2704
|
* Wait until every async plugin registered with {@link App.register} has
|
|
@@ -2643,7 +2917,9 @@ export class App {
|
|
|
2643
2917
|
}
|
|
2644
2918
|
if (coldPreBody !== undefined) {
|
|
2645
2919
|
const guardResult = coldPreBody(ctx);
|
|
2646
|
-
const guarded = isPromiseLike(guardResult)
|
|
2920
|
+
const guarded = isPromiseLike(guardResult)
|
|
2921
|
+
? await guardResult
|
|
2922
|
+
: guardResult;
|
|
2647
2923
|
if (guarded instanceof Response) {
|
|
2648
2924
|
copyContextHeaders(ctx, guarded);
|
|
2649
2925
|
if (!guarded.headers.has("x-request-id")) {
|
|
@@ -2655,7 +2931,9 @@ export class App {
|
|
|
2655
2931
|
}
|
|
2656
2932
|
if (coldGuards !== undefined) {
|
|
2657
2933
|
const guardResult = coldGuards(ctx);
|
|
2658
|
-
const guarded = isPromiseLike(guardResult)
|
|
2934
|
+
const guarded = isPromiseLike(guardResult)
|
|
2935
|
+
? await guardResult
|
|
2936
|
+
: guardResult;
|
|
2659
2937
|
if (guarded instanceof Response) {
|
|
2660
2938
|
copyContextHeaders(ctx, guarded);
|
|
2661
2939
|
if (!guarded.headers.has("x-request-id")) {
|
|
@@ -2703,7 +2981,7 @@ export class App {
|
|
|
2703
2981
|
}
|
|
2704
2982
|
throw new NotFoundError(`No route for ${request.method} ${pathname}`);
|
|
2705
2983
|
}
|
|
2706
|
-
const { def, hooks, mergedHooks: allHooks, hasFinalizeHook } = match.handler;
|
|
2984
|
+
const { def, hooks, mergedHooks: allHooks, hasFinalizeHook, } = match.handler;
|
|
2707
2985
|
activeErrorHook = allHooks.onError;
|
|
2708
2986
|
activeResponseHook = allHooks.onResponse;
|
|
2709
2987
|
activeSendHook = allHooks.onSend;
|
|
@@ -2733,7 +3011,9 @@ export class App {
|
|
|
2733
3011
|
Object.assign(state, routeDecorations);
|
|
2734
3012
|
if (allHooks.preBody !== undefined) {
|
|
2735
3013
|
const preBodyResult = allHooks.preBody(ctx);
|
|
2736
|
-
const preBody = isPromiseLike(preBodyResult)
|
|
3014
|
+
const preBody = isPromiseLike(preBodyResult)
|
|
3015
|
+
? await preBodyResult
|
|
3016
|
+
: preBodyResult;
|
|
2737
3017
|
const overriddenId = state.requestId;
|
|
2738
3018
|
if (typeof overriddenId === "string" && overriddenId.length > 0) {
|
|
2739
3019
|
requestId = overriddenId;
|
|
@@ -2757,7 +3037,9 @@ export class App {
|
|
|
2757
3037
|
ctx = isPromiseLike(validatedCtx) ? await validatedCtx : validatedCtx;
|
|
2758
3038
|
if (allHooks.beforeHandle !== undefined) {
|
|
2759
3039
|
const beforeResult = allHooks.beforeHandle(ctx);
|
|
2760
|
-
const before = isPromiseLike(beforeResult)
|
|
3040
|
+
const before = isPromiseLike(beforeResult)
|
|
3041
|
+
? await beforeResult
|
|
3042
|
+
: beforeResult;
|
|
2761
3043
|
// Honor any request id override applied by middleware (e.g. the
|
|
2762
3044
|
// `requestId()` Hooks bundle replaces the framework-generated value
|
|
2763
3045
|
// with a trusted incoming header or a user-supplied generator).
|
|
@@ -2783,7 +3065,9 @@ export class App {
|
|
|
2783
3065
|
let result = isPromiseLike(runResult) ? await runResult : runResult;
|
|
2784
3066
|
if (allHooks.afterHandle !== undefined) {
|
|
2785
3067
|
const afterResult = allHooks.afterHandle(ctx, result);
|
|
2786
|
-
const afterReturn = isPromiseLike(afterResult)
|
|
3068
|
+
const afterReturn = isPromiseLike(afterResult)
|
|
3069
|
+
? await afterResult
|
|
3070
|
+
: afterResult;
|
|
2787
3071
|
if (afterReturn !== undefined)
|
|
2788
3072
|
result = afterReturn;
|
|
2789
3073
|
}
|
|
@@ -2877,7 +3161,9 @@ export class App {
|
|
|
2877
3161
|
// `err instanceof HttpError` first: the framework's own thrown
|
|
2878
3162
|
// problem errors short-circuit before any signal/option lookup.
|
|
2879
3163
|
const isHttp = err instanceof HttpError;
|
|
2880
|
-
const disconnectCode = isHttp
|
|
3164
|
+
const disconnectCode = isHttp
|
|
3165
|
+
? 0
|
|
3166
|
+
: (this.options.disconnectStatusCode ?? 499);
|
|
2881
3167
|
if (disconnectCode > 0 && request.signal?.aborted === true) {
|
|
2882
3168
|
log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
|
|
2883
3169
|
const res = new Response(null, {
|
|
@@ -2935,7 +3221,9 @@ export class App {
|
|
|
2935
3221
|
* @returns Fulfills with the `Response` produced by the matching handler.
|
|
2936
3222
|
*/
|
|
2937
3223
|
request(input, init) {
|
|
2938
|
-
const url = typeof input === "string" && input.startsWith("/")
|
|
3224
|
+
const url = typeof input === "string" && input.startsWith("/")
|
|
3225
|
+
? `http://test.local${input}`
|
|
3226
|
+
: input;
|
|
2939
3227
|
const req = url instanceof Request ? url : new Request(url, init);
|
|
2940
3228
|
return this.fetch(req);
|
|
2941
3229
|
}
|
|
@@ -3092,7 +3380,9 @@ function inferOperationId(method, path) {
|
|
|
3092
3380
|
const suffix = path
|
|
3093
3381
|
.slice(1)
|
|
3094
3382
|
.split("/")
|
|
3095
|
-
.map((segment) => segment.startsWith(":")
|
|
3383
|
+
.map((segment) => segment.startsWith(":")
|
|
3384
|
+
? `By${capitalizeWords(segment.slice(1))}`
|
|
3385
|
+
: capitalizeWords(segment))
|
|
3096
3386
|
.join("");
|
|
3097
3387
|
return `${method.toLowerCase()}${suffix}`;
|
|
3098
3388
|
}
|
|
@@ -3120,7 +3410,9 @@ function joinPath(a, b) {
|
|
|
3120
3410
|
function healthRouteKey(request, trustProxyHeaders) {
|
|
3121
3411
|
if (!trustProxyHeaders)
|
|
3122
3412
|
return "global";
|
|
3123
|
-
return request.headers.get("x-real-ip") ??
|
|
3413
|
+
return (request.headers.get("x-real-ip") ??
|
|
3414
|
+
request.headers.get("fly-client-ip") ??
|
|
3415
|
+
"global");
|
|
3124
3416
|
}
|
|
3125
3417
|
/** True when the app declared a trusted reverse-proxy posture. */
|
|
3126
3418
|
function appTrustsProxyHeaders(options) {
|
|
@@ -3244,7 +3536,9 @@ export function topoSortExtensions(exts) {
|
|
|
3244
3536
|
const bHeaders = b.responseHeaders;
|
|
3245
3537
|
if (!bHeaders || bHeaders.length === 0)
|
|
3246
3538
|
continue;
|
|
3247
|
-
const overlap = bHeaders
|
|
3539
|
+
const overlap = bHeaders
|
|
3540
|
+
.map((h) => h.toLowerCase())
|
|
3541
|
+
.filter((h) => aSet.has(h));
|
|
3248
3542
|
if (overlap.length === 0)
|
|
3249
3543
|
continue;
|
|
3250
3544
|
const declared = (a.before ?? []).includes(b.name) ||
|
|
@@ -3306,11 +3600,16 @@ function securityMarkersFromHooks(layers) {
|
|
|
3306
3600
|
hasCsrf,
|
|
3307
3601
|
hasAuth,
|
|
3308
3602
|
cacheBeforeTenancy: cacheIndex !== -1 && tenancyIndex !== -1 && cacheIndex < tenancyIndex,
|
|
3309
|
-
replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex
|
|
3603
|
+
replayBeforeBudget: replayIndex !== -1 && budgetIndex !== -1 && replayIndex < budgetIndex
|
|
3604
|
+
? replayName
|
|
3605
|
+
: null,
|
|
3310
3606
|
};
|
|
3311
3607
|
}
|
|
3312
3608
|
function isStateChangingMethod(method) {
|
|
3313
|
-
return method === "POST" ||
|
|
3609
|
+
return (method === "POST" ||
|
|
3610
|
+
method === "PUT" ||
|
|
3611
|
+
method === "PATCH" ||
|
|
3612
|
+
method === "DELETE");
|
|
3314
3613
|
}
|
|
3315
3614
|
/**
|
|
3316
3615
|
* Extract the pathname from a fully-qualified request URL without
|
|
@@ -3420,7 +3719,9 @@ function getOriginFast(url) {
|
|
|
3420
3719
|
return url.slice(0, end);
|
|
3421
3720
|
}
|
|
3422
3721
|
function mergeHooks(layers) {
|
|
3423
|
-
const pick = (key) => layers
|
|
3722
|
+
const pick = (key) => layers
|
|
3723
|
+
.map((h) => h[key])
|
|
3724
|
+
.filter((f) => typeof f === "function");
|
|
3424
3725
|
const requiredScopes = requiredScopesFromHooks(layers);
|
|
3425
3726
|
const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
|
|
3426
3727
|
const hooks = {
|
|
@@ -3450,7 +3751,9 @@ function requiredScopesFromHooks(layers) {
|
|
|
3450
3751
|
}
|
|
3451
3752
|
function stampRequiredScopes(hooks, scopes) {
|
|
3452
3753
|
if (scopes.length > 0) {
|
|
3453
|
-
hooks[REQUIRE_SCOPES_HOOK_MARKER] = [
|
|
3754
|
+
hooks[REQUIRE_SCOPES_HOOK_MARKER] = [
|
|
3755
|
+
...scopes,
|
|
3756
|
+
];
|
|
3454
3757
|
}
|
|
3455
3758
|
}
|
|
3456
3759
|
function mergeBeforeHandle(beforeHandle, requiredScopes) {
|
|
@@ -3522,7 +3825,9 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
|
|
|
3522
3825
|
return {
|
|
3523
3826
|
...(configuration ?? {}),
|
|
3524
3827
|
authentication: {
|
|
3525
|
-
...(authentication &&
|
|
3828
|
+
...(authentication &&
|
|
3829
|
+
typeof authentication === "object" &&
|
|
3830
|
+
!Array.isArray(authentication)
|
|
3526
3831
|
? authentication
|
|
3527
3832
|
: {}),
|
|
3528
3833
|
preferredSecurityScheme,
|
|
@@ -3559,7 +3864,9 @@ function finishFinalize(res, ctx, hooks, stripFingerprint) {
|
|
|
3559
3864
|
return res;
|
|
3560
3865
|
}
|
|
3561
3866
|
function isPromiseLike(value) {
|
|
3562
|
-
return value !== null &&
|
|
3867
|
+
return (value !== null &&
|
|
3868
|
+
typeof value === "object" &&
|
|
3869
|
+
typeof value.then === "function");
|
|
3563
3870
|
}
|
|
3564
3871
|
/**
|
|
3565
3872
|
* Allocation-free finalizer for the common case (no `onSend`/`onResponse`
|
|
@@ -3789,7 +4096,10 @@ function validateContext(ctx, def, opts) {
|
|
|
3789
4096
|
const rawParams = ctx.params;
|
|
3790
4097
|
const buildHeaders = () => ctx.headers;
|
|
3791
4098
|
const buildQuery = () => ctx.query;
|
|
3792
|
-
const hasSchema = def.request?.params ||
|
|
4099
|
+
const hasSchema = def.request?.params ||
|
|
4100
|
+
def.request?.query ||
|
|
4101
|
+
def.request?.headers ||
|
|
4102
|
+
def.request?.body;
|
|
3793
4103
|
const finishContext = () => ctx;
|
|
3794
4104
|
if (!hasSchema) {
|
|
3795
4105
|
return finishContext();
|
|
@@ -3833,7 +4143,8 @@ function validateContext(ctx, def, opts) {
|
|
|
3833
4143
|
const declared = request.headers.get("content-length");
|
|
3834
4144
|
if (declared !== null) {
|
|
3835
4145
|
const declaredBytes = Number(declared);
|
|
3836
|
-
if (Number.isFinite(declaredBytes) &&
|
|
4146
|
+
if (Number.isFinite(declaredBytes) &&
|
|
4147
|
+
declaredBytes > opts.bodyLimitBytes) {
|
|
3837
4148
|
throw new PayloadTooLargeError(opts.bodyLimitBytes);
|
|
3838
4149
|
}
|
|
3839
4150
|
}
|
|
@@ -3906,7 +4217,7 @@ function toIssues(issues) {
|
|
|
3906
4217
|
return issues.map((i) => ({
|
|
3907
4218
|
message: i.message,
|
|
3908
4219
|
path: (i.path ?? [])
|
|
3909
|
-
.map((p) =>
|
|
4220
|
+
.map((p) => typeof p === "object" && p && "key" in p ? p.key : p)
|
|
3910
4221
|
.join("."),
|
|
3911
4222
|
}));
|
|
3912
4223
|
}
|
|
@@ -4037,7 +4348,8 @@ async function readBodySlow(req, ct, limit, multipart) {
|
|
|
4037
4348
|
typeof v.arrayBuffer === "function";
|
|
4038
4349
|
if (isFile) {
|
|
4039
4350
|
files++;
|
|
4040
|
-
if (multipart?.maxFileBytes !== undefined &&
|
|
4351
|
+
if (multipart?.maxFileBytes !== undefined &&
|
|
4352
|
+
v.size > multipart.maxFileBytes) {
|
|
4041
4353
|
throw new PayloadTooLargeError(multipart.maxFileBytes);
|
|
4042
4354
|
}
|
|
4043
4355
|
}
|
|
@@ -4266,7 +4578,8 @@ function withTimeout(p, ms, request) {
|
|
|
4266
4578
|
const OMIT_STACK_IN_LOG = Symbol.for("daloyjs.error.omitStackInLog");
|
|
4267
4579
|
function serializeErr(err) {
|
|
4268
4580
|
if (err instanceof Error) {
|
|
4269
|
-
if (err[OMIT_STACK_IN_LOG] ===
|
|
4581
|
+
if (err[OMIT_STACK_IN_LOG] ===
|
|
4582
|
+
true) {
|
|
4270
4583
|
return { name: err.name, message: err.message };
|
|
4271
4584
|
}
|
|
4272
4585
|
return { name: err.name, message: err.message, stack: err.stack };
|