@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
|
@@ -0,0 +1,485 @@
|
|
|
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
|
+
* Thrown by {@link resilientFetch} (and {@link CircuitBreaker.execute})
|
|
56
|
+
* when the circuit is open and the call is refused without hitting the
|
|
57
|
+
* network. Distinct from a network failure so callers can render a
|
|
58
|
+
* dedicated "service temporarily unavailable" path.
|
|
59
|
+
*
|
|
60
|
+
* @since 0.37.0
|
|
61
|
+
*/
|
|
62
|
+
export class CircuitOpenError extends Error {
|
|
63
|
+
/** Milliseconds until the breaker will next allow a trial request. */
|
|
64
|
+
retryAfterMs;
|
|
65
|
+
constructor(retryAfterMs) {
|
|
66
|
+
super(`circuit breaker is open; retry in ~${Math.max(0, Math.round(retryAfterMs))}ms`);
|
|
67
|
+
this.name = "CircuitOpenError";
|
|
68
|
+
this.retryAfterMs = retryAfterMs;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Thrown by {@link resilientFetch} when a single attempt exceeds the
|
|
73
|
+
* configured `timeoutMs`. A caller-initiated abort (via a `signal`
|
|
74
|
+
* passed in the request init) surfaces as the caller's own `AbortError`
|
|
75
|
+
* instead and is never retried.
|
|
76
|
+
*
|
|
77
|
+
* @since 0.37.0
|
|
78
|
+
*/
|
|
79
|
+
export class FetchTimeoutError extends Error {
|
|
80
|
+
/** The timeout that was exceeded, in milliseconds. */
|
|
81
|
+
timeoutMs;
|
|
82
|
+
constructor(timeoutMs) {
|
|
83
|
+
super(`outbound fetch timed out after ${timeoutMs}ms`);
|
|
84
|
+
this.name = "FetchTimeoutError";
|
|
85
|
+
this.timeoutMs = timeoutMs;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A standalone three-state circuit breaker. {@link resilientFetch}
|
|
90
|
+
* builds on this, but it is exported for callers who want to protect a
|
|
91
|
+
* non-`fetch` dependency (a database driver, a gRPC client, …) with the
|
|
92
|
+
* same semantics.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* const breaker = new CircuitBreaker({ failureThreshold: 3 });
|
|
97
|
+
* const rows = await breaker.execute(() => db.query("SELECT 1"));
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* @since 0.37.0
|
|
101
|
+
*/
|
|
102
|
+
export class CircuitBreaker {
|
|
103
|
+
#failureThreshold;
|
|
104
|
+
#resetTimeoutMs;
|
|
105
|
+
#halfOpenMaxAttempts;
|
|
106
|
+
#successThreshold;
|
|
107
|
+
#onStateChange;
|
|
108
|
+
#now;
|
|
109
|
+
#state = "closed";
|
|
110
|
+
#failureCount = 0;
|
|
111
|
+
#successCount = 0;
|
|
112
|
+
#openedAt = 0;
|
|
113
|
+
#halfOpenInFlight = 0;
|
|
114
|
+
constructor(options = {}) {
|
|
115
|
+
const failureThreshold = options.failureThreshold ?? 5;
|
|
116
|
+
const resetTimeoutMs = options.resetTimeoutMs ?? 30_000;
|
|
117
|
+
const halfOpenMaxAttempts = options.halfOpenMaxAttempts ?? 1;
|
|
118
|
+
const successThreshold = options.successThreshold ?? 1;
|
|
119
|
+
if (!Number.isInteger(failureThreshold) || failureThreshold < 1) {
|
|
120
|
+
throw new RangeError("CircuitBreaker: failureThreshold must be a positive integer");
|
|
121
|
+
}
|
|
122
|
+
if (!Number.isFinite(resetTimeoutMs) || resetTimeoutMs < 0) {
|
|
123
|
+
throw new RangeError("CircuitBreaker: resetTimeoutMs must be a non-negative number");
|
|
124
|
+
}
|
|
125
|
+
if (!Number.isInteger(halfOpenMaxAttempts) || halfOpenMaxAttempts < 1) {
|
|
126
|
+
throw new RangeError("CircuitBreaker: halfOpenMaxAttempts must be a positive integer");
|
|
127
|
+
}
|
|
128
|
+
if (!Number.isInteger(successThreshold) || successThreshold < 1) {
|
|
129
|
+
throw new RangeError("CircuitBreaker: successThreshold must be a positive integer");
|
|
130
|
+
}
|
|
131
|
+
this.#failureThreshold = failureThreshold;
|
|
132
|
+
this.#resetTimeoutMs = resetTimeoutMs;
|
|
133
|
+
this.#halfOpenMaxAttempts = halfOpenMaxAttempts;
|
|
134
|
+
this.#successThreshold = successThreshold;
|
|
135
|
+
if (options.onStateChange)
|
|
136
|
+
this.#onStateChange = options.onStateChange;
|
|
137
|
+
this.#now = options.now ?? Date.now;
|
|
138
|
+
}
|
|
139
|
+
/** The breaker's current state, after applying any pending timeout. */
|
|
140
|
+
get state() {
|
|
141
|
+
if (this.#state === "open" && this.#now() - this.#openedAt >= this.#resetTimeoutMs) {
|
|
142
|
+
return "half-open";
|
|
143
|
+
}
|
|
144
|
+
return this.#state;
|
|
145
|
+
}
|
|
146
|
+
/** Milliseconds until the breaker will next admit a trial request. */
|
|
147
|
+
get retryAfterMs() {
|
|
148
|
+
if (this.#state !== "open")
|
|
149
|
+
return 0;
|
|
150
|
+
return Math.max(0, this.#resetTimeoutMs - (this.#now() - this.#openedAt));
|
|
151
|
+
}
|
|
152
|
+
#transition(next) {
|
|
153
|
+
const previous = this.#state;
|
|
154
|
+
if (previous === next)
|
|
155
|
+
return;
|
|
156
|
+
this.#state = next;
|
|
157
|
+
if (next === "open")
|
|
158
|
+
this.#openedAt = this.#now();
|
|
159
|
+
if (next === "closed") {
|
|
160
|
+
this.#failureCount = 0;
|
|
161
|
+
this.#successCount = 0;
|
|
162
|
+
this.#halfOpenInFlight = 0;
|
|
163
|
+
}
|
|
164
|
+
if (next === "half-open") {
|
|
165
|
+
this.#successCount = 0;
|
|
166
|
+
this.#halfOpenInFlight = 0;
|
|
167
|
+
}
|
|
168
|
+
this.#onStateChange?.(next, previous);
|
|
169
|
+
}
|
|
170
|
+
/** Reserve a slot, throwing {@link CircuitOpenError} if none is free. */
|
|
171
|
+
#admit() {
|
|
172
|
+
if (this.#state === "open") {
|
|
173
|
+
if (this.#now() - this.#openedAt >= this.#resetTimeoutMs) {
|
|
174
|
+
this.#transition("half-open");
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
throw new CircuitOpenError(this.retryAfterMs);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (this.#state === "half-open") {
|
|
181
|
+
if (this.#halfOpenInFlight >= this.#halfOpenMaxAttempts) {
|
|
182
|
+
throw new CircuitOpenError(this.retryAfterMs);
|
|
183
|
+
}
|
|
184
|
+
this.#halfOpenInFlight++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
#onSuccess() {
|
|
188
|
+
if (this.#state === "half-open") {
|
|
189
|
+
this.#halfOpenInFlight = Math.max(0, this.#halfOpenInFlight - 1);
|
|
190
|
+
this.#successCount++;
|
|
191
|
+
if (this.#successCount >= this.#successThreshold) {
|
|
192
|
+
this.#transition("closed");
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.#failureCount = 0;
|
|
197
|
+
}
|
|
198
|
+
#onFailure() {
|
|
199
|
+
if (this.#state === "half-open") {
|
|
200
|
+
this.#halfOpenInFlight = Math.max(0, this.#halfOpenInFlight - 1);
|
|
201
|
+
this.#transition("open");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.#failureCount++;
|
|
205
|
+
if (this.#failureCount >= this.#failureThreshold) {
|
|
206
|
+
this.#transition("open");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Run `fn` under breaker supervision. Throws {@link CircuitOpenError}
|
|
211
|
+
* immediately when the circuit is open. A thrown error (other than
|
|
212
|
+
* `CircuitOpenError`) counts as a failure; a returned value counts as
|
|
213
|
+
* a success. Use {@link recordOutcome} from {@link resilientFetch}
|
|
214
|
+
* when an HTTP *response* (not a thrown error) should count as a
|
|
215
|
+
* failure.
|
|
216
|
+
*/
|
|
217
|
+
async execute(fn) {
|
|
218
|
+
this.#admit();
|
|
219
|
+
try {
|
|
220
|
+
const result = await fn();
|
|
221
|
+
this.#onSuccess();
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (err instanceof CircuitOpenError)
|
|
226
|
+
throw err;
|
|
227
|
+
this.#onFailure();
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Record an externally-determined outcome. Lets a caller treat a
|
|
233
|
+
* non-throwing result (e.g. an HTTP 503 response) as a failure while
|
|
234
|
+
* still flowing the value back. Returns nothing; pair with an explicit
|
|
235
|
+
* {@link admit}/{@link release} when you need full manual control.
|
|
236
|
+
*/
|
|
237
|
+
recordOutcome(success) {
|
|
238
|
+
if (success)
|
|
239
|
+
this.#onSuccess();
|
|
240
|
+
else
|
|
241
|
+
this.#onFailure();
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Reserve a breaker slot for a manually-supervised call. Throws
|
|
245
|
+
* {@link CircuitOpenError} if the circuit will not admit the call.
|
|
246
|
+
* Must be paired with exactly one {@link recordOutcome} or
|
|
247
|
+
* {@link release}.
|
|
248
|
+
*/
|
|
249
|
+
admit() {
|
|
250
|
+
this.#admit();
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Release a slot reserved by {@link admit} without recording a success
|
|
254
|
+
* or failure. Use for outcomes that are not an upstream health signal
|
|
255
|
+
* (a caller-initiated abort, an SSRF refusal). Counts and state are
|
|
256
|
+
* left untouched aside from freeing a half-open probe slot.
|
|
257
|
+
*/
|
|
258
|
+
release() {
|
|
259
|
+
if (this.#state === "half-open") {
|
|
260
|
+
this.#halfOpenInFlight = Math.max(0, this.#halfOpenInFlight - 1);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const DEFAULT_RETRYABLE_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
265
|
+
const DEFAULT_RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504];
|
|
266
|
+
const DEFAULT_BREAKER_FAILURE_STATUSES = [500, 502, 503, 504];
|
|
267
|
+
/** Abortable sleep that resolves early (without throwing) if cancelled. */
|
|
268
|
+
function defaultSleep(ms, signal) {
|
|
269
|
+
if (ms <= 0)
|
|
270
|
+
return Promise.resolve();
|
|
271
|
+
return new Promise((resolve) => {
|
|
272
|
+
const timer = setTimeout(() => {
|
|
273
|
+
signal?.removeEventListener("abort", onAbort);
|
|
274
|
+
resolve();
|
|
275
|
+
}, ms);
|
|
276
|
+
const onAbort = () => {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
resolve();
|
|
279
|
+
};
|
|
280
|
+
if (signal) {
|
|
281
|
+
if (signal.aborted) {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
resolve();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
287
|
+
}
|
|
288
|
+
// Do not keep the event loop alive solely for a backoff timer.
|
|
289
|
+
timer.unref?.();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/** Parse a `Retry-After` header (delta-seconds or HTTP-date) to ms. */
|
|
293
|
+
function parseRetryAfter(value, now) {
|
|
294
|
+
if (!value)
|
|
295
|
+
return undefined;
|
|
296
|
+
const trimmed = value.trim();
|
|
297
|
+
if (/^\d+$/.test(trimmed)) {
|
|
298
|
+
return Number(trimmed) * 1000;
|
|
299
|
+
}
|
|
300
|
+
const dateMs = Date.parse(trimmed);
|
|
301
|
+
if (!Number.isNaN(dateMs)) {
|
|
302
|
+
return Math.max(0, dateMs - now);
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Combine a caller-supplied signal with a fresh timeout signal. Returns
|
|
308
|
+
* the merged signal plus a `cleanup` to clear the timer / listeners and
|
|
309
|
+
* a `timedOut` flag so the caller can distinguish our timeout from the
|
|
310
|
+
* caller's own abort.
|
|
311
|
+
*/
|
|
312
|
+
function withTimeout(timeoutMs, external) {
|
|
313
|
+
const controller = new AbortController();
|
|
314
|
+
let timedOut = false;
|
|
315
|
+
let timer;
|
|
316
|
+
const onExternalAbort = () => {
|
|
317
|
+
controller.abort(external.reason);
|
|
318
|
+
};
|
|
319
|
+
if (external) {
|
|
320
|
+
if (external.aborted) {
|
|
321
|
+
controller.abort(external.reason);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
external.addEventListener("abort", onExternalAbort, { once: true });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (timeoutMs > 0 && !controller.signal.aborted) {
|
|
328
|
+
timer = setTimeout(() => {
|
|
329
|
+
timedOut = true;
|
|
330
|
+
controller.abort();
|
|
331
|
+
}, timeoutMs);
|
|
332
|
+
timer.unref?.();
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
signal: controller.signal,
|
|
336
|
+
cleanup: () => {
|
|
337
|
+
if (timer)
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
external?.removeEventListener("abort", onExternalAbort);
|
|
340
|
+
},
|
|
341
|
+
timedOut: () => timedOut,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function isAbortError(err) {
|
|
345
|
+
return err instanceof Error && err.name === "AbortError";
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Wrap a `fetch` with per-call timeout, retry-with-backoff, and a shared
|
|
349
|
+
* circuit breaker. The returned function has the same call signature as
|
|
350
|
+
* the global `fetch`.
|
|
351
|
+
*
|
|
352
|
+
* Layer it over {@link fetchGuard} to keep SSRF protection underneath:
|
|
353
|
+
*
|
|
354
|
+
* ```ts
|
|
355
|
+
* const safeFetch = resilientFetch({ fetch: fetchGuard(), timeoutMs: 2_000 });
|
|
356
|
+
* ```
|
|
357
|
+
*
|
|
358
|
+
* @since 0.37.0
|
|
359
|
+
*/
|
|
360
|
+
export function resilientFetch(options = {}) {
|
|
361
|
+
const baseFetch = options.fetch ?? globalThis.fetch;
|
|
362
|
+
if (typeof baseFetch !== "function") {
|
|
363
|
+
throw new Error("resilientFetch(): no global fetch available; pass options.fetch.");
|
|
364
|
+
}
|
|
365
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
366
|
+
const retries = options.retries ?? 2;
|
|
367
|
+
const retryDelayMs = options.retryDelayMs ?? 100;
|
|
368
|
+
const maxRetryDelayMs = options.maxRetryDelayMs ?? 2_000;
|
|
369
|
+
const backoffFactor = options.backoffFactor ?? 2;
|
|
370
|
+
const jitter = options.jitter ?? true;
|
|
371
|
+
const respectRetryAfter = options.respectRetryAfter ?? true;
|
|
372
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
373
|
+
throw new RangeError("resilientFetch(): timeoutMs must be a non-negative number");
|
|
374
|
+
}
|
|
375
|
+
if (!Number.isInteger(retries) || retries < 0) {
|
|
376
|
+
throw new RangeError("resilientFetch(): retries must be a non-negative integer");
|
|
377
|
+
}
|
|
378
|
+
const retryMethods = new Set((options.retryableMethods ?? DEFAULT_RETRYABLE_METHODS).map((m) => m.toUpperCase()));
|
|
379
|
+
const retryStatuses = new Set(options.retryableStatuses ?? DEFAULT_RETRYABLE_STATUSES);
|
|
380
|
+
const breakerFailureStatuses = new Set(options.circuitBreakerFailureStatuses ?? DEFAULT_BREAKER_FAILURE_STATUSES);
|
|
381
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
382
|
+
let breaker;
|
|
383
|
+
if (options.circuitBreaker !== false) {
|
|
384
|
+
breaker =
|
|
385
|
+
options.circuitBreaker instanceof CircuitBreaker
|
|
386
|
+
? options.circuitBreaker
|
|
387
|
+
: new CircuitBreaker(options.circuitBreaker ?? {});
|
|
388
|
+
}
|
|
389
|
+
function backoffFor(attempt, response) {
|
|
390
|
+
if (respectRetryAfter && response) {
|
|
391
|
+
const fromHeader = parseRetryAfter(response.headers.get("retry-after"), Date.now());
|
|
392
|
+
if (fromHeader !== undefined)
|
|
393
|
+
return Math.min(maxRetryDelayMs, fromHeader);
|
|
394
|
+
}
|
|
395
|
+
const exp = retryDelayMs * backoffFactor ** (attempt - 1);
|
|
396
|
+
const capped = Math.min(maxRetryDelayMs, exp);
|
|
397
|
+
// Backoff jitter is a load-spreading heuristic, not a security primitive;
|
|
398
|
+
// a non-cryptographic PRNG is the correct, conventional choice here.
|
|
399
|
+
return jitter ? Math.random() * capped : capped; // daloy-allow-weak-random: backoff jitter is not a security primitive
|
|
400
|
+
}
|
|
401
|
+
function shouldRetry(ctx) {
|
|
402
|
+
if (options.isRetryable)
|
|
403
|
+
return options.isRetryable(ctx);
|
|
404
|
+
if (!retryMethods.has(ctx.request.method.toUpperCase()))
|
|
405
|
+
return false;
|
|
406
|
+
if (ctx.response)
|
|
407
|
+
return retryStatuses.has(ctx.response.status);
|
|
408
|
+
return true; // network error / timeout on an idempotent method
|
|
409
|
+
}
|
|
410
|
+
const resilient = async (input, init) => {
|
|
411
|
+
// Materialise once so method/headers are stable across retries and
|
|
412
|
+
// the caller's signal can be combined per attempt.
|
|
413
|
+
const request = new Request(input, init);
|
|
414
|
+
const callerSignal = init?.signal ?? request.signal;
|
|
415
|
+
const run = async () => {
|
|
416
|
+
let lastError;
|
|
417
|
+
for (let attempt = 1; attempt <= retries + 1; attempt++) {
|
|
418
|
+
const { signal, cleanup, timedOut } = withTimeout(timeoutMs, callerSignal);
|
|
419
|
+
let response;
|
|
420
|
+
try {
|
|
421
|
+
// Clone the request per attempt so a consumed body can be re-sent.
|
|
422
|
+
response = await baseFetch(request.clone(), { signal });
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
cleanup();
|
|
426
|
+
// Caller cancelled: never retry, never count as upstream failure.
|
|
427
|
+
if (isAbortError(err) && callerSignal?.aborted)
|
|
428
|
+
throw err;
|
|
429
|
+
// An SSRF refusal from an underlying fetchGuard is a hard, terminal
|
|
430
|
+
// decision about the request itself — never retried.
|
|
431
|
+
if (err instanceof Error && err.name === "SsrfBlockedError")
|
|
432
|
+
throw err;
|
|
433
|
+
// Our timeout fired.
|
|
434
|
+
lastError = timedOut() && isAbortError(err) ? new FetchTimeoutError(timeoutMs) : err;
|
|
435
|
+
const ctx = { attempt, request, error: lastError };
|
|
436
|
+
if (attempt <= retries && shouldRetry(ctx)) {
|
|
437
|
+
const delay = backoffFor(attempt);
|
|
438
|
+
options.onRetry?.(ctx, delay);
|
|
439
|
+
await sleep(delay, callerSignal ?? undefined);
|
|
440
|
+
if (callerSignal?.aborted)
|
|
441
|
+
throw lastError;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
throw lastError;
|
|
445
|
+
}
|
|
446
|
+
cleanup();
|
|
447
|
+
const ctx = { attempt, request, response };
|
|
448
|
+
if (attempt <= retries && shouldRetry(ctx)) {
|
|
449
|
+
const delay = backoffFor(attempt, response);
|
|
450
|
+
options.onRetry?.(ctx, delay);
|
|
451
|
+
await sleep(delay, callerSignal ?? undefined);
|
|
452
|
+
if (callerSignal?.aborted)
|
|
453
|
+
return response;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
return response;
|
|
457
|
+
}
|
|
458
|
+
// Unreachable: the loop always returns or throws.
|
|
459
|
+
throw lastError;
|
|
460
|
+
};
|
|
461
|
+
if (!breaker)
|
|
462
|
+
return run();
|
|
463
|
+
// Supervise with the breaker. A retryable-but-exhausted server-error
|
|
464
|
+
// response must count as a failure, so we admit/record manually.
|
|
465
|
+
breaker.admit();
|
|
466
|
+
try {
|
|
467
|
+
const response = await run();
|
|
468
|
+
breaker.recordOutcome(!breakerFailureStatuses.has(response.status));
|
|
469
|
+
return response;
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
// SSRF refusals and caller aborts are not upstream health signals.
|
|
473
|
+
if (err instanceof CircuitOpenError)
|
|
474
|
+
throw err;
|
|
475
|
+
const isCallerAbort = isAbortError(err) && callerSignal?.aborted;
|
|
476
|
+
const isSsrf = err instanceof Error && err.name === "SsrfBlockedError";
|
|
477
|
+
if (isCallerAbort || isSsrf)
|
|
478
|
+
breaker.release();
|
|
479
|
+
else
|
|
480
|
+
breaker.recordOutcome(false);
|
|
481
|
+
throw err;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
return resilient;
|
|
485
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Country-level access control for {@link Hooks}. The {@link geoBlock}
|
|
3
|
+
* middleware enforces ISO 3166-1 alpha-2 country allow- and deny-lists by
|
|
4
|
+
* mapping the client IP to a country **using an operator-supplied lookup** —
|
|
5
|
+
* Daloy bundles no GeoIP database and adds no runtime dependency, keeping the
|
|
6
|
+
* `@daloyjs/core` zero-dependency floor intact.
|
|
7
|
+
*
|
|
8
|
+
* Two resolution strategies are supported, exactly one of which must be wired:
|
|
9
|
+
*
|
|
10
|
+
* - `lookupCountry(ip)` — you own the IP → country mapping (e.g. a MaxMind
|
|
11
|
+
* GeoLite2 reader, an `ip2location` reader, or your own table). Daloy
|
|
12
|
+
* resolves the client IP (reusing the same `X-Forwarded-For` / `X-Real-IP`
|
|
13
|
+
* handling as {@link "./ip-restriction.js".ipRestriction}) and hands you the
|
|
14
|
+
* string.
|
|
15
|
+
* - `resolveCountry(ctx)` — the country is already attached to the request by
|
|
16
|
+
* an upstream edge (e.g. Cloudflare's `CF-IPCountry`, AWS CloudFront's
|
|
17
|
+
* `CloudFront-Viewer-Country`, Vercel's `x-vercel-ip-country`); you read it
|
|
18
|
+
* straight off the context.
|
|
19
|
+
*
|
|
20
|
+
* Like the other network guards this fails **closed for allow-lists** (an
|
|
21
|
+
* unknown country is rejected when an allow-list is configured) and **open for
|
|
22
|
+
* deny-only** configurations, so a missing lookup cannot silently widen access.
|
|
23
|
+
*
|
|
24
|
+
* @module
|
|
25
|
+
* @since 0.37.0
|
|
26
|
+
*/
|
|
27
|
+
import type { BaseContext, Hooks } from "./types.js";
|
|
28
|
+
/**
|
|
29
|
+
* Why a request was (or would have been) blocked by {@link geoBlock}.
|
|
30
|
+
*
|
|
31
|
+
* - `"denied_country"` — the resolved country is on the `deny` list.
|
|
32
|
+
* - `"not_in_allowlist"` — an `allow` list is configured and the resolved
|
|
33
|
+
* country is not on it.
|
|
34
|
+
* - `"unknown_country"` — the country could not be resolved and
|
|
35
|
+
* `allowUnknownCountry` was `false`.
|
|
36
|
+
*
|
|
37
|
+
* @since 0.37.0
|
|
38
|
+
*/
|
|
39
|
+
export type GeoBlockReason = "denied_country" | "not_in_allowlist" | "unknown_country";
|
|
40
|
+
/**
|
|
41
|
+
* The decision {@link geoBlock} reached for a request, passed to `onBlock` and
|
|
42
|
+
* stamped (for allowed requests) on `ctx.state[stateKey]`.
|
|
43
|
+
*
|
|
44
|
+
* @since 0.37.0
|
|
45
|
+
*/
|
|
46
|
+
export interface GeoBlockDecision {
|
|
47
|
+
/** Resolved client IP, when the `lookupCountry` strategy was used. */
|
|
48
|
+
readonly ip?: string;
|
|
49
|
+
/** Resolved ISO 3166-1 alpha-2 country code (upper-cased), if known. */
|
|
50
|
+
readonly country?: string;
|
|
51
|
+
/** Why the request was blocked. */
|
|
52
|
+
readonly reason: GeoBlockReason;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Country code resolved from the client context (e.g. an edge-injected
|
|
56
|
+
* header). Return `undefined`/`null`/`""` when the country is unknown.
|
|
57
|
+
*
|
|
58
|
+
* @since 0.37.0
|
|
59
|
+
*/
|
|
60
|
+
export type CountryFromContext = (ctx: BaseContext<any, any>) => string | undefined | null | Promise<string | undefined | null>;
|
|
61
|
+
/**
|
|
62
|
+
* Operator-supplied IP → country lookup (e.g. a MaxMind reader). Return
|
|
63
|
+
* `undefined`/`null`/`""` when the IP cannot be mapped to a country.
|
|
64
|
+
*
|
|
65
|
+
* @since 0.37.0
|
|
66
|
+
*/
|
|
67
|
+
export type CountryFromIp = (ip: string) => string | undefined | null | Promise<string | undefined | null>;
|
|
68
|
+
/**
|
|
69
|
+
* What to record on `ctx.state[stateKey]` for an allowed request.
|
|
70
|
+
*
|
|
71
|
+
* @since 0.37.0
|
|
72
|
+
*/
|
|
73
|
+
export interface GeoState {
|
|
74
|
+
/** Resolved ISO 3166-1 alpha-2 country code (upper-cased), if known. */
|
|
75
|
+
readonly country?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Options for {@link geoBlock}. At least one of `allow` or `deny` must be a
|
|
79
|
+
* non-empty list, and exactly one of `lookupCountry` or `resolveCountry` must
|
|
80
|
+
* be provided.
|
|
81
|
+
*
|
|
82
|
+
* @since 0.37.0
|
|
83
|
+
*/
|
|
84
|
+
export interface GeoBlockOptions {
|
|
85
|
+
/**
|
|
86
|
+
* ISO 3166-1 alpha-2 country codes (case-insensitive) that are allowed.
|
|
87
|
+
* When non-empty, any request whose resolved country is not on this list is
|
|
88
|
+
* rejected. An unknown country is rejected too unless
|
|
89
|
+
* `allowUnknownCountry` is set.
|
|
90
|
+
*/
|
|
91
|
+
allow?: readonly string[];
|
|
92
|
+
/**
|
|
93
|
+
* ISO 3166-1 alpha-2 country codes (case-insensitive) that are always
|
|
94
|
+
* rejected. A `deny` match wins over an `allow` match (least privilege).
|
|
95
|
+
*/
|
|
96
|
+
deny?: readonly string[];
|
|
97
|
+
/**
|
|
98
|
+
* Operator-supplied IP → country mapping. Mutually exclusive with
|
|
99
|
+
* `resolveCountry`. Daloy resolves the client IP first (see `resolveIp` /
|
|
100
|
+
* `trustProxyHeaders`) and passes it to this function.
|
|
101
|
+
*/
|
|
102
|
+
lookupCountry?: CountryFromIp;
|
|
103
|
+
/**
|
|
104
|
+
* Read the country straight off the request context (e.g. an edge header
|
|
105
|
+
* such as `CF-IPCountry`). Mutually exclusive with `lookupCountry`.
|
|
106
|
+
*/
|
|
107
|
+
resolveCountry?: CountryFromContext;
|
|
108
|
+
/**
|
|
109
|
+
* Override the source of the client IP for the `lookupCountry` strategy. By
|
|
110
|
+
* default Daloy fails closed because Web-standard `Request` objects do not
|
|
111
|
+
* expose the peer address. Ignored when `resolveCountry` is used.
|
|
112
|
+
*/
|
|
113
|
+
resolveIp?: (ctx: BaseContext<any, any>) => string | undefined;
|
|
114
|
+
/**
|
|
115
|
+
* Read `X-Forwarded-For` / `X-Real-IP` in the default IP resolver. Defaults
|
|
116
|
+
* to `false` because those headers are client-spoofable unless every
|
|
117
|
+
* request reaches Daloy through a proxy chain you control. Ignored when
|
|
118
|
+
* `resolveCountry` is used or a custom `resolveIp` is supplied.
|
|
119
|
+
*/
|
|
120
|
+
trustProxyHeaders?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* What to do when the country cannot be resolved. Defaults to `false` when
|
|
123
|
+
* an `allow` list is configured (fail closed — an unknown country is not on
|
|
124
|
+
* the allow-list) and `true` for deny-only configurations (fail open). Set
|
|
125
|
+
* explicitly to override.
|
|
126
|
+
*/
|
|
127
|
+
allowUnknownCountry?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* `"block"` (default) rejects with HTTP `403`; `"log"` lets the request
|
|
130
|
+
* through after invoking `onBlock`, for safe rollout / monitoring.
|
|
131
|
+
*/
|
|
132
|
+
mode?: "block" | "log";
|
|
133
|
+
/**
|
|
134
|
+
* Response message when a request is rejected. Defaults to
|
|
135
|
+
* `"Access from your region is not permitted"`. Avoid echoing the country
|
|
136
|
+
* or IP — doing so can leak topology to attackers.
|
|
137
|
+
*/
|
|
138
|
+
message?: string;
|
|
139
|
+
/**
|
|
140
|
+
* Observability hook invoked for every blocked (or, in `"log"` mode,
|
|
141
|
+
* would-be-blocked) request. Never receives allowed requests.
|
|
142
|
+
*/
|
|
143
|
+
onBlock?: (decision: GeoBlockDecision) => void;
|
|
144
|
+
/**
|
|
145
|
+
* `ctx.state` key under which the resolved {@link GeoState} is stamped for
|
|
146
|
+
* allowed requests. Defaults to `"geo"`.
|
|
147
|
+
*/
|
|
148
|
+
stateKey?: string;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Block or allow requests by client country. Daloy ships no GeoIP database;
|
|
152
|
+
* supply either an IP → country `lookupCountry` (e.g. a MaxMind reader) or a
|
|
153
|
+
* `resolveCountry` that reads an edge-injected country header.
|
|
154
|
+
*
|
|
155
|
+
* @example MaxMind-style IP lookup behind a trusted proxy
|
|
156
|
+
* ```ts
|
|
157
|
+
* import maxmind from "maxmind"; // operator dependency, not a Daloy one
|
|
158
|
+
* const reader = await maxmind.open<{ country?: { iso_code?: string } }>("GeoLite2-Country.mmdb");
|
|
159
|
+
* app.use(geoBlock({
|
|
160
|
+
* deny: ["KP", "IR"],
|
|
161
|
+
* trustProxyHeaders: true,
|
|
162
|
+
* lookupCountry: (ip) => reader.get(ip)?.country?.iso_code,
|
|
163
|
+
* }));
|
|
164
|
+
* ```
|
|
165
|
+
*
|
|
166
|
+
* @example Cloudflare edge header (no IP lookup needed)
|
|
167
|
+
* ```ts
|
|
168
|
+
* app.use(geoBlock({
|
|
169
|
+
* allow: ["US", "CA", "GB"],
|
|
170
|
+
* resolveCountry: (ctx) => ctx.request.headers.get("cf-ipcountry"),
|
|
171
|
+
* }));
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* On reject the middleware throws a {@link ForbiddenError}, which Daloy renders
|
|
175
|
+
* as RFC 9457 `application/problem+json` with `Cache-Control: no-store`.
|
|
176
|
+
*
|
|
177
|
+
* @param opts - Geo-blocking configuration.
|
|
178
|
+
* @returns {@link Hooks} to register via `app.use(...)`.
|
|
179
|
+
* @throws Error when neither `allow` nor `deny` is provided, when both or
|
|
180
|
+
* neither of `lookupCountry` / `resolveCountry` are provided, when a country
|
|
181
|
+
* code is malformed, or when `mode` is invalid.
|
|
182
|
+
* @since 0.37.0
|
|
183
|
+
*/
|
|
184
|
+
export declare function geoBlock(opts: GeoBlockOptions): Hooks;
|