actly 1.2.0 → 1.3.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 (151) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/MIGRATION.md +141 -0
  3. package/README.md +326 -218
  4. package/SECURITY.md +56 -0
  5. package/dist/core/act.cjs +406 -220
  6. package/dist/core/act.d.ts +6 -84
  7. package/dist/core/act.d.ts.map +1 -1
  8. package/dist/core/act.js +405 -222
  9. package/dist/core/executor.cjs +0 -27
  10. package/dist/core/executor.d.ts +0 -41
  11. package/dist/core/executor.d.ts.map +1 -1
  12. package/dist/core/executor.js +0 -28
  13. package/dist/core/health.cjs +146 -17
  14. package/dist/core/health.d.ts +20 -5
  15. package/dist/core/health.d.ts.map +1 -1
  16. package/dist/core/health.js +140 -18
  17. package/dist/core/shutdown.cjs +19 -8
  18. package/dist/core/shutdown.d.ts +1 -4
  19. package/dist/core/shutdown.d.ts.map +1 -1
  20. package/dist/core/shutdown.js +18 -9
  21. package/dist/core/tenant.cjs +61 -19
  22. package/dist/core/tenant.d.ts +5 -10
  23. package/dist/core/tenant.d.ts.map +1 -1
  24. package/dist/core/tenant.js +61 -20
  25. package/dist/errors.cjs +70 -103
  26. package/dist/errors.d.ts +33 -90
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +66 -103
  29. package/dist/index.cjs +25 -22
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -15
  33. package/dist/observability.cjs +0 -39
  34. package/dist/observability.d.ts +19 -78
  35. package/dist/observability.d.ts.map +1 -1
  36. package/dist/observability.js +0 -40
  37. package/dist/policies/bulkhead.cjs +44 -4
  38. package/dist/policies/bulkhead.d.ts +0 -0
  39. package/dist/policies/bulkhead.d.ts.map +1 -1
  40. package/dist/policies/bulkhead.js +44 -5
  41. package/dist/policies/cache.cjs +22 -142
  42. package/dist/policies/cache.d.ts +0 -46
  43. package/dist/policies/cache.d.ts.map +1 -1
  44. package/dist/policies/cache.js +22 -143
  45. package/dist/policies/circuitBreaker.cjs +125 -31
  46. package/dist/policies/circuitBreaker.d.ts +0 -0
  47. package/dist/policies/circuitBreaker.d.ts.map +1 -1
  48. package/dist/policies/circuitBreaker.js +125 -32
  49. package/dist/policies/dedupe.cjs +19 -66
  50. package/dist/policies/dedupe.d.ts +0 -23
  51. package/dist/policies/dedupe.d.ts.map +1 -1
  52. package/dist/policies/dedupe.js +19 -67
  53. package/dist/policies/noop.cjs +6 -0
  54. package/dist/policies/noop.d.ts +3 -0
  55. package/dist/policies/noop.d.ts.map +1 -0
  56. package/dist/policies/noop.js +3 -0
  57. package/dist/policies/rateLimit.cjs +15 -5
  58. package/dist/policies/rateLimit.d.ts +0 -0
  59. package/dist/policies/rateLimit.d.ts.map +1 -1
  60. package/dist/policies/rateLimit.js +15 -6
  61. package/dist/policies/retry.cjs +100 -65
  62. package/dist/policies/retry.d.ts +0 -27
  63. package/dist/policies/retry.d.ts.map +1 -1
  64. package/dist/policies/retry.js +100 -66
  65. package/dist/policies/timeout.cjs +32 -60
  66. package/dist/policies/timeout.d.ts +0 -13
  67. package/dist/policies/timeout.d.ts.map +1 -1
  68. package/dist/policies/timeout.js +32 -71
  69. package/dist/stores/base.cjs +0 -10
  70. package/dist/stores/base.d.ts +4 -67
  71. package/dist/stores/base.d.ts.map +1 -1
  72. package/dist/stores/base.js +0 -11
  73. package/dist/stores/memory.cjs +75 -88
  74. package/dist/stores/memory.d.ts +7 -74
  75. package/dist/stores/memory.d.ts.map +1 -1
  76. package/dist/stores/memory.js +75 -89
  77. package/dist/testing/index.cjs +56 -0
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.d.ts.map +1 -0
  80. package/dist/testing/index.js +52 -0
  81. package/dist/types/index.cjs +0 -1
  82. package/dist/types/index.d.ts +17 -307
  83. package/dist/types/index.d.ts.map +1 -1
  84. package/dist/types/index.js +0 -2
  85. package/dist/utils/abort.cjs +14 -111
  86. package/dist/utils/abort.d.ts +3 -74
  87. package/dist/utils/abort.d.ts.map +1 -1
  88. package/dist/utils/abort.js +14 -112
  89. package/dist/utils/abortPool.cjs +1 -2
  90. package/dist/utils/abortPool.d.ts +0 -0
  91. package/dist/utils/abortPool.d.ts.map +1 -1
  92. package/dist/utils/abortPool.js +1 -3
  93. package/dist/utils/backoff.cjs +3 -21
  94. package/dist/utils/backoff.d.ts +0 -11
  95. package/dist/utils/backoff.d.ts.map +1 -1
  96. package/dist/utils/backoff.js +3 -22
  97. package/dist/utils/decorator.cjs +44 -0
  98. package/dist/utils/decorator.d.ts +3 -0
  99. package/dist/utils/decorator.d.ts.map +1 -0
  100. package/dist/utils/decorator.js +41 -0
  101. package/dist/utils/key.cjs +13 -45
  102. package/dist/utils/key.d.ts +0 -27
  103. package/dist/utils/key.d.ts.map +1 -1
  104. package/dist/utils/key.js +13 -46
  105. package/dist/utils/limits.cjs +9 -27
  106. package/dist/utils/limits.d.ts +9 -27
  107. package/dist/utils/limits.d.ts.map +1 -1
  108. package/dist/utils/limits.js +9 -28
  109. package/dist/utils/safeCall.cjs +28 -0
  110. package/dist/utils/safeCall.d.ts +2 -0
  111. package/dist/utils/safeCall.d.ts.map +1 -0
  112. package/dist/utils/safeCall.js +25 -0
  113. package/dist/utils/sanitize.cjs +22 -1
  114. package/dist/utils/sanitize.d.ts +0 -0
  115. package/dist/utils/sanitize.d.ts.map +1 -1
  116. package/dist/utils/sanitize.js +22 -2
  117. package/dist/utils/validate.cjs +81 -18
  118. package/dist/utils/validate.d.ts +6 -19
  119. package/dist/utils/validate.d.ts.map +1 -1
  120. package/dist/utils/validate.js +81 -19
  121. package/package.json +38 -4
  122. package/dist/core/act.js.map +0 -1
  123. package/dist/core/executor.js.map +0 -1
  124. package/dist/core/health.js.map +0 -1
  125. package/dist/core/shutdown.js.map +0 -1
  126. package/dist/core/tenant.js.map +0 -1
  127. package/dist/errors.js.map +0 -1
  128. package/dist/index.js.map +0 -1
  129. package/dist/observability.js.map +0 -1
  130. package/dist/policies/bulkhead.js.map +0 -1
  131. package/dist/policies/cache.js.map +0 -1
  132. package/dist/policies/circuitBreaker.js.map +0 -1
  133. package/dist/policies/dedupe.js.map +0 -1
  134. package/dist/policies/rateLimit.js.map +0 -1
  135. package/dist/policies/retry.js.map +0 -1
  136. package/dist/policies/timeout.js.map +0 -1
  137. package/dist/state/store.cjs +0 -14
  138. package/dist/state/store.d.ts +0 -11
  139. package/dist/state/store.d.ts.map +0 -1
  140. package/dist/state/store.js +0 -11
  141. package/dist/state/store.js.map +0 -1
  142. package/dist/stores/base.js.map +0 -1
  143. package/dist/stores/memory.js.map +0 -1
  144. package/dist/types/index.js.map +0 -1
  145. package/dist/utils/abort.js.map +0 -1
  146. package/dist/utils/abortPool.js.map +0 -1
  147. package/dist/utils/backoff.js.map +0 -1
  148. package/dist/utils/key.js.map +0 -1
  149. package/dist/utils/limits.js.map +0 -1
  150. package/dist/utils/sanitize.js.map +0 -1
  151. package/dist/utils/validate.js.map +0 -1
