@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -3
  3. package/bin/daloy.mjs +2 -0
  4. package/dist/adapters/bun.js +16 -9
  5. package/dist/adapters/deno.js +7 -1
  6. package/dist/adapters/node.d.ts +25 -0
  7. package/dist/adapters/node.js +32 -0
  8. package/dist/app.d.ts +200 -6
  9. package/dist/app.js +235 -50
  10. package/dist/asyncapi.d.ts +98 -0
  11. package/dist/asyncapi.js +212 -0
  12. package/dist/auto-ban.d.ts +205 -0
  13. package/dist/auto-ban.js +222 -0
  14. package/dist/bot-guard.d.ts +209 -0
  15. package/dist/bot-guard.js +291 -0
  16. package/dist/cli.d.ts +8 -0
  17. package/dist/cli.js +113 -4
  18. package/dist/client.d.ts +23 -0
  19. package/dist/client.js +16 -0
  20. package/dist/concurrency-limit.d.ts +135 -0
  21. package/dist/concurrency-limit.js +254 -0
  22. package/dist/docs.d.ts +57 -6
  23. package/dist/docs.js +34 -3
  24. package/dist/errors.d.ts +43 -0
  25. package/dist/errors.js +57 -0
  26. package/dist/fetch-guard.js +4 -0
  27. package/dist/fetch-resilience.d.ts +295 -0
  28. package/dist/fetch-resilience.js +485 -0
  29. package/dist/geo-block.d.ts +184 -0
  30. package/dist/geo-block.js +153 -0
  31. package/dist/hashing.d.ts +2 -1
  32. package/dist/hashing.js +12 -1
  33. package/dist/http-signatures.d.ts +303 -0
  34. package/dist/http-signatures.js +782 -0
  35. package/dist/idempotency.d.ts +204 -0
  36. package/dist/idempotency.js +341 -0
  37. package/dist/index.d.ts +39 -5
  38. package/dist/index.js +19 -2
  39. package/dist/ip-reputation.d.ts +198 -0
  40. package/dist/ip-reputation.js +253 -0
  41. package/dist/jwk.d.ts +15 -0
  42. package/dist/jwk.js +24 -2
  43. package/dist/load-shedding.d.ts +5 -0
  44. package/dist/logger.js +6 -2
  45. package/dist/metrics.d.ts +208 -0
  46. package/dist/metrics.js +452 -0
  47. package/dist/middleware.js +0 -10
  48. package/dist/mtls.d.ts +266 -0
  49. package/dist/mtls.js +488 -0
  50. package/dist/multipart.js +1 -1
  51. package/dist/openapi-diff.d.ts +79 -0
  52. package/dist/openapi-diff.js +246 -0
  53. package/dist/openapi.js +4 -1
  54. package/dist/pagination.d.ts +210 -0
  55. package/dist/pagination.js +353 -0
  56. package/dist/rate-limit-redis.d.ts +8 -0
  57. package/dist/rate-limit-redis.js +8 -0
  58. package/dist/request-decompression.d.ts +200 -0
  59. package/dist/request-decompression.js +363 -0
  60. package/dist/response-cache.d.ts +205 -0
  61. package/dist/response-cache.js +374 -0
  62. package/dist/router.d.ts +22 -0
  63. package/dist/router.js +64 -7
  64. package/dist/safe-redirect.d.ts +2 -2
  65. package/dist/safe-redirect.js +3 -8
  66. package/dist/sbom.cdx.json +9 -9
  67. package/dist/sbom.spdx.json +5 -5
  68. package/dist/scheduler.d.ts +315 -0
  69. package/dist/scheduler.js +546 -0
  70. package/dist/security.d.ts +61 -7
  71. package/dist/security.js +75 -8
  72. package/dist/session.js +3 -3
  73. package/dist/types.d.ts +33 -0
  74. package/dist/waf.d.ts +213 -0
  75. package/dist/waf.js +334 -0
  76. package/dist/webhook-delivery.d.ts +263 -0
  77. package/dist/webhook-delivery.js +311 -0
  78. package/dist/websocket.d.ts +52 -0
  79. package/dist/websocket.js +13 -0
  80. 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
- /** 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
@@ -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
@@ -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;