@daloyjs/core 0.36.0 → 0.38.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -3
  3. package/bin/daloy.mjs +2 -0
  4. package/dist/adapters/bun.js +16 -9
  5. package/dist/adapters/deno.js +7 -1
  6. package/dist/adapters/node.d.ts +25 -0
  7. package/dist/adapters/node.js +32 -0
  8. package/dist/app.d.ts +200 -6
  9. package/dist/app.js +235 -50
  10. package/dist/asyncapi.d.ts +98 -0
  11. package/dist/asyncapi.js +212 -0
  12. package/dist/auto-ban.d.ts +205 -0
  13. package/dist/auto-ban.js +222 -0
  14. package/dist/bot-guard.d.ts +209 -0
  15. package/dist/bot-guard.js +291 -0
  16. package/dist/cli.d.ts +8 -0
  17. package/dist/cli.js +113 -4
  18. package/dist/client.d.ts +23 -0
  19. package/dist/client.js +16 -0
  20. package/dist/concurrency-limit.d.ts +135 -0
  21. package/dist/concurrency-limit.js +254 -0
  22. package/dist/docs.d.ts +57 -6
  23. package/dist/docs.js +34 -3
  24. package/dist/errors.d.ts +43 -0
  25. package/dist/errors.js +57 -0
  26. package/dist/fetch-guard.js +4 -0
  27. package/dist/fetch-resilience.d.ts +295 -0
  28. package/dist/fetch-resilience.js +485 -0
  29. package/dist/geo-block.d.ts +184 -0
  30. package/dist/geo-block.js +153 -0
  31. package/dist/hashing.d.ts +2 -1
  32. package/dist/hashing.js +12 -1
  33. package/dist/http-signatures.d.ts +303 -0
  34. package/dist/http-signatures.js +782 -0
  35. package/dist/idempotency.d.ts +204 -0
  36. package/dist/idempotency.js +341 -0
  37. package/dist/index.d.ts +39 -5
  38. package/dist/index.js +19 -2
  39. package/dist/ip-reputation.d.ts +198 -0
  40. package/dist/ip-reputation.js +253 -0
  41. package/dist/jwk.d.ts +15 -0
  42. package/dist/jwk.js +24 -2
  43. package/dist/load-shedding.d.ts +5 -0
  44. package/dist/logger.js +6 -2
  45. package/dist/metrics.d.ts +208 -0
  46. package/dist/metrics.js +452 -0
  47. package/dist/middleware.js +0 -10
  48. package/dist/mtls.d.ts +266 -0
  49. package/dist/mtls.js +488 -0
  50. package/dist/multipart.js +1 -1
  51. package/dist/openapi-diff.d.ts +79 -0
  52. package/dist/openapi-diff.js +246 -0
  53. package/dist/openapi.js +4 -1
  54. package/dist/pagination.d.ts +210 -0
  55. package/dist/pagination.js +353 -0
  56. package/dist/rate-limit-redis.d.ts +8 -0
  57. package/dist/rate-limit-redis.js +8 -0
  58. package/dist/request-decompression.d.ts +200 -0
  59. package/dist/request-decompression.js +363 -0
  60. package/dist/response-cache.d.ts +205 -0
  61. package/dist/response-cache.js +374 -0
  62. package/dist/router.d.ts +22 -0
  63. package/dist/router.js +64 -7
  64. package/dist/safe-redirect.d.ts +2 -2
  65. package/dist/safe-redirect.js +3 -8
  66. package/dist/sbom.cdx.json +9 -9
  67. package/dist/sbom.spdx.json +5 -5
  68. package/dist/scheduler.d.ts +315 -0
  69. package/dist/scheduler.js +546 -0
  70. package/dist/security.d.ts +61 -7
  71. package/dist/security.js +75 -8
  72. package/dist/session.js +3 -3
  73. package/dist/types.d.ts +33 -0
  74. package/dist/waf.d.ts +213 -0
  75. package/dist/waf.js +334 -0
  76. package/dist/webhook-delivery.d.ts +263 -0
  77. package/dist/webhook-delivery.js +311 -0
  78. package/dist/websocket.d.ts +52 -0
  79. package/dist/websocket.js +13 -0
  80. package/package.json +79 -3
package/dist/app.js CHANGED
@@ -2,7 +2,7 @@ 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 { validate } from "./schema.js";
5
- import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
5
+ import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
6
6
  import { createLogger, noopLogger } from "./logger.js";
7
7
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
8
8
  import { docsContentSecurityPolicy, scalarHtml, swaggerUiHtml, } from "./docs.js";