@@ -1,51 +1,19 @@
1
1
  "use strict";
2
- // ─── AbortSignal helpers ──────────────────────────────────────────────────────
3
- //
4
- // Contract: zero listener accumulation on long-lived AbortSignals.
5
- //
6
- // Every `addEventListener('abort', ...)` MUST be paired with a
7
- // `removeEventListener` on the success path, OR use `AbortSignal.any()`
8
- // (Node 20+ native) which handles cleanup automatically.
9
- //
10
- // Long-lived signals (server shutdown, request pool) must not accumulate
11
- // one listener per `act()` call — that would cause
12
- // `MaxListenersExceededWarning` and unbounded closure retention.
13
2
  Object.defineProperty(exports, "__esModule", { value: true });
14
3
  exports.anySignal = anySignal;
15
4
  exports.raceAbort = raceAbort;
16
5
  exports.sleep = sleep;
17
6
  exports.isAbortError = isAbortError;
18
7
  exports.linkSignal = linkSignal;
19
- /**
20
- * Compose multiple AbortSignals into one. Aborts when ANY input aborts,
21
- * with the same reason.
22
- *
23
- * Uses native `AbortSignal.any()` on Node 20+ (zero allocations, no
24
- * listener leaks — the runtime owns the lifecycle). Falls back to a
25
- * polyfill that explicitly removes listeners on first abort.
26
- *
27
- * # Listener safety
28
- *
29
- * The polyfill registers `{ once: true }` listeners on each input and
30
- * manually removes the others when one fires. After settlement, the
31
- * composite signal holds zero references to the inputs — they may be GC'd.
32
- */
8
+ const NATIVE_ANY = AbortSignal.any;
33
9
  function anySignal(signals) {
34
- // Filter out undefined / null defensively.
35
10
  const filtered = signals.filter((s) => s != null);
36
- if (filtered.length === 0) {
37
- // No inputs → never-aborting signal. Use a fresh controller so callers
38
- // get a real AbortSignal, not a hand-rolled mock.
11
+ if (filtered.length === 0)
39
12
  return new AbortController().signal;
40
- }
41
13
  if (filtered.length === 1)
42
14
  return filtered[0];
43
- // Native path (Node 20+, modern browsers, Bun).
44
- const native = AbortSignal.any;
45
- if (typeof native === 'function') {
46
- return native.call(AbortSignal, filtered);
47
- }
48
- // Polyfill: only used when native is missing. Listener-safe.
15
+ if (NATIVE_ANY)
16
+ return NATIVE_ANY.call(AbortSignal, filtered);
49
17
  const controller = new AbortController();
50
18
  const listeners = [];
51
19
  for (const signal of filtered) {
@@ -55,41 +23,20 @@ function anySignal(signals) {
55
23
  }
56
24
  const onAbort = () => {
57
25
  controller.abort(signal.reason);
58
- // Remove all other listeners — they hold closures over `signal`
59
- // and would otherwise keep the inputs alive past settlement.
60
26
  for (const off of listeners)
61
27
  off();
62
28
  };
63
29
  signal.addEventListener('abort', onAbort, { once: true });
64
30
  listeners.push(() => signal.removeEventListener('abort', onAbort));
65
31
  }
32
+ if (controller.signal.aborted) {
33
+ for (const off of listeners)
34
+ off();
35
+ }
66
36
  return controller.signal;
67
37
  }
