fastfetch-api-fetch-enhancer 2.0.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 (72) hide show
  1. package/.idea/FastFetch-Smart-API-Fetcher.iml +12 -0
  2. package/.idea/modules.xml +8 -0
  3. package/.idea/vcs.xml +6 -0
  4. package/LICENSE +21 -0
  5. package/README.md +101 -0
  6. package/__tests__/demo.test.ts +216 -0
  7. package/__tests__/test_database.json +5002 -0
  8. package/coverage/clover.xml +463 -0
  9. package/coverage/coverage-final.json +11 -0
  10. package/coverage/lcov-report/base.css +224 -0
  11. package/coverage/lcov-report/block-navigation.js +87 -0
  12. package/coverage/lcov-report/circuit-breaker.ts.html +547 -0
  13. package/coverage/lcov-report/client.ts.html +1858 -0
  14. package/coverage/lcov-report/errors.ts.html +415 -0
  15. package/coverage/lcov-report/fastFetch.ts.html +1045 -0
  16. package/coverage/lcov-report/favicon.png +0 -0
  17. package/coverage/lcov-report/index.html +251 -0
  18. package/coverage/lcov-report/index.ts.html +241 -0
  19. package/coverage/lcov-report/metrics.ts.html +685 -0
  20. package/coverage/lcov-report/middleware.ts.html +403 -0
  21. package/coverage/lcov-report/offline-queue.ts.html +535 -0
  22. package/coverage/lcov-report/prettify.css +1 -0
  23. package/coverage/lcov-report/prettify.js +2 -0
  24. package/coverage/lcov-report/queue.ts.html +421 -0
  25. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  26. package/coverage/lcov-report/sorter.js +196 -0
  27. package/coverage/lcov-report/streaming.ts.html +466 -0
  28. package/coverage/lcov.info +908 -0
  29. package/dist/circuit-breaker.d.ts +61 -0
  30. package/dist/circuit-breaker.d.ts.map +1 -0
  31. package/dist/circuit-breaker.js +106 -0
  32. package/dist/client.d.ts +215 -0
  33. package/dist/client.d.ts.map +1 -0
  34. package/dist/client.js +391 -0
  35. package/dist/errors.d.ts +56 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +91 -0
  38. package/dist/fastFetch.d.ts +65 -0
  39. package/dist/fastFetch.d.ts.map +1 -0
  40. package/dist/fastFetch.js +209 -0
  41. package/dist/index.d.ts +18 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +18 -0
  44. package/dist/metrics.d.ts +71 -0
  45. package/dist/metrics.d.ts.map +1 -0
  46. package/dist/metrics.js +131 -0
  47. package/dist/middleware.d.ts +66 -0
  48. package/dist/middleware.d.ts.map +1 -0
  49. package/dist/middleware.js +45 -0
  50. package/dist/offline-queue.d.ts +65 -0
  51. package/dist/offline-queue.d.ts.map +1 -0
  52. package/dist/offline-queue.js +120 -0
  53. package/dist/queue.d.ts +33 -0
  54. package/dist/queue.d.ts.map +1 -0
  55. package/dist/queue.js +76 -0
  56. package/dist/streaming.d.ts +40 -0
  57. package/dist/streaming.d.ts.map +1 -0
  58. package/dist/streaming.js +98 -0
  59. package/index.d.ts +167 -0
  60. package/jest.config.js +16 -0
  61. package/package.json +55 -0
  62. package/src/circuit-breaker.ts +154 -0
  63. package/src/client.ts +591 -0
  64. package/src/errors.ts +110 -0
  65. package/src/fastFetch.ts +320 -0
  66. package/src/index.ts +52 -0
  67. package/src/metrics.ts +200 -0
  68. package/src/middleware.ts +106 -0
  69. package/src/offline-queue.ts +150 -0
  70. package/src/queue.ts +112 -0
  71. package/src/streaming.ts +127 -0
  72. package/tsconfig.json +18 -0
