@daloyjs/core 0.36.0 → 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 +21 -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 +144 -1
  8. package/dist/app.js +208 -1
  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");
@@ -261,6 +263,12 @@ export class App {
261
263
  installedPlugins = new Set();
262
264
  closeHooks = [];
263
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;
264
272
  /** Idle-connection close hooks (adapter-registered, sync). */
265
273
  idleConnectionCloseHooks = [];
266
274
  pluginInstalledListeners = [];
@@ -861,11 +869,16 @@ export class App {
861
869
  handler: async () => {
862
870
  const title = opts.title ?? (await resolveInfo()).title;
863
871
  const html = ui === "swagger"
864
- ? swaggerUiHtml({ specUrl: openapiPath, title })
872
+ ? swaggerUiHtml({
873
+ specUrl: openapiPath,
874
+ title,
875
+ assets: opts.assets,
876
+ })
865
877
  : scalarHtml({
866
878
  specUrl: openapiPath,
867
879
  title,
868
880
  configuration: opts.scalar,
881
+ assets: opts.assets,
869
882
  });
870
883
  return {
871
884
  status: 200,
@@ -930,6 +943,13 @@ export class App {
930
943
  auth: def.auth ?? this.groupAuth,
931
944
  };
932
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
+ }
933
953
  const sources = [...this.groupHooks, def.hooks ?? {}];
934
954
  const hooks = mergeHooks(sources);
935
955
  const corsOriginAllows = corsOriginAllowsFromHooks(sources);
@@ -1082,6 +1102,166 @@ export class App {
1082
1102
  });
1083
1103
  return this;
1084
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
+ }
1085
1265
  registerHealthRoute(kind, opts, handler) {
1086
1266
  const isHealth = kind === "healthcheck";
1087
1267
  const defaultPath = (isHealth ? "/healthz" : "/readyz");
@@ -2666,6 +2846,23 @@ async function readBody(req, ct, limit, multipart) {
2666
2846
  const bytes = await readBodyLimited(req, limit);
2667
2847
  return new TextDecoder().decode(bytes);
2668
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
+ }
2669
2866
  function serializeResult(result, def, validateResponses) {
2670
2867
  const spec = def.responses[result.status];
2671
2868
  if (!spec) {
@@ -2677,6 +2874,16 @@ function serializeResult(result, def, validateResponses) {
2677
2874
  const treatAsJson = !explicitCt || explicitCt.includes("application/json");
2678
2875
  if (!explicitCt)
2679
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
+ }
2680
2887
  let body;
2681
2888
  let rawBody = null;
2682
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;
@@ -0,0 +1,212 @@
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 { openapiToYAML } from "./openapi.js";
16
+ /**
17
+ * Convert a Standard Schema to JSON Schema for an AsyncAPI message payload.
18
+ *
19
+ * Mirrors the OpenAPI generator's permissive strategy: use `toJSONSchema()`
20
+ * when the schema exposes it (Zod 4, Valibot, ...), and otherwise fall back to
21
+ * a permissive `{}` so generation never throws on an unconvertible schema.
22
+ *
23
+ * @param schema - The Standard Schema to convert, or `undefined`.
24
+ * @returns A JSON-Schema-shaped object, or `undefined` when no schema given.
25
+ */
26
+ function toPayloadSchema(schema) {
27
+ if (!schema)
28
+ return undefined;
29
+ const anySchema = schema;
30
+ if (typeof anySchema.toJSONSchema === "function") {
31
+ try {
32
+ return anySchema.toJSONSchema();
33
+ }
34
+ catch {
35
+ /* fall through to permissive placeholder */
36
+ }
37
+ }
38
+ return {};
39
+ }
40
+ /**
41
+ * Derive a stable, unique channel/operation key from a WebSocket path.
42
+ *
43
+ * Strips the leading slash, drops `:param` / `{param}` markers, and camelCases
44
+ * the remaining segments (`/chat/:room/feed` → `chatRoomFeed`). Falls back to
45
+ * `root` for `/`. Collisions are de-duplicated by the caller.
46
+ *
47
+ * @param path - The registered WebSocket route path.
48
+ * @returns A safe identifier base for AsyncAPI keys.
49
+ */
50
+ function pathToKey(path) {
51
+ const segments = path
52
+ .split("/")
53
+ .map((s) => s.replace(/[:{}]/g, ""))
54
+ .filter((s) => s.length > 0);
55
+ if (segments.length === 0)
56
+ return "root";
57
+ return segments
58
+ .map((seg, i) => {
59
+ const clean = seg.replace(/[^A-Za-z0-9]+/g, " ").trim();
60
+ const parts = clean.split(/\s+/).filter(Boolean);
61
+ return parts
62
+ .map((part, j) => i === 0 && j === 0
63
+ ? part.charAt(0).toLowerCase() + part.slice(1)
64
+ : part.charAt(0).toUpperCase() + part.slice(1))
65
+ .join("");
66
+ })
67
+ .join("");
68
+ }
69
+ /**
70
+ * Extract `:param` names from a WebSocket route path in declaration order.
71
+ *
72
+ * @param path - The registered WebSocket route path.
73
+ * @returns The list of path-parameter names (without the leading colon).
74
+ */
75
+ function extractParams(path) {
76
+ const names = [];
77
+ for (const match of path.matchAll(/:([A-Za-z0-9_]+)/g)) {
78
+ names.push(match[1]);
79
+ }
80
+ return names;
81
+ }
82
+ /**
83
+ * Generate an AsyncAPI 3.0 document from a registered {@link App}'s WebSocket
84
+ * routes.
85
+ *
86
+ * Every `app.ws()` route becomes one channel (its address + path parameters)
87
+ * and one or more operations:
88
+ *
89
+ * - a `receive` operation for client→server messages — payload taken from the
90
+ * route's `meta.receive` schema, falling back to the handler's
91
+ * `request.body` schema (the same schema used for payload-size checks).
92
+ * - a `send` operation for server→client messages — emitted only when the
93
+ * route declares a `meta.send` schema.
94
+ *
95
+ * The output is a plain JSON-serializable object: hand it to AsyncAPI Studio,
96
+ * write it to disk for codegen, or serve it from a route. When the app has no
97
+ * WebSocket routes the document still validates, with empty `channels` and
98
+ * `operations` maps.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * import { generateAsyncAPI } from "@daloyjs/core/asyncapi";
103
+ * import { writeFileSync } from "node:fs";
104
+ *
105
+ * const doc = generateAsyncAPI(app, {
106
+ * info: { title: "Realtime API", version: "1.0.0" },
107
+ * servers: { production: { host: "api.example.com", protocol: "wss" } },
108
+ * });
109
+ * writeFileSync("./generated/asyncapi.json", JSON.stringify(doc, null, 2));
110
+ * ```
111
+ *
112
+ * @param app - The application whose WebSocket routes are documented.
113
+ * @param options - Document metadata and optional named servers.
114
+ * @returns A JSON-serializable AsyncAPI 3.0 document.
115
+ * @since 0.37.0
116
+ */
117
+ export function generateAsyncAPI(app, options) {
118
+ const channels = {};
119
+ const operations = {};
120
+ const messages = {};
121
+ const usedKeys = new Set();
122
+ const entries = app.webSocketRoutes.list();
123
+ for (const entry of entries) {
124
+ const path = entry.path;
125
+ const meta = entry.handler.meta;
126
+ // Derive a unique channel key (operationId override > path-derived slug).
127
+ let key = meta?.operationId ?? pathToKey(path);
128
+ if (usedKeys.has(key)) {
129
+ let suffix = 2;
130
+ while (usedKeys.has(`${key}${suffix}`))
131
+ suffix += 1;
132
+ key = `${key}${suffix}`;
133
+ }
134
+ usedKeys.add(key);
135
+ const address = path.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
136
+ const paramNames = extractParams(path);
137
+ const channelMessages = {};
138
+ // Inbound: a WebSocket route can always receive client messages, so a
139
+ // `receive` operation is always emitted (permissive payload when no schema).
140
+ const receiveSchema = meta?.receive ?? entry.handler.request?.body;
141
+ const receiveMsgKey = `${key}Receive`;
142
+ messages[receiveMsgKey] = {
143
+ name: receiveMsgKey,
144
+ title: `${key} inbound message`,
145
+ payload: toPayloadSchema(receiveSchema) ?? {},
146
+ };
147
+ channelMessages.receiveMessage = {
148
+ $ref: `#/components/messages/${receiveMsgKey}`,
149
+ };
150
+ operations[receiveMsgKey] = {
151
+ action: "receive",
152
+ channel: { $ref: `#/channels/${key}` },
153
+ ...(meta?.summary ? { summary: meta.summary } : {}),
154
+ ...(meta?.description ? { description: meta.description } : {}),
155
+ ...(meta?.tags ? { tags: meta.tags.map((t) => ({ name: t })) } : {}),
156
+ messages: [{ $ref: `#/channels/${key}/messages/receiveMessage` }],
157
+ };
158
+ // Outbound: only emitted when the route declares an outbound schema.
159
+ const sendSchema = meta?.send;
160
+ if (sendSchema) {
161
+ const sendMsgKey = `${key}Send`;
162
+ messages[sendMsgKey] = {
163
+ name: sendMsgKey,
164
+ title: `${key} outbound message`,
165
+ payload: toPayloadSchema(sendSchema) ?? {},
166
+ };
167
+ channelMessages.sendMessage = {
168
+ $ref: `#/components/messages/${sendMsgKey}`,
169
+ };
170
+ operations[sendMsgKey] = {
171
+ action: "send",
172
+ channel: { $ref: `#/channels/${key}` },
173
+ ...(meta?.summary ? { summary: meta.summary } : {}),
174
+ ...(meta?.tags ? { tags: meta.tags.map((t) => ({ name: t })) } : {}),
175
+ messages: [{ $ref: `#/channels/${key}/messages/sendMessage` }],
176
+ };
177
+ }
178
+ const parameters = {};
179
+ for (const name of paramNames) {
180
+ parameters[name] = { description: `Path parameter \`${name}\`.` };
181
+ }
182
+ channels[key] = {
183
+ address,
184
+ ...(meta?.summary ? { summary: meta.summary } : {}),
185
+ ...(meta?.description ? { description: meta.description } : {}),
186
+ ...(paramNames.length ? { parameters } : {}),
187
+ messages: channelMessages,
188
+ };
189
+ }
190
+ return {
191
+ asyncapi: "3.0.0",
192
+ info: options.info,
193
+ ...(options.servers ? { servers: options.servers } : {}),
194
+ channels,
195
+ operations,
196
+ components: { messages },
197
+ };
198
+ }
199
+ /**
200
+ * Serialize an AsyncAPI document to YAML.
201
+ *
202
+ * Thin alias over the dependency-free YAML 1.2 emitter shared with the
203
+ * OpenAPI generator ({@link openapiToYAML}) — AsyncAPI and OpenAPI documents
204
+ * are both plain JSON-compatible objects, so the same emitter applies.
205
+ *
206
+ * @param doc - The AsyncAPI document produced by {@link generateAsyncAPI}.
207
+ * @returns The document rendered as a YAML string.
208
+ * @since 0.37.0
209
+ */
210
+ export function asyncapiToYAML(doc) {
211
+ return openapiToYAML(doc);
212
+ }