@daloyjs/core 0.35.2 → 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 +22 -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 +223 -1
- package/dist/app.js +358 -8
- 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
|
@@ -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
|
+
}
|
package/dist/docs.d.ts
CHANGED
|
@@ -92,18 +92,69 @@ export interface ScalarReferenceConfiguration {
|
|
|
92
92
|
spec?: never;
|
|
93
93
|
url?: never;
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Override CDN URLs and pin Subresource Integrity (SRI) hashes for the docs
|
|
97
|
+
* UI assets.
|
|
98
|
+
*
|
|
99
|
+
* Supplying an `*Integrity` value emits an `integrity="…"` attribute plus a
|
|
100
|
+
* `crossorigin` attribute on the matching `<script>` / `<link>` tag so the
|
|
101
|
+
* browser refuses to execute a CDN asset whose bytes don't match the pinned
|
|
102
|
+
* hash. SRI is only meaningful against a **version-pinned** URL
|
|
103
|
+
* (e.g. `…/@scalar/api-reference@1.25.0`); pair each integrity hash with a
|
|
104
|
+
* pinned `*Url`, since the framework's default URLs intentionally track the
|
|
105
|
+
* latest upstream release and therefore cannot carry a stable hash.
|
|
106
|
+
*
|
|
107
|
+
* @since 0.37.0
|
|
108
|
+
*/
|
|
109
|
+
export interface DocsAssetOptions {
|
|
110
|
+
/** Override the Scalar API Reference bundle URL (useful for self-hosting). */
|
|
111
|
+
scalarScriptUrl?: string;
|
|
112
|
+
/**
|
|
113
|
+
* SRI hash for {@link scalarScriptUrl}. One or more space-separated
|
|
114
|
+
* `sha256-`/`sha384-`/`sha512-` base64 digests. Invalid values throw.
|
|
115
|
+
*
|
|
116
|
+
* @since 0.37.0
|
|
117
|
+
*/
|
|
118
|
+
scalarScriptIntegrity?: string;
|
|
119
|
+
/** Override the Swagger UI stylesheet URL (useful for self-hosting). */
|
|
120
|
+
swaggerUiCssUrl?: string;
|
|
121
|
+
/**
|
|
122
|
+
* SRI hash for {@link swaggerUiCssUrl}. One or more space-separated
|
|
123
|
+
* `sha256-`/`sha384-`/`sha512-` base64 digests. Invalid values throw.
|
|
124
|
+
*
|
|
125
|
+
* @since 0.37.0
|
|
126
|
+
*/
|
|
127
|
+
swaggerUiCssIntegrity?: string;
|
|
128
|
+
/** Override the Swagger UI bundle URL (useful for self-hosting). */
|
|
129
|
+
swaggerUiBundleUrl?: string;
|
|
130
|
+
/**
|
|
131
|
+
* SRI hash for {@link swaggerUiBundleUrl}. One or more space-separated
|
|
132
|
+
* `sha256-`/`sha384-`/`sha512-` base64 digests. Invalid values throw.
|
|
133
|
+
*
|
|
134
|
+
* @since 0.37.0
|
|
135
|
+
*/
|
|
136
|
+
swaggerUiBundleIntegrity?: string;
|
|
137
|
+
/**
|
|
138
|
+
* `crossorigin` attribute value emitted alongside any pinned integrity
|
|
139
|
+
* hash. SRI on a cross-origin asset requires CORS, so this defaults to
|
|
140
|
+
* `"anonymous"`; use `"use-credentials"` only when the asset host needs
|
|
141
|
+
* credentialed requests.
|
|
142
|
+
*
|
|
143
|
+
* @since 0.37.0
|
|
144
|
+
*/
|
|
145
|
+
crossOrigin?: "anonymous" | "use-credentials";
|
|
146
|
+
}
|
|
95
147
|
/** Shared options for {@link scalarHtml} and {@link swaggerUiHtml}. */
|
|
96
148
|
export interface DocsOptions {
|
|
97
149
|
/** Absolute or relative URL of the OpenAPI document to render. */
|
|
98
150
|
specUrl: string;
|
|
99
151
|
/** `<title>` of the generated HTML page. */
|
|
100
152
|
title?: string;
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
};
|
|
153
|
+
/**
|
|
154
|
+
* Override CDN URLs and pin SRI hashes for the docs UI assets (useful for
|
|
155
|
+
* self-hosting or supply-chain hardening). See {@link DocsAssetOptions}.
|
|
156
|
+
*/
|
|
157
|
+
assets?: DocsAssetOptions;
|
|
107
158
|
/** CSP `nonce` to apply to inline/script tags; must match the response CSP. */
|
|
108
159
|
scriptNonce?: string;
|
|
109
160
|
}
|
package/dist/docs.js
CHANGED
|
@@ -8,9 +8,37 @@
|
|
|
8
8
|
* (You can self-host the assets if your CSP forbids CDNs.)
|
|
9
9
|
*/
|
|
10
10
|
const JSDELIVR_ORIGIN = "https://cdn.jsdelivr.net";
|
|
11
|
+
/**
|
|
12
|
+
* Matches a single Subresource Integrity digest: a `sha256-`/`sha384-`/
|
|
13
|
+
* `sha512-` prefix followed by standard base64 (with up to two `=` pads).
|
|
14
|
+
* Linear-time / ReDoS-safe (no nested or overlapping quantifiers).
|
|
15
|
+
*/
|
|
16
|
+
const SRI_HASH = /^sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2}$/;
|
|
11
17
|
function nonceAttr(nonce) {
|
|
12
18
|
return nonce ? ` nonce="${escapeHtml(nonce)}"` : "";
|
|
13
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the `integrity`/`crossorigin` attribute fragment for a docs asset.
|
|
22
|
+
*
|
|
23
|
+
* Returns an empty string when no `integrity` value is supplied. When one is
|
|
24
|
+
* supplied it is validated as one or more space-separated SRI digests and a
|
|
25
|
+
* `crossorigin` attribute (default `"anonymous"`) is emitted alongside it.
|
|
26
|
+
* A malformed integrity value throws a {@link TypeError} so a typo fails
|
|
27
|
+
* loudly instead of silently shipping a docs page with no SRI protection.
|
|
28
|
+
*
|
|
29
|
+
* @throws {TypeError} when `integrity` is provided but is not a valid SRI value.
|
|
30
|
+
*/
|
|
31
|
+
function integrityAttr(integrity, crossOrigin) {
|
|
32
|
+
if (integrity === undefined)
|
|
33
|
+
return "";
|
|
34
|
+
const tokens = integrity.trim().split(/\s+/);
|
|
35
|
+
if (integrity.trim() === "" || tokens.some((t) => !SRI_HASH.test(t))) {
|
|
36
|
+
throw new TypeError(`Invalid Subresource Integrity value: ${JSON.stringify(integrity)}. ` +
|
|
37
|
+
`Expected one or more space-separated "sha256-"/"sha384-"/"sha512-" base64 hashes.`);
|
|
38
|
+
}
|
|
39
|
+
const co = crossOrigin ?? "anonymous";
|
|
40
|
+
return ` integrity="${escapeHtml(integrity.trim())}" crossorigin="${escapeHtml(co)}"`;
|
|
41
|
+
}
|
|
14
42
|
/**
|
|
15
43
|
* Render a Scalar API Reference HTML page that loads `opts.specUrl`.
|
|
16
44
|
*
|
|
@@ -23,6 +51,7 @@ export function scalarHtml(opts) {
|
|
|
23
51
|
const url = escapeHtml(opts.specUrl);
|
|
24
52
|
const scriptUrl = escapeHtml(opts.assets?.scalarScriptUrl ??
|
|
25
53
|
`${JSDELIVR_ORIGIN}/npm/@scalar/api-reference`);
|
|
54
|
+
const scriptSri = integrityAttr(opts.assets?.scalarScriptIntegrity, opts.assets?.crossOrigin);
|
|
26
55
|
const nonce = nonceAttr(opts.scriptNonce);
|
|
27
56
|
const configuration = scalarConfigurationAttr(opts.specUrl, opts.configuration);
|
|
28
57
|
return `<!doctype html>
|
|
@@ -32,7 +61,7 @@ export function scalarHtml(opts) {
|
|
|
32
61
|
<title>${title}</title>
|
|
33
62
|
</head><body>
|
|
34
63
|
<script id="api-reference" data-url="${url}"${configuration}${nonce}></script>
|
|
35
|
-
<script src="${scriptUrl}"${nonce}></script>
|
|
64
|
+
<script src="${scriptUrl}"${scriptSri}${nonce}></script>
|
|
36
65
|
</body></html>`;
|
|
37
66
|
}
|
|
38
67
|
/**
|
|
@@ -46,16 +75,18 @@ export function swaggerUiHtml(opts) {
|
|
|
46
75
|
`${JSDELIVR_ORIGIN}/npm/swagger-ui-dist/swagger-ui.css`);
|
|
47
76
|
const bundleUrl = escapeHtml(opts.assets?.swaggerUiBundleUrl ??
|
|
48
77
|
`${JSDELIVR_ORIGIN}/npm/swagger-ui-dist/swagger-ui-bundle.js`);
|
|
78
|
+
const cssSri = integrityAttr(opts.assets?.swaggerUiCssIntegrity, opts.assets?.crossOrigin);
|
|
79
|
+
const bundleSri = integrityAttr(opts.assets?.swaggerUiBundleIntegrity, opts.assets?.crossOrigin);
|
|
49
80
|
const nonce = nonceAttr(opts.scriptNonce);
|
|
50
81
|
return `<!doctype html>
|
|
51
82
|
<html><head>
|
|
52
83
|
<meta charset="utf-8" />
|
|
53
84
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
54
85
|
<title>${title}</title>
|
|
55
|
-
<link rel="stylesheet" href="${cssUrl}" />
|
|
86
|
+
<link rel="stylesheet" href="${cssUrl}"${cssSri} />
|
|
56
87
|
</head><body>
|
|
57
88
|
<div id="swagger"></div>
|
|
58
|
-
<script src="${bundleUrl}"${nonce}></script>
|
|
89
|
+
<script src="${bundleUrl}"${bundleSri}${nonce}></script>
|
|
59
90
|
<script${nonce}>window.onload=()=>SwaggerUIBundle({url:"${url}",dom_id:"#swagger"});</script>
|
|
60
91
|
</body></html>`;
|
|
61
92
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -148,6 +148,11 @@ export declare function httpError(opts: HttpErrorOptions): HttpError;
|
|
|
148
148
|
* `Retry-After` or `Allow`. In production mode, 5xx `detail` is scrubbed to
|
|
149
149
|
* avoid information disclosure.
|
|
150
150
|
*
|
|
151
|
+
* **Security note:** only **5xx** `detail` is scrubbed in production. A `4xx`
|
|
152
|
+
* `detail` is always returned to the client (it is assumed to be
|
|
153
|
+
* client-actionable), so never place secrets or internal diagnostics in the
|
|
154
|
+
* `detail` of a 4xx error.
|
|
155
|
+
*
|
|
151
156
|
* Prefer the dedicated subclasses (`BadRequestError`, `NotFoundError`, ...)
|
|
152
157
|
* for common statuses; instantiate `HttpError` directly only for unusual
|
|
153
158
|
* status codes or fully-custom problem documents.
|
|
@@ -233,6 +238,21 @@ export declare class ValidationError extends HttpError {
|
|
|
233
238
|
export declare class NotFoundError extends HttpError {
|
|
234
239
|
constructor(detail?: string);
|
|
235
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* `409 Conflict` — the request could not be completed because it conflicts
|
|
243
|
+
* with the current state of the target resource. The built-in
|
|
244
|
+
* {@link idempotency} middleware throws this when a second request arrives
|
|
245
|
+
* with an `Idempotency-Key` that is still being processed by an in-flight
|
|
246
|
+
* request (the original response has not been produced yet). The response
|
|
247
|
+
* carries `Cache-Control: no-store` so a private cache cannot mask the
|
|
248
|
+
* conflict.
|
|
249
|
+
*
|
|
250
|
+
* @param detail - Optional human-readable explanation surfaced to the client.
|
|
251
|
+
* @since 0.37.0
|
|
252
|
+
*/
|
|
253
|
+
export declare class ConflictError extends HttpError {
|
|
254
|
+
constructor(detail?: string);
|
|
255
|
+
}
|
|
236
256
|
/**
|
|
237
257
|
* `401 Unauthorized` — authentication is required and missing or invalid.
|
|
238
258
|
* Pair with a `WWW-Authenticate` header on the response when issuing a
|
package/dist/errors.js
CHANGED
|
@@ -143,6 +143,11 @@ export function httpError(opts) {
|
|
|
143
143
|
* `Retry-After` or `Allow`. In production mode, 5xx `detail` is scrubbed to
|
|
144
144
|
* avoid information disclosure.
|
|
145
145
|
*
|
|
146
|
+
* **Security note:** only **5xx** `detail` is scrubbed in production. A `4xx`
|
|
147
|
+
* `detail` is always returned to the client (it is assumed to be
|
|
148
|
+
* client-actionable), so never place secrets or internal diagnostics in the
|
|
149
|
+
* `detail` of a 4xx error.
|
|
150
|
+
*
|
|
146
151
|
* Prefer the dedicated subclasses (`BadRequestError`, `NotFoundError`, ...)
|
|
147
152
|
* for common statuses; instantiate `HttpError` directly only for unusual
|
|
148
153
|
* status codes or fully-custom problem documents.
|
|
@@ -290,6 +295,28 @@ export class NotFoundError extends HttpError {
|
|
|
290
295
|
this.name = "NotFoundError";
|
|
291
296
|
}
|
|
292
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* `409 Conflict` — the request could not be completed because it conflicts
|
|
300
|
+
* with the current state of the target resource. The built-in
|
|
301
|
+
* {@link idempotency} middleware throws this when a second request arrives
|
|
302
|
+
* with an `Idempotency-Key` that is still being processed by an in-flight
|
|
303
|
+
* request (the original response has not been produced yet). The response
|
|
304
|
+
* carries `Cache-Control: no-store` so a private cache cannot mask the
|
|
305
|
+
* conflict.
|
|
306
|
+
*
|
|
307
|
+
* @param detail - Optional human-readable explanation surfaced to the client.
|
|
308
|
+
* @since 0.37.0
|
|
309
|
+
*/
|
|
310
|
+
export class ConflictError extends HttpError {
|
|
311
|
+
constructor(detail) {
|
|
312
|
+
super(409, {
|
|
313
|
+
type: "https://daloyjs.dev/errors/conflict",
|
|
314
|
+
title: "Conflict",
|
|
315
|
+
...(detail ? { detail } : {}),
|
|
316
|
+
}, { "cache-control": "no-store" });
|
|
317
|
+
this.name = "ConflictError";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
293
320
|
/**
|
|
294
321
|
* `401 Unauthorized` — authentication is required and missing or invalid.
|
|
295
322
|
* Pair with a `WWW-Authenticate` header on the response when issuing a
|
package/dist/fetch-guard.js
CHANGED
|
@@ -286,6 +286,10 @@ export function fetchGuard(options = {}) {
|
|
|
286
286
|
const method = request.method.toUpperCase();
|
|
287
287
|
const shouldDowngrade = res.status === 303 ||
|
|
288
288
|
((res.status === 301 || res.status === 302) && method !== "GET" && method !== "HEAD");
|
|
289
|
+
// Committed to following this hop. Drain the intermediate 3xx body so
|
|
290
|
+
// the underlying socket isn't pinned until GC (Node/undici keep the
|
|
291
|
+
// connection open while an un-consumed body stream is outstanding).
|
|
292
|
+
void res.body?.cancel();
|
|
289
293
|
request = shouldDowngrade
|
|
290
294
|
? new Request(next, {
|
|
291
295
|
method: "GET",
|