package/src/client.ts ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * FastFetch client factory — `createClient()`.
3
+ *
4
+ * Creates a pre-configured `FastFetchClient` instance with:
5
+ * - Base URL + default headers
6
+ * - Request / Response / Error interceptors (v1.x, preserved)
7
+ * - Koa-style middleware pipeline (`api.use()`)
8
+ * - Circuit breaker
9
+ * - Concurrency-limited request queue
10
+ * - Built-in metrics
11
+ * - SSE streaming (`api.stream()`)
12
+ * - Offline request queue
13
+ * - Typed HTTP method helpers (get / post / put / patch / delete / json)
14
+ * - Auto-serialisation of request body (plain objects → JSON)
15
+ */
16
+
17
+ import { fastFetch, FastFetchOptions } from "./fastFetch.js";
18
+ import { HttpError, NetworkError } from "./errors.js";
19
+ import { MetricsCollector } from "./metrics.js";
20
+ import {
21
+ CircuitBreaker,
22
+ CircuitBreakerOptions,
23
+ } from "./circuit-breaker.js";
24
+ import { RequestQueue, QueuePriority } from "./queue.js";
25
+ import {
26
+ compose,
27
+ FastFetchContext,
28
+ MiddlewareFn,
29
+ } from "./middleware.js";
30
+ import { consumeSSE, SSEHandler } from "./streaming.js";
31
+ import { OfflineQueue } from "./offline-queue.js";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Interceptor types (v1.x API — kept for backward compatibility)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export type RequestInterceptor = (
38
+ url: string,
39
+ init: RequestInit & FastFetchOptions,
40
+ ) => (RequestInit & FastFetchOptions) | Promise<RequestInit & FastFetchOptions>;
41
+
42
+ export type ResponseInterceptor = (
43
+ response: Response,
44
+ ) => Response | Promise<Response>;
45
+
46
+ export type ErrorInterceptor = (error: unknown) => unknown | Promise<unknown>;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // ClientOptions
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export interface ClientOptions extends FastFetchOptions {
53
+ /**
54
+ * Base URL prepended to all relative paths.
55
+ * @example `'https://api.example.com/v1'`
56
+ */
57
+ baseURL?: string;
58
+
59
+ /**
60
+ * Default headers merged into every request.
61
+ * Per-request headers take precedence.
62
+ */
63
+ headers?: Record<string, string>;
64
+
65
+ /**
66
+ * Throw an `HttpError` for non-2xx responses (default: `true`).
67
+ * Set to `false` to receive the raw Response regardless of status.
68
+ */
69
+ throwOnError?: boolean;
70
+
71
+ /**
72
+ * Circuit breaker configuration.
73
+ * When provided, the circuit breaker wraps every request.
74
+ */
75
+ circuitBreaker?: CircuitBreakerOptions;
76
+
77
+ /**
78
+ * Maximum number of concurrent in-flight requests for this client.
79
+ * Additional requests are queued until a slot becomes free.
80
+ */
81
+ maxConcurrent?: number;
82
+
83
+ /**
84
+ * Rolling window size for the built-in metrics collector (default: 1 000).
85
+ */
86
+ metricsWindowSize?: number;
87
+
88
+ /**
89
+ * When `true`, mutating requests (POST/PUT/PATCH/DELETE) made while
90
+ * `navigator.onLine === false` are queued and replayed on reconnect.
91
+ * No-op in non-browser environments.
92
+ */
93
+ offlineQueue?: boolean;
94
+
95
+ /** v1.x interceptors — still supported. Prefer `use()` for new code. */
96
+ interceptors?: {
97
+ request?: RequestInterceptor[];
98
+ response?: ResponseInterceptor[];
99
+ error?: ErrorInterceptor[];
100
+ };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // FastFetchClient
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export class FastFetchClient {
108
+ // Configuration
109
+ private readonly baseURL: string;
110
+ private readonly defaultHeaders: Record<string, string>;
111
+ private readonly defaultOptions: FastFetchOptions & { throwOnError?: boolean };
112
+ private readonly clientThrowOnError: boolean;
113
+
114
+ // v1.x interceptors
115
+ private readonly requestInterceptors: RequestInterceptor[];
116
+ private readonly responseInterceptors: ResponseInterceptor[];
117
+ private readonly errorInterceptors: ErrorInterceptor[];
118
+
119
+ // v2.x systems
120
+ private readonly middlewares: MiddlewareFn[] = [];
121
+ private readonly _circuitBreaker?: CircuitBreaker;
122
+ private readonly _queue?: RequestQueue;
123
+ private readonly _offlineQueue?: OfflineQueue;
124
+ private readonly _metrics: MetricsCollector;
125
+
126
+ constructor(options: ClientOptions = {}) {
127
+ const {
128
+ baseURL = "",
129
+ headers = {},
130
+ interceptors = {},
131
+ throwOnError = true,
132
+ circuitBreaker: cbOptions,
133
+ maxConcurrent,
134
+ metricsWindowSize,
135
+ offlineQueue: useOfflineQueue,
136
+ // FastFetchOptions passed to every request
137
+ retries,
138
+ retryDelay,
139
+ deduplicate,
140
+ shouldRetry,
141
+ timeout,
142
+ fastCache,
143
+ cacheTTL,
144
+ onRetry,
145
+ debug,
146
+ } = options;
147
+
148
+ this.baseURL = baseURL.replace(/\/$/, "");
149
+ this.defaultHeaders = headers;
150
+ this.clientThrowOnError = throwOnError;
151
+ this.defaultOptions = {
152
+ retries,
153
+ retryDelay,
154
+ deduplicate,
155
+ shouldRetry,
156
+ timeout,
157
+ fastCache,
158
+ cacheTTL,
159
+ onRetry,
160
+ debug,
161
+ };
162
+
163
+ // v1.x interceptors
164
+ this.requestInterceptors = interceptors.request ?? [];
165
+ this.responseInterceptors = interceptors.response ?? [];
166
+ this.errorInterceptors = interceptors.error ?? [];
167
+
168
+ // v2.x systems
169
+ this._metrics = new MetricsCollector(metricsWindowSize);
170
+
171
+ if (cbOptions) {
172
+ this._circuitBreaker = new CircuitBreaker(cbOptions);
173
+ }
174
+ if (maxConcurrent !== undefined) {
175
+ this._queue = new RequestQueue(maxConcurrent);
176
+ }
177
+ if (useOfflineQueue) {
178
+ this._offlineQueue = new OfflineQueue();
179
+ this._offlineQueue.start();
180
+ }
181
+ }
182
+
183
+ // ── Middleware registration ──────────────────────────────────────────────
184
+
185
+ /**
186
+ * Register a middleware function.
187
+ *
188
+ * Middleware runs in registration order (Koa onion model):
189
+ * - Code *before* `await next()` executes on the way *down* (pre-request).
190
+ * - Code *after* `await next()` executes on the way *up* (post-response).
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * api.use(async (ctx, next) => {
195
+ * ctx.init.headers['X-Request-Id'] = crypto.randomUUID();
196
+ * const start = Date.now();
197
+ * await next();
198
+ * console.log(`${ctx.method} ${ctx.url} — ${Date.now() - start}ms ${ctx.response?.status}`);
199
+ * });
200
+ * ```
201
+ */
202
+ use(middleware: MiddlewareFn): this {
203
+ this.middlewares.push(middleware);
204
+ return this; // chainable
205
+ }
206
+
207
+ // ── Metrics ──────────────────────────────────────────────────────────────
208
+
209
+ /**
210
+ * Built-in metrics collector.
211
+ * Call `.metrics.snapshot()` to get request counts, error rates, and latency percentiles.
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * console.table(api.metrics.snapshot().byEndpoint);
216
+ * ```
217
+ */
218
+ get metrics(): MetricsCollector {
219
+ return this._metrics;
220
+ }
221
+
222
+ // ── Circuit breaker ───────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Direct access to the circuit breaker (if configured).
226
+ * Useful for inspecting state or manually resetting in tests.
227
+ */
228
+ get circuitBreaker(): CircuitBreaker | undefined {
229
+ return this._circuitBreaker;
230
+ }
231
+
232
+ // ── Concurrency queue ────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Direct access to the concurrency queue (if configured).
236
+ * Useful for observing `pending` / `active` counts.
237
+ */
238
+ get queue(): RequestQueue | undefined {
239
+ return this._queue;
240
+ }
241
+
242
+ // ── Internal helpers ─────────────────────────────────────────────────────
243
+
244
+ private resolveURL(path: string): string {
245
+ if (
246
+ !this.baseURL ||
247
+ path.startsWith("http://") ||
248
+ path.startsWith("https://")
249
+ ) {
250
+ return path;
251
+ }
252
+ const sep = path.startsWith("/") ? "" : "/";
253
+ return `${this.baseURL}${sep}${path}`;
254
+ }
255
+
256
+ private mergeInit(
257
+ init?: RequestInit & FastFetchOptions,
258
+ ): RequestInit & FastFetchOptions {
259
+ return {
260
+ ...(this.defaultOptions as object),
261
+ ...(init as object),
262
+ headers: {
263
+ ...this.defaultHeaders,
264
+ ...(init?.headers as Record<string, string> | undefined),
265
+ },
266
+ } as RequestInit & FastFetchOptions;
267
+ }
268
+
269
+ /**
270
+ * Auto-serialise the request body.
271
+ * - Plain objects / arrays → JSON string + `Content-Type: application/json`
272
+ * - FormData / URLSearchParams / Blob / ArrayBuffer / string → passed through
273
+ */
274
+ private autoSerializeBody(
275
+ body: unknown,
276
+ headers: Record<string, string>,
277
+ ): { body: BodyInit | undefined; headers: Record<string, string> } {
278
+ if (body === undefined || body === null) {
279
+ return { body: undefined, headers };
280
+ }
281
+ if (
282
+ body instanceof FormData ||
283
+ body instanceof URLSearchParams ||
284
+ body instanceof Blob ||
285
+ body instanceof ArrayBuffer ||
286
+ typeof body === "string"
287
+ ) {
288
+ return { body: body as BodyInit, headers };
289
+ }
290
+ // Plain object or array — serialize to JSON
291
+ return {
292
+ body: JSON.stringify(body),
293
+ headers: { "Content-Type": "application/json", ...headers },
294
+ };
295
+ }
296
+
297
+ // ── Core fetch method ────────────────────────────────────────────────────
298
+
299
+ /**
300
+ * Make an HTTP request. Runs the full pipeline:
301
+ * request interceptors → middleware → circuit breaker → queue → fastFetch → response interceptors.
302
+ *
303
+ * Throws `HttpError` on non-2xx by default (disable with `throwOnError: false`).
304
+ */
305
+ async fetch(
306
+ path: string,
307
+ init?: RequestInit & FastFetchOptions & { throwOnError?: boolean },
308
+ ): Promise<Response> {
309
+ const url = this.resolveURL(path);
310
+ const mergedInit = this.mergeInit(init);
311
+ const method = ((mergedInit as any).method ?? "GET").toUpperCase();
312
+
313
+ // ── Offline queue check ──────────────────────────────────────────────
314
+ if (this._offlineQueue?.shouldQueue(method)) {
315
+ this._offlineQueue.enqueue(url, mergedInit as RequestInit);
316
+ return new Response(JSON.stringify({ queued: true, offline: true }), {
317
+ status: 202,
318
+ headers: { "Content-Type": "application/json" },
319
+ });
320
+ }
321
+
322
+ // ── Build context ────────────────────────────────────────────────────
323
+ const ctx: FastFetchContext = {
324
+ url,
325
+ method,
326
+ init: mergedInit,
327
+ meta: {},
328
+ };
329
+
330
+ const start = Date.now();
331
+ let success = false;
332
+ let httpStatus: number | undefined;
333
+
334
+ try {
335
+ // ── v1.x request interceptors ──────────────────────────────────────
336
+ for (const interceptor of this.requestInterceptors) {
337
+ ctx.init = await interceptor(ctx.url, ctx.init);
338
+ }
339
+
340
+ // ── Determine effective throwOnError ───────────────────────────────
341
+ const shouldThrow =
342
+ (init as any)?.throwOnError !== undefined
343
+ ? (init as any).throwOnError
344
+ : this.clientThrowOnError;
345
+
346
+ // ── Innermost middleware: actual HTTP request ───────────────────────
347
+ const coreMiddleware: MiddlewareFn = async (ctx, next) => {
348
+ const doFetch = async (): Promise<Response> => {
349
+ // fastFetch handles retries + dedup + cache internally
350
+ const response = await fastFetch(ctx.url, ctx.init);
351
+ if (!response.ok && shouldThrow) {
352
+ throw new HttpError(response);
353
+ }
354
+ return response;
355
+ };
356
+
357
+ let response: Response;
358
+
359
+ if (this._circuitBreaker && this._queue) {
360
+ response = await this._queue.enqueue(
361
+ () => this._circuitBreaker!.execute(doFetch),
362
+ "normal",
363
+ );
364
+ } else if (this._circuitBreaker) {
365
+ response = await this._circuitBreaker.execute(doFetch);
366
+ } else if (this._queue) {
367
+ response = await this._queue.enqueue(doFetch, "normal");
368
+ } else {
369
+ response = await doFetch();
370
+ }
371
+
372
+ ctx.response = response;
373
+ httpStatus = response.status;
374
+ success = response.ok;
375
+ ctx.duration = Date.now() - start;
376
+
377
+ await next(); // nothing beyond this in the chain
378
+ };
379
+
380
+ // ── Compose and run pipeline ───────────────────────────────────────
381
+ const pipeline = compose([...this.middlewares, coreMiddleware]);
382
+ await pipeline(ctx);
383
+
384
+ // ── v1.x response interceptors ─────────────────────────────────────
385
+ let finalResponse = ctx.response!;
386
+ for (const interceptor of this.responseInterceptors) {
387
+ finalResponse = await interceptor(finalResponse);
388
+ }
389
+
390
+ return finalResponse;
391
+ } catch (err) {
392
+ ctx.duration = ctx.duration ?? Date.now() - start;
393
+ success = false;
394
+
395
+ // ── v1.x error interceptors ────────────────────────────────────────
396
+ let handledErr: unknown = err;
397
+ for (const interceptor of this.errorInterceptors) {
398
+ handledErr = await interceptor(handledErr);
399
+ }
400
+ throw handledErr;
401
+ } finally {
402
+ this._metrics.record(
403
+ url,
404
+ ctx.duration ?? Date.now() - start,
405
+ success,
406
+ httpStatus,
407
+ );
408
+ }
409
+ }
410
+
411
+ // ── Typed JSON helper ────────────────────────────────────────────────────
412
+
413
+ /**
414
+ * Fetch and automatically parse the response body as JSON.
415
+ * Returns a typed `Promise<T>`.
416
+ *
417
+ * @example
418
+ * ```ts
419
+ * const user = await api.json<User>('/users/1');
420
+ * const posts = await api.json<Post[]>('/posts?limit=10');
421
+ * ```
422
+ */
423
+ async json<T = unknown>(
424
+ path: string,
425
+ init?: RequestInit & FastFetchOptions,
426
+ ): Promise<T> {
427
+ const res = await this.fetch(path, {
428
+ ...init,
429
+ headers: {
430
+ Accept: "application/json",
431
+ ...(init?.headers as Record<string, string> | undefined),
432
+ },
433
+ });
434
+ return res.json() as Promise<T>;
435
+ }
436
+
437
+ // ── SSE streaming ─────────────────────────────────────────────────────────
438
+
439
+ /**
440
+ * Open a Server-Sent Events stream and call `handler` for each event.
441
+ *
442
+ * The method resolves when the stream ends or the provided `AbortSignal` fires.
443
+ * Automatically sets `Accept: text/event-stream` and `Cache-Control: no-cache`.
444
+ *
445
+ * @example
446
+ * ```ts
447
+ * const ctrl = new AbortController();
448
+ * await api.stream('/api/ai/generate', (event) => {
449
+ * process.stdout.write(event.data);
450
+ * }, { signal: ctrl.signal });
451
+ * ```
452
+ */
453
+ async stream(
454
+ path: string,
455
+ handler: SSEHandler,
456
+ init?: RequestInit & FastFetchOptions,
457
+ ): Promise<void> {
458
+ const res = await this.fetch(path, {
459
+ ...init,
460
+ // SSE streams should always throw on error
461
+ ...({"throwOnError": true} as any),
462
+ headers: {
463
+ Accept: "text/event-stream",
464
+ "Cache-Control": "no-cache",
465
+ ...(init?.headers as Record<string, string> | undefined),
466
+ },
467
+ });
468
+
469
+ if (!res.body) {
470
+ throw new NetworkError(
471
+ "Response body is null — cannot open SSE stream. " +
472
+ "Ensure the server returns a `Content-Type: text/event-stream` response.",
473
+ );
474
+ }
475
+
476
+ const reader =
477
+ res.body.getReader() as ReadableStreamDefaultReader<Uint8Array>;
478
+ await consumeSSE(reader, handler, (init as any)?.signal);
479
+ }
480
+
481
+ // ── HTTP method shorthands ───────────────────────────────────────────────
482
+
483
+ /** GET request — returns raw `Response`. Use `.json<T>()` for parsed data. */
484
+ get(path: string, init?: RequestInit & FastFetchOptions): Promise<Response> {
485
+ return this.fetch(path, { ...init, method: "GET" });
486
+ }
487
+
488
+ /**
489
+ * POST request. Body is automatically serialised:
490
+ * - Plain objects / arrays → JSON (+ `Content-Type: application/json`)
491
+ * - FormData / URLSearchParams / string → passed through as-is
492
+ */
493
+ post<B = unknown>(
494
+ path: string,
495
+ body?: B,
496
+ init?: RequestInit & FastFetchOptions,
497
+ ): Promise<Response> {
498
+ const headers = init?.headers as Record<string, string> | undefined;
499
+ const serialized = this.autoSerializeBody(body, headers ?? {});
500
+ return this.fetch(path, {
501
+ ...init,
502
+ method: "POST",
503
+ body: serialized.body,
504
+ headers: serialized.headers,
505
+ });
506
+ }
507
+
508
+ /**
509
+ * PUT request with auto body serialisation.
510
+ */
511
+ put<B = unknown>(
512
+ path: string,
513
+ body?: B,
514
+ init?: RequestInit & FastFetchOptions,
515
+ ): Promise<Response> {
516
+ const headers = init?.headers as Record<string, string> | undefined;
517
+ const serialized = this.autoSerializeBody(body, headers ?? {});
518
+ return this.fetch(path, {
519
+ ...init,
520
+ method: "PUT",
521
+ body: serialized.body,
522
+ headers: serialized.headers,
523
+ });
524
+ }
525
+
526
+ /**
527
+ * PATCH request with auto body serialisation.
528
+ */
529
+ patch<B = unknown>(
530
+ path: string,
531
+ body?: B,
532
+ init?: RequestInit & FastFetchOptions,
533
+ ): Promise<Response> {
534
+ const headers = init?.headers as Record<string, string> | undefined;
535
+ const serialized = this.autoSerializeBody(body, headers ?? {});
536
+ return this.fetch(path, {
537
+ ...init,
538
+ method: "PATCH",
539
+ body: serialized.body,
540
+ headers: serialized.headers,
541
+ });
542
+ }
543
+
544
+ /** DELETE request. */
545
+ delete(
546
+ path: string,
547
+ init?: RequestInit & FastFetchOptions,
548
+ ): Promise<Response> {
549
+ return this.fetch(path, { ...init, method: "DELETE" });
550
+ }
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Factory
555
+ // ---------------------------------------------------------------------------
556
+
557
+ /**
558
+ * Create a pre-configured `FastFetchClient` instance.
559
+ *
560
+ * @example
561
+ * ```ts
562
+ * import { createClient } from 'fastfetch-api-fetch-enhancer';
563
+ *
564
+ * const api = createClient({
565
+ * baseURL: 'https://api.example.com/v1',
566
+ * headers: { Authorization: `Bearer ${token}` },
567
+ * retries: 3,
568
+ * timeout: 8_000,
569
+ * circuitBreaker: { threshold: 5, timeout: 30_000 },
570
+ * maxConcurrent: 10,
571
+ * offlineQueue: true,
572
+ * });
573
+ *
574
+ * // Register middleware
575
+ * api.use(async (ctx, next) => {
576
+ * ctx.init.headers['X-Request-Id'] = crypto.randomUUID();
577
+ * await next();
578
+ * console.log(`${ctx.method} ${ctx.url} → ${ctx.response?.status} in ${ctx.duration}ms`);
579
+ * });
580
+ *
581
+ * const user = await api.json<User>('/users/1');
582
+ * const posts = await api.json<Post[]>('/posts');
583
+ * await api.post('/users', { name: 'Alice', role: 'admin' }); // auto-JSON
584
+ * await api.stream('/events', (e) => console.log(e.data));
585
+ *
586
+ * console.table(api.metrics.snapshot().byEndpoint);
587
+ * ```
588
+ */
589
+ export function createClient(options?: ClientOptions): FastFetchClient {
590
+ return new FastFetchClient(options);
591
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * FastFetch typed error hierarchy.
3
+ *
4
+ * All errors extend `FastFetchError` so callers can do:
5
+ * catch (e) { if (e instanceof HttpError) { ... } }
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Base
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export class FastFetchError extends Error {
13
+ constructor(message: string, cause?: unknown) {
14
+ super(message);
15
+ this.name = "FastFetchError";
16
+ if (cause !== undefined) {
17
+ (this as any).cause = cause;
18
+ }
19
+ if (Error.captureStackTrace) {
20
+ Error.captureStackTrace(this, this.constructor);
21
+ }
22
+ }
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // HTTP-level error (response received, status not ok)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Thrown when the server returns a non-2xx response and `throwOnError` is
31
+ * enabled. Provides direct access to the original Response so callers can
32
+ * still read the body.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * try {
37
+ * await api.json<User>('/users/99');
38
+ * } catch (e) {
39
+ * if (e instanceof HttpError) {
40
+ * console.error(e.status, e.statusText);
41
+ * const body = await e.response.json(); // still accessible
42
+ * }
43
+ * }
44
+ * ```
45
+ */
46
+ export class HttpError extends FastFetchError {
47
+ readonly status: number;
48
+ readonly statusText: string;
49
+ /** The original Response — body is still consumable. */
50
+ readonly response: Response;
51
+
52
+ constructor(response: Response) {
53
+ super(`HTTP ${response.status} ${response.statusText}`);
54
+ this.name = "HttpError";
55
+ this.status = response.status;
56
+ this.statusText = response.statusText;
57
+ this.response = response;
58
+ }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Timeout error (AbortController fired our timer)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Thrown when a request is aborted by FastFetch's own timeout mechanism
67
+ * (not a user-supplied `AbortSignal`).
68
+ */
69
+ export class TimeoutError extends FastFetchError {
70
+ readonly timeout: number;
71
+
72
+ constructor(timeoutMs: number) {
73
+ super(`Request timed out after ${timeoutMs}ms`);
74
+ this.name = "TimeoutError";
75
+ this.timeout = timeoutMs;
76
+ }
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Network error (no response at all — DNS, CORS, connection refused…)
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Thrown on network-level failures where no HTTP response was received.
85
+ * Wraps the underlying `TypeError` or `DOMException` as `cause`.
86
+ */
87
+ export class NetworkError extends FastFetchError {
88
+ constructor(message: string, cause?: unknown) {
89
+ super(message, cause);
90
+ this.name = "NetworkError";
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Circuit-open error (circuit breaker is OPEN, request immediately rejected)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Thrown immediately when the circuit breaker is in the OPEN state.
100
+ * No network request is made, protecting the downstream service.
101
+ */
102
+ export class CircuitOpenError extends FastFetchError {
103
+ constructor() {
104
+ super(
105
+ "Circuit breaker is OPEN — request rejected to protect the downstream service. " +
106
+ "The circuit will attempt a probe request after the configured timeout.",
107
+ );
108
+ this.name = "CircuitOpenError";
109
+ }
110
+ }