@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/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
|
|
@@ -279,6 +299,29 @@ export declare class MethodNotAllowedError extends HttpError {
|
|
|
279
299
|
export declare class PayloadTooLargeError extends HttpError {
|
|
280
300
|
constructor(limit: number);
|
|
281
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* `431 Request Header Fields Too Large` — thrown when an incoming request
|
|
304
|
+
* carries more distinct header fields than {@link AppOptions.maxHeaderCount}.
|
|
305
|
+
*
|
|
306
|
+
* This is the portable, JS-layer counterpart to the native "max header
|
|
307
|
+
* count" caps that web servers and proxies expose (e.g. NGINX's
|
|
308
|
+
* `max_headers`, Node's `server.maxHeadersCount`). It is defence-in-depth
|
|
309
|
+
* against header-*count* amplification — the dimension abused by the
|
|
310
|
+
* "HTTP/2 Bomb" (Calif, 2026), where per-header server-side bookkeeping is
|
|
311
|
+
* the amplifier rather than header size. A flood of nearly-empty headers
|
|
312
|
+
* never reaches the router because the count cap fires first.
|
|
313
|
+
*
|
|
314
|
+
* The native HPACK session-memory pinning that the bomb relies on must
|
|
315
|
+
* still be mitigated at the runtime/proxy that terminates HTTP/2 (apply the
|
|
316
|
+
* vendor fix and cap the header count there); this guard protects the
|
|
317
|
+
* application tier on every runtime regardless of upstream configuration.
|
|
318
|
+
*
|
|
319
|
+
* @param limit - The configured maximum header count that was exceeded.
|
|
320
|
+
* @since 0.38.0
|
|
321
|
+
*/
|
|
322
|
+
export declare class RequestHeaderFieldsTooLargeError extends HttpError {
|
|
323
|
+
constructor(limit: number);
|
|
324
|
+
}
|
|
282
325
|
/**
|
|
283
326
|
* `415 Unsupported Media Type` — thrown when the request `Content-Type` is
|
|
284
327
|
* not in {@link AppOptions.allowedContentTypes} for a route that declares 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
|
|
@@ -368,6 +395,36 @@ export class PayloadTooLargeError extends HttpError {
|
|
|
368
395
|
this.name = "PayloadTooLargeError";
|
|
369
396
|
}
|
|
370
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* `431 Request Header Fields Too Large` — thrown when an incoming request
|
|
400
|
+
* carries more distinct header fields than {@link AppOptions.maxHeaderCount}.
|
|
401
|
+
*
|
|
402
|
+
* This is the portable, JS-layer counterpart to the native "max header
|
|
403
|
+
* count" caps that web servers and proxies expose (e.g. NGINX's
|
|
404
|
+
* `max_headers`, Node's `server.maxHeadersCount`). It is defence-in-depth
|
|
405
|
+
* against header-*count* amplification — the dimension abused by the
|
|
406
|
+
* "HTTP/2 Bomb" (Calif, 2026), where per-header server-side bookkeeping is
|
|
407
|
+
* the amplifier rather than header size. A flood of nearly-empty headers
|
|
408
|
+
* never reaches the router because the count cap fires first.
|
|
409
|
+
*
|
|
410
|
+
* The native HPACK session-memory pinning that the bomb relies on must
|
|
411
|
+
* still be mitigated at the runtime/proxy that terminates HTTP/2 (apply the
|
|
412
|
+
* vendor fix and cap the header count there); this guard protects the
|
|
413
|
+
* application tier on every runtime regardless of upstream configuration.
|
|
414
|
+
*
|
|
415
|
+
* @param limit - The configured maximum header count that was exceeded.
|
|
416
|
+
* @since 0.38.0
|
|
417
|
+
*/
|
|
418
|
+
export class RequestHeaderFieldsTooLargeError extends HttpError {
|
|
419
|
+
constructor(limit) {
|
|
420
|
+
super(431, {
|
|
421
|
+
type: "https://daloyjs.dev/errors/request-header-fields-too-large",
|
|
422
|
+
title: "Request Header Fields Too Large",
|
|
423
|
+
detail: `Request carries more than ${limit} header fields`,
|
|
424
|
+
});
|
|
425
|
+
this.name = "RequestHeaderFieldsTooLargeError";
|
|
426
|
+
}
|
|
427
|
+
}
|
|
371
428
|
/**
|
|
372
429
|
* `415 Unsupported Media Type` — thrown when the request `Content-Type` is
|
|
373
430
|
* not in {@link AppOptions.allowedContentTypes} for a route that declares 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",
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resilientFetch()` — circuit breaker, retry-with-backoff, and per-call
|
|
3
|
+
* timeout for outbound `fetch`, designed to layer **on top of**
|
|
4
|
+
* {@link fetchGuard} (which only covers SSRF on egress).
|
|
5
|
+
*
|
|
6
|
+
* `fetchGuard()` answers "is this outbound address safe?". This module
|
|
7
|
+
* answers "is this upstream healthy, and how do we behave when it is
|
|
8
|
+
* not?" — the operational half of a mature outbound HTTP client
|
|
9
|
+
* (timeouts that prevent a hung upstream from exhausting your event
|
|
10
|
+
* loop, bounded retries that ride out a blip without amplifying an
|
|
11
|
+
* outage, and a circuit breaker that fails fast when an upstream is
|
|
12
|
+
* clearly down). The two compose: wrap an SSRF-guarded fetch in a
|
|
13
|
+
* resilient one and you get both safety and resilience with zero runtime
|
|
14
|
+
* dependencies.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { fetchGuard, resilientFetch } from "@daloyjs/core";
|
|
18
|
+
*
|
|
19
|
+
* const safeFetch = resilientFetch({
|
|
20
|
+
* fetch: fetchGuard(), // SSRF floor underneath
|
|
21
|
+
* timeoutMs: 2_000, // abort any call that stalls past 2s
|
|
22
|
+
* retries: 2, // up to 2 retries on transient failures
|
|
23
|
+
* circuitBreaker: { failureThreshold: 5, resetTimeoutMs: 30_000 },
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* const res = await safeFetch("https://api.example.com/things");
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* ## Design notes
|
|
30
|
+
*
|
|
31
|
+
* - **Per-call timeout** is enforced with an `AbortController` that is
|
|
32
|
+
* combined with any caller-supplied `signal`, so cancellation works in
|
|
33
|
+
* both directions. A timeout surfaces as {@link FetchTimeoutError}; a
|
|
34
|
+
* caller-initiated abort surfaces as the caller's own `AbortError` and
|
|
35
|
+
* is **never** retried or counted as an upstream failure.
|
|
36
|
+
* - **Retry-with-backoff** only retries idempotent methods
|
|
37
|
+
* (`GET`/`HEAD`/`OPTIONS`/`PUT`/`DELETE`) and a conservative set of
|
|
38
|
+
* transient statuses (`408`, `429`, `500`, `502`, `503`, `504`) plus
|
|
39
|
+
* network errors and timeouts. Backoff is exponential with full
|
|
40
|
+
* jitter and honours a `Retry-After` header when present. A
|
|
41
|
+
* {@link SsrfBlockedError} is treated as a hard refusal — never
|
|
42
|
+
* retried, never trips the breaker.
|
|
43
|
+
* - **Circuit breaker** is a classic three-state machine
|
|
44
|
+
* (`closed → open → half-open`). Consecutive failures past the
|
|
45
|
+
* threshold open the circuit; while open every call fails fast with
|
|
46
|
+
* {@link CircuitOpenError} until `resetTimeoutMs` elapses, after which
|
|
47
|
+
* a limited number of trial requests probe the upstream. The breaker
|
|
48
|
+
* is shared across every call made through the returned function, so a
|
|
49
|
+
* single hot upstream is protected process-wide.
|
|
50
|
+
*
|
|
51
|
+
* @module
|
|
52
|
+
* @since 0.37.0
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The three states of a {@link CircuitBreaker}.
|
|
56
|
+
*
|
|
57
|
+
* - `closed` — normal operation; calls pass through and failures are
|
|
58
|
+
* counted.
|
|
59
|
+
* - `open` — the upstream is considered down; calls fail fast with
|
|
60
|
+
* {@link CircuitOpenError} without touching the network.
|
|
61
|
+
* - `half-open` — a recovery probe window; a limited number of trial
|
|
62
|
+
* calls are allowed through to test whether the upstream has healed.
|
|
63
|
+
*
|
|
64
|
+
* @since 0.37.0
|
|
65
|
+
*/
|
|
66
|
+
export type CircuitState = "closed" | "open" | "half-open";
|
|
67
|
+
/**
|
|
68
|
+
* Thrown by {@link resilientFetch} (and {@link CircuitBreaker.execute})
|
|
69
|
+
* when the circuit is open and the call is refused without hitting the
|
|
70
|
+
* network. Distinct from a network failure so callers can render a
|
|
71
|
+
* dedicated "service temporarily unavailable" path.
|
|
72
|
+
*
|
|
73
|
+
* @since 0.37.0
|
|
74
|
+
*/
|
|
75
|
+
export declare class CircuitOpenError extends Error {
|
|
76
|
+
/** Milliseconds until the breaker will next allow a trial request. */
|
|
77
|
+
readonly retryAfterMs: number;
|
|
78
|
+
constructor(retryAfterMs: number);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Thrown by {@link resilientFetch} when a single attempt exceeds the
|
|
82
|
+
* configured `timeoutMs`. A caller-initiated abort (via a `signal`
|
|
83
|
+
* passed in the request init) surfaces as the caller's own `AbortError`
|
|
84
|
+
* instead and is never retried.
|
|
85
|
+
*
|
|
86
|
+
* @since 0.37.0
|
|
87
|
+
*/
|
|
88
|
+
export declare class FetchTimeoutError extends Error {
|
|
89
|
+
/** The timeout that was exceeded, in milliseconds. */
|
|
90
|
+
readonly timeoutMs: number;
|
|
91
|
+
constructor(timeoutMs: number);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Tuning for the {@link CircuitBreaker}. All fields are optional and
|
|
95
|
+
* default to a conservative posture suited to a single upstream.
|
|
96
|
+
*
|
|
97
|
+
* @since 0.37.0
|
|
98
|
+
*/
|
|
99
|
+
export interface CircuitBreakerOptions {
|
|
100
|
+
/**
|
|
101
|
+
* Number of consecutive failures that trips the breaker from `closed`
|
|
102
|
+
* to `open`. Default `5`.
|
|
103
|
+
*/
|
|
104
|
+
failureThreshold?: number;
|
|
105
|
+
/**
|
|
106
|
+
* Time the breaker stays `open` before allowing trial requests
|
|
107
|
+
* (transition to `half-open`), in milliseconds. Default `30_000`.
|
|
108
|
+
*/
|
|
109
|
+
resetTimeoutMs?: number;
|
|
110
|
+
/**
|
|
111
|
+
* Number of concurrent trial requests permitted while `half-open`.
|
|
112
|
+
* Extra calls during the probe window fail fast with
|
|
113
|
+
* {@link CircuitOpenError}. Default `1`.
|
|
114
|
+
*/
|
|
115
|
+
halfOpenMaxAttempts?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Number of consecutive trial successes required to close the breaker
|
|
118
|
+
* again. Default `1`.
|
|
119
|
+
*/
|
|
120
|
+
successThreshold?: number;
|
|
121
|
+
/**
|
|
122
|
+
* Observe state transitions (e.g. to emit a metric or log). Called
|
|
123
|
+
* synchronously with the previous and next state.
|
|
124
|
+
*/
|
|
125
|
+
onStateChange?: (next: CircuitState, previous: CircuitState) => void;
|
|
126
|
+
/**
|
|
127
|
+
* Monotonic clock, primarily for deterministic tests. Defaults to
|
|
128
|
+
* `Date.now`.
|
|
129
|
+
*/
|
|
130
|
+
now?: () => number;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A standalone three-state circuit breaker. {@link resilientFetch}
|
|
134
|
+
* builds on this, but it is exported for callers who want to protect a
|
|
135
|
+
* non-`fetch` dependency (a database driver, a gRPC client, …) with the
|
|
136
|
+
* same semantics.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* const breaker = new CircuitBreaker({ failureThreshold: 3 });
|
|
141
|
+
* const rows = await breaker.execute(() => db.query("SELECT 1"));
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* @since 0.37.0
|
|
145
|
+
*/
|
|
146
|
+
export declare class CircuitBreaker {
|
|
147
|
+
#private;
|
|
148
|
+
constructor(options?: CircuitBreakerOptions);
|
|
149
|
+
/** The breaker's current state, after applying any pending timeout. */
|
|
150
|
+
get state(): CircuitState;
|
|
151
|
+
/** Milliseconds until the breaker will next admit a trial request. */
|
|
152
|
+
get retryAfterMs(): number;
|
|
153
|
+
/**
|
|
154
|
+
* Run `fn` under breaker supervision. Throws {@link CircuitOpenError}
|
|
155
|
+
* immediately when the circuit is open. A thrown error (other than
|
|
156
|
+
* `CircuitOpenError`) counts as a failure; a returned value counts as
|
|
157
|
+
* a success. Use {@link recordOutcome} from {@link resilientFetch}
|
|
158
|
+
* when an HTTP *response* (not a thrown error) should count as a
|
|
159
|
+
* failure.
|
|
160
|
+
*/
|
|
161
|
+
execute<T>(fn: () => Promise<T>): Promise<T>;
|
|
162
|
+
/**
|
|
163
|
+
* Record an externally-determined outcome. Lets a caller treat a
|
|
164
|
+
* non-throwing result (e.g. an HTTP 503 response) as a failure while
|
|
165
|
+
* still flowing the value back. Returns nothing; pair with an explicit
|
|
166
|
+
* {@link admit}/{@link release} when you need full manual control.
|
|
167
|
+
*/
|
|
168
|
+
recordOutcome(success: boolean): void;
|
|
169
|
+
/**
|
|
170
|
+
* Reserve a breaker slot for a manually-supervised call. Throws
|
|
171
|
+
* {@link CircuitOpenError} if the circuit will not admit the call.
|
|
172
|
+
* Must be paired with exactly one {@link recordOutcome} or
|
|
173
|
+
* {@link release}.
|
|
174
|
+
*/
|
|
175
|
+
admit(): void;
|
|
176
|
+
/**
|
|
177
|
+
* Release a slot reserved by {@link admit} without recording a success
|
|
178
|
+
* or failure. Use for outcomes that are not an upstream health signal
|
|
179
|
+
* (a caller-initiated abort, an SSRF refusal). Counts and state are
|
|
180
|
+
* left untouched aside from freeing a half-open probe slot.
|
|
181
|
+
*/
|
|
182
|
+
release(): void;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Context passed to the {@link ResilientFetchOptions.onRetry} hook and
|
|
186
|
+
* the {@link ResilientFetchOptions.isRetryable} predicate.
|
|
187
|
+
*
|
|
188
|
+
* @since 0.37.0
|
|
189
|
+
*/
|
|
190
|
+
export interface RetryContext {
|
|
191
|
+
/** 1-based attempt number that just failed. */
|
|
192
|
+
readonly attempt: number;
|
|
193
|
+
/** The request that was attempted. */
|
|
194
|
+
readonly request: Request;
|
|
195
|
+
/** The response received, when the failure was a retryable status. */
|
|
196
|
+
readonly response?: Response;
|
|
197
|
+
/** The error thrown, when the failure was a network error or timeout. */
|
|
198
|
+
readonly error?: unknown;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Options for {@link resilientFetch}. Every field is optional; the
|
|
202
|
+
* defaults bias toward safe, low-amplification behaviour.
|
|
203
|
+
*
|
|
204
|
+
* @since 0.37.0
|
|
205
|
+
*/
|
|
206
|
+
export interface ResilientFetchOptions {
|
|
207
|
+
/**
|
|
208
|
+
* Underlying fetch implementation. Defaults to `globalThis.fetch`.
|
|
209
|
+
* Pass a {@link fetchGuard} result to keep the SSRF floor underneath
|
|
210
|
+
* the resilience layer.
|
|
211
|
+
*/
|
|
212
|
+
fetch?: typeof fetch;
|
|
213
|
+
/**
|
|
214
|
+
* Per-attempt timeout in milliseconds. Each retry gets a fresh
|
|
215
|
+
* timeout. Set `0` to disable. Default `10_000`.
|
|
216
|
+
*/
|
|
217
|
+
timeoutMs?: number;
|
|
218
|
+
/**
|
|
219
|
+
* Maximum number of retries **after** the first attempt. `0` disables
|
|
220
|
+
* retrying. Default `2` (so up to 3 total attempts).
|
|
221
|
+
*/
|
|
222
|
+
retries?: number;
|
|
223
|
+
/**
|
|
224
|
+
* Base backoff delay in milliseconds for the first retry. Default
|
|
225
|
+
* `100`.
|
|
226
|
+
*/
|
|
227
|
+
retryDelayMs?: number;
|
|
228
|
+
/** Upper bound on any single backoff delay. Default `2_000`. */
|
|
229
|
+
maxRetryDelayMs?: number;
|
|
230
|
+
/** Exponential backoff multiplier. Default `2`. */
|
|
231
|
+
backoffFactor?: number;
|
|
232
|
+
/**
|
|
233
|
+
* Apply full jitter (`delay * random()`) to backoff to avoid
|
|
234
|
+
* thundering-herd retries. Default `true`.
|
|
235
|
+
*/
|
|
236
|
+
jitter?: boolean;
|
|
237
|
+
/**
|
|
238
|
+
* HTTP methods that are safe to retry. Default the idempotent set:
|
|
239
|
+
* `GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`. Non-idempotent methods
|
|
240
|
+
* (`POST`, `PATCH`) are never retried unless added here.
|
|
241
|
+
*/
|
|
242
|
+
retryableMethods?: readonly string[];
|
|
243
|
+
/**
|
|
244
|
+
* Response statuses that should be retried. Default
|
|
245
|
+
* `[408, 429, 500, 502, 503, 504]`.
|
|
246
|
+
*/
|
|
247
|
+
retryableStatuses?: readonly number[];
|
|
248
|
+
/**
|
|
249
|
+
* Honour a `Retry-After` header on a retryable response (seconds or
|
|
250
|
+
* HTTP-date), capped by `maxRetryDelayMs`. Default `true`.
|
|
251
|
+
*/
|
|
252
|
+
respectRetryAfter?: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Override the retry decision entirely. Return `true` to retry the
|
|
255
|
+
* given outcome. When provided, replaces the method/status defaults.
|
|
256
|
+
*/
|
|
257
|
+
isRetryable?: (context: RetryContext) => boolean;
|
|
258
|
+
/**
|
|
259
|
+
* Observe each retry, e.g. to emit a metric. Called with the failed
|
|
260
|
+
* attempt's context and the delay before the next attempt.
|
|
261
|
+
*/
|
|
262
|
+
onRetry?: (context: RetryContext, delayMs: number) => void;
|
|
263
|
+
/**
|
|
264
|
+
* Circuit breaker configuration, an existing {@link CircuitBreaker}
|
|
265
|
+
* instance to share across clients, or `false` to disable. Default
|
|
266
|
+
* enabled with {@link CircuitBreakerOptions} defaults.
|
|
267
|
+
*/
|
|
268
|
+
circuitBreaker?: CircuitBreakerOptions | CircuitBreaker | false;
|
|
269
|
+
/**
|
|
270
|
+
* Response statuses that count as an upstream failure for the circuit
|
|
271
|
+
* breaker. Default `[500, 502, 503, 504]`. A failing status still
|
|
272
|
+
* flows back to the caller after retries are exhausted.
|
|
273
|
+
*/
|
|
274
|
+
circuitBreakerFailureStatuses?: readonly number[];
|
|
275
|
+
/**
|
|
276
|
+
* Sleep implementation, primarily for deterministic tests. Receives
|
|
277
|
+
* the delay and an `AbortSignal` that fires if the caller cancels.
|
|
278
|
+
* Defaults to a `setTimeout`-based abortable sleep.
|
|
279
|
+
*/
|
|
280
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Wrap a `fetch` with per-call timeout, retry-with-backoff, and a shared
|
|
284
|
+
* circuit breaker. The returned function has the same call signature as
|
|
285
|
+
* the global `fetch`.
|
|
286
|
+
*
|
|
287
|
+
* Layer it over {@link fetchGuard} to keep SSRF protection underneath:
|
|
288
|
+
*
|
|
289
|
+
* ```ts
|
|
290
|
+
* const safeFetch = resilientFetch({ fetch: fetchGuard(), timeoutMs: 2_000 });
|
|
291
|
+
* ```
|
|
292
|
+
*
|
|
293
|
+
* @since 0.37.0
|
|
294
|
+
*/
|
|
295
|
+
export declare function resilientFetch(options?: ResilientFetchOptions): typeof fetch;
|