68
- /**
69
- * Race a promise against an AbortSignal.
70
- *
71
- * - If the signal is already aborted, rejects immediately with `signal.reason`.
72
- * - If the signal aborts while the promise is pending, rejects with `signal.reason`.
73
- * - If the promise settles first, returns its value (or rejects with its error).
74
- *
75
- * # Listener safety
76
- *
77
- * On success path, the abort listener is explicitly removed. A
78
- * `{ once: true }` listener would stay attached on long-lived signals,
79
- * leaking one closure per call.
80
- *
81
- * # Mark-as-handled
82
- *
83
- * When the signal aborts first, the original `promise` may still settle
84
- * later (success or failure). We attach a no-op `.catch` to it so V8
85
- * doesn't emit an `unhandledRejection` warning. The error is NOT
86
- * swallowed from the caller — the caller already received `signal.reason`.
87
- */
88
38
  function raceAbort(promise, signal) {
89
39
  if (signal.aborted) {
90
- // Mark the promise as handled — its eventual rejection won't surface
91
- // as an unhandled rejection. Its eventual success is dropped silently,
92
- // which is correct: the caller has already moved on.
93
40
  promise.catch(() => { });
94
41
  return Promise.reject(signal.reason);
95
42
  }
@@ -102,8 +49,6 @@ function raceAbort(promise, signal) {
102
49
  signal.removeEventListener('abort', onAbort);
103
50
  reject(signal.reason);
104
51
  };
105
- // Not using `{ once: true }` — we want explicit removal on success
106
- // path so the listener doesn't linger on a long-lived signal.
107
52
  signal.addEventListener('abort', onAbort);
108
53
  promise.then((value) => {
109
54
  if (settled)
@@ -120,20 +65,7 @@ function raceAbort(promise, signal) {
120
65
  });
121
66
  });
122
67
  }
