@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
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Bot / User-Agent management middleware. Mirrors the bot-rule layer that
3
+ * Nginx, Cloudflare, and other WAFs run at the edge, but inside the app where
4
+ * the framework already owns request parsing and client-IP resolution.
5
+ *
6
+ * {@link botGuard} does three opt-in jobs:
7
+ *
8
+ * 1. **Block empty / missing `User-Agent`** — a common signature of crude
9
+ * scrapers and vulnerability scanners (on by default).
10
+ * 2. **Block known-abusive `User-Agent` strings** — caller-supplied substrings
11
+ * or `RegExp`s.
12
+ * 3. **Verify declared crawlers** — when a request *claims* to be Googlebot or
13
+ * Bingbot, confirm it via reverse-DNS + forward-confirm (the method Google
14
+ * and Bing themselves document) so a spoofed `User-Agent` can't impersonate a
15
+ * trusted crawler. Verification results are cached per IP to keep DNS off the
16
+ * hot path.
17
+ *
18
+ * The middleware is dependency-free and runtime-portable. The default DNS
19
+ * resolver is lazily imported from `node:dns/promises`; supply a custom
20
+ * {@link BotResolver} on non-Node runtimes or in tests.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { botGuard, WELL_KNOWN_BOTS } from "@daloyjs/core";
25
+ *
26
+ * app.use(
27
+ * botGuard({
28
+ * trustProxyHeaders: true,
29
+ * blockedUserAgents: [/sqlmap/i, /nikto/i, "masscan"],
30
+ * verifiedBots: WELL_KNOWN_BOTS, // spoofed Googlebot/Bingbot → 403
31
+ * }),
32
+ * );
33
+ * ```
34
+ *
35
+ * @module
36
+ * @since 0.37.0
37
+ */
38
+ import { ForbiddenError } from "./errors.js";
39
+ const DEFAULT_MESSAGE = "Bot access denied";
40
+ const DEFAULT_CACHE_TTL_MS = 60 * 60_000;
41
+ const DEFAULT_CACHE_MAX = 10_000;
42
+ /**
43
+ * Built-in {@link VerifiedBotRule} for Googlebot (and other Google crawlers),
44
+ * verified against Google's documented `*.googlebot.com` / `*.google.com`
45
+ * reverse-DNS domains.
46
+ *
47
+ * @since 0.37.0
48
+ */
49
+ export const GOOGLEBOT = {
50
+ name: "Googlebot",
51
+ userAgent: /googlebot|google-inspectiontool|storebot-google|googleother|google-extended/i,
52
+ domains: [".googlebot.com", ".google.com"],
53
+ };
54
+ /**
55
+ * Built-in {@link VerifiedBotRule} for Bingbot, verified against Microsoft's
56
+ * documented `*.search.msn.com` reverse-DNS domain.
57
+ *
58
+ * @since 0.37.0
59
+ */
60
+ export const BINGBOT = {
61
+ name: "Bingbot",
62
+ userAgent: /bingbot|bingpreview|adidxbot|msnbot/i,
63
+ domains: [".search.msn.com"],
64
+ };
65
+ /**
66
+ * Convenience bundle of the built-in verified-crawler rules
67
+ * ({@link GOOGLEBOT}, {@link BINGBOT}).
68
+ *
69
+ * @since 0.37.0
70
+ */
71
+ export const WELL_KNOWN_BOTS = [GOOGLEBOT, BINGBOT];
72
+ function matchesUserAgent(ua, patterns) {
73
+ const lower = ua.toLowerCase();
74
+ for (const pattern of patterns) {
75
+ if (typeof pattern === "string") {
76
+ if (pattern && lower.includes(pattern.toLowerCase()))
77
+ return true;
78
+ }
79
+ else if (pattern.test(ua)) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+ function forwardedIpResolver(ctx) {
86
+ const headers = ctx.request.headers;
87
+ const forwarded = headers.get("x-forwarded-for");
88
+ if (forwarded) {
89
+ const first = forwarded.split(",")[0]?.trim();
90
+ if (first)
91
+ return first;
92
+ }
93
+ return ctx.request.headers.get("x-real-ip") ?? undefined;
94
+ }
95
+ function noIpResolver(_ctx) {
96
+ return undefined;
97
+ }
98
+ function createDefaultResolver() {
99
+ let dnsPromise = null;
100
+ const load = async () => {
101
+ if (!dnsPromise) {
102
+ dnsPromise = import("node:dns/promises")
103
+ .then((m) => ({
104
+ reverse: m.reverse,
105
+ lookup: m.lookup,
106
+ }))
107
+ .catch(() => null);
108
+ }
109
+ const dns = await dnsPromise;
110
+ if (!dns) {
111
+ throw new Error("botGuard: no DNS resolver available on this runtime. Pass options.resolver.");
112
+ }
113
+ return dns;
114
+ };
115
+ return {
116
+ async reverse(ip) {
117
+ const dns = await load();
118
+ return dns.reverse(ip);
119
+ },
120
+ async forward(hostname) {
121
+ const dns = await load();
122
+ const results = await dns.lookup(hostname, { all: true });
123
+ return results.map((r) => r.address);
124
+ },
125
+ };
126
+ }
127
+ /**
128
+ * Build the default DNS resolver backed by a lazily-imported
129
+ * `node:dns/promises`. Used internally by {@link botGuard} when no custom
130
+ * {@link BotGuardOptions.resolver} is supplied, and exported for tests. Throws
131
+ * on runtimes without `node:dns/promises` so callers are told to supply their
132
+ * own resolver.
133
+ *
134
+ * @returns A {@link BotResolver} backed by the platform's DNS.
135
+ * @internal
136
+ */
137
+ export function _createDefaultBotResolver() {
138
+ return createDefaultResolver();
139
+ }
140
+ /**
141
+ * Confirm that `hostname` ends with one of the allowed `domains`. A leading dot
142
+ * in a domain enforces a subdomain boundary so `evil-googlebot.com` cannot match
143
+ * `.googlebot.com`; a bare domain also matches the apex exactly.
144
+ *
145
+ * @internal
146
+ */
147
+ function hostnameMatchesDomains(hostname, domains) {
148
+ const host = hostname.toLowerCase().replace(/\.$/, "");
149
+ for (const domain of domains) {
150
+ const d = domain.toLowerCase();
151
+ if (d.startsWith(".")) {
152
+ if (host.endsWith(d))
153
+ return true;
154
+ }
155
+ else if (host === d || host.endsWith(`.${d}`)) {
156
+ return true;
157
+ }
158
+ }
159
+ return false;
160
+ }
161
+ /**
162
+ * Reverse-DNS + forward-confirm a client IP against a verified-bot rule, the way
163
+ * Google and Bing document. Returns `true` only when a PTR hostname both ends in
164
+ * an allowed domain and forward-resolves back to the same IP.
165
+ *
166
+ * @internal
167
+ */
168
+ async function verifyCrawler(resolver, ip, rule) {
169
+ const hostnames = await resolver.reverse(ip);
170
+ for (const hostname of hostnames) {
171
+ if (!hostnameMatchesDomains(hostname, rule.domains))
172
+ continue;
173
+ const addresses = await resolver.forward(hostname);
174
+ if (addresses.includes(ip))
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ /**
180
+ * Bot / User-Agent management middleware. Blocks empty or known-abusive
181
+ * `User-Agent` strings and verifies declared crawlers (Googlebot/Bingbot) via
182
+ * reverse-DNS + forward-confirm, so a spoofed `User-Agent` cannot impersonate a
183
+ * trusted crawler.
184
+ *
185
+ * All checks are opt-in and allowlist-friendly: {@link BotGuardOptions.allowUserAgents}
186
+ * is consulted first and bypasses every other rule.
187
+ *
188
+ * @param opts - Bot-guard configuration.
189
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
190
+ * @throws Error when `verifiedBots` is set without an IP source
191
+ * (`resolveIp` or `trustProxyHeaders`), or when `mode` is invalid.
192
+ * @since 0.37.0
193
+ */
194
+ export function botGuard(opts = {}) {
195
+ const blockEmpty = opts.blockEmptyUserAgent !== false;
196
+ const blocked = opts.blockedUserAgents ?? [];
197
+ const allowed = opts.allowUserAgents ?? [];
198
+ const verifiedBots = opts.verifiedBots ?? [];
199
+ const blockUnverifiable = opts.blockUnverifiableBots !== false;
200
+ const message = opts.message ?? DEFAULT_MESSAGE;
201
+ const cacheTtlMs = opts.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
202
+ const cacheMax = opts.cacheMaxEntries ?? DEFAULT_CACHE_MAX;
203
+ const mode = opts.mode ?? "block";
204
+ if (mode !== "block" && mode !== "log") {
205
+ throw new Error('botGuard(): mode must be "block" or "log".');
206
+ }
207
+ const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
208
+ if (verifiedBots.length > 0 && !opts.resolveIp && !opts.trustProxyHeaders) {
209
+ throw new Error("botGuard(): verifiedBots requires a client-IP source — provide resolveIp " +
210
+ "or set trustProxyHeaders, otherwise declared crawlers cannot be verified.");
211
+ }
212
+ const resolver = opts.resolver ?? createDefaultResolver();
213
+ // Per-IP verification cache (keyed by `ip\u0000botName`) so a crawler's DNS
214
+ // round-trip is paid once per TTL, not on every request.
215
+ const cache = new Map();
216
+ const readCache = (key) => {
217
+ const entry = cache.get(key);
218
+ if (!entry)
219
+ return undefined;
220
+ if (entry.expiresMs <= Date.now()) {
221
+ cache.delete(key);
222
+ return undefined;
223
+ }
224
+ return entry.verified;
225
+ };
226
+ const writeCache = (key, verified) => {
227
+ const now = Date.now();
228
+ cache.set(key, { verified, expiresMs: now + cacheTtlMs });
229
+ if (cache.size > cacheMax) {
230
+ for (const [k, v] of cache)
231
+ if (v.expiresMs <= now)
232
+ cache.delete(k);
233
+ }
234
+ };
235
+ const reject = (event) => {
236
+ opts.onBlock?.(event);
237
+ if (mode === "block")
238
+ throw new ForbiddenError(message);
239
+ };
240
+ return {
241
+ async beforeHandle(ctx) {
242
+ const ua = ctx.request.headers.get("user-agent") ?? "";
243
+ // Allowlist wins over every other rule.
244
+ if (allowed.length > 0 && matchesUserAgent(ua, allowed))
245
+ return undefined;
246
+ if (!ua.trim()) {
247
+ if (blockEmpty)
248
+ reject({ reason: "empty-user-agent", userAgent: ua });
249
+ return undefined;
250
+ }
251
+ if (blocked.length > 0 && matchesUserAgent(ua, blocked)) {
252
+ reject({ reason: "blocked-user-agent", userAgent: ua });
253
+ return undefined;
254
+ }
255
+ const rule = verifiedBots.find((r) => r.userAgent.test(ua));
256
+ if (!rule)
257
+ return undefined;
258
+ const ip = resolveIp(ctx);
259
+ if (!ip) {
260
+ if (blockUnverifiable) {
261
+ reject({ reason: "unverifiable-bot", userAgent: ua, botName: rule.name });
262
+ }
263
+ return undefined;
264
+ }
265
+ const cacheKey = `${ip}\u0000${rule.name}`;
266
+ const cached = readCache(cacheKey);
267
+ if (cached === true)
268
+ return undefined;
269
+ if (cached === false) {
270
+ reject({ reason: "spoofed-bot", userAgent: ua, ip, botName: rule.name });
271
+ return undefined;
272
+ }
273
+ let verified;
274
+ try {
275
+ verified = await verifyCrawler(resolver, ip, rule);
276
+ }
277
+ catch {
278
+ // DNS failure — cannot confirm. Don't cache transient errors.
279
+ if (blockUnverifiable) {
280
+ reject({ reason: "unverifiable-bot", userAgent: ua, ip, botName: rule.name });
281
+ }
282
+ return undefined;
283
+ }
284
+ writeCache(cacheKey, verified);
285
+ if (!verified) {
286
+ reject({ reason: "spoofed-bot", userAgent: ua, ip, botName: rule.name });
287
+ }
288
+ return undefined;
289
+ },
290
+ };
291
+ }
package/dist/cli.d.ts CHANGED
@@ -24,6 +24,11 @@ export interface CliIO {
24
24
  * can omit it.
25
25
  */
26
26
  spawn?: (command: string, args: readonly string[]) => Promise<number>;
27
+ /**
28
+ * Read a UTF-8 text file by path. Required for `daloy diff`; optional so
29
+ * unit tests that only exercise `inspect` can omit it.
30
+ */
31
+ readTextFile?: (path: string) => Promise<string>;
27
32
  /**
28
33
  * Override runtime detection (defaults to inspecting `globalThis.process.versions`).
29
34
  * Mainly exists for tests.
@@ -40,6 +45,7 @@ export interface CliOptions {
40
45
  check: boolean;
41
46
  schemas: boolean;
42
47
  openapi: boolean;
48
+ asyncapi: boolean;
43
49
  ai: boolean;
44
50
  /**
45
51
  * Output format for `--ai` and `--openapi`. Defaults to `"json"`.
@@ -61,6 +67,8 @@ export interface CliOptions {
61
67
  auditSecrets?: boolean;
62
68
  /** `daloy doctor` — disable the default-defaults audit. */
63
69
  noAuditDefaults?: boolean;
70
+ /** Positional arguments collected in order (used by `daloy diff`). */
71
+ positionals?: string[];
64
72
  }
65
73
  /** Runtime detected for `daloy dev`. */
66
74
  export type DevRuntime = "node" | "bun" | "deno";
package/dist/cli.js CHANGED
@@ -10,7 +10,9 @@
10
10
  * `process.argv`, `process.stdout`, dynamic `import()`, and `process.exit`.
11
11
  */
12
12
  import { runContractTests } from "./contract.js";
13
+ import { diffOpenAPI } from "./openapi-diff.js";
13
14
  import { generateOpenAPI, openapiToYAML } from "./openapi.js";
15
+ import { generateAsyncAPI, asyncapiToYAML } from "./asyncapi.js";
14
16
  const HELP = `daloy — DaloyJS CLI
