@foundatiofx/fetchclient 1.0.1 → 1.1.1

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 (106) hide show
  1. package/esm/mod.js +3 -1
  2. package/esm/src/CircuitBreaker.js +356 -0
  3. package/esm/src/CircuitBreakerMiddleware.js +167 -0
  4. package/esm/src/DefaultHelpers.js +23 -0
  5. package/esm/src/FetchClient.js +1 -1
  6. package/esm/src/FetchClientCache.js +85 -8
  7. package/esm/src/FetchClientProvider.js +58 -3
  8. package/esm/src/mocks/MockHistory.js +63 -0
  9. package/esm/src/mocks/MockRegistry.js +267 -0
  10. package/esm/src/mocks/MockResponseBuilder.js +88 -0
  11. package/esm/src/mocks/mod.js +24 -0
  12. package/esm/src/mocks/types.js +1 -0
  13. package/package.json +12 -2
  14. package/readme.md +117 -7
  15. package/script/mod.js +9 -1
  16. package/script/src/CircuitBreaker.js +361 -0
  17. package/script/src/CircuitBreakerMiddleware.js +174 -0
  18. package/script/src/DefaultHelpers.js +26 -0
  19. package/script/src/FetchClient.js +1 -1
  20. package/script/src/FetchClientCache.js +85 -8
  21. package/script/src/FetchClientProvider.js +58 -3
  22. package/script/src/mocks/MockHistory.js +67 -0
  23. package/script/src/mocks/MockRegistry.js +271 -0
  24. package/script/src/mocks/MockResponseBuilder.js +92 -0
  25. package/script/src/mocks/mod.js +29 -0
  26. package/script/src/mocks/types.js +2 -0
  27. package/types/deps/jsr.io/@std/assert/1.0.18/almost_equals.d.ts.map +1 -0
  28. package/types/deps/jsr.io/@std/assert/1.0.18/array_includes.d.ts.map +1 -0
  29. package/types/deps/jsr.io/@std/assert/1.0.18/assert.d.ts.map +1 -0
  30. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/assertion_error.d.ts.map +1 -1
  31. package/types/deps/jsr.io/@std/assert/1.0.18/equal.d.ts.map +1 -0
  32. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/equals.d.ts.map +1 -1
  33. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/exists.d.ts.map +1 -1
  34. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/fail.d.ts.map +1 -1
  35. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/false.d.ts.map +1 -1
  36. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/greater.d.ts.map +1 -1
  37. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/greater_or_equal.d.ts.map +1 -1
  38. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/instance_of.d.ts.map +1 -1
  39. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/is_error.d.ts.map +1 -1
  40. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/less.d.ts.map +1 -1
  41. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/less_or_equal.d.ts.map +1 -1
  42. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/match.d.ts.map +1 -1
  43. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/mod.d.ts.map +1 -1
  44. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_equals.d.ts.map +1 -1
  45. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_instance_of.d.ts.map +1 -1
  46. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_match.d.ts.map +1 -1
  47. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/not_strict_equals.d.ts.map +1 -1
  48. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/object_match.d.ts.map +1 -1
  49. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/rejects.d.ts.map +1 -1
  50. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/strict_equals.d.ts.map +1 -1
  51. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/string_includes.d.ts.map +1 -1
  52. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/throws.d.ts.map +1 -1
  53. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/unimplemented.d.ts.map +1 -1
  54. package/types/deps/jsr.io/@std/assert/{1.0.14 → 1.0.18}/unreachable.d.ts.map +1 -1
  55. package/types/deps/jsr.io/@std/internal/1.0.12/build_message.d.ts.map +1 -0
  56. package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/diff.d.ts.map +1 -1
  57. package/types/deps/jsr.io/@std/internal/1.0.12/diff_str.d.ts.map +1 -0
  58. package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/format.d.ts.map +1 -1
  59. package/types/deps/jsr.io/@std/internal/{1.0.10 → 1.0.12}/styles.d.ts.map +1 -1
  60. package/types/deps/jsr.io/@std/internal/1.0.12/types.d.ts.map +1 -0
  61. package/types/mod.d.ts +3 -1
  62. package/types/mod.d.ts.map +1 -1
  63. package/types/src/CircuitBreaker.d.ts +154 -0
  64. package/types/src/CircuitBreaker.d.ts.map +1 -0
  65. package/types/src/CircuitBreakerMiddleware.d.ts +93 -0
  66. package/types/src/CircuitBreakerMiddleware.d.ts.map +1 -0
  67. package/types/src/DefaultHelpers.d.ts +19 -0
  68. package/types/src/DefaultHelpers.d.ts.map +1 -1
  69. package/types/src/FetchClient.d.ts.map +1 -1
  70. package/types/src/FetchClientCache.d.ts +26 -1
  71. package/types/src/FetchClientCache.d.ts.map +1 -1
  72. package/types/src/FetchClientProvider.d.ts +24 -0
  73. package/types/src/FetchClientProvider.d.ts.map +1 -1
  74. package/types/src/RequestOptions.d.ts +6 -1
  75. package/types/src/RequestOptions.d.ts.map +1 -1
  76. package/types/src/mocks/MockHistory.d.ts +22 -0
  77. package/types/src/mocks/MockHistory.d.ts.map +1 -0
  78. package/types/src/mocks/MockRegistry.d.ts +113 -0
  79. package/types/src/mocks/MockRegistry.d.ts.map +1 -0
  80. package/types/src/mocks/MockResponseBuilder.d.ts +60 -0
  81. package/types/src/mocks/MockResponseBuilder.d.ts.map +1 -0
  82. package/types/src/mocks/mod.d.ts +26 -0
  83. package/types/src/mocks/mod.d.ts.map +1 -0
  84. package/types/src/mocks/types.d.ts +47 -0
  85. package/types/src/mocks/types.d.ts.map +1 -0
  86. package/types/src/tests/Caching.test.d.ts.map +1 -0
  87. package/types/src/tests/CircuitBreaker.test.d.ts.map +1 -0
  88. package/types/src/tests/ErrorHandling.test.d.ts.map +1 -0
  89. package/types/src/tests/HttpMethods.test.d.ts.map +1 -0
  90. package/types/src/tests/Integration.test.d.ts.map +1 -0
  91. package/types/src/tests/JsonParsing.test.d.ts.map +1 -0
  92. package/types/src/tests/Middleware.test.d.ts.map +1 -0
  93. package/types/src/tests/MockRegistry.test.d.ts.map +1 -0
  94. package/types/src/tests/Provider.test.d.ts.map +1 -0
  95. package/types/src/tests/RateLimit.test.d.ts.map +1 -0
  96. package/types/src/tests/TimeoutAbort.test.d.ts.map +1 -0
  97. package/types/src/tests/UrlBuilding.test.d.ts.map +1 -0
  98. package/types/deps/jsr.io/@std/assert/1.0.14/almost_equals.d.ts.map +0 -1
  99. package/types/deps/jsr.io/@std/assert/1.0.14/array_includes.d.ts.map +0 -1
  100. package/types/deps/jsr.io/@std/assert/1.0.14/assert.d.ts.map +0 -1
  101. package/types/deps/jsr.io/@std/assert/1.0.14/equal.d.ts.map +0 -1
  102. package/types/deps/jsr.io/@std/internal/1.0.10/build_message.d.ts.map +0 -1
  103. package/types/deps/jsr.io/@std/internal/1.0.10/diff_str.d.ts.map +0 -1
  104. package/types/deps/jsr.io/@std/internal/1.0.10/types.d.ts.map +0 -1
  105. package/types/src/FetchClient.test.d.ts.map +0 -1
  106. package/types/src/RateLimit.test.d.ts.map +0 -1
