@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.
- package/LICENSE +21 -0
- package/README.md +34 -3
- 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 +25 -0
- package/dist/adapters/node.js +32 -0
- package/dist/app.d.ts +200 -6
- package/dist/app.js +235 -50
- 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 +113 -4
- package/dist/client.d.ts +23 -0
- package/dist/client.js +16 -0
- 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 +43 -0
- package/dist/errors.js +57 -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 +39 -5
- package/dist/index.js +19 -2
- 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 +61 -7
- package/dist/security.js +75 -8
- 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 +79 -3
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 --
|
|
39
|
-
(default: json). YAML saves ~20–40%% of
|
|
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
|
|
@@ -567,6 +651,31 @@ async function runDoctor(opts, io) {
|
|
|
567
651
|
"JSON parsers are not DoS-amplified by a multipart-sized blob.",
|
|
568
652
|
});
|
|
569
653
|
}
|
|
654
|
+
// Header-count cap audit. The framework's portable maxHeaderCount
|
|
655
|
+
// guard is the application-tier defence against header-*count*
|
|
656
|
+
// amplification (the "HTTP/2 Bomb" dimension). Surface a finding when
|
|
657
|
+
// it is disabled (0) or raised to an implausibly generous value, both
|
|
658
|
+
// of which let a header flood reach routing.
|
|
659
|
+
const maxHeaderCount = o.maxHeaderCount;
|
|
660
|
+
if (maxHeaderCount === 0) {
|
|
661
|
+
findings.push({
|
|
662
|
+
level: "warn",
|
|
663
|
+
code: "audit.maxHeaderCount.disabled",
|
|
664
|
+
message: "maxHeaderCount is 0 — the header-count flood guard is disabled. " +
|
|
665
|
+
"A request carrying thousands of header fields reaches routing. " +
|
|
666
|
+
"Keep a finite cap (default 100) unless an upstream proxy already " +
|
|
667
|
+
"enforces one (NGINX max_headers, Node server.maxHeadersCount).",
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
else if (typeof maxHeaderCount === "number" && maxHeaderCount > 1000) {
|
|
671
|
+
findings.push({
|
|
672
|
+
level: "warn",
|
|
673
|
+
code: "audit.maxHeaderCount.blanket",
|
|
674
|
+
message: `maxHeaderCount is ${maxHeaderCount} (> 1000). Realistic requests ` +
|
|
675
|
+
"carry a few dozen headers; a cap this high weakens the " +
|
|
676
|
+
"header-count amplification defence.",
|
|
677
|
+
});
|
|
678
|
+
}
|
|
570
679
|
// Idle-timeout / request-timeout audit. Reaffirms the
|
|
571
680
|
// existing requestTimeoutMs check; also surface an explicit zero
|
|
572
681
|
// idleTimeoutMs in production. The framework also keeps adapter
|
package/dist/client.d.ts
CHANGED
|
@@ -17,6 +17,13 @@ export type RoutesOf<A extends App> = A["routes"][number];
|
|
|
17
17
|
* Typed client surface generated from an `App`. The result is a record keyed
|
|
18
18
|
* by each route's `operationId` whose values are async methods inferred from
|
|
19
19
|
* the route's request and response schemas.
|
|
20
|
+
*
|
|
21
|
+
* The per-method types are recovered from the `App`'s accumulated route tuple,
|
|
22
|
+
* which is built up as you **chain** `app.route(...)` calls. If the `App` type
|
|
23
|
+
* is widened back to its bare default — e.g. a `const app: App` annotation, a
|
|
24
|
+
* `: App` factory return type, or registering routes as separate statements
|
|
25
|
+
* rather than a chain — the tuple is erased and this type collapses to an
|
|
26
|
+
* untyped, string-indexed record.
|
|
20
27
|
*/
|
|
21
28
|
export type ClientFor<A extends App> = {
|
|
22
29
|
[R in Extract<RoutesOf<A>, {
|
|
@@ -58,10 +65,26 @@ export interface ClientOptions {
|
|
|
58
65
|
* For non-TypeScript consumers, run `pnpm gen` to emit a fully-typed SDK
|
|
59
66
|
* from the OpenAPI document instead.
|
|
60
67
|
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* The method signatures are inferred from the `App`'s accumulated route tuple,
|
|
70
|
+
* so chain your `app.route(...)` registrations and let TypeScript infer the
|
|
71
|
+
* variable's type. A widening `const app: App` annotation, a `: App` factory
|
|
72
|
+
* return type, or registering routes as separate statements erases the
|
|
73
|
+
* per-route types and yields an untyped client.
|
|
74
|
+
*
|
|
61
75
|
* @example
|
|
62
76
|
* ```ts
|
|
63
77
|
* import { createClient } from "@daloyjs/core/client";
|
|
64
78
|
*
|
|
79
|
+
* const app = new App().route({
|
|
80
|
+
* method: "GET",
|
|
81
|
+
* path: "/books/:id",
|
|
82
|
+
* operationId: "getBook",
|
|
83
|
+
* request: { params: z.object({ id: z.string() }) },
|
|
84
|
+
* responses: { 200: { description: "OK", body: z.object({ id: z.string(), title: z.string() }) } },
|
|
85
|
+
* handler: ({ params }) => ({ status: 200, body: { id: params.id, title: "Dune" } }),
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
65
88
|
* const client = createClient(app, { baseUrl: "https://api.example.com" });
|
|
66
89
|
* const res = await client.getBook({ params: { id: "123" } });
|
|
67
90
|
* if (res.status === 200) console.log(res.body.title);
|
package/dist/client.js
CHANGED
|
@@ -22,10 +22,26 @@
|
|
|
22
22
|
* For non-TypeScript consumers, run `pnpm gen` to emit a fully-typed SDK
|
|
23
23
|
* from the OpenAPI document instead.
|
|
24
24
|
*
|
|
25
|
+
* @remarks
|
|
26
|
+
* The method signatures are inferred from the `App`'s accumulated route tuple,
|
|
27
|
+
* so chain your `app.route(...)` registrations and let TypeScript infer the
|
|
28
|
+
* variable's type. A widening `const app: App` annotation, a `: App` factory
|
|
29
|
+
* return type, or registering routes as separate statements erases the
|
|
30
|
+
* per-route types and yields an untyped client.
|
|
31
|
+
*
|
|
25
32
|
* @example
|
|
26
33
|
* ```ts
|
|
27
34
|
* import { createClient } from "@daloyjs/core/client";
|
|
28
35
|
*
|
|
36
|
+
* const app = new App().route({
|
|
37
|
+
* method: "GET",
|
|
38
|
+
* path: "/books/:id",
|
|
39
|
+
* operationId: "getBook",
|
|
40
|
+
* request: { params: z.object({ id: z.string() }) },
|
|
41
|
+
* responses: { 200: { description: "OK", body: z.object({ id: z.string(), title: z.string() }) } },
|
|
42
|
+
* handler: ({ params }) => ({ status: 200, body: { id: params.id, title: "Dune" } }),
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
29
45
|
* const client = createClient(app, { baseUrl: "https://api.example.com" });
|
|
30
46
|
* const res = await client.getBook({ params: { id: "123" } });
|
|
31
47
|
* if (res.status === 200) console.log(res.body.title);
|
|
@@ -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;
|
|
@@ -0,0 +1,254 @@
|
|
|
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 { HttpError } from "./errors.js";
|
|
49
|
+
const DEFAULT_MESSAGE = "Concurrency limit exceeded";
|
|
50
|
+
/** Monotonic id so multiple mounted limiters use distinct per-request state slots. */
|
|
51
|
+
let instanceCounter = 0;
|
|
52
|
+
function assertPositiveInteger(name, value) {
|
|
53
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
54
|
+
throw new Error(`concurrencyLimit(): ${name} must be a positive integer.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function assertNonNegativeInteger(name, value) {
|
|
58
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
59
|
+
throw new Error(`concurrencyLimit(): ${name} must be a non-negative integer.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function forwardedKey(ctx) {
|
|
63
|
+
const forwarded = ctx.request.headers.get("x-forwarded-for");
|
|
64
|
+
const first = forwarded ? forwarded.split(",")[0].trim() : "";
|
|
65
|
+
if (first)
|
|
66
|
+
return first;
|
|
67
|
+
return ctx.request.headers.get("x-real-ip") ?? undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Extract just the pathname from a request URL without a full `URL` parse where possible. */
|
|
70
|
+
function pathnameOf(url) {
|
|
71
|
+
const schemeEnd = url.indexOf("://");
|
|
72
|
+
if (schemeEnd === -1) {
|
|
73
|
+
try {
|
|
74
|
+
return new URL(url).pathname;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return url;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const pathStart = url.indexOf("/", schemeEnd + 3);
|
|
81
|
+
if (pathStart === -1)
|
|
82
|
+
return "/";
|
|
83
|
+
let end = url.length;
|
|
84
|
+
const q = url.indexOf("?", pathStart);
|
|
85
|
+
if (q !== -1)
|
|
86
|
+
end = q;
|
|
87
|
+
const h = url.indexOf("#", pathStart);
|
|
88
|
+
if (h !== -1 && h < end)
|
|
89
|
+
end = h;
|
|
90
|
+
return url.slice(pathStart, end);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Build the per-request bucket-key resolver for the configured {@link ConcurrencyLimitOptions.scope}.
|
|
94
|
+
*
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
function buildScopeResolver(opts) {
|
|
98
|
+
const scope = opts.scope ?? "global";
|
|
99
|
+
if (typeof scope === "function")
|
|
100
|
+
return scope;
|
|
101
|
+
if (scope === "global")
|
|
102
|
+
return () => "global";
|
|
103
|
+
if (scope === "route") {
|
|
104
|
+
return (ctx) => `${ctx.request.method} ${pathnameOf(ctx.request.url)}`;
|
|
105
|
+
}
|
|
106
|
+
// scope === "client"
|
|
107
|
+
if (!opts.keyGenerator && !opts.trustProxyHeaders) {
|
|
108
|
+
throw new Error('concurrencyLimit(): scope "client" requires keyGenerator or trustProxyHeaders so ' +
|
|
109
|
+
"clients can be identified; otherwise every caller shares one bucket.");
|
|
110
|
+
}
|
|
111
|
+
const resolve = opts.keyGenerator ?? forwardedKey;
|
|
112
|
+
return (ctx) => {
|
|
113
|
+
const id = resolve(ctx);
|
|
114
|
+
return id === undefined ? undefined : `client:${id}`;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Bound the number of in-flight requests per route and/or per client with a
|
|
119
|
+
* bounded FIFO queue and a fast `503`, the in-app equivalent of HAProxy's
|
|
120
|
+
* `maxconn` + request queue. Complements the global `maxConnections` socket cap
|
|
121
|
+
* and `loadShedding()` process-pressure shedding.
|
|
122
|
+
*
|
|
123
|
+
* A request acquires a slot in `beforeHandle`; if the bucket is saturated it
|
|
124
|
+
* waits in a bounded FIFO queue (subject to {@link ConcurrencyLimitOptions.maxQueue}
|
|
125
|
+
* and {@link ConcurrencyLimitOptions.queueTimeoutMs}) and is rejected with `503`
|
|
126
|
+
* when the queue is full or the wait times out. The slot is released on the
|
|
127
|
+
* response path (`onSend`), so it is freed for success, error, and
|
|
128
|
+
* short-circuit responses alike.
|
|
129
|
+
*
|
|
130
|
+
* @param opts - Concurrency-limit configuration; `maxConcurrent` is required.
|
|
131
|
+
* @returns A {@link Hooks} bundle ready for `app.use(...)`.
|
|
132
|
+
* @throws Error when `maxConcurrent` is not a positive integer, `maxQueue` /
|
|
133
|
+
* `queueTimeoutMs` / `retryAfterSeconds` are out of range, or `scope: "client"`
|
|
134
|
+
* is used without an identity source.
|
|
135
|
+
* @since 0.37.0
|
|
136
|
+
*/
|
|
137
|
+
export function concurrencyLimit(opts) {
|
|
138
|
+
assertPositiveInteger("maxConcurrent", opts.maxConcurrent);
|
|
139
|
+
const maxConcurrent = opts.maxConcurrent;
|
|
140
|
+
const maxQueue = opts.maxQueue ?? 0;
|
|
141
|
+
assertNonNegativeInteger("maxQueue", maxQueue);
|
|
142
|
+
const queueTimeoutMs = opts.queueTimeoutMs ?? 0;
|
|
143
|
+
assertNonNegativeInteger("queueTimeoutMs", queueTimeoutMs);
|
|
144
|
+
const retryAfterSeconds = opts.retryAfterSeconds ?? 1;
|
|
145
|
+
assertNonNegativeInteger("retryAfterSeconds", retryAfterSeconds);
|
|
146
|
+
const message = opts.message ?? DEFAULT_MESSAGE;
|
|
147
|
+
const resolveKey = buildScopeResolver(opts);
|
|
148
|
+
const buckets = new Map();
|
|
149
|
+
// Unique per-request state slots so multiple concurrencyLimit() mounts on the
|
|
150
|
+
// same group don't clobber each other's acquired-flag / bucket-key bookkeeping.
|
|
151
|
+
const id = instanceCounter++;
|
|
152
|
+
const ACQUIRED_KEY = `__concurrencyAcquired_${id}`;
|
|
153
|
+
const BUCKET_KEY = `__concurrencyBucket_${id}`;
|
|
154
|
+
const reject503 = (rejection) => {
|
|
155
|
+
opts.onReject?.(rejection);
|
|
156
|
+
const headers = retryAfterSeconds > 0 ? { "retry-after": String(retryAfterSeconds) } : undefined;
|
|
157
|
+
throw new HttpError(503, {
|
|
158
|
+
type: "https://daloyjs.dev/errors/concurrency-limit",
|
|
159
|
+
title: "Service Unavailable",
|
|
160
|
+
detail: message,
|
|
161
|
+
}, headers);
|
|
162
|
+
};
|
|
163
|
+
const getBucket = (key) => {
|
|
164
|
+
let bucket = buckets.get(key);
|
|
165
|
+
if (!bucket) {
|
|
166
|
+
bucket = { active: 0, queue: [] };
|
|
167
|
+
buckets.set(key, bucket);
|
|
168
|
+
}
|
|
169
|
+
return bucket;
|
|
170
|
+
};
|
|
171
|
+
/** Release a slot back to a bucket: hand it to the next waiter, or free it. */
|
|
172
|
+
const release = (key) => {
|
|
173
|
+
const bucket = buckets.get(key);
|
|
174
|
+
if (!bucket)
|
|
175
|
+
return;
|
|
176
|
+
const next = bucket.queue.shift();
|
|
177
|
+
if (next) {
|
|
178
|
+
if (next.timer !== undefined)
|
|
179
|
+
clearTimeout(next.timer);
|
|
180
|
+
next.resolve();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
bucket.active--;
|
|
184
|
+
// Reclaim empty buckets so per-client / per-route keys don't leak memory.
|
|
185
|
+
if (bucket.active <= 0 && bucket.queue.length === 0) {
|
|
186
|
+
bucket.active = 0;
|
|
187
|
+
buckets.delete(key);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
async beforeHandle(ctx) {
|
|
192
|
+
const key = resolveKey(ctx);
|
|
193
|
+
if (key === undefined)
|
|
194
|
+
return undefined; // fail-open: not subject to limiting
|
|
195
|
+
const bucket = getBucket(key);
|
|
196
|
+
if (bucket.active < maxConcurrent) {
|
|
197
|
+
bucket.active++;
|
|
198
|
+
}
|
|
199
|
+
else if (maxQueue > 0 && bucket.queue.length < maxQueue) {
|
|
200
|
+
await new Promise((resolve, reject) => {
|
|
201
|
+
const waiter = { resolve, reject, timer: undefined };
|
|
202
|
+
if (queueTimeoutMs > 0) {
|
|
203
|
+
waiter.timer = setTimeout(() => {
|
|
204
|
+
const idx = bucket.queue.indexOf(waiter);
|
|
205
|
+
if (idx !== -1)
|
|
206
|
+
bucket.queue.splice(idx, 1);
|
|
207
|
+
try {
|
|
208
|
+
reject503({
|
|
209
|
+
key,
|
|
210
|
+
reason: "queue-timeout",
|
|
211
|
+
active: bucket.active,
|
|
212
|
+
queued: bucket.queue.length,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
reject(err);
|
|
217
|
+
}
|
|
218
|
+
}, queueTimeoutMs);
|
|
219
|
+
const timer = waiter.timer;
|
|
220
|
+
if (typeof timer.unref === "function")
|
|
221
|
+
timer.unref();
|
|
222
|
+
}
|
|
223
|
+
bucket.queue.push(waiter);
|
|
224
|
+
});
|
|
225
|
+
// Admitted from the queue: the releaser left `active` unchanged for us.
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
reject503({
|
|
229
|
+
key,
|
|
230
|
+
reason: "queue-full",
|
|
231
|
+
active: bucket.active,
|
|
232
|
+
queued: bucket.queue.length,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const state = ctx.state;
|
|
236
|
+
state[ACQUIRED_KEY] = true;
|
|
237
|
+
state[BUCKET_KEY] = key;
|
|
238
|
+
return undefined;
|
|
239
|
+
},
|
|
240
|
+
onSend(_res, ctx) {
|
|
241
|
+
if (!ctx)
|
|
242
|
+
return undefined;
|
|
243
|
+
const state = ctx.state;
|
|
244
|
+
if (state[ACQUIRED_KEY] !== true)
|
|
245
|
+
return undefined;
|
|
246
|
+
// Guard against a double release if onSend somehow runs twice.
|
|
247
|
+
state[ACQUIRED_KEY] = false;
|
|
248
|
+
const key = state[BUCKET_KEY];
|
|
249
|
+
if (typeof key === "string")
|
|
250
|
+
release(key);
|
|
251
|
+
return undefined;
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|