15
17
 
16
18
  Usage:
@@ -25,19 +27,26 @@ Commands:
25
27
  Exits non-zero on any violation so the
26
28
  command can guard container HEALTHCHECK and CI
27
29
  deploy steps.
30
+ diff <baseline> <current>
31
+ Compare two OpenAPI 3.1 JSON documents and report
32
+ added, removed, and changed operations. Exits 1
33
+ when a breaking change is detected so it can gate
34
+ CI; pass --json for machine-readable output.
28
35
 
29
36
  Options:
30
37
  --json Print machine-readable JSON instead of a table.
31
38
  --check Run the contract test suite; exit 1 on errors.
32
39
  --schemas Include per-route schema presence (body/query/...).
33
40
  --openapi Print the OpenAPI 3.1 document for the App.
41
+ --asyncapi Print the AsyncAPI 3.0 document for the App's
42
+ WebSocket (app.ws()) surfaces.
34
43
  --ai Print an AI/codegen-friendly dump of the
35
44
  route catalog with schemas and meta examples
36
45
  (suitable for feeding to an LLM or for writing
37
46
  to a sibling routes.json / routes.yaml).
38
- --format <fmt> Output format for --ai and --openapi: json | yaml
39
- (default: json). YAML saves ~20–40%% of LLM
40
- tokens versus JSON for the same payload.
47
+ --format <fmt> Output format for --ai, --openapi and --asyncapi:
48
+ json | yaml (default: json). YAML saves ~20–40%% of
49
+ LLM tokens versus JSON for the same payload.
41
50
  --yaml Shorthand for --format yaml.