@@ -3,19 +3,36 @@
3
3
  */
4
4
  export class FetchClientCache {
5
5
  cache = new Map();
6
+ tagIndex = new Map();
6
7
  /**
7
8
  * Sets a response in the cache with the specified key.
8
9
  * @param key - The cache key.
9
10
  * @param response - The response to be cached.
10
11
  * @param cacheDuration - The duration for which the response should be cached (in milliseconds).
12
+ * @param tags - Optional tags for grouping and invalidating cache entries.
11
13
  */
12
- set(key, response, cacheDuration) {
13
- this.cache.set(this.getHash(key), {
14
+ set(key, response, cacheDuration, tags) {
15
+ const hash = this.getHash(key);
16
+ const normalizedTags = tags ?? [];
17
+ // Remove old tag associations if entry exists
18
+ const existingEntry = this.cache.get(hash);
19
+ if (existingEntry) {
20
+ this.removeTagAssociations(hash, existingEntry.tags);
21
+ }
22
+ this.cache.set(hash, {
14
23
  key,
24
+ tags: normalizedTags,
15
25
  lastAccess: new Date(),
16
26
  expires: new Date(Date.now() + (cacheDuration ?? 60000)),
17
27
  response,
18
28
  });
29
+ // Add new tag associations
30
+ for (const tag of normalizedTags) {
31
+ if (!this.tagIndex.has(tag)) {
32
+ this.tagIndex.set(tag, new Set());
33
+ }
34
+ this.tagIndex.get(tag).add(hash);
35
+ }
19
36
  }
20
37
  /**
21
38
  * Retrieves a response from the cache with the specified key.
@@ -23,12 +40,14 @@ export class FetchClientCache {
23
40
  * @returns The cached response, or null if the response is not found or has expired.
24
41
  */
25
42
  get(key) {
26
- const cacheEntry = this.cache.get(this.getHash(key));
43
+ const hash = this.getHash(key);
44
+ const cacheEntry = this.cache.get(hash);
27
45
  if (!cacheEntry) {
28
46
  return null;
29
47
  }
30
48
  if (cacheEntry.expires < new Date()) {
31
- this.cache.delete(this.getHash(key));
49
+ this.removeTagAssociations(hash, cacheEntry.tags);
50
+ this.cache.delete(hash);
32
51
  return null;
33
52
  }
34
53
  cacheEntry.lastAccess = new Date();
@@ -40,7 +59,12 @@ export class FetchClientCache {
40
59
  * @returns True if the response was successfully deleted, false otherwise.
41
60
  */
42
61
  delete(key) {
43
- return this.cache.delete(this.getHash(key));
62
+ const hash = this.getHash(key);
63
+ const entry = this.cache.get(hash);
64
+ if (entry) {
65
+ this.removeTagAssociations(hash, entry.tags);
66
+ }
67
+ return this.cache.delete(hash);
44
68
  }
45
69
  /**
46
70
  * Deletes all responses from the cache that have keys beginning with the specified key.
@@ -49,15 +73,56 @@ export class FetchClientCache {
49
73
  */
50
74
  deleteAll(prefix) {
51
75
  let count = 0;
52
- for (const key of this.cache.keys()) {
53
- if (key.startsWith(this.getHash(prefix))) {
54
- if (this.cache.delete(key)) {
76
+ const prefixHash = this.getHash(prefix);
77
+ for (const [hash, entry] of this.cache.entries()) {
78
+ if (hash.startsWith(prefixHash)) {
79
+ this.removeTagAssociations(hash, entry.tags);
80
+ if (this.cache.delete(hash)) {
81
+ count++;
82
+ }
83
+ }
84
+ }
85
+ return count;
86
+ }
87
+ /**
88
+ * Deletes all responses from the cache that have the specified tag.
89
+ * @param tag - The cache tag.
90
+ * @returns The number of responses that were deleted.
91
+ */
92
+ deleteByTag(tag) {
93
+ const hashes = this.tagIndex.get(tag);
94
+ if (!hashes) {
95
+ return 0;
96
+ }
97
+ let count = 0;
98
+ for (const hash of hashes) {
99
+ const entry = this.cache.get(hash);
100
+ if (entry) {
101
+ // Remove this entry's associations from all its tags
102
+ this.removeTagAssociations(hash, entry.tags);
103
+ if (this.cache.delete(hash)) {
55
104
  count++;
56
105
  }
57
106
  }
58
107
  }
59
108
  return count;
60
109
  }
110
+ /**
111
+ * Gets all tags currently in use in the cache.
112
+ * @returns An array of all cache tags.
113
+ */
114
+ getTags() {
115
+ return Array.from(this.tagIndex.keys()).filter((tag) => this.tagIndex.get(tag).size > 0);
116
+ }
117
+ /**
118
+ * Gets the tags associated with a cache entry.
119
+ * @param key - The cache key.
120
+ * @returns The tags associated with the entry, or an empty array if not found.
121
+ */
122
+ getEntryTags(key) {
123
+ const entry = this.cache.get(this.getHash(key));
124
+ return entry?.tags ?? [];
125
+ }
61
126
  /**
62
127
  * Checks if a response exists in the cache with the specified key.
63
128
  * @param key - The cache key.
@@ -78,6 +143,7 @@ export class FetchClientCache {
78
143
  */
79
144
  clear() {
80
145
  this.cache.clear();
146
+ this.tagIndex.clear();
81
147
  }
82
148
  getHash(key) {
83
149
  if (key instanceof Array) {
@@ -85,4 +151,15 @@ export class FetchClientCache {
85
151
  }
86
152
  return key;
87
153
  }
154
+ removeTagAssociations(hash, tags) {
155
+ for (const tag of tags) {
156
+ const hashes = this.tagIndex.get(tag);
157
+ if (hashes) {
158
+ hashes.delete(hash);
159
+ if (hashes.size === 0) {
160
+ this.tagIndex.delete(tag);
161
+ }
162
+ }
163
+ }
164
+ }
88
165
  }
@@ -4,6 +4,8 @@ import { FetchClientCache } from "./FetchClientCache.js";
4
4
  import { ObjectEvent } from "./ObjectEvent.js";
5
5
  import { RateLimitMiddleware, } from "./RateLimitMiddleware.js";
6
6
  import { groupByDomain } from "./RateLimiter.js";
7
+ import { CircuitBreakerMiddleware, } from "./CircuitBreakerMiddleware.js";
8
+ import { groupByDomain as circuitBreakerGroupByDomain, } from "./CircuitBreaker.js";
7
9
  /**
8
10
  * Represents a provider for creating instances of the FetchClient class with shared default options and cache.
9
11
  */
@@ -12,6 +14,9 @@ export class FetchClientProvider {
12
14
  #fetch;
13
15
  #cache;
14
16
  #rateLimitMiddleware;
17
+ #rateLimitMiddlewareFunc;
18
+ #circuitBreakerMiddleware;
19
+ #circuitBreakerMiddlewareFunc;
15
20
  #counter = new Counter();
16
21
  #onLoading = new ObjectEvent();
17
22
  /**
@@ -168,7 +173,8 @@ export class FetchClientProvider {
168
173
  */
169
174
  useRateLimit(options) {
170
175
  this.#rateLimitMiddleware = new RateLimitMiddleware(options);
171
- this.useMiddleware(this.#rateLimitMiddleware.middleware());
176
+ this.#rateLimitMiddlewareFunc = this.#rateLimitMiddleware.middleware();
177
+ this.useMiddleware(this.#rateLimitMiddlewareFunc);
172
178
  }
173
179
  /**
174
180
  * Enables rate limiting for all FetchClient instances created by this provider.
@@ -179,7 +185,8 @@ export class FetchClientProvider {
179
185
  ...options,
180
186
  getGroupFunc: groupByDomain,
181
187
  });
182
- this.useMiddleware(this.#rateLimitMiddleware.middleware());
188
+ this.#rateLimitMiddlewareFunc = this.#rateLimitMiddleware.middleware();
189
+ this.useMiddleware(this.#rateLimitMiddlewareFunc);
183
190
  }
184
191
  /**
185
192
  * Gets the rate limiter instance used for rate limiting.
@@ -192,8 +199,56 @@ export class FetchClientProvider {
192
199
  * Removes the rate limiting middleware from all FetchClient instances created by this provider.
193
200
  */
194
201
  removeRateLimit() {
202
+ const middlewareFunc = this.#rateLimitMiddlewareFunc;
195
203
  this.#rateLimitMiddleware = undefined;
196
- this.#options.middleware = this.#options.middleware?.filter((m) => !(m instanceof RateLimitMiddleware));
204
+ this.#rateLimitMiddlewareFunc = undefined;
205
+ if (middlewareFunc) {
206
+ this.#options.middleware = this.#options.middleware?.filter((m) => m !== middlewareFunc);
207
+ }
208
+ }
209
+ /**
210
+ * Enables circuit breaker for all FetchClient instances created by this provider.
211
+ * The circuit breaker monitors failures and blocks requests when a service is failing,
212
+ * allowing time for recovery.
213
+ * @param options - The circuit breaker configuration options.
214
+ */
215
+ useCircuitBreaker(options) {
216
+ this.#circuitBreakerMiddleware = new CircuitBreakerMiddleware(options);
217
+ this.#circuitBreakerMiddlewareFunc = this.#circuitBreakerMiddleware
218
+ .middleware();
219
+ this.useMiddleware(this.#circuitBreakerMiddlewareFunc);
220
+ }
221
+ /**
222
+ * Enables per-domain circuit breaker for all FetchClient instances created by this provider.
223
+ * Each domain gets its own circuit breaker, so failures on one domain don't affect others.
224
+ * @param options - The circuit breaker configuration options.
225
+ */
226
+ usePerDomainCircuitBreaker(options) {
227
+ this.#circuitBreakerMiddleware = new CircuitBreakerMiddleware({
228
+ ...options,
229
+ getGroupFunc: circuitBreakerGroupByDomain,
230
+ });
231
+ this.#circuitBreakerMiddlewareFunc = this.#circuitBreakerMiddleware
232
+ .middleware();
233
+ this.useMiddleware(this.#circuitBreakerMiddlewareFunc);
234
+ }
235
+ /**
236
+ * Gets the circuit breaker instance.
237
+ * @returns The circuit breaker instance, or undefined if not enabled.
238
+ */
239
+ get circuitBreaker() {
240
+ return this.#circuitBreakerMiddleware?.circuitBreaker;
241
+ }
242
+ /**
243
+ * Removes the circuit breaker middleware from all FetchClient instances created by this provider.
244
+ */
245
+ removeCircuitBreaker() {
246
+ const middlewareFunc = this.#circuitBreakerMiddlewareFunc;
247
+ this.#circuitBreakerMiddleware = undefined;
248
+ this.#circuitBreakerMiddlewareFunc = undefined;
249
+ if (middlewareFunc) {
250
+ this.#options.middleware = this.#options.middleware?.filter((m) => m !== middlewareFunc);
251
+ }
197
252
  }
198
253
  }
199
254
  const provider = new FetchClientProvider();
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Implementation of MockHistory that tracks recorded requests.
3
+ */
4
+ export class MockHistoryImpl {
5
+ #get = [];
6
+ #post = [];
7
+ #put = [];
8
+ #patch = [];
9
+ #delete = [];
10
+ #all = [];
11
+ get get() {
12
+ return [...this.#get];
13
+ }
14
+ get post() {
15
+ return [...this.#post];
16
+ }
17
+ get put() {
18
+ return [...this.#put];
19
+ }
20
+ get patch() {
21
+ return [...this.#patch];
22
+ }
23
+ get delete() {
24
+ return [...this.#delete];
25
+ }
26
+ get all() {
27
+ return [...this.#all];
28
+ }
29
+ /**
30
+ * Records a request in the history.
31
+ */
32
+ record(request) {
33
+ this.#all.push(request);
34
+ switch (request.method.toUpperCase()) {
35
+ case "GET":
36
+ this.#get.push(request);
37
+ break;
38
+ case "POST":
39
+ this.#post.push(request);
40
+ break;
41
+ case "PUT":
42
+ this.#put.push(request);
43
+ break;
44
+ case "PATCH":
45
+ this.#patch.push(request);
46
+ break;
47
+ case "DELETE":
48
+ this.#delete.push(request);
49
+ break;
50
+ }
51
+ }
52
+ /**
53
+ * Clears all recorded history.
54
+ */
55
+ clear() {
56
+ this.#get = [];
57
+ this.#post = [];
58
+ this.#put = [];
59
+ this.#patch = [];
60
+ this.#delete = [];
61
+ this.#all = [];
62
+ }
63
+ }
@@ -0,0 +1,267 @@
1
+ import { MockHistoryImpl } from "./MockHistory.js";
2
+ import { MockResponseBuilder } from "./MockResponseBuilder.js";
3
+ /**
4
+ * A registry for defining mock responses that can be installed on a
5
+ * FetchClient or FetchClientProvider, or used as a standalone fetch replacement.
6
+ *
7
+ * @example Install on FetchClientProvider
8
+ * ```typescript
9
+ * const mocks = new MockRegistry();
10
+ * mocks.onGet('/api/users').reply(200, [{ id: 1 }]);
11
+ *
12
+ * const provider = new FetchClientProvider();
13
+ * mocks.install(provider);
14
+ *
15
+ * const client = provider.getFetchClient();
16
+ * const response = await client.getJSON('/api/users');
17
+ * ```
18
+ *
19
+ * @example Use as standalone fetch replacement
20
+ * ```typescript
21
+ * const mocks = new MockRegistry();
22
+ * mocks.onGet('/api/users').reply(200, [{ id: 1 }]);
23
+ *
24
+ * // Use directly as fetch
25
+ * const response = await mocks.fetch('/api/users');
26
+ *
27
+ * // Or pass to any library expecting a fetch function
28
+ * const client = new SomeHttpClient({ fetch: mocks.fetch });
29
+ * ```
30
+ */
31
+ export class MockRegistry {
32
+ #mocks = [];
33
+ #history = new MockHistoryImpl();
34
+ #target = null;
35
+ #originalFetch = undefined;
36
+ /**
37
+ * Creates a mock for GET requests matching the given URL.
38
+ * @param url - URL string or RegExp to match
39
+ */
40
+ onGet(url) {
41
+ return this.#addMock("GET", url);
42
+ }
43
+ /**
44
+ * Creates a mock for POST requests matching the given URL.
45
+ * @param url - URL string or RegExp to match
46
+ */
47
+ onPost(url) {
48
+ return this.#addMock("POST", url);
49
+ }
50
+ /**
51
+ * Creates a mock for PUT requests matching the given URL.
52
+ * @param url - URL string or RegExp to match
53
+ */
54
+ onPut(url) {
55
+ return this.#addMock("PUT", url);
56
+ }
57
+ /**
58
+ * Creates a mock for PATCH requests matching the given URL.
59
+ * @param url - URL string or RegExp to match
60
+ */
61
+ onPatch(url) {
62
+ return this.#addMock("PATCH", url);
63
+ }
64
+ /**
65
+ * Creates a mock for DELETE requests matching the given URL.
66
+ * @param url - URL string or RegExp to match
67
+ */
68
+ onDelete(url) {
69
+ return this.#addMock("DELETE", url);
70
+ }
71
+ /**
72
+ * Creates a mock for any HTTP method matching the given URL.
73
+ * @param url - URL string or RegExp to match
74
+ */
75
+ onAny(url) {
76
+ return this.#addMock(null, url);
77
+ }
78
+ #addMock(method, url) {
79
+ const mock = {
80
+ method,
81
+ url,
82
+ status: 200,
83
+ once: false,
84
+ passthrough: false,
85
+ timeout: false,
86
+ };
87
+ this.#mocks.push(mock);
88
+ return new MockResponseBuilder(mock, this);
89
+ }
90
+ /**
91
+ * Installs the mock registry on a FetchClient or FetchClientProvider.
92
+ * Replaces the fetch implementation to intercept requests.
93
+ *
94
+ * @param target - The FetchClient or FetchClientProvider to install on
95
+ * @throws Error if already installed on another target
96
+ */
97
+ install(target) {
98
+ if (this.#target) {
99
+ throw new Error("MockRegistry is already installed. Call restore() first.");
100
+ }
101
+ // If target is FetchClient, use its provider
102
+ const provider = "provider" in target && typeof target.provider !== "undefined"
103
+ ? target.provider
104
+ : target;
105
+ this.#target = provider;
106
+ this.#originalFetch = provider.fetch;
107
+ // Replace fetch with our mock handler
108
+ provider.fetch = ((input, init) => {
109
+ return this.#handleRequest(input, init);
110
+ });
111
+ }
112
+ /**
113
+ * Restores the original fetch implementation.
114
+ */
115
+ restore() {
116
+ if (!this.#target)
117
+ return;
118
+ this.#target.fetch = this.#originalFetch;
119
+ this.#target = null;
120
+ this.#originalFetch = undefined;
121
+ }
122
+ async #handleRequest(input, init) {
123
+ const signal = init?.signal;
124
+ // Check if already aborted
125
+ if (signal?.aborted) {
126
+ throw signal.reason;
127
+ }
128
+ const request = new Request(input, init);
129
+ this.#history.record(request);
130
+ const mock = this.#match(request);
131
+ if (!mock) {
132
+ // No mock found - call original fetch or global fetch
133
+ if (this.#originalFetch) {
134
+ return this.#originalFetch(input, init);
135
+ }
136
+ return fetch(input, init);
137
+ }
138
+ if (mock.passthrough) {
139
+ if (this.#originalFetch) {
140
+ return this.#originalFetch(input, init);
141
+ }
142
+ return fetch(input, init);
143
+ }
144
+ if (mock.delay) {
145
+ await this.#delayWithAbort(mock.delay, signal);
146
+ }
147
+ // Check again after delay
148
+ if (signal?.aborted) {
149
+ throw signal.reason;
150
+ }
151
+ if (mock.networkError) {
152
+ throw new TypeError(mock.networkError);
153
+ }
154
+ if (mock.timeout) {
155
+ throw new DOMException("The operation was aborted.", "TimeoutError");
156
+ }
157
+ // Build mock response
158
+ const headers = new Headers(mock.headers);
159
+ if (mock.data !== undefined && !headers.has("Content-Type")) {
160
+ headers.set("Content-Type", "application/json");
161
+ }
162
+ return new Response(mock.data !== undefined ? JSON.stringify(mock.data) : null, { status: mock.status, headers });
163
+ }
164
+ /**
165
+ * Delay that respects abort signals.
166
+ */
167
+ #delayWithAbort(ms, signal) {
168
+ return new Promise((resolve, reject) => {
169
+ const timeoutId = setTimeout(resolve, ms);
170
+ if (signal) {
171
+ const abortHandler = () => {
172
+ clearTimeout(timeoutId);
173
+ reject(signal.reason);
174
+ };
175
+ if (signal.aborted) {
176
+ clearTimeout(timeoutId);
177
+ reject(signal.reason);
178
+ return;
179
+ }
180
+ signal.addEventListener("abort", abortHandler, { once: true });
181
+ }
182
+ });
183
+ }
184
+ #match(request) {
185
+ for (let i = 0; i < this.#mocks.length; i++) {
186
+ const mock = this.#mocks[i];
187
+ // Check method
188
+ if (mock.method && mock.method !== request.method)
189
+ continue;
190
+ // Check URL
191
+ const url = request.url;
192
+ if (mock.url instanceof RegExp) {
193
+ if (!mock.url.test(url))
194
+ continue;
195
+ }
196
+ else {
197
+ // Match if URL ends with the pattern or contains it
198
+ if (!url.endsWith(mock.url) && !url.includes(mock.url))
199
+ continue;
200
+ }
201
+ // Check headers if specified
202
+ if (mock.headerMatchers) {
203
+ let headersMatch = true;
204
+ for (const [key, value] of Object.entries(mock.headerMatchers)) {
205
+ if (request.headers.get(key) !== value) {
206
+ headersMatch = false;
207
+ break;
208
+ }
209
+ }
210
+ if (!headersMatch)
211
+ continue;
212
+ }
213
+ // Found a match - remove if once
214
+ if (mock.once) {
215
+ this.#mocks.splice(i, 1);
216
+ }
217
+ return mock;
218
+ }
219
+ return null;
220
+ }
221
+ /**
222
+ * Gets the recorded request history.
223
+ */
224
+ get history() {
225
+ return this.#history;
226
+ }
227
+ /**
228
+ * Gets the mock fetch function for standalone use.
229
+ * This allows using MockRegistry with any code that accepts a fetch function.
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * const mocks = new MockRegistry();
234
+ * mocks.onGet('/api/data').reply(200, { value: 42 });
235
+ *
236
+ * // Use directly
237
+ * const response = await mocks.fetch('/api/data');
238
+ *
239
+ * // Or pass to other libraries
240
+ * const client = new SomeClient({ fetch: mocks.fetch });
241
+ * ```
242
+ */
243
+ get fetch() {
244
+ return ((input, init) => {
245
+ return this.#handleRequest(input, init);
246
+ });
247
+ }
248
+ /**
249
+ * Clears all mocks and history.
250
+ */
251
+ reset() {
252
+ this.resetMocks();
253
+ this.resetHistory();
254
+ }
255
+ /**
256
+ * Clears all mocks but keeps history.
257
+ */
258
+ resetMocks() {
259
+ this.#mocks = [];
260
+ }
261
+ /**
262
+ * Clears history but keeps mocks.
263
+ */
264
+ resetHistory() {
265
+ this.#history.clear();
266
+ }
267
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Fluent builder for configuring mock responses.
3
+ * @template T The type of the registry (for chaining)
4
+ */
5
+ export class MockResponseBuilder {
6
+ #mock;
7
+ #registry;
8
+ constructor(mock, registry) {
9
+ this.#mock = mock;
10
+ this.#registry = registry;
11
+ }
12
+ /**
13
+ * Sets the response status, data, and optional headers.
14
+ * @param status - HTTP status code
15
+ * @param data - Response data (will be JSON stringified)
16
+ * @param headers - Optional response headers
17
+ * @returns The registry for chaining
18
+ */
19
+ reply(status, data, headers) {
20
+ this.#mock.status = status;
21
+ this.#mock.data = data;
22
+ this.#mock.headers = headers;
23
+ return this.#registry;
24
+ }
25
+ /**
26
+ * Sets a one-time response that is removed after the first match.
27
+ * @param status - HTTP status code
28
+ * @param data - Response data (will be JSON stringified)
29
+ * @param headers - Optional response headers
30
+ * @returns The registry for chaining
31
+ */
32
+ replyOnce(status, data, headers) {
33
+ this.#mock.once = true;
34
+ return this.reply(status, data, headers);
35
+ }
36
+ /**
37
+ * Simulates a network error by throwing a TypeError.
38
+ * @param message - Error message (default: "Network error")
39
+ * @returns The registry for chaining
40
+ */
41
+ networkError(message = "Network error") {
42
+ this.#mock.networkError = message;
43
+ return this.#registry;
44
+ }
45
+ /**
46
+ * Simulates a timeout by throwing a TimeoutError.
47
+ * @returns The registry for chaining
48
+ */
49
+ timeout() {
50
+ this.#mock.timeout = true;
51
+ return this.#registry;
52
+ }
53
+ /**
54
+ * Passes the request through to the real fetch implementation.
55
+ * @returns The registry for chaining
56
+ */
57
+ passthrough() {
58
+ this.#mock.passthrough = true;
59
+ return this.#registry;
60
+ }
61
+ /**
62
+ * Adds header matching requirements for this mock.
63
+ * @param headers - Headers that must be present and match
64
+ * @returns This builder for further configuration
65
+ */
66
+ withHeaders(headers) {
67
+ this.#mock.headerMatchers = headers;
68
+ return this;
69
+ }
70
+ /**
71
+ * Adds body matching requirements for this mock.
72
+ * @param body - Exact body to match, or a predicate function
73
+ * @returns This builder for further configuration
74
+ */
75
+ withBody(body) {
76
+ this.#mock.bodyMatcher = body;
77
+ return this;
78
+ }
79
+ /**
80
+ * Adds a delay before returning the response.
81
+ * @param ms - Delay in milliseconds
82
+ * @returns This builder for further configuration
83
+ */
84
+ delay(ms) {
85
+ this.#mock.delay = ms;
86
+ return this;
87
+ }
88
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Mock utilities for testing FetchClient.
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { FetchClientProvider } from "@foundatiofx/fetchclient";
7
+ * import { MockRegistry } from "@foundatiofx/fetchclient/mocks";
8
+ *
9
+ * const mocks = new MockRegistry();
10
+ * mocks.onGet('/api/users').reply(200, [{ id: 1 }]);
11
+ *
12
+ * const provider = new FetchClientProvider();
13
+ * mocks.install(provider);
14
+ *
15
+ * const client = provider.getFetchClient();
16
+ * const response = await client.getJSON('/api/users');
17
+ *
18
+ * mocks.restore();
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+ export { MockRegistry } from "./MockRegistry.js";
24
+ export { MockResponseBuilder } from "./MockResponseBuilder.js";
@@ -0,0 +1 @@
1
+ export {};