@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.
Files changed (77) hide show
  1. package/README.md +22 -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 +223 -1
  8. package/dist/app.js +358 -8
  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,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
- /** Override CDN URLs for the docs UI assets (useful for self-hosting). */
102
- assets?: {
103
- scalarScriptUrl?: string;
104
- swaggerUiCssUrl?: string;
105
- swaggerUiBundleUrl?: string;
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
@@ -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",