@@ -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");
@@ -147,6 +149,7 @@ function applySecurityPreset(options) {
147
149
  const DEFAULTS = {
148
150
  bodyLimitBytes: 1024 * 1024,
149
151
  requestTimeoutMs: 30_000,
152
+ maxHeaderCount: DEFAULT_MAX_HEADER_COUNT,
150
153
  validateResponses: true,
151
154
  };
152
155
  const TEXT_ENCODER = new TextEncoder();
@@ -179,58 +182,21 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
179
182
  */
180
183
  export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
181
184
  /**
182
- * Contract-first HTTP application.
183
- *
184
- * `App` is the top-level entry point: register {@link RouteDefinition routes}
185
- * with {@link App.route}, layer cross-cutting behavior with
186
- * {@link App.use}/{@link App.register}, then expose the application to a
187
- * runtime via {@link App.fetch} (Web standard) or one of the adapter subpaths
188
- * such as `@daloyjs/core/node`, `@daloyjs/core/cloudflare`, or
189
- * `@daloyjs/core/lambda`.
190
- *
191
- * The same `App` instance powers:
192
- *
193
- * - request routing (`Router` under the hood)
194
- * - request/response validation against Standard-Schema validators
195
- * - OpenAPI 3.1 generation (`generateOpenAPI(app)`)
196
- * - typed in-process client (`createClient(app)`) and generated SDK
197
- * - graceful shutdown and lifecycle observability
198
- *
199
- * `App` is **runtime-agnostic**: the same instance runs on Node, Bun, Deno,
200
- * Cloudflare Workers, Vercel Edge, AWS Lambda, and Fastly Compute via the
201
- * dedicated adapters.
202
- *
203
- * @example
204
- * ```ts
205
- * import { App, secureHeaders } from "@daloyjs/core";
206
- * import { z } from "zod";
207
- *
208
- * const app = new App({ title: "Books API", version: "1.0.0" });
209
- *
210
- * app.use(secureHeaders());
211
- *
212
- * app.route({
213
- * method: "GET",
214
- * path: "/books/:id",
215
- * operationId: "getBook",
216
- * request: { params: z.object({ id: z.uuid() }) },
217
- * responses: {
218
- * 200: { description: "OK", body: z.object({ id: z.string(), title: z.string() }) },
219
- * },
220
- * handler: ({ params }) => ({ status: 200, body: { id: params.id, title: "Dune" } }),
221
- * });
222
- *
223
- * // Node:
224
- * import { serve } from "@daloyjs/core/node";
225
- * serve(app, { port: 3000 });
226
- * ```
227
- *
228
185
  * @since 0.1.0
229
186
  */
230
187
  export class App {
231
188
  options;
232
189
  log;
233
- /** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
190
+ /**
191
+ * Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
192
+ *
193
+ * Statically the property is typed as the `Routes` tuple so that
194
+ * {@link App.route} can accumulate each registered route's literal
195
+ * `operationId`, request, and response types. The typed client
196
+ * (`createClient(app)`) reads this tuple to derive a precisely-typed method
197
+ * per route. At runtime it is an ordinary growable array — the tuple typing
198
+ * is a compile-time view only.
199
+ */
234
200
  routes = [];
235
201
  router = new Router();
236
202
  /**
@@ -261,6 +227,12 @@ export class App {
261
227
  installedPlugins = new Set();
262
228
  closeHooks = [];
263
229
  closeHooksRun = false;
230
+ /**
231
+ * Lazily-created in-process scheduler backing {@link App.cron}. Started on
232
+ * the first `cron()` call and stopped from an `onClose` hook so its lifecycle
233
+ * is tied to graceful shutdown.
234
+ */
235
+ scheduler;
264
236
  /** Idle-connection close hooks (adapter-registered, sync). */
265
237
  idleConnectionCloseHooks = [];
266
238
  pluginInstalledListeners = [];
@@ -320,6 +292,7 @@ export class App {
320
292
  validateResponses: resolved.validateResponses ?? DEFAULTS.validateResponses,
321
293
  bodyLimitBytes: resolved.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
322
294
  requestTimeoutMs: resolved.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
295
+ maxHeaderCount: resolved.maxHeaderCount ?? DEFAULTS.maxHeaderCount,
323
296
  ...resolved,
324
297
  };
325
298
  this.log =
@@ -448,6 +421,7 @@ export class App {
448
421
  trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
449
422
  bodyLimitBytes: this.options.bodyLimitBytes,
450
423
  requestTimeoutMs: this.options.requestTimeoutMs,
424
+ maxHeaderCount: this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT,
451
425
  stripServerHeaders: o.stripServerHeaders !== false,
452
426
  production: this.isProduction(),
453
427
  });
@@ -861,11 +835,16 @@ export class App {
861
835
  handler: async () => {
862
836
  const title = opts.title ?? (await resolveInfo()).title;
863
837
  const html = ui === "swagger"
864
- ? swaggerUiHtml({ specUrl: openapiPath, title })
838
+ ? swaggerUiHtml({
839
+ specUrl: openapiPath,
840
+ title,
841
+ assets: opts.assets,
842
+ })
865
843
  : scalarHtml({
866
844
  specUrl: openapiPath,
867
845
  title,
868
846
  configuration: opts.scalar,
847
+ assets: opts.assets,
869
848
  });
870
849
  return {
871
850
  status: 200,
@@ -902,8 +881,16 @@ export class App {
902
881
  * });
903
882
  * ```
904
883
  *
884
+ * The return type widens `Routes` with the freshly-registered route so
885
+ * that chained registration (`new App().route(a).route(b)`) accumulates a
886
+ * precise tuple. The typed client (`createClient(app)`) consumes that tuple
887
+ * to expose a method per `operationId` with parameters and responses
888
+ * inferred from the route's own schemas. Non-chained calls
889
+ * (`app.route(a); app.route(b);`) keep the variable's original type, so
890
+ * chain the calls when you want the inferred client surface.
891
+ *
905
892
  * @param def - The route definition.
906
- * @returns This `App` instance for chaining.
893
+ * @returns This `App` instance (widened with the new route) for chaining.
907
894
  */
908
895
  route(def) {
909
896
  // Refuse non-canonical HTTP methods at runtime.
@@ -930,6 +917,13 @@ export class App {
930
917
  auth: def.auth ?? this.groupAuth,
931
918
  };
932
919
  this.assertRouteAuthPayloadConfig(merged);
920
+ // Normalize an optional RFC 8594 sunset date to a stable IMF-fixdate
921
+ // (HTTP date) string once, at registration time, so the hot response
922
+ // path can emit the `Sunset` header without re-parsing per request and
923
+ // a bad value fails fast rather than silently emitting garbage.
924
+ if (merged.sunset !== undefined) {
925
+ merged.sunset = normalizeSunset(merged.sunset, merged.method, fullPath);
926
+ }
933
927
  const sources = [...this.groupHooks, def.hooks ?? {}];
934
928
  const hooks = mergeHooks(sources);
935
929
  const corsOriginAllows = corsOriginAllowsFromHooks(sources);
@@ -945,6 +939,9 @@ export class App {
945
939
  ...sources,
946
940
  ]);
947
941
  this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
942
+ // `routes` is statically a readonly tuple so the typed client can infer
943
+ // per-route methods; at runtime it is a growable array, so we push through
944
+ // a mutable view.
948
945
  this.routes.push(merged);
949
946
  this.routeSecurityMarkers.push({
950
947
  method: merged.method,
@@ -1082,6 +1079,166 @@ export class App {
1082
1079
  });
1083
1080
  return this;
1084
1081
  }
1082
+ /**
1083
+ * Register an opt-in, auth-guarded Prometheus / OpenMetrics scrape route
1084
+ * and install RED (Rate / Errors / Duration) instrumentation for every
1085
+ * route registered **after** this call. The third observability pillar
1086
+ * alongside the structured logger and the OpenTelemetry tracer.
1087
+ *
1088
+ * Exposes, in the Prometheus text exposition format:
1089
+ * - `<prefix>http_requests_total{method,route,status}` — request counter,
1090
+ * - `<prefix>http_request_duration_seconds{method,route}` — latency histogram,
1091
+ * - `<prefix>http_requests_in_flight` — concurrency gauge,
1092
+ * - process gauges (resident memory, heap used, uptime) on Node-like runtimes.
1093
+ *
1094
+ * The scrape route inherits the same hardened posture as
1095
+ * {@link App.healthcheck}: optional bearer token compared via
1096
+ * {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
1097
+ * refuse-to-boot guard in production (an unauthenticated `/metrics`
1098
+ * endpoint leaks internal route names, latency, and traffic volume) unless
1099
+ * a token is supplied or `acknowledgeUnauthenticated: true` is passed.
1100
+ *
1101
+ * Call this **before** registering the routes you want measured — like any
1102
+ * `app.use(...)` middleware, the instrumentation only wraps routes added
1103
+ * afterwards. Pass `opts.registry` to register custom application metrics
1104
+ * that are rendered alongside the built-in HTTP series.
1105
+ *
1106
+ * @param opts - Path, auth, rate-limit, registry, and label configuration.
1107
+ * @returns `this` for chaining.
1108
+ * @since 0.37.0
1109
+ */
1110
+ metrics(opts = {}) {
1111
+ const path = (opts.path ?? "/metrics");
1112
+ const registry = opts.registry ?? new MetricsRegistry();
1113
+ const rateLimitConfig = opts.rateLimit === false
1114
+ ? null
1115
+ : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
1116
+ const token = opts.token;
1117
+ // Refuse-to-boot: an unauthenticated metrics scrape in production is a
1118
+ // documented info-disclosure surface (route inventory, latency
1119
+ // distributions, request volume, process memory). Force an explicit
1120
+ // acknowledgement, mirroring app.healthcheck().
1121
+ if (this.options.secureDefaults !== false &&
1122
+ this.isProduction() &&
1123
+ token === undefined &&
1124
+ opts.acknowledgeUnauthenticated !== true) {
1125
+ throw new Error(`app.metrics() refused in production: provide opts.token to require ` +
1126
+ `Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
1127
+ `to acknowledge that this scrape endpoint is reachable without credentials.`);
1128
+ }
1129
+ // Install RED instrumentation as a group hook so it wraps every route
1130
+ // registered after this call. Always exclude the scrape path itself, plus
1131
+ // any caller-supplied predicate.
1132
+ const exclude = (p) => p === path || (opts.exclude ? opts.exclude(p) : false);
1133
+ this.groupHooks.push(httpMetrics({
1134
+ registry,
1135
+ route: opts.route,
1136
+ maxRouteCardinality: opts.maxRouteCardinality,
1137
+ buckets: opts.buckets,
1138
+ exclude,
1139
+ }));
1140
+ const buckets = rateLimitConfig
1141
+ ? new Map()
1142
+ : null;
1143
+ this.route({
1144
+ method: "GET",
1145
+ path,
1146
+ operationId: "metrics",
1147
+ tags: ["Observability"],
1148
+ summary: "Prometheus metrics scrape endpoint",
1149
+ handler: async ({ request }) => {
1150
+ if (buckets && rateLimitConfig) {
1151
+ const key = healthRouteKey(request);
1152
+ const now = Date.now();
1153
+ const entry = buckets.get(key);
1154
+ if (!entry || entry.resetMs <= now) {
1155
+ buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
1156
+ }
1157
+ else {
1158
+ entry.count++;
1159
+ if (entry.count > rateLimitConfig.limit) {
1160
+ throw new TooManyRequestsError(Math.ceil((entry.resetMs - now) / 1000));
1161
+ }
1162
+ }
1163
+ }
1164
+ if (token !== undefined) {
1165
+ const h = request.headers.get("authorization") ?? "";
1166
+ const m = /^Bearer\s+(.+)$/i.exec(h);
1167
+ if (!m) {
1168
+ throw new HttpError(401, {
1169
+ type: "https://daloyjs.dev/errors/unauthorized",
1170
+ title: "Unauthorized",
1171
+ detail: "Metrics scrape requires a bearer token.",
1172
+ }, { "www-authenticate": 'Bearer realm="metrics"' });
1173
+ }
1174
+ if (!timingSafeEqual(m[1], token)) {
1175
+ throw new ForbiddenError("Invalid metrics scrape token.");
1176
+ }
1177
+ }
1178
+ return {
1179
+ status: 200,
1180
+ body: registry.render(),
1181
+ headers: {
1182
+ "content-type": PROMETHEUS_CONTENT_TYPE,
1183
+ "cache-control": "no-store",
1184
+ },
1185
+ };
1186
+ },
1187
+ responses: {
1188
+ 200: { description: "Prometheus metrics exposition." },
1189
+ 429: { description: "Too many scrape requests." },
1190
+ },
1191
+ });
1192
+ return this;
1193
+ }
1194
+ /**
1195
+ * Register an in-process scheduled task (cron). The first call lazily creates
1196
+ * an app-managed {@link Scheduler}, wires it to the app logger, starts it,
1197
+ * and registers an `onClose` hook so it is drained on graceful shutdown
1198
+ * (in-flight runs are awaited, then aborted if they outlast the shutdown
1199
+ * grace period).
1200
+ *
1201
+ * The schedule is **queue-agnostic** — it runs work in *this* process on a
1202
+ * fixed interval or cron expression. Use it for periodic maintenance
1203
+ * (cache sweeps, token refresh, reconciliation) rather than as a distributed
1204
+ * job queue. Each task is **single-flight**: if a tick fires while the
1205
+ * previous run is still in progress, the tick is skipped and counted, so a
1206
+ * slow task can never pile up overlapping runs.
1207
+ *
1208
+ * @example
1209
+ * ```ts
1210
+ * app.cron({ name: "sweep", cron: "0 * * * *" }, async ({ signal }) => {
1211
+ * await purgeExpiredSessions({ signal });
1212
+ * });
1213
+ * ```
1214
+ *
1215
+ * @param def - The task definition. Exactly one of `intervalMs` or `cron`.
1216
+ * @param handler - The function to run on each tick.
1217
+ * @returns This `App` instance for chaining.
1218
+ * @throws {RangeError} on invalid options (see {@link Scheduler.define}).
1219
+ * @throws {@link CronParseError} if a `cron` expression is malformed.
1220
+ */
1221
+ cron(def, handler) {
1222
+ if (this.scheduler === undefined) {
1223
+ const scheduler = new Scheduler({ logger: this.log.child({ component: "scheduler" }) });
1224
+ this.scheduler = scheduler;
1225
+ scheduler.start();
1226
+ // Drain the scheduler during the post-drain close phase so periodic
1227
+ // work stops cleanly alongside database pools and other resources.
1228
+ this.onClose(() => scheduler.stop());
1229
+ }
1230
+ this.scheduler.define(def, handler);
1231
+ return this;
1232
+ }
1233
+ /**
1234
+ * The app-managed {@link Scheduler} backing {@link App.cron}, or `undefined`
1235
+ * if no scheduled task has been registered. Exposed for inspection
1236
+ * (`getState()` / `list()`) and manual triggering (`runNow()`); the lifecycle
1237
+ * is owned by the app.
1238
+ */
1239
+ get scheduledTasks() {
1240
+ return this.scheduler;
1241
+ }
1085
1242
  registerHealthRoute(kind, opts, handler) {
1086
1243
  const isHealth = kind === "healthcheck";
1087
1244
  const defaultPath = (isHealth ? "/healthz" : "/readyz");
@@ -1654,6 +1811,7 @@ export class App {
1654
1811
  try {
1655
1812
  assertNoDuplicateSingletonHeaders(request.headers);
1656
1813
  assertNoReservedInternalHeaders(request.headers);
1814
+ assertHeaderCountWithinLimit(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
1657
1815
  this.assertTrustProxyConfigured(request);
1658
1816
  this.assertBootGuards();
1659
1817
  if (globalHooks.onRequest !== undefined) {
@@ -2666,6 +2824,23 @@ async function readBody(req, ct, limit, multipart) {
2666
2824
  const bytes = await readBodyLimited(req, limit);
2667
2825
  return new TextDecoder().decode(bytes);
2668
2826
  }
2827
+ /**
2828
+ * Validate and normalize a route's RFC 8594 `sunset` value to an IMF-fixdate
2829
+ * (HTTP date) string. Accepts an ISO-8601/parseable string or a `Date`.
2830
+ * Throws at registration time when the value cannot be parsed into a valid
2831
+ * date so a typo never silently ships a malformed `Sunset` header.
2832
+ *
2833
+ * @internal
2834
+ */
2835
+ function normalizeSunset(value, method, path) {
2836
+ const date = value instanceof Date ? value : new Date(value);
2837
+ if (Number.isNaN(date.getTime())) {
2838
+ throw new Error(`app.route(): invalid sunset date for ${method} ${path}: ` +
2839
+ `${JSON.stringify(value)}. Provide an ISO-8601 string, an HTTP date, ` +
2840
+ `or a Date instance.`);
2841
+ }
2842
+ return date.toUTCString();
2843
+ }
2669
2844
  function serializeResult(result, def, validateResponses) {
2670
2845
  const spec = def.responses[result.status];
2671
2846
  if (!spec) {
@@ -2677,6 +2852,16 @@ function serializeResult(result, def, validateResponses) {
2677
2852
  const treatAsJson = !explicitCt || explicitCt.includes("application/json");
2678
2853
  if (!explicitCt)
2679
2854
  headers.set("content-type", "application/json");
2855
+ // RFC 8594 deprecation lifecycle headers. A route with an explicit
2856
+ // `sunset` date is implicitly deprecated. Never overwrite a value the
2857
+ // handler set deliberately.
2858
+ if (def.deprecated === true || def.sunset !== undefined) {
2859
+ if (!headers.has("deprecation"))
2860
+ headers.set("deprecation", "true");
2861
+ if (def.sunset !== undefined && !headers.has("sunset")) {
2862
+ headers.set("sunset", def.sunset);
2863
+ }
2864
+ }
2680
2865
  let body;
2681
2866
  let rawBody = null;
2682
2867
  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;