42
51
  --tag <tag> Only show routes that declare this tag.
43
52
  --method <method> Only show routes for this HTTP method.
@@ -69,8 +78,12 @@ Examples:
69
78
  daloy inspect --openapi > openapi.json
70
79
  daloy inspect --ai --yaml > routes.yaml
71
80
  daloy inspect --openapi --format yaml > openapi.yaml
81
+ daloy inspect --asyncapi > asyncapi.json
82
+ daloy inspect --asyncapi --format yaml > asyncapi.yaml
72
83
  daloy dev
73
84
  daloy dev src/server.ts
85
+ daloy diff openapi.published.json openapi.json
86
+ daloy diff --json openapi.published.json openapi.json
74
87
  `;
75
88
  const DEFAULT_ENTRIES = [
76
89
  "src/app.ts",
@@ -235,13 +248,14 @@ export function parseArgs(argv) {
235
248
  check: false,
236
249
  schemas: false,
237
250
  openapi: false,
251
+ asyncapi: false,
238
252
  ai: false,
239
253
  help: false,
240
254
  version: false,
241
255
  };
242
256
  let command = "inspect";
243
257
  let i = 0;
244
- if (argv[0] === "inspect" || argv[0] === "dev" || argv[0] === "help" || argv[0] === "doctor") {
258
+ if (argv[0] === "inspect" || argv[0] === "dev" || argv[0] === "help" || argv[0] === "doctor" || argv[0] === "diff") {
245
259
  command = argv[0];
246
260
  i = 1;
247
261
  }
@@ -262,6 +276,9 @@ export function parseArgs(argv) {
262
276
  case "--openapi":
263
277
  opts.openapi = true;
264
278
  break;
279
+ case "--asyncapi":
280
+ opts.asyncapi = true;
281
+ break;
265
282
  case "--ai":
266
283
  opts.ai = true;
267
284
  break;
@@ -308,6 +325,7 @@ export function parseArgs(argv) {
308
325
  if (a.startsWith("-")) {
309
326
  throw new Error(`Unknown flag: ${a}`);
310
327
  }
328
+ (opts.positionals ??= []).push(a);
311
329
  opts.entry = a;
312
330
  }
313
331
  }
@@ -349,6 +367,9 @@ export async function runCli(argv, io) {
349
367
  if (command === "doctor") {
350
368
  return runDoctor(opts, io);
351
369
  }
370
+ if (command === "diff") {
371
+ return runDiff(opts, io);
372
+ }
352
373
  if (command !== "inspect") {
353
374
  io.stderr(`Unknown command: ${command}\n\n${HELP}`);
354
375
  return { exitCode: 2 };
@@ -372,6 +393,17 @@ export async function runCli(argv, io) {
372
393
  io.stdout(`${JSON.stringify(doc, null, opts.json ? 0 : 2)}\n`);
373
394
  return { exitCode: 0 };
374
395
  }
396
+ if (opts.asyncapi) {
397
+ const doc = generateAsyncAPI(app, {
398
+ info: { title: "App", version: "0.0.0" },
399
+ });
400
+ if (opts.format === "yaml") {
401
+ io.stdout(asyncapiToYAML(doc));
402
+ return { exitCode: 0 };
403
+ }
404
+ io.stdout(`${JSON.stringify(doc, null, opts.json ? 0 : 2)}\n`);
405
+ return { exitCode: 0 };
406
+ }
375
407
  if (opts.ai) {
376
408
  const dump = buildAiDump(app, opts);
377
409
  if (opts.format === "yaml") {
@@ -460,6 +492,58 @@ function formatContract(report) {
460
492
  out.push("FAIL.");
461
493
  return `${out.join("\n")}\n`;
462
494
  }
495
+ /**
496
+ * `daloy diff <baseline> <current>` — compare two OpenAPI 3.1 JSON documents
497
+ * and report added, removed, and changed operations. Exits 1 when a breaking
498
+ * change is detected so it can gate CI; `--json` emits machine-readable output.
499
+ *
500
+ * @internal
501
+ */
502
+ async function runDiff(opts, io) {
503
+ const positionals = opts.positionals ?? [];
504
+ if (positionals.length !== 2) {
505
+ io.stderr(`daloy diff requires two file paths: <baseline> <current>\n\n${HELP}`);
506
+ return { exitCode: 2 };
507
+ }
508
+ if (!io.readTextFile) {
509
+ io.stderr("daloy diff: this environment cannot read files.\n");
510
+ return { exitCode: 2 };
511
+ }
512
+ const [baselinePath, currentPath] = positionals;
513
+ let baseline;
514
+ let current;
515
+ try {
516
+ baseline = JSON.parse(await io.readTextFile(baselinePath));
517
+ current = JSON.parse(await io.readTextFile(currentPath));
518
+ }
519
+ catch (err) {
520
+ io.stderr(`daloy diff: failed to read or parse input: ${err.message}\n`);
521
+ return { exitCode: 1 };
522
+ }
523
+ const result = diffOpenAPI(baseline, current);
524
+ const hasBreaking = result.breaking.length > 0;
525
+ if (opts.json) {
526
+ io.stdout(`${JSON.stringify(result, null, 2)}\n`);
527
+ return { exitCode: hasBreaking ? 1 : 0 };
528
+ }
529
+ const out = [];
530
+ const fmt = (c) => ` [${c.severity === "breaking" ? "BREAKING" : "ok"}] ${c.kind} ${c.location}` +
531
+ (c.detail ? ` — ${c.detail}` : "");
532
+ const total = result.breaking.length + result.nonBreaking.length;
533
+ if (total === 0) {
534
+ out.push("Specs match: no changes detected.");
535
+ }
536
+ else {
537
+ out.push(`OpenAPI changes: ${total} · ${result.breaking.length} breaking`);
538
+ for (const change of result.breaking)
539
+ out.push(fmt(change));
540
+ for (const change of result.nonBreaking)
541
+ out.push(fmt(change));
542
+ }
543
+ out.push(hasBreaking ? "FAIL: breaking changes detected." : "OK.");
544
+ io.stdout(`${out.join("\n")}\n`);
545
+ return { exitCode: hasBreaking ? 1 : 0 };
546
+ }
463
547
  /**
464
548
  * `daloy doctor` — boot-time + CLI audit. Loads the user's
465
549
  * App entry and runs the secure-by-default checklist. Exits non-zero on any
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Per-route / per-client concurrency limiting with bounded FIFO queueing.
3
+ *
4
+ * Where the Node adapter's `maxConnections` caps *sockets* at accept time and
5
+ * `loadShedding()` rejects traffic under *process* pressure, {@link concurrencyLimit}
6
+ * bounds the number of requests **in flight through a given surface** — the
7
+ * in-app equivalent of HAProxy's `maxconn` + request queue. Each request tries
8
+ * to acquire a slot from a semaphore; if all slots are busy it waits in a
9
+ * bounded FIFO queue (up to {@link ConcurrencyLimitOptions.maxQueue}) for up to
10
+ * {@link ConcurrencyLimitOptions.queueTimeoutMs}, and is rejected with a fast
11
+ * `503 Service Unavailable` (+ `Retry-After`) once the queue is full or the
12
+ * wait times out. The slot is released when the response is finalized.
13
+ *
14
+ * The limiter can be partitioned with {@link ConcurrencyLimitOptions.scope}:
15
+ *
16
+ * - `"global"` (default) — one shared budget across the whole mount.
17
+ * - `"route"` — a separate budget per `method + path`, so a single hot endpoint
18
+ * can't starve the others mounted under the same guard.
19
+ * - `"client"` — a separate budget per client identity (requires
20
+ * {@link ConcurrencyLimitOptions.trustProxyHeaders} or a
21
+ * {@link ConcurrencyLimitOptions.keyGenerator}); a heavy client can't consume
22
+ * everyone else's slots.
23
+ * - a custom function — return a bucket key, or `undefined` to skip limiting
24
+ * for that request (fail-open).
25
+ *
26
+ * The middleware is dependency-free and runtime-portable: it acquires in
27
+ * {@link "./types.js".Hooks.beforeHandle} and releases in
28
+ * {@link "./types.js".Hooks.onSend}, which the framework runs on the success,
29
+ * error, and short-circuit response paths alike, so a slot is never leaked.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * import { App, concurrencyLimit } from "@daloyjs/core";
34
+ *
35
+ * const app = new App();
36
+ * // At most 100 in flight per route, queue up to 50 more, wait at most 2s.
37
+ * app.use(concurrencyLimit({
38
+ * maxConcurrent: 100,
39
+ * maxQueue: 50,
40
+ * queueTimeoutMs: 2000,
41
+ * scope: "route",
42
+ * }));
43
+ * ```
44
+ *
45
+ * @module
46
+ * @since 0.37.0
47
+ */
48
+ import type { BaseContext, Hooks } from "./types.js";
49
+ /**
50
+ * Details of a request rejected by {@link concurrencyLimit}, passed to
51
+ * {@link ConcurrencyLimitOptions.onReject}.
52
+ *
53
+ * @since 0.37.0
54
+ */
55
+ export interface ConcurrencyRejection {
56
+ /** The bucket key whose budget was exhausted. */
57
+ key: string;
58
+ /** Why the request was rejected. */
59
+ reason: "queue-full" | "queue-timeout";
60
+ /** In-flight requests for the bucket at rejection time. */
61
+ active: number;
62
+ /** Requests already waiting in the bucket's queue at rejection time. */
63
+ queued: number;
64
+ }
65
+ /**
66
+ * Configuration for {@link concurrencyLimit}.
67
+ *
68
+ * @since 0.37.0
69
+ */
70
+ export interface ConcurrencyLimitOptions {
71
+ /**
72
+ * Maximum number of requests allowed in flight per bucket at once. Required,
73
+ * positive integer. Additional requests queue (up to {@link maxQueue}) or are
74
+ * rejected with `503`.
75
+ */
76
+ maxConcurrent: number;
77
+ /**
78
+ * Maximum number of requests allowed to wait in a bucket's FIFO queue while
79
+ * all slots are busy. Default `0` (no queue — overflow is rejected
80
+ * immediately). A waiting request is admitted in arrival order as slots free.
81
+ */
82
+ maxQueue?: number;
83
+ /**
84
+ * Maximum time, in ms, a request may wait in the queue before being rejected
85
+ * with `503`. Default `0`, which means "wait indefinitely" — only meaningful
86
+ * when {@link maxQueue} `> 0`. Set a finite value to bound tail latency.
87
+ */
88
+ queueTimeoutMs?: number;
89
+ /**
90
+ * How to partition the concurrency budget. `"global"` (default) shares one
91
+ * budget; `"route"` keys by `method + path`; `"client"` keys by client
92
+ * identity (needs {@link trustProxyHeaders} or {@link keyGenerator}); a
93
+ * function returns a custom bucket key (or `undefined` to skip limiting).
94
+ */
95
+ scope?: "global" | "route" | "client" | ((ctx: BaseContext<any, any>) => string | undefined);
96
+ /**
97
+ * Read `X-Forwarded-For` / `X-Real-IP` when `scope: "client"`. Off by default
98
+ * because those headers are client-spoofable unless every request reaches the
99
+ * app through a proxy chain you control.
100
+ */
101
+ trustProxyHeaders?: boolean;
102
+ /**
103
+ * Custom client-identity resolver for `scope: "client"`. Overrides
104
+ * {@link trustProxyHeaders}. Returning `undefined` skips limiting for the
105
+ * request (fail-open).
106
+ */
107
+ keyGenerator?: (ctx: BaseContext<any, any>) => string | undefined;
108
+ /** `Retry-After` seconds on the `503` rejection. Default `1`. `0` omits the header. */
109
+ retryAfterSeconds?: number;
110
+ /** `detail` for the `503` problem+json. Default `"Concurrency limit exceeded"`. */
111
+ message?: string;
112
+ /** Called when a request is rejected (queue full or wait timed out). */
113
+ onReject?: (rejection: ConcurrencyRejection) => void;
114
+ }
115
+ /**
116
+ * Bound the number of in-flight requests per route and/or per client with a
117
+ * bounded FIFO queue and a fast `503`, the in-app equivalent of HAProxy's
118
+ * `maxconn` + request queue. Complements the global `maxConnections` socket cap
119
+ * and `loadShedding()` process-pressure shedding.
120
+ *
121
+ * A request acquires a slot in `beforeHandle`; if the bucket is saturated it
122
+ * waits in a bounded FIFO queue (subject to {@link ConcurrencyLimitOptions.maxQueue}
123
+ * and {@link ConcurrencyLimitOptions.queueTimeoutMs}) and is rejected with `503`
124
+ * when the queue is full or the wait times out. The slot is released on the
125
+ * response path (`onSend`), so it is freed for success, error, and
126
+ * short-circuit responses alike.
127
+ *
128
+ * @param opts - Concurrency-limit configuration; `maxConcurrent` is required.
129
+ * @returns A {@link Hooks} bundle ready for `app.use(...)`.
130
+ * @throws Error when `maxConcurrent` is not a positive integer, `maxQueue` /
131
+ * `queueTimeoutMs` / `retryAfterSeconds` are out of range, or `scope: "client"`
132
+ * is used without an identity source.
133
+ * @since 0.37.0
134
+ */
135
+ export declare function concurrencyLimit(opts: ConcurrencyLimitOptions): Hooks;