@daloyjs/core 0.35.2 → 0.37.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.
Files changed (77) hide show
  1. package/README.md +22 -2
  2. package/bin/daloy.mjs +2 -0
  3. package/dist/adapters/bun.js +16 -9
  4. package/dist/adapters/deno.js +7 -1
  5. package/dist/adapters/node.d.ts +11 -0
  6. package/dist/adapters/node.js +24 -0
  7. package/dist/app.d.ts +223 -1
  8. package/dist/app.js +358 -8
  9. package/dist/asyncapi.d.ts +98 -0
  10. package/dist/asyncapi.js +212 -0
  11. package/dist/auto-ban.d.ts +205 -0
  12. package/dist/auto-ban.js +222 -0
  13. package/dist/bot-guard.d.ts +209 -0
  14. package/dist/bot-guard.js +291 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.js +88 -4
  17. package/dist/concurrency-limit.d.ts +135 -0
  18. package/dist/concurrency-limit.js +254 -0
  19. package/dist/docs.d.ts +57 -6
  20. package/dist/docs.js +34 -3
  21. package/dist/errors.d.ts +20 -0
  22. package/dist/errors.js +27 -0
  23. package/dist/fetch-guard.js +4 -0
  24. package/dist/fetch-resilience.d.ts +295 -0
  25. package/dist/fetch-resilience.js +485 -0
  26. package/dist/geo-block.d.ts +184 -0
  27. package/dist/geo-block.js +153 -0
  28. package/dist/hashing.d.ts +2 -1
  29. package/dist/hashing.js +12 -1
  30. package/dist/http-signatures.d.ts +303 -0
  31. package/dist/http-signatures.js +782 -0
  32. package/dist/idempotency.d.ts +204 -0
  33. package/dist/idempotency.js +341 -0
  34. package/dist/index.d.ts +38 -4
  35. package/dist/index.js +18 -1
  36. package/dist/ip-reputation.d.ts +198 -0
  37. package/dist/ip-reputation.js +253 -0
  38. package/dist/jwk.d.ts +15 -0
  39. package/dist/jwk.js +24 -2
  40. package/dist/load-shedding.d.ts +5 -0
  41. package/dist/logger.js +6 -2
  42. package/dist/metrics.d.ts +208 -0
  43. package/dist/metrics.js +452 -0
  44. package/dist/middleware.js +0 -10
  45. package/dist/mtls.d.ts +266 -0
  46. package/dist/mtls.js +488 -0
  47. package/dist/multipart.js +1 -1
  48. package/dist/openapi-diff.d.ts +79 -0
  49. package/dist/openapi-diff.js +246 -0
  50. package/dist/openapi.js +4 -1
  51. package/dist/pagination.d.ts +210 -0
  52. package/dist/pagination.js +353 -0
  53. package/dist/rate-limit-redis.d.ts +8 -0
  54. package/dist/rate-limit-redis.js +8 -0
  55. package/dist/request-decompression.d.ts +200 -0
  56. package/dist/request-decompression.js +363 -0
  57. package/dist/response-cache.d.ts +205 -0
  58. package/dist/response-cache.js +374 -0
  59. package/dist/router.d.ts +22 -0
  60. package/dist/router.js +64 -7
  61. package/dist/safe-redirect.d.ts +2 -2
  62. package/dist/safe-redirect.js +3 -8
  63. package/dist/sbom.cdx.json +9 -9
  64. package/dist/sbom.spdx.json +5 -5
  65. package/dist/scheduler.d.ts +315 -0
  66. package/dist/scheduler.js +546 -0
  67. package/dist/security.d.ts +27 -7
  68. package/dist/security.js +27 -7
  69. package/dist/session.js +3 -3
  70. package/dist/types.d.ts +33 -0
  71. package/dist/waf.d.ts +213 -0
  72. package/dist/waf.js +334 -0
  73. package/dist/webhook-delivery.d.ts +263 -0
  74. package/dist/webhook-delivery.js +311 -0
  75. package/dist/websocket.d.ts +52 -0
  76. package/dist/websocket.js +13 -0
  77. package/package.json +76 -2
