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.
- package/.idea/FastFetch-Smart-API-Fetcher.iml +12 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/__tests__/demo.test.ts +216 -0
- package/__tests__/test_database.json +5002 -0
- package/coverage/clover.xml +463 -0
- package/coverage/coverage-final.json +11 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/circuit-breaker.ts.html +547 -0
- package/coverage/lcov-report/client.ts.html +1858 -0
- package/coverage/lcov-report/errors.ts.html +415 -0
- package/coverage/lcov-report/fastFetch.ts.html +1045 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +251 -0
- package/coverage/lcov-report/index.ts.html +241 -0
- package/coverage/lcov-report/metrics.ts.html +685 -0
- package/coverage/lcov-report/middleware.ts.html +403 -0
- package/coverage/lcov-report/offline-queue.ts.html +535 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/queue.ts.html +421 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +196 -0
- package/coverage/lcov-report/streaming.ts.html +466 -0
- package/coverage/lcov.info +908 -0
- package/dist/circuit-breaker.d.ts +61 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +106 -0
- package/dist/client.d.ts +215 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +391 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +91 -0
- package/dist/fastFetch.d.ts +65 -0
- package/dist/fastFetch.d.ts.map +1 -0
- package/dist/fastFetch.js +209 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/metrics.d.ts +71 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +131 -0
- package/dist/middleware.d.ts +66 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +45 -0
- package/dist/offline-queue.d.ts +65 -0
- package/dist/offline-queue.d.ts.map +1 -0
- package/dist/offline-queue.js +120 -0
- package/dist/queue.d.ts +33 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +76 -0
- package/dist/streaming.d.ts +40 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +98 -0
- package/index.d.ts +167 -0
- package/jest.config.js +16 -0
- package/package.json +55 -0
- package/src/circuit-breaker.ts +154 -0
- package/src/client.ts +591 -0
- package/src/errors.ts +110 -0
- package/src/fastFetch.ts +320 -0
- package/src/index.ts +52 -0
- package/src/metrics.ts +200 -0
- package/src/middleware.ts +106 -0
- package/src/offline-queue.ts +150 -0
- package/src/queue.ts +112 -0
- package/src/streaming.ts +127 -0
- 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
|
+
}
|