@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/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([this.options.hooks ?? {}]);
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([this.options.hooks ?? {}, ...this.groupHooks]);
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 && typeof options.logger.info === "function"
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 ? { write: telemetryWrite } : {}),
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 ? "default" : o.crashOnUnhandledRejection,
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 && typeof this.options.secureHeaders === "object"
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" ? this.options.loadShedding : {};
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" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") {
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" ? getOriginFast(requestUrl) : requestUrl.origin;
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" ? new URL(requestUrl).origin : requestUrl.origin;
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 || this.options.production !== 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 && this.bootGuard.error === 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] = true;
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 ? { servers: 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 ? { webhooks: 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: { description: "OpenAPI 3.1 document for this application, in YAML." },
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 ? { connectOrigins: docsConnectOrigins } : {}),
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 ? null : (opts.yamlPath ?? "/asyncapi.yaml");
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: { description: "AsyncAPI 3.0 document for this application's WebSocket channels." },
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: { description: "AsyncAPI 3.0 document for this application, in YAML." },
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([globalHookLayer, ...sources]);
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 || typeof options !== "object" || typeof possibleHandler !== "function") {
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 || this.pendingPlugins.size > 0 || this.pluginBootError.failed) {
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. Pass `opts.registry` to register custom application metrics
1806
- * that are rendered alongside the built-in HTTP series.
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 ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
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 ? new Map() : null;
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, { count: 1, resetMs: now + rateLimitConfig.windowMs });
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({ logger: this.log.child({ component: "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 *&ldquo;run this work somewhere, eventually&rdquo;* —
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 ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
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 ? new Map() : null;
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, { count: 1, resetMs: now + rateLimitConfig.windowMs });
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 ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
2037
- const buckets = rateLimitConfig ? new Map() : null;
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, { count: 1, resetMs: now + rateLimitConfig.windowMs });
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
- const child = new App({ ...this.options, docs: false });
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 = [...this.groupHooks, ...(config.hooks ? [config.hooks] : [])];
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] === true);
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) && opts.override !== true) {
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 && Object.prototype.hasOwnProperty.call(this.decorations, key)) {
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 && !name && this.isProduction() && this.options.secureDefaults !== false) {
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.finally(() => this.pendingPlugins.delete(tracked)).catch(() => { });
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 ? Promise.all(promises).then(() => undefined) : undefined;
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) ? await guardResult : 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) ? await guardResult : 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) ? await preBodyResult : 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) ? await beforeResult : 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) ? await afterResult : 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 ? 0 : (this.options.disconnectStatusCode ?? 499);
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("/") ? `http://test.local${input}` : input;
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(":") ? `By${capitalizeWords(segment.slice(1))}` : capitalizeWords(segment))
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") ?? request.headers.get("fly-client-ip") ?? "global";
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.map((h) => h.toLowerCase()).filter((h) => aSet.has(h));
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 ? replayName : null,
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" || method === "PUT" || method === "PATCH" || method === "DELETE";
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.map((h) => h[key]).filter((f) => typeof f === "function");
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] = [...scopes];
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 && typeof authentication === "object" && !Array.isArray(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 && typeof value === "object" && typeof value.then === "function";
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 || def.request?.query || def.request?.headers || def.request?.body;
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) && declaredBytes > opts.bodyLimitBytes) {
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) => (typeof p === "object" && p && "key" in p ? p.key : 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 && v.size > multipart.maxFileBytes) {
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] === true) {
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 };