package/dist/app.js CHANGED
@@ -10,6 +10,8 @@ import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN
10
10
  import { COMPRESSION_HOOK_MARKER } from "./compression.js";
11
11
  import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
12
12
  import { loadShedding as loadSheddingMiddleware } from "./load-shedding.js";
13
+ import { httpMetrics, MetricsRegistry, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
14
+ import { Scheduler, } from "./scheduler.js";
13
15
  import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
14
16
  import { assertBehindProxy } from "./conn-info.js";
15
17
  const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
@@ -40,6 +42,36 @@ let insecureDefaultsLoggedThisProcess = false;
40
42
  export function _resetInsecureDefaultsLogForTests() {
41
43
  insecureDefaultsLoggedThisProcess = false;
42
44
  }
45
+ /**
46
+ * The exact set of fields the `"internal-service"` preset flips off when
47
+ * the caller has not set them explicitly. Surfaced through the boot
48
+ * audit log entry so operators can see which guards the preset turned
49
+ * off without re-reading the framework source.
50
+ */
51
+ const INTERNAL_SERVICE_PRESET_DISABLED = Object.freeze([
52
+ "secureHeaders auto-install",
53
+ "corsCrossOriginGuard (state-changing cross-origin write rejection)",
54
+ "csrf boot guard (session() + state-changing route)",
55
+ "unconfigured X-Forwarded-* / trustProxy guard",
56
+ ]);
57
+ /**
58
+ * Defaults that the `"internal-service"` preset keeps on. Logged at boot
59
+ * alongside the disabled list so the audit entry shows the full posture.
60
+ */
61
+ const INTERNAL_SERVICE_PRESET_KEPT = Object.freeze([
62
+ "bodyLimitBytes (1 MiB default)",
63
+ "requestTimeoutMs (30 s default)",
64
+ "crashOnUnhandledRejection (production)",
65
+ "weak session secret refuse-to-boot",
66
+ "cors({ origin: '*' }) refuse-to-boot",
67
+ "anonymous stateful plugin refuse-to-boot",
68
+ "stripServerHeaders",
69
+ "RFC 9457 problem+json prod redaction",
70
+ "JWT algorithm allowlist + timingSafeEqual credential comparison",
71
+ "prototype-pollution-safe parsers + isForbiddenObjectKey",
72
+ "fetchGuard() SSRF defaults",
73
+ "schema .strict() + response validation when enabled",
74
+ ]);
43
75
  /**
44
76
  * List of secure-by-default surfaces disabled when `secureDefaults: false`
45
77
  * is set. Surfaced through the once-per-process `error` log so the operator
@@ -74,6 +106,46 @@ const CANONICAL_HTTP_METHODS = new Set([
74
106
  "HEAD",
75
107
  "OPTIONS",
76
108
  ]);
109
+ /**
110
+ * Apply a topology-aware security preset on top of caller-supplied
111
+ * options. Returns a new options object where preset defaults fill in
112
+ * any field the caller left `undefined`; explicit caller values always
113
+ * win. Pure / no side effects — the boot audit log is emitted
114
+ * separately by {@link App.logSecurityPresetIfApplied} so this helper is
115
+ * safe to call from `new App({ preset: ... })` in test setups.
116
+ *
117
+ * The `"internal-service"` preset turns off:
118
+ * - `secureHeaders` auto-install (browser-only headers)
119
+ * - `corsCrossOriginGuard` (no browser Origin to guard against)
120
+ * - `csrf` (set to `"off"` — service-to-service callers aren't browsers)
121
+ * - `trustProxy` (set to `false` — explicitly ignore `X-Forwarded-*`
122
+ * and silence the unconfigured-proxy 500 guard; the immediate peer
123
+ * inside the mesh *is* the caller)
124
+ *
125
+ * Everything else (body limits, request timeouts, JWT allowlist,
126
+ * `crashOnUnhandledRejection`, weak-secret refuse-to-boot, cors-wildcard
127
+ * refuse-to-boot, anonymous stateful plugin refuse-to-boot,
128
+ * `stripServerHeaders`, RFC 9457 prod redaction, schema strictness,
129
+ * `fetchGuard`, parser safety) stays at its standard secure-by-default
130
+ * value.
131
+ *
132
+ * @internal
133
+ */
134
+ function applySecurityPreset(options) {
135
+ if (options.preset !== "internal-service")
136
+ return options;
137
+ const out = { ...options };
138
+ if (out.secureHeaders === undefined)
139
+ out.secureHeaders = false;
140
+ if (out.corsCrossOriginGuard === undefined)
141
+ out.corsCrossOriginGuard = false;
142
+ if (out.csrf === undefined)
143
+ out.csrf = "off";
144
+ if (out.trustProxy === undefined && out.behindProxy === undefined) {
145
+ out.trustProxy = false;
146
+ }
147
+ return out;
148
+ }
77
149
  const DEFAULTS = {
78
150
  bodyLimitBytes: 1024 * 1024,
79
151
  requestTimeoutMs: 30_000,
@@ -191,6 +263,12 @@ export class App {
191
263
  installedPlugins = new Set();
192
264
  closeHooks = [];
193
265
  closeHooksRun = false;
266
+ /**
267
+ * Lazily-created in-process scheduler backing {@link App.cron}. Started on
268
+ * the first `cron()` call and stopped from an `onClose` hook so its lifecycle
269
+ * is tied to graceful shutdown.
270
+ */
271
+ scheduler;
194
272
  /** Idle-connection close hooks (adapter-registered, sync). */
195
273
  idleConnectionCloseHooks = [];
196
274
  pluginInstalledListeners = [];
@@ -245,11 +323,12 @@ export class App {
245
323
  return this._globalCorsAllowsCache;
246
324
  }
247
325
  constructor(options = {}) {
326
+ const resolved = applySecurityPreset(options);
248
327
  this.options = {
249
- validateResponses: options.validateResponses ?? DEFAULTS.validateResponses,
250
- bodyLimitBytes: options.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
251
- requestTimeoutMs: options.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
252
- ...options,
328
+ validateResponses: resolved.validateResponses ?? DEFAULTS.validateResponses,
329
+ bodyLimitBytes: resolved.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
330
+ requestTimeoutMs: resolved.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
331
+ ...resolved,
253
332
  };
254
333
  this.log =
255
334
  options.logger === false
@@ -264,6 +343,7 @@ export class App {
264
343
  if (this.options.hooks)
265
344
  this.assertSecureHookConfig(this.options.hooks);
266
345
  this.assertInsecureDefaultsAcknowledged();
346
+ this.logSecurityPresetIfApplied(options);
267
347
  this.installSecureDefaults();
268
348
  this.maybeInstallCrashHandlers();
269
349
  this.maybeMountDocs();
@@ -318,6 +398,68 @@ export class App {
318
398
  }, `app({ secureDefaults: false }) disables: ${DISABLED_BY_INSECURE_DEFAULTS.join(", ")}.`);
319
399
  }
320
400
  }
401
+ /**
402
+ * Emit the one-time boot audit entry for an applied security preset.
403
+ * Called from the constructor with the *original* (pre-preset) options
404
+ * so the log captures which fields the preset filled in vs. which the
405
+ * caller set explicitly. Logged at `info` so the line shows up in
406
+ * standard production log shipping without being noisy.
407
+ *
408
+ * Operators can audit the live posture at any time through
409
+ * {@link App.getSecurityPosture}.
410
+ *
411
+ * @since 0.34.0
412
+ */
413
+ logSecurityPresetIfApplied(originalOptions) {
414
+ if (originalOptions.preset !== "internal-service")
415
+ return;
416
+ const userOverrode = [];
417
+ if (originalOptions.secureHeaders !== undefined)
418
+ userOverrode.push("secureHeaders");
419
+ if (originalOptions.corsCrossOriginGuard !== undefined) {
420
+ userOverrode.push("corsCrossOriginGuard");
421
+ }
422
+ if (originalOptions.csrf !== undefined)
423
+ userOverrode.push("csrf");
424
+ if (originalOptions.trustProxy !== undefined)
425
+ userOverrode.push("trustProxy");
426
+ if (originalOptions.behindProxy !== undefined)
427
+ userOverrode.push("behindProxy");
428
+ this.log.info({
429
+ event: "security.preset.applied",
430
+ preset: "internal-service",
431
+ disabled: INTERNAL_SERVICE_PRESET_DISABLED,
432
+ kept: INTERNAL_SERVICE_PRESET_KEPT,
433
+ userOverrode,
434
+ }, `Applied security preset "internal-service": disabled ${INTERNAL_SERVICE_PRESET_DISABLED.length} topology-dependent guards; kept ${INTERNAL_SERVICE_PRESET_KEPT.length} input/credential/SSRF guards on. See app.getSecurityPosture() for the live snapshot.`);
435
+ }
436
+ /**
437
+ * Structured snapshot of the live security posture. Returns the same
438
+ * data the constructor logs under the `security.preset.applied` audit
439
+ * event plus the resolved values of every secure-by-default knob, so
440
+ * operators can build a `/__security` introspection route or a CI
441
+ * audit without parsing the framework source.
442
+ *
443
+ * @since 0.34.0
444
+ */
445
+ getSecurityPosture() {
446
+ const o = this.options;
447
+ return Object.freeze({
448
+ preset: o.preset,
449
+ secureDefaults: o.secureDefaults !== false,
450
+ secureHeaders: o.secureDefaults !== false && o.secureHeaders !== false,
451
+ corsCrossOriginGuard: o.secureDefaults !== false && o.corsCrossOriginGuard !== false,
452
+ csrf: o.csrf === "off" ? "off" : "on",
453
+ crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
454
+ ? "default"
455
+ : o.crashOnUnhandledRejection,
456
+ trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
457
+ bodyLimitBytes: this.options.bodyLimitBytes,
458
+ requestTimeoutMs: this.options.requestTimeoutMs,
459
+ stripServerHeaders: o.stripServerHeaders !== false,
460
+ production: this.isProduction(),
461
+ });
462
+ }
321
463
  /**
322
464
  * Install the secure-by-default global hooks. Currently:
323
465
  * - {@link secureHeaders} as a group-level hook so every response carries
@@ -727,11 +869,16 @@ export class App {
727
869
  handler: async () => {
728
870
  const title = opts.title ?? (await resolveInfo()).title;
729
871
  const html = ui === "swagger"
730
- ? swaggerUiHtml({ specUrl: openapiPath, title })
872
+ ? swaggerUiHtml({
873
+ specUrl: openapiPath,
874
+ title,
875
+ assets: opts.assets,
876
+ })
731
877
  : scalarHtml({
732
878
  specUrl: openapiPath,
733
879
  title,
734
880
  configuration: opts.scalar,
881
+ assets: opts.assets,
735
882
  });
736
883
  return {
737
884
  status: 200,
@@ -796,6 +943,13 @@ export class App {
796
943
  auth: def.auth ?? this.groupAuth,
797
944
  };
798
945
  this.assertRouteAuthPayloadConfig(merged);
946
+ // Normalize an optional RFC 8594 sunset date to a stable IMF-fixdate
947
+ // (HTTP date) string once, at registration time, so the hot response
948
+ // path can emit the `Sunset` header without re-parsing per request and
949
+ // a bad value fails fast rather than silently emitting garbage.
950
+ if (merged.sunset !== undefined) {
951
+ merged.sunset = normalizeSunset(merged.sunset, merged.method, fullPath);
952
+ }
799
953
  const sources = [...this.groupHooks, def.hooks ?? {}];
800
954
  const hooks = mergeHooks(sources);
801
955
  const corsOriginAllows = corsOriginAllowsFromHooks(sources);
@@ -948,6 +1102,166 @@ export class App {
948
1102
  });
949
1103
  return this;
950
1104
  }
1105
+ /**
1106
+ * Register an opt-in, auth-guarded Prometheus / OpenMetrics scrape route
1107
+ * and install RED (Rate / Errors / Duration) instrumentation for every
1108
+ * route registered **after** this call. The third observability pillar
1109
+ * alongside the structured logger and the OpenTelemetry tracer.
1110
+ *
1111
+ * Exposes, in the Prometheus text exposition format:
1112
+ * - `<prefix>http_requests_total{method,route,status}` — request counter,
1113
+ * - `<prefix>http_request_duration_seconds{method,route}` — latency histogram,
1114
+ * - `<prefix>http_requests_in_flight` — concurrency gauge,
1115
+ * - process gauges (resident memory, heap used, uptime) on Node-like runtimes.
1116
+ *
1117
+ * The scrape route inherits the same hardened posture as
1118
+ * {@link App.healthcheck}: optional bearer token compared via
1119
+ * {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
1120
+ * refuse-to-boot guard in production (an unauthenticated `/metrics`
1121
+ * endpoint leaks internal route names, latency, and traffic volume) unless
1122
+ * a token is supplied or `acknowledgeUnauthenticated: true` is passed.
1123
+ *
1124
+ * Call this **before** registering the routes you want measured — like any
1125
+ * `app.use(...)` middleware, the instrumentation only wraps routes added
1126
+ * afterwards. Pass `opts.registry` to register custom application metrics
1127
+ * that are rendered alongside the built-in HTTP series.
1128
+ *
1129
+ * @param opts - Path, auth, rate-limit, registry, and label configuration.
1130
+ * @returns `this` for chaining.
1131
+ * @since 0.37.0
1132
+ */
1133
+ metrics(opts = {}) {
1134
+ const path = (opts.path ?? "/metrics");
1135
+ const registry = opts.registry ?? new MetricsRegistry();
1136
+ const rateLimitConfig = opts.rateLimit === false
1137
+ ? null
1138
+ : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1139
+ const token = opts.token;
1140
+ // Refuse-to-boot: an unauthenticated metrics scrape in production is a
1141
+ // documented info-disclosure surface (route inventory, latency
1142
+ // distributions, request volume, process memory). Force an explicit
1143
+ // acknowledgement, mirroring app.healthcheck().
1144
+ if (this.options.secureDefaults !== false &&
1145
+ this.isProduction() &&
1146
+ token === undefined &&
1147
+ opts.acknowledgeUnauthenticated !== true) {
1148
+ throw new Error(`app.metrics() refused in production: provide opts.token to require ` +
1149
+ `Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
1150
+ `to acknowledge that this scrape endpoint is reachable without credentials.`);
1151
+ }
1152
+ // Install RED instrumentation as a group hook so it wraps every route
1153
+ // registered after this call. Always exclude the scrape path itself, plus
1154
+ // any caller-supplied predicate.
1155
+ const exclude = (p) => p === path || (opts.exclude ? opts.exclude(p) : false);
1156
+ this.groupHooks.push(httpMetrics({
1157
+ registry,
1158
+ route: opts.route,
1159
+ maxRouteCardinality: opts.maxRouteCardinality,
1160
+ buckets: opts.buckets,
1161
+ exclude,
1162
+ }));
1163
+ const buckets = rateLimitConfig
1164
+ ? new Map()
1165
+ : null;
1166
+ this.route({
1167
+ method: "GET",
1168
+ path,
1169
+ operationId: "metrics",
1170
+ tags: ["Observability"],
1171
+ summary: "Prometheus metrics scrape endpoint",
1172
+ handler: async ({ request }) => {
1173
+ if (buckets && rateLimitConfig) {
1174
+ const key = healthRouteKey(request);
1175
+ const now = Date.now();
1176
+ const entry = buckets.get(key);
1177
+ if (!entry || entry.resetMs <= now) {
1178
+ buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
1179
+ }
1180
+ else {
1181
+ entry.count++;
1182
+ if (entry.count > rateLimitConfig.limit) {
1183
+ throw new TooManyRequestsError(Math.ceil((entry.resetMs - now) / 1000));
1184
+ }
1185
+ }
1186
+ }
1187
+ if (token !== undefined) {
1188
+ const h = request.headers.get("authorization") ?? "";
1189
+ const m = /^Bearer\s+(.+)$/i.exec(h);
1190
+ if (!m) {
1191
+ throw new HttpError(401, {
1192
+ type: "https://daloyjs.dev/errors/unauthorized",
1193
+ title: "Unauthorized",
1194
+ detail: "Metrics scrape requires a bearer token.",
1195
+ }, { "www-authenticate": 'Bearer realm="metrics"' });
1196
+ }
1197
+ if (!timingSafeEqual(m[1], token)) {
1198
+ throw new ForbiddenError("Invalid metrics scrape token.");
1199
+ }
1200
+ }
1201
+ return {
1202
+ status: 200,
1203
+ body: registry.render(),
1204
+ headers: {
1205
+ "content-type": PROMETHEUS_CONTENT_TYPE,
1206
+ "cache-control": "no-store",
1207
+ },
1208
+ };
1209
+ },
1210
+ responses: {
1211
+ 200: { description: "Prometheus metrics exposition." },
1212
+ 429: { description: "Too many scrape requests." },
1213
+ },
1214
+ });
1215
+ return this;
1216
+ }
1217
+ /**
1218
+ * Register an in-process scheduled task (cron). The first call lazily creates
1219
+ * an app-managed {@link Scheduler}, wires it to the app logger, starts it,
1220
+ * and registers an `onClose` hook so it is drained on graceful shutdown
1221
+ * (in-flight runs are awaited, then aborted if they outlast the shutdown
1222
+ * grace period).
1223
+ *
1224
+ * The schedule is **queue-agnostic** — it runs work in *this* process on a
1225
+ * fixed interval or cron expression. Use it for periodic maintenance
1226
+ * (cache sweeps, token refresh, reconciliation) rather than as a distributed
1227
+ * job queue. Each task is **single-flight**: if a tick fires while the
1228
+ * previous run is still in progress, the tick is skipped and counted, so a
1229
+ * slow task can never pile up overlapping runs.
1230
+ *
1231
+ * @example
1232
+ * ```ts
1233
+ * app.cron({ name: "sweep", cron: "0 * * * *" }, async ({ signal }) => {
1234
+ * await purgeExpiredSessions({ signal });
1235
+ * });
1236
+ * ```
1237
+ *
1238
+ * @param def - The task definition. Exactly one of `intervalMs` or `cron`.
1239
+ * @param handler - The function to run on each tick.
1240
+ * @returns This `App` instance for chaining.
1241
+ * @throws {RangeError} on invalid options (see {@link Scheduler.define}).
1242
+ * @throws {@link CronParseError} if a `cron` expression is malformed.
1243
+ */
1244
+ cron(def, handler) {
1245
+ if (this.scheduler === undefined) {
1246
+ const scheduler = new Scheduler({ logger: this.log.child({ component: "scheduler" }) });
1247
+ this.scheduler = scheduler;
1248
+ scheduler.start();
1249
+ // Drain the scheduler during the post-drain close phase so periodic
1250
+ // work stops cleanly alongside database pools and other resources.
1251
+ this.onClose(() => scheduler.stop());
1252
+ }
1253
+ this.scheduler.define(def, handler);
1254
+ return this;
1255
+ }
1256
+ /**
1257
+ * The app-managed {@link Scheduler} backing {@link App.cron}, or `undefined`
1258
+ * if no scheduled task has been registered. Exposed for inspection
1259
+ * (`getState()` / `list()`) and manual triggering (`runNow()`); the lifecycle
1260
+ * is owned by the app.
1261
+ */
1262
+ get scheduledTasks() {
1263
+ return this.scheduler;
1264
+ }
951
1265
  registerHealthRoute(kind, opts, handler) {
952
1266
  const isHealth = kind === "healthcheck";
953
1267
  const defaultPath = (isHealth ? "/healthz" : "/readyz");
@@ -1499,7 +1813,7 @@ export class App {
1499
1813
  });
1500
1814
  }
1501
1815
  this.inflight++;
1502
- const requestId = randomId();
1816
+ let requestId = randomId();
1503
1817
  // Skip the per-request child-logger allocation when the app was
1504
1818
  // constructed with `{ logger: false }`. noopLogger.child() returns
1505
1819
  // itself, so the binding is wasted work on every request.
@@ -1662,6 +1976,13 @@ export class App {
1662
1976
  if (allHooks.beforeHandle !== undefined) {
1663
1977
  const beforeResult = allHooks.beforeHandle(ctx);
1664
1978
  const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
1979
+ // Honor any request id override applied by middleware (e.g. the
1980
+ // `requestId()` Hooks bundle replaces the framework-generated value
1981
+ // with a trusted incoming header or a user-supplied generator).
1982
+ const overriddenId = state.requestId;
1983
+ if (typeof overriddenId === "string" && overriddenId.length > 0) {
1984
+ requestId = overriddenId;
1985
+ }
1665
1986
  if (before instanceof Response) {
1666
1987
  copyContextHeaders(ctx, before);
1667
1988
  if (!before.headers.has("x-request-id"))
@@ -1686,8 +2007,10 @@ export class App {
1686
2007
  const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
1687
2008
  let response = isPromiseLike(serializeResultRes) ? await serializeResultRes : serializeResultRes;
1688
2009
  copyContextHeaders(ctx, response);
1689
- if (!response.headers.has("x-request-id"))
1690
- response.headers.set("x-request-id", requestId);
2010
+ // `serializeResult` always builds a fresh Response with no request id
2011
+ // skip the `has()` probe and set directly. Saves one undici contains()
2012
+ // call per request on the hot path.
2013
+ response.headers.set("x-request-id", requestId);
1691
2014
  let finalized;
1692
2015
  if (hasFinalizeHook) {
1693
2016
  const fin = finalizeResponse(response, ctx, allHooks, stripFingerprint);
@@ -2523,6 +2846,23 @@ async function readBody(req, ct, limit, multipart) {
2523
2846
  const bytes = await readBodyLimited(req, limit);
2524
2847
  return new TextDecoder().decode(bytes);
2525
2848
  }
2849
+ /**
2850
+ * Validate and normalize a route's RFC 8594 `sunset` value to an IMF-fixdate
2851
+ * (HTTP date) string. Accepts an ISO-8601/parseable string or a `Date`.
2852
+ * Throws at registration time when the value cannot be parsed into a valid
2853
+ * date so a typo never silently ships a malformed `Sunset` header.
2854
+ *
2855
+ * @internal
2856
+ */
2857
+ function normalizeSunset(value, method, path) {
2858
+ const date = value instanceof Date ? value : new Date(value);
2859
+ if (Number.isNaN(date.getTime())) {
2860
+ throw new Error(`app.route(): invalid sunset date for ${method} ${path}: ` +
2861
+ `${JSON.stringify(value)}. Provide an ISO-8601 string, an HTTP date, ` +
2862
+ `or a Date instance.`);
2863
+ }
2864
+ return date.toUTCString();
2865
+ }
2526
2866
  function serializeResult(result, def, validateResponses) {
2527
2867
  const spec = def.responses[result.status];
2528
2868
  if (!spec) {
@@ -2534,6 +2874,16 @@ function serializeResult(result, def, validateResponses) {
2534
2874
  const treatAsJson = !explicitCt || explicitCt.includes("application/json");
2535
2875
  if (!explicitCt)
2536
2876
  headers.set("content-type", "application/json");
2877
+ // RFC 8594 deprecation lifecycle headers. A route with an explicit
2878
+ // `sunset` date is implicitly deprecated. Never overwrite a value the
2879
+ // handler set deliberately.
2880
+ if (def.deprecated === true || def.sunset !== undefined) {
2881
+ if (!headers.has("deprecation"))
2882
+ headers.set("deprecation", "true");
2883
+ if (def.sunset !== undefined && !headers.has("sunset")) {
2884
+ headers.set("sunset", def.sunset);
2885
+ }
2886
+ }
2537
2887
  let body;
2538
2888
  let rawBody = null;
2539
2889
  let isStream = false;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * AsyncAPI 3.0 document generator for WebSocket surfaces.
3
+ *
4
+ * Built-in, dependency-free, and a deliberate mirror of the OpenAPI 3.1
5
+ * generator in `./openapi.ts`: it turns every `app.ws()` route into an
6
+ * AsyncAPI **channel** (the socket address + path parameters) and one or more
7
+ * **operations** (`receive` for client→server messages, `send` for
8
+ * server→client messages). The RFC 6455 stack and its CSWSH defenses finally
9
+ * get a contract/doc artifact, extending the contract-first story past HTTP.
10
+ *
11
+ * If a message schema exposes a `toJSONSchema()` method (Zod 4, Valibot, ...)
12
+ * we use it; otherwise we emit a permissive `{}` placeholder rather than fail
13
+ * — docs and tooling still work, just with looser types for that payload.
14
+ */
15
+ import type { App } from "./app.js";
16
+ /** AsyncAPI [Info Object](https://www.asyncapi.com/docs/reference/specification/v3.0.0#infoObject) header fields. */
17
+ export interface AsyncAPIInfo {
18
+ /** Human-readable API title shown by AsyncAPI Studio / docs. */
19
+ title: string;
20
+ /** Semantic API version (independent of your package version). */
21
+ version: string;
22
+ /** Optional CommonMark long description rendered at the top of the docs. */
23
+ description?: string;
24
+ }
25
+ /**
26
+ * AsyncAPI [Server Object](https://www.asyncapi.com/docs/reference/specification/v3.0.0#serverObject).
27
+ * Unlike OpenAPI's `servers` array, AsyncAPI keys servers by name.
28
+ */
29
+ export interface AsyncAPIServer {
30
+ /** Host (and optional port) the socket is reachable at, e.g. `api.example.com`. */
31
+ host: string;
32
+ /** Transport protocol, typically `ws` or `wss`. */
33
+ protocol: string;
34
+ /** Optional protocol version. */
35
+ protocolVersion?: string;
36
+ /** Optional base path prefixing channel addresses, e.g. `/realtime`. */
37
+ pathname?: string;
38
+ /** Optional human-readable server description. */
39
+ description?: string;
40
+ }
41
+ /** Options for {@link generateAsyncAPI}. */
42
+ export interface AsyncAPIOptions {
43
+ /** Required `info` block (title + version). */
44
+ info: AsyncAPIInfo;
45
+ /**
46
+ * Optional named servers exposed in the document. AsyncAPI keys servers by
47
+ * name (`{ production: { host, protocol } }`), not by an array.
48
+ */
49
+ servers?: Record<string, AsyncAPIServer>;
50
+ }
51
+ /**
52
+ * Generate an AsyncAPI 3.0 document from a registered {@link App}'s WebSocket
53
+ * routes.
54
+ *
55
+ * Every `app.ws()` route becomes one channel (its address + path parameters)
56
+ * and one or more operations:
57
+ *
58
+ * - a `receive` operation for client→server messages — payload taken from the
59
+ * route's `meta.receive` schema, falling back to the handler's
60
+ * `request.body` schema (the same schema used for payload-size checks).
61
+ * - a `send` operation for server→client messages — emitted only when the
62
+ * route declares a `meta.send` schema.
63
+ *
64
+ * The output is a plain JSON-serializable object: hand it to AsyncAPI Studio,
65
+ * write it to disk for codegen, or serve it from a route. When the app has no
66
+ * WebSocket routes the document still validates, with empty `channels` and
67
+ * `operations` maps.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { generateAsyncAPI } from "@daloyjs/core/asyncapi";
72
+ * import { writeFileSync } from "node:fs";
73
+ *
74
+ * const doc = generateAsyncAPI(app, {
75
+ * info: { title: "Realtime API", version: "1.0.0" },
76
+ * servers: { production: { host: "api.example.com", protocol: "wss" } },
77
+ * });
78
+ * writeFileSync("./generated/asyncapi.json", JSON.stringify(doc, null, 2));
79
+ * ```
80
+ *
81
+ * @param app - The application whose WebSocket routes are documented.
82
+ * @param options - Document metadata and optional named servers.
83
+ * @returns A JSON-serializable AsyncAPI 3.0 document.
84
+ * @since 0.37.0
85
+ */
86
+ export declare function generateAsyncAPI(app: App, options: AsyncAPIOptions): Record<string, unknown>;
87
+ /**
88
+ * Serialize an AsyncAPI document to YAML.
89
+ *
90
+ * Thin alias over the dependency-free YAML 1.2 emitter shared with the
91
+ * OpenAPI generator ({@link openapiToYAML}) — AsyncAPI and OpenAPI documents
92
+ * are both plain JSON-compatible objects, so the same emitter applies.
93
+ *
94
+ * @param doc - The AsyncAPI document produced by {@link generateAsyncAPI}.
95
+ * @returns The document rendered as a YAML string.
96
+ * @since 0.37.0
97
+ */
98
+ export declare function asyncapiToYAML(doc: Record<string, unknown>): string;