123
- /**
124
- * Sleep for `ms` milliseconds, but abort early if `signal` fires.
125
- *
126
- * Resolves normally on timer expiry. Rejects with `signal.reason` if the
127
- * signal aborts before the timer fires. If the signal is already aborted
128
- * when called, rejects synchronously (in microtask).
129
- *
130
- * # Timer hygiene
131
- *
132
- * The internal `setTimeout` is `unref`'d on Node so it doesn't keep the
133
- * event loop alive solely for this sleep. On browsers there is no
134
- * equivalent — the timer is short-lived enough not to matter.
135
- */
136
- function sleep(ms, signal) {
68
+ function sleep(ms, signal, opts) {
137
69
  if (ms <= 0) {
138
70
  if (signal?.aborted)
139
71
  return Promise.reject(signal.reason);
@@ -146,12 +78,9 @@ function sleep(ms, signal) {
146
78
  signal?.removeEventListener('abort', onAbort);
147
79
  resolve();
148
80
  }, ms);
149
- // NOTE: do NOT `unref()` this timer. Unlike InMemoryStore's autoCleanup
150
- // interval (which is a background housekeeping task), `sleep()` IS the
151
- // operation the caller is awaiting. If we unref'd it, Node could exit
152
- // the process while a retry delay was pending — silently dropping the
153
- // operation. The timer is short-lived (cleared in onAbort or after ms),
154
- // so the cost of keeping the loop alive is bounded.
81
+ if (opts?.unref && typeof timer.unref === 'function') {
82
+ timer.unref();
83
+ }
155
84
  const onAbort = () => {
156
85
  clearTimeout(timer);
157
86
  reject(signal.reason);
@@ -159,20 +88,14 @@ function sleep(ms, signal) {
159
88
  signal?.addEventListener('abort', onAbort);
160
89
  });
161
90
  }
162
- /**
163
- * True if `err` is an `AbortError` (DOMException name or Error name).
164
- *
165
- * The default `shouldRetry` predicate uses this to skip retrying on
166
- * cancellations — if the caller aborted, retrying would just abort again.
167
- */
168
91
  function isAbortError(err) {
169
92
  if (err == null || typeof err !== 'object')
170
93
  return false;
94
+ if (err.code === 'ACTLY_ABORT')
95
+ return true;
171
96
  const name = err.name;
172
97
  if (name === 'AbortError')
173
98
  return true;
174
- // DOMException with name 'TimeoutError' is what AbortSignal.timeout throws.
175
- // Distinguish from our own TimeoutError class by checking for DOMException.
176
99
  if (name === 'TimeoutError' &&
177
100
  err instanceof Error &&
178
101
  typeof DOMException !== 'undefined' &&
@@ -181,26 +104,6 @@ function isAbortError(err) {
181
104
  }
182
105
  return false;
183
106
  }
184
- /**
185
- * Link a parent signal to a child controller: when the parent aborts, the
186
- * child is aborted with the same reason.
187
- *
188
- * # Contract
189
- *
190
- * Returns an `unlink()` function that removes the listener. Callers MUST
191
- * call `unlink()` on success path — otherwise the listener stays attached
192
- * to the parent forever, leaking one closure per call.
193
- *
194
- * If the parent is already aborted, the child is aborted synchronously
195
- * and `unlink` is a no-op.
196
- *
197
- * # Prefer `anySignal()` instead
198
- *
199
- * For new code, prefer `anySignal([parent, timeoutSignal])` which uses
200
- * the native Node 20+ implementation and handles cleanup automatically.
201
- * This function exists for call sites that need an `AbortController`
202
- * (not just a signal) — e.g. to layer additional abort sources.
203
- */
204
107
  function linkSignal(parent, child) {
205
108
  if (parent.aborted) {
206
109
  child.abort(parent.reason);
@@ -1,79 +1,8 @@
1
- /**
2
- * Compose multiple AbortSignals into one. Aborts when ANY input aborts,
3
- * with the same reason.
4
- *
5
- * Uses native `AbortSignal.any()` on Node 20+ (zero allocations, no
6
- * listener leaks — the runtime owns the lifecycle). Falls back to a
7
- * polyfill that explicitly removes listeners on first abort.
8
- *
9
- * # Listener safety
10
- *
11
- * The polyfill registers `{ once: true }` listeners on each input and
12
- * manually removes the others when one fires. After settlement, the
13
- * composite signal holds zero references to the inputs — they may be GC'd.
14
- */
15
1
  export declare function anySignal(signals: ReadonlyArray<AbortSignal>): AbortSignal;
16
- /**
17
- * Race a promise against an AbortSignal.
18
- *
19
- * - If the signal is already aborted, rejects immediately with `signal.reason`.
20
- * - If the signal aborts while the promise is pending, rejects with `signal.reason`.
21
- * - If the promise settles first, returns its value (or rejects with its error).
22
- *
23
- * # Listener safety
24
- *
25
- * On success path, the abort listener is explicitly removed. A
26
- * `{ once: true }` listener would stay attached on long-lived signals,
27
- * leaking one closure per call.
28
- *
29
- * # Mark-as-handled
30
- *
31
- * When the signal aborts first, the original `promise` may still settle
32
- * later (success or failure). We attach a no-op `.catch` to it so V8
33
- * doesn't emit an `unhandledRejection` warning. The error is NOT
34
- * swallowed from the caller — the caller already received `signal.reason`.
35
- */
36
2
  export declare function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>;
37
- /**
38
- * Sleep for `ms` milliseconds, but abort early if `signal` fires.
39
- *
40
- * Resolves normally on timer expiry. Rejects with `signal.reason` if the
41
- * signal aborts before the timer fires. If the signal is already aborted
42
- * when called, rejects synchronously (in microtask).
43
- *
44
- * # Timer hygiene
45
- *
46
- * The internal `setTimeout` is `unref`'d on Node so it doesn't keep the
47
- * event loop alive solely for this sleep. On browsers there is no
48
- * equivalent — the timer is short-lived enough not to matter.
49
- */
50
- export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
51
- /**
52
- * True if `err` is an `AbortError` (DOMException name or Error name).
53
- *
54
- * The default `shouldRetry` predicate uses this to skip retrying on
55
- * cancellations — if the caller aborted, retrying would just abort again.
56
- */
3
+ export declare function sleep(ms: number, signal?: AbortSignal, opts?: {
4
+ unref?: boolean;
5
+ }): Promise<void>;
57
6
  export declare function isAbortError(err: unknown): boolean;
58
- /**
59
- * Link a parent signal to a child controller: when the parent aborts, the
60
- * child is aborted with the same reason.
61
- *
62
- * # Contract
63
- *
64
- * Returns an `unlink()` function that removes the listener. Callers MUST
65
- * call `unlink()` on success path — otherwise the listener stays attached
66
- * to the parent forever, leaking one closure per call.
67
- *
68
- * If the parent is already aborted, the child is aborted synchronously
69
- * and `unlink` is a no-op.
70
- *
71
- * # Prefer `anySignal()` instead
72
- *
73
- * For new code, prefer `anySignal([parent, timeoutSignal])` which uses
74
- * the native Node 20+ implementation and handles cleanup automatically.
75
- * This function exists for call sites that need an `AbortController`
76
- * (not just a signal) — e.g. to layer additional abort sources.
77
- */
78
7
  export declare function linkSignal(parent: AbortSignal, child: AbortController): () => void;
79
8
  //# sourceMappingURL=abort.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"abort.d.ts","sourceRoot":"","sources":["../../src/utils/abort.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,aAAa,CAAC,WAAW,CAAC,GAAG,WAAW,CAsC1E;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,SAAS,CAAC,CAAC,EACzB,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,CAAC,CAAC,CAsCZ;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BrE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAelD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,eAAe,GACrB,MAAM,IAAI,CAQZ"}
1
+ {"version":3,"file":"abort.d.ts","sourceRoot":"","sources":["../../src/utils/abort.ts"],"names":[],"mappings":"AAaA,wBAAgB,SAAS,CAAC,OAAO,EAAE,aAAa,CAAC,WAAW,CAAC,GAAG,WAAW,CAgC1E;AAOD,wBAAgB,SAAS,CAAC,CAAC,EACzB,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,CAAC,CAAC,CAmCZ;AAWD,wBAAgB,KAAK,CACnB,EAAE,EAAE,MAAM,EACV,MAAM,CAAC,EAAE,WAAW,EACpB,IAAI,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GACzB,OAAO,CAAC,IAAI,CAAC,CAyBf;AAQD,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAelD;AASD,wBAAgB,UAAU,CACxB,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,eAAe,GACrB,MAAM,IAAI,CAQZ"}
@@ -1,44 +1,12 @@
1
- // ─── AbortSignal helpers ──────────────────────────────────────────────────────
2
- //
3
- // Contract: zero listener accumulation on long-lived AbortSignals.
4
- //
5
- // Every `addEventListener('abort', ...)` MUST be paired with a
6
- // `removeEventListener` on the success path, OR use `AbortSignal.any()`
7
- // (Node 20+ native) which handles cleanup automatically.
8
- //
9
- // Long-lived signals (server shutdown, request pool) must not accumulate
10
- // one listener per `act()` call — that would cause
11
- // `MaxListenersExceededWarning` and unbounded closure retention.
12
- /**
13
- * Compose multiple AbortSignals into one. Aborts when ANY input aborts,
14
- * with the same reason.
15
- *
16
- * Uses native `AbortSignal.any()` on Node 20+ (zero allocations, no
17
- * listener leaks — the runtime owns the lifecycle). Falls back to a
18
- * polyfill that explicitly removes listeners on first abort.
19
- *
20
- * # Listener safety
21
- *
22
- * The polyfill registers `{ once: true }` listeners on each input and
23
- * manually removes the others when one fires. After settlement, the
24
- * composite signal holds zero references to the inputs — they may be GC'd.
25
- */
1
+ const NATIVE_ANY = AbortSignal.any;
26
2
  export function anySignal(signals) {
27
- // Filter out undefined / null defensively.
28
3
  const filtered = signals.filter((s) => s != null);
29
- if (filtered.length === 0) {
30
- // No inputs → never-aborting signal. Use a fresh controller so callers
31
- // get a real AbortSignal, not a hand-rolled mock.
4
+ if (filtered.length === 0)
32
5
  return new AbortController().signal;
33
- }
34
6
  if (filtered.length === 1)
35
7
  return filtered[0];
36
- // Native path (Node 20+, modern browsers, Bun).
37
- const native = AbortSignal.any;
38
- if (typeof native === 'function') {
39
- return native.call(AbortSignal, filtered);
40
- }
41
- // Polyfill: only used when native is missing. Listener-safe.
8
+ if (NATIVE_ANY)
9
+ return NATIVE_ANY.call(AbortSignal, filtered);
42
10
  const controller = new AbortController();
43
11
  const listeners = [];
44
12
  for (const signal of filtered) {
@@ -48,41 +16,20 @@ export function anySignal(signals) {
48
16
  }
49
17
  const onAbort = () => {
50
18
  controller.abort(signal.reason);
51
- // Remove all other listeners — they hold closures over `signal`
52
- // and would otherwise keep the inputs alive past settlement.
53
19
  for (const off of listeners)
54
20
  off();
55
21
  };
56
22
  signal.addEventListener('abort', onAbort, { once: true });
57
23
  listeners.push(() => signal.removeEventListener('abort', onAbort));
58
24
  }
25
+ if (controller.signal.aborted) {
26
+ for (const off of listeners)
27
+ off();
28
+ }
59
29
  return controller.signal;
60
30
  }
61
- /**
62
- * Race a promise against an AbortSignal.
63
- *
64
- * - If the signal is already aborted, rejects immediately with `signal.reason`.
65
- * - If the signal aborts while the promise is pending, rejects with `signal.reason`.
66
- * - If the promise settles first, returns its value (or rejects with its error).
67
- *
68
- * # Listener safety
69
- *
70
- * On success path, the abort listener is explicitly removed. A
71
- * `{ once: true }` listener would stay attached on long-lived signals,
72
- * leaking one closure per call.
73
- *
74
- * # Mark-as-handled
75
- *
76
- * When the signal aborts first, the original `promise` may still settle
77
- * later (success or failure). We attach a no-op `.catch` to it so V8
78
- * doesn't emit an `unhandledRejection` warning. The error is NOT
79
- * swallowed from the caller — the caller already received `signal.reason`.
80
- */
81
31
  export function raceAbort(promise, signal) {
82
32
  if (signal.aborted) {
83
- // Mark the promise as handled — its eventual rejection won't surface
84
- // as an unhandled rejection. Its eventual success is dropped silently,
85
- // which is correct: the caller has already moved on.
86
33
  promise.catch(() => { });
87
34
  return Promise.reject(signal.reason);
88
35
  }
@@ -95,8 +42,6 @@ export function raceAbort(promise, signal) {
95
42
  signal.removeEventListener('abort', onAbort);
96
43
  reject(signal.reason);
97
44
  };
98
- // Not using `{ once: true }` — we want explicit removal on success
99
- // path so the listener doesn't linger on a long-lived signal.
100
45
  signal.addEventListener('abort', onAbort);
101
46
  promise.then((value) => {
102
47
  if (settled)
@@ -113,20 +58,7 @@ export function raceAbort(promise, signal) {
113
58
  });
114
59
  });
115
60
  }
116
- /**
117
- * Sleep for `ms` milliseconds, but abort early if `signal` fires.
118
- *
119
- * Resolves normally on timer expiry. Rejects with `signal.reason` if the
120
- * signal aborts before the timer fires. If the signal is already aborted
121
- * when called, rejects synchronously (in microtask).
122
- *
123
- * # Timer hygiene
124
- *
125
- * The internal `setTimeout` is `unref`'d on Node so it doesn't keep the
126
- * event loop alive solely for this sleep. On browsers there is no
127
- * equivalent — the timer is short-lived enough not to matter.
128
- */
129
- export function sleep(ms, signal) {
61
+ export function sleep(ms, signal, opts) {
130
62
  if (ms <= 0) {
131
63
  if (signal?.aborted)
132
64
  return Promise.reject(signal.reason);
@@ -139,12 +71,9 @@ export function sleep(ms, signal) {
139
71
  signal?.removeEventListener('abort', onAbort);
140
72
  resolve();
141
73
  }, ms);
142
- // NOTE: do NOT `unref()` this timer. Unlike InMemoryStore's autoCleanup
143
- // interval (which is a background housekeeping task), `sleep()` IS the
144
- // operation the caller is awaiting. If we unref'd it, Node could exit
145
- // the process while a retry delay was pending — silently dropping the
146
- // operation. The timer is short-lived (cleared in onAbort or after ms),
147
- // so the cost of keeping the loop alive is bounded.
74
+ if (opts?.unref && typeof timer.unref === 'function') {
75
+ timer.unref();
76
+ }
148
77
  const onAbort = () => {
149
78
  clearTimeout(timer);
150
79
  reject(signal.reason);
@@ -152,20 +81,14 @@ export function sleep(ms, signal) {
152
81
  signal?.addEventListener('abort', onAbort);
153
82
  });
154
83
  }
155
- /**
156
- * True if `err` is an `AbortError` (DOMException name or Error name).
157
- *
158
- * The default `shouldRetry` predicate uses this to skip retrying on
159
- * cancellations — if the caller aborted, retrying would just abort again.
160
- */
161
84
  export function isAbortError(err) {
162
85
  if (err == null || typeof err !== 'object')
163
86
  return false;
87
+ if (err.code === 'ACTLY_ABORT')
88
+ return true;
164
89
  const name = err.name;
165
90
  if (name === 'AbortError')
166
91
  return true;
167
- // DOMException with name 'TimeoutError' is what AbortSignal.timeout throws.
168
- // Distinguish from our own TimeoutError class by checking for DOMException.
169
92
  if (name === 'TimeoutError' &&
170
93
  err instanceof Error &&
171
94
  typeof DOMException !== 'undefined' &&
@@ -174,26 +97,6 @@ export function isAbortError(err) {
174
97
  }
175
98
  return false;
176
99
  }
177
- /**
178
- * Link a parent signal to a child controller: when the parent aborts, the
179
- * child is aborted with the same reason.
180
- *
181
- * # Contract
182
- *
183
- * Returns an `unlink()` function that removes the listener. Callers MUST
184
- * call `unlink()` on success path — otherwise the listener stays attached
185
- * to the parent forever, leaking one closure per call.
186
- *
187
- * If the parent is already aborted, the child is aborted synchronously
188
- * and `unlink` is a no-op.
189
- *
190
- * # Prefer `anySignal()` instead
191
- *
192
- * For new code, prefer `anySignal([parent, timeoutSignal])` which uses
193
- * the native Node 20+ implementation and handles cleanup automatically.
194
- * This function exists for call sites that need an `AbortController`
195
- * (not just a signal) — e.g. to layer additional abort sources.
196
- */
197
100
  export function linkSignal(parent, child) {
198
101
  if (parent.aborted) {
199
102
  child.abort(parent.reason);
@@ -203,4 +106,3 @@ export function linkSignal(parent, child) {
203
106
  parent.addEventListener('abort', onAbort);
204
107
  return () => parent.removeEventListener('abort', onAbort);
205
108
  }
206
- //# sourceMappingURL=abort.js.map
@@ -8,9 +8,8 @@ const MAX_POOL_SIZE = 64;
8
8
  function acquireController() {
9
9
  while (pool.length > 0) {
10
10
  const c = pool.pop();
11
- if (!c.signal.aborted) {
11
+ if (!c.signal.aborted)
12
12
  return c;
13
- }
14
13
  }
15
14
  return new AbortController();
16
15
  }
File without changes
@@ -1 +1 @@
1
- {"version":3,"file":"abortPool.d.ts","sourceRoot":"","sources":["../../src/utils/abortPool.ts"],"names":[],"mappings":"AAGA,wBAAgB,iBAAiB,IAAI,eAAe,CAQnD;AAED,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,eAAe,GAAG,IAAI,CAI1D;AAED,wBAAgB,QAAQ,IAAI,MAAM,CAEjC"}
1
+ {"version":3,"file":"abortPool.d.ts","sourceRoot":"","sources":["../../src/utils/abortPool.ts"],"names":[],"mappings":"AAMA,wBAAgB,iBAAiB,IAAI,eAAe,CAOnD;AAED,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,eAAe,GAAG,IAAI,CAK1D;AAED,wBAAgB,QAAQ,IAAI,MAAM,CAEjC"}
@@ -3,9 +3,8 @@ const MAX_POOL_SIZE = 64;
3
3
  export function acquireController() {
4
4
  while (pool.length > 0) {
5
5
  const c = pool.pop();
6
- if (!c.signal.aborted) {
6
+ if (!c.signal.aborted)
7
7
  return c;
8
- }
9
8
  }
10
9
  return new AbortController();
11
10
  }
@@ -19,4 +18,3 @@ export function releaseController(c) {
19
18
  export function poolSize() {
20
19
  return pool.length;
21
20
  }
22
- //# sourceMappingURL=abortPool.js.map
@@ -1,22 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.computeDelay = computeDelay;
4
- /**
5
- * Compute the delay before the next retry attempt, applying backoff, cap, and
6
- * jitter in that order.
7
- *
8
- * Order matters:
9
- * 1. `backoff` grows the base delay geometrically/linearly.
10
- * 2. `maxDelay` caps the result (prevents exponential blowup).
11
- * 3. `jitter` randomises within `[0, delay]` (prevents thundering herd).
12
- *
13
- * Returns 0 if `delayMs` is 0 or undefined — skipping the sleep entirely.
14
- */
15
4
  function computeDelay(attempt, opts) {
16
5
  const base = opts.delayMs ?? 0;
17
6
  if (base === 0)
18
7
  return 0;
19
- // Step 1: backoff
20
8
  let delay;
21
9
  switch (opts.backoff ?? 'none') {
22
10
  case 'linear':
@@ -27,24 +15,18 @@ function computeDelay(attempt, opts) {
27
15
  break;
28
16
  default: delay = base;
29
17
  }
30
- // Step 2: cap (guard against Infinity and NaN before Math.min)
31
18
  const max = opts.maxDelay ?? Number.POSITIVE_INFINITY;
32
19
  if (!Number.isFinite(delay))
33
20
  delay = max;
34
21
  delay = Math.min(delay, max);
35
- // Step 3: jitter
36
- // All jitter variants produce a delay in [0, delay].
37
- // The `decorrelated` formula `base + random() * (delay - base)` assumes
38
- // delay >= base, which can be violated when maxDelay caps below base.
39
- // Fix: clamp the final result to [0, delay] to guarantee the cap holds.
40
22
  switch (opts.jitter ?? 'full') {
41
23
  case 'none': return delay;
42
24
  case 'full': return Math.random() * delay;
43
25
  case 'equal': return delay / 2 + Math.random() * delay / 2;
44
26
  case 'decorrelated': {
45
- // If delay < base (maxDelay capped below base), decorrelated degrades
46
- // to full jitter — the formula would otherwise produce values > delay.
47
- const lo = Math.min(base, delay);
27
+ if (delay < base)
28
+ return Math.random() * delay;
29
+ const lo = base;
48
30
  const hi = delay;
49
31
  const result = lo + Math.random() * (hi - lo);
50
32
  return Math.max(0, Math.min(result, delay));
@@ -1,14 +1,3 @@
1
1
  import type { RetryOptions } from '../types/index.js';
2
- /**
3
- * Compute the delay before the next retry attempt, applying backoff, cap, and
4
- * jitter in that order.
5
- *
6
- * Order matters:
7
- * 1. `backoff` grows the base delay geometrically/linearly.
8
- * 2. `maxDelay` caps the result (prevents exponential blowup).
9
- * 3. `jitter` randomises within `[0, delay]` (prevents thundering herd).
10
- *
11
- * Returns 0 if `delayMs` is 0 or undefined — skipping the sleep entirely.
12
- */
13
2
  export declare function computeDelay(attempt: number, opts: RetryOptions): number;
14
3
  //# sourceMappingURL=backoff.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"backoff.d.ts","sourceRoot":"","sources":["../../src/utils/backoff.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG,MAAM,CAoCxE"}
1
+ {"version":3,"file":"backoff.d.ts","sourceRoot":"","sources":["../../src/utils/backoff.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AASrD,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG,MAAM,CAgCxE"}
@@ -1,19 +1,7 @@
1
- /**
2
- * Compute the delay before the next retry attempt, applying backoff, cap, and
3
- * jitter in that order.
4
- *
5
- * Order matters:
6
- * 1. `backoff` grows the base delay geometrically/linearly.
7
- * 2. `maxDelay` caps the result (prevents exponential blowup).
8
- * 3. `jitter` randomises within `[0, delay]` (prevents thundering herd).
9
- *
10
- * Returns 0 if `delayMs` is 0 or undefined — skipping the sleep entirely.
11
- */
12
1
  export function computeDelay(attempt, opts) {
13
2
  const base = opts.delayMs ?? 0;
14
3
  if (base === 0)
15
4
  return 0;
16
- // Step 1: backoff
17
5
  let delay;
18
6
  switch (opts.backoff ?? 'none') {
19
7
  case 'linear':
@@ -24,24 +12,18 @@ export function computeDelay(attempt, opts) {
24
12
  break;
25
13
  default: delay = base;
26
14
  }
27
- // Step 2: cap (guard against Infinity and NaN before Math.min)
28
15
  const max = opts.maxDelay ?? Number.POSITIVE_INFINITY;
29
16
  if (!Number.isFinite(delay))
30
17
  delay = max;
31
18
  delay = Math.min(delay, max);
32
- // Step 3: jitter
33
- // All jitter variants produce a delay in [0, delay].
34
- // The `decorrelated` formula `base + random() * (delay - base)` assumes
35
- // delay >= base, which can be violated when maxDelay caps below base.
36
- // Fix: clamp the final result to [0, delay] to guarantee the cap holds.
37
19
  switch (opts.jitter ?? 'full') {
38
20
  case 'none': return delay;
39
21
  case 'full': return Math.random() * delay;
40
22
  case 'equal': return delay / 2 + Math.random() * delay / 2;
41
23
  case 'decorrelated': {
42
- // If delay < base (maxDelay capped below base), decorrelated degrades
43
- // to full jitter — the formula would otherwise produce values > delay.
44
- const lo = Math.min(base, delay);
24
+ if (delay < base)
25
+ return Math.random() * delay;
26
+ const lo = base;
45
27
  const hi = delay;
46
28
  const result = lo + Math.random() * (hi - lo);
47
29
  return Math.max(0, Math.min(result, delay));
@@ -49,4 +31,3 @@ export function computeDelay(attempt, opts) {
49
31
  default: return delay;
50
32
  }
51
33
  }
52
- //# sourceMappingURL=backoff.js.map