@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.
- package/README.md +21 -2
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +24 -0
- package/dist/app.d.ts +144 -1
- package/dist/app.js +208 -1
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +88 -4
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +27 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +38 -4
- package/dist/index.js +18 -1
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +27 -7
- package/dist/security.js +27 -7
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- 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({
|
|
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;
|
package/dist/asyncapi.js
ADDED
|
@@ -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
|
+
}
|