@daloyjs/core 0.36.0 → 0.37.0

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