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
@@ -0,0 +1,209 @@
1
+ import fetch from "cross-fetch";
2
+ import { HttpError, TimeoutError, NetworkError, } from "./errors.js";
3
+ // ---------------------------------------------------------------------------
4
+ // Internal state
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * In-flight request map for deduplication.
8
+ * Maps a stable request signature → the pending Promise<Response>.
9
+ */
10
+ const inFlightMap = new Map();
11
+ /** In-memory TTL cache. */
12
+ const responseCache = new Map();
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+ /** Stable key for dedup and cache, based on URL + method + headers + body. */
17
+ function makeKey(input, init) {
18
+ const normalized = {
19
+ url: typeof input === "string" ? input : input.url,
20
+ method: (init?.method ?? "GET").toUpperCase(),
21
+ headers: init?.headers ?? {},
22
+ body: init?.body ?? null,
23
+ };
24
+ return JSON.stringify(normalized);
25
+ }
26
+ /** Returns true for HTTP status codes that we retry by default. */
27
+ function isRetryableStatus(status) {
28
+ return status === 429 || (status >= 500 && status <= 599);
29
+ }
30
+ /**
31
+ * Parse the `Retry-After` response header and return the delay in ms.
32
+ * Supports both numeric (seconds) and HTTP-date formats.
33
+ * Returns `null` if the header is missing or unparseable.
34
+ */
35
+ function parseRetryAfter(response) {
36
+ const header = response.headers?.get("Retry-After");
37
+ if (!header)
38
+ return null;
39
+ // Numeric: number of seconds
40
+ const seconds = Number(header);
41
+ if (!isNaN(seconds))
42
+ return Math.max(0, seconds * 1000);
43
+ // HTTP-date: "Wed, 21 Oct 2025 07:28:00 GMT"
44
+ const date = new Date(header);
45
+ if (!isNaN(date.getTime())) {
46
+ return Math.max(0, date.getTime() - Date.now());
47
+ }
48
+ return null;
49
+ }
50
+ /** Run fetch with an AbortController-based timeout. */
51
+ async function fetchWithTimeout(input, init, timeoutMs) {
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
54
+ try {
55
+ return await fetch(input, { ...init, signal: controller.signal });
56
+ }
57
+ finally {
58
+ clearTimeout(timer);
59
+ }
60
+ }
61
+ function sleep(ms) {
62
+ return new Promise((resolve) => setTimeout(resolve, ms));
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Core fastFetch function
66
+ // ---------------------------------------------------------------------------
67
+ /**
68
+ * FastFetch — A smarter `fetch()` wrapper.
69
+ *
70
+ * Features:
71
+ * - **Retry** with exponential backoff (`retries`, `retryDelay`, `shouldRetry`, `onRetry`)
72
+ * - **Smart default retry** — automatically retries 5xx and 429 responses
73
+ * - **Retry-After** — respects the `Retry-After` header on 429 responses
74
+ * - **Timeout** — aborts the request after `timeout` ms
75
+ * - **Deduplication** — merges identical in-flight requests
76
+ * - **TTL Cache** — caches successful GET responses for `cacheTTL` ms
77
+ * - **Debug logging** — optional verbose logging via `debug: true`
78
+ */
79
+ export async function fastFetch(input, init) {
80
+ const { retries = 0, retryDelay = 1000, deduplicate = true, shouldRetry, timeout, fastCache = false, cacheTTL = 30000, onRetry, throwOnError = false, debug = false, } = init || {};
81
+ const method = (init?.method ?? "GET").toUpperCase();
82
+ const isGet = method === "GET";
83
+ const log = (...args) => {
84
+ if (debug)
85
+ console.log("[FastFetch]", ...args);
86
+ };
87
+ log("Starting request:", method, input);
88
+ // ── TTL Cache check (GET only) ─────────────────────────────────────────
89
+ const cacheKey = makeKey(input, init);
90
+ if (fastCache && isGet) {
91
+ const entry = responseCache.get(cacheKey);
92
+ if (entry && Date.now() < entry.expiresAt) {
93
+ log("Cache hit:", cacheKey);
94
+ return entry.response.clone();
95
+ }
96
+ // Stale or missing — remove if it was there
97
+ responseCache.delete(cacheKey);
98
+ }
99
+ // ── Deduplication check ────────────────────────────────────────────────
100
+ const dedupKey = cacheKey; // same key works for both
101
+ if (deduplicate) {
102
+ if (inFlightMap.has(dedupKey)) {
103
+ log("Reusing in-flight request:", dedupKey);
104
+ const shared = await inFlightMap.get(dedupKey);
105
+ return shared.clone();
106
+ }
107
+ }
108
+ // ── Build the retry loop ───────────────────────────────────────────────
109
+ let attempt = 0;
110
+ const promise = (async function fetchWithRetry() {
111
+ while (true) {
112
+ attempt++;
113
+ log(`Attempt #${attempt}`);
114
+ let response;
115
+ try {
116
+ response = timeout
117
+ ? await fetchWithTimeout(input, init ?? {}, timeout)
118
+ : await fetch(input, init);
119
+ }
120
+ catch (rawError) {
121
+ // Wrap raw DOMException / TypeError into typed FastFetch errors
122
+ let typedError;
123
+ if (rawError?.name === "AbortError" && timeout) {
124
+ typedError = new TimeoutError(timeout);
125
+ }
126
+ else if (rawError instanceof TypeError ||
127
+ rawError?.name === "TypeError") {
128
+ typedError = new NetworkError(rawError.message, rawError);
129
+ }
130
+ else {
131
+ typedError = rawError; // pass CircuitOpenError & others through
132
+ }
133
+ log(`Network/abort error on attempt #${attempt}:`, typedError.message);
134
+ // TimeoutError — don't retry by default (retrying a timed-out request
135
+ // will just time out again and waste the user's time).
136
+ const isTimeout = typedError instanceof TimeoutError;
137
+ const shouldDoRetry = shouldRetry
138
+ ? shouldRetry(typedError, attempt)
139
+ : !isTimeout && attempt <= retries;
140
+ if (shouldDoRetry && attempt <= retries) {
141
+ const delay = retryDelay * Math.pow(2, attempt - 1);
142
+ log(`Retrying in ${delay}ms…`);
143
+ onRetry?.(attempt, typedError, delay);
144
+ await sleep(delay);
145
+ continue;
146
+ }
147
+ throw typedError;
148
+ }
149
+ // HTTP-level failure
150
+ if (!response.ok) {
151
+ const shouldDoRetry = shouldRetry
152
+ ? shouldRetry(response, attempt)
153
+ : isRetryableStatus(response.status) && attempt <= retries;
154
+ if (shouldDoRetry && attempt <= retries) {
155
+ // Respect Retry-After header (e.g. on 429)
156
+ const retryAfterMs = parseRetryAfter(response);
157
+ const delay = retryAfterMs ?? retryDelay * Math.pow(2, attempt - 1);
158
+ log(`HTTP ${response.status} — retrying in ${delay}ms (attempt ${attempt}/${retries})`);
159
+ onRetry?.(attempt, response, delay);
160
+ await sleep(delay);
161
+ continue;
162
+ }
163
+ }
164
+ log(`Succeeded on attempt #${attempt} (status: ${response.status})`);
165
+ // Throw HttpError if caller opted in and response is not ok
166
+ if (!response.ok && throwOnError) {
167
+ throw new HttpError(response);
168
+ }
169
+ return response;
170
+ }
171
+ })();
172
+ // ── Register in-flight for dedup ───────────────────────────────────────
173
+ if (deduplicate) {
174
+ inFlightMap.set(dedupKey, promise);
175
+ }
176
+ let result;
177
+ try {
178
+ result = await promise;
179
+ }
180
+ finally {
181
+ if (deduplicate) {
182
+ inFlightMap.delete(dedupKey);
183
+ }
184
+ }
185
+ // ── Populate TTL cache ─────────────────────────────────────────────────
186
+ if (fastCache && isGet && result.ok) {
187
+ responseCache.set(cacheKey, {
188
+ response: result.clone(),
189
+ expiresAt: Date.now() + cacheTTL,
190
+ });
191
+ log(`Cached response for ${cacheTTL}ms`);
192
+ }
193
+ return result.clone();
194
+ }
195
+ /**
196
+ * Manually clear all TTL-cached responses, or a single entry by URL.
197
+ */
198
+ export function clearCache(url) {
199
+ if (url) {
200
+ // Remove any entry whose key contains the URL
201
+ for (const key of responseCache.keys()) {
202
+ if (key.includes(url))
203
+ responseCache.delete(key);
204
+ }
205
+ }
206
+ else {
207
+ responseCache.clear();
208
+ }
209
+ }
@@ -0,0 +1,18 @@
1
+ export { fastFetch, clearCache } from "./fastFetch.js";
2
+ export type { FastFetchOptions } from "./fastFetch.js";
3
+ export { createClient, FastFetchClient } from "./client.js";
4
+ export type { ClientOptions, RequestInterceptor, ResponseInterceptor, ErrorInterceptor, } from "./client.js";
5
+ export { FastFetchError, HttpError, TimeoutError, NetworkError, CircuitOpenError, } from "./errors.js";
6
+ export { MetricsCollector } from "./metrics.js";
7
+ export type { MetricsSnapshot, EndpointMetrics, LatencyPercentiles, } from "./metrics.js";
8
+ export { CircuitBreaker } from "./circuit-breaker.js";
9
+ export type { CircuitBreakerOptions, CircuitState, } from "./circuit-breaker.js";
10
+ export { RequestQueue } from "./queue.js";
11
+ export type { QueuePriority } from "./queue.js";
12
+ export { compose } from "./middleware.js";
13
+ export type { FastFetchContext, MiddlewareFn } from "./middleware.js";
14
+ export { consumeSSE } from "./streaming.js";
15
+ export type { SSEEvent, SSEHandler } from "./streaming.js";
16
+ export { OfflineQueue } from "./offline-queue.js";
17
+ export type { OfflineRequest, ReplayResult } from "./offline-queue.js";
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACvD,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGvD,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC5D,YAAY,EACV,aAAa,EACb,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,cAAc,EACd,SAAS,EACT,YAAY,EACZ,YAAY,EACZ,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAChD,YAAY,EACV,eAAe,EACf,eAAe,EACf,kBAAkB,GACnB,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EACV,qBAAqB,EACrB,YAAY,GACb,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAGhD,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC1C,YAAY,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAGtE,OAAO,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC5C,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAG3D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ // Core fetch function + options
2
+ export { fastFetch, clearCache } from "./fastFetch.js";
3
+ // Client factory + types
4
+ export { createClient, FastFetchClient } from "./client.js";
5
+ // Typed error hierarchy
6
+ export { FastFetchError, HttpError, TimeoutError, NetworkError, CircuitOpenError, } from "./errors.js";
7
+ // Metrics
8
+ export { MetricsCollector } from "./metrics.js";
9
+ // Circuit breaker
10
+ export { CircuitBreaker } from "./circuit-breaker.js";
11
+ // Concurrency queue
12
+ export { RequestQueue } from "./queue.js";
13
+ // Middleware
14
+ export { compose } from "./middleware.js";
15
+ // SSE Streaming
16
+ export { consumeSSE } from "./streaming.js";
17
+ // Offline queue
18
+ export { OfflineQueue } from "./offline-queue.js";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * FastFetch lightweight metrics collector.
3
+ *
4
+ * Tracks request latencies, success/error rates, and per-endpoint breakdowns
5
+ * using a rolling window of the last N samples.
6
+ */
7
+ export interface LatencyPercentiles {
8
+ /** Median (50th percentile) latency in ms. */
9
+ p50: number;
10
+ /** 95th percentile latency in ms. */
11
+ p95: number;
12
+ /** 99th percentile latency in ms. */
13
+ p99: number;
14
+ min: number;
15
+ max: number;
16
+ /** Mean (average) latency in ms. */
17
+ mean: number;
18
+ }
19
+ export interface EndpointMetrics {
20
+ /** Total requests recorded for this endpoint. */
21
+ count: number;
22
+ /** Number of failed requests (non-2xx or thrown error). */
23
+ errors: number;
24
+ /** Success rate in [0, 1]. */
25
+ successRate: number;
26
+ latency: LatencyPercentiles;
27
+ }
28
+ export interface MetricsSnapshot {
29
+ /** Total requests in the current rolling window. */
30
+ totalRequests: number;
31
+ /** Overall success rate in [0, 1]. */
32
+ successRate: number;
33
+ /** Overall error rate in [0, 1]. */
34
+ errorRate: number;
35
+ /** Aggregated latency across all endpoints. */
36
+ latency: LatencyPercentiles;
37
+ /** Per-endpoint breakdown (URL paths normalised — numeric IDs replaced with :id). */
38
+ byEndpoint: Record<string, EndpointMetrics>;
39
+ }
40
+ export declare class MetricsCollector {
41
+ private samples;
42
+ private readonly windowSize;
43
+ /**
44
+ * @param windowSize Maximum number of samples to retain (default: 1 000).
45
+ * Older samples are evicted when the window is full.
46
+ */
47
+ constructor(windowSize?: number);
48
+ /** Record a completed request. Called automatically by FastFetchClient. */
49
+ record(url: string, duration: number, success: boolean, status?: number): void;
50
+ private percentile;
51
+ private computeLatency;
52
+ /**
53
+ * Normalise a URL for grouping — strips query params and replaces numeric
54
+ * path segments with `:id` so `/users/1` and `/users/2` are the same bucket.
55
+ */
56
+ private normalizeUrl;
57
+ /**
58
+ * Return a serialisable metrics snapshot of the current rolling window.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * console.table(api.metrics.snapshot().byEndpoint);
63
+ * ```
64
+ */
65
+ snapshot(): MetricsSnapshot;
66
+ /** Reset all collected samples. */
67
+ reset(): void;
68
+ /** Current number of samples in the rolling window. */
69
+ get size(): number;
70
+ }
71
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../src/metrics.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,MAAM,WAAW,kBAAkB;IACjC,8CAA8C;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ,qCAAqC;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,qCAAqC;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,MAAM,EAAE,MAAM,CAAC;IACf,8BAA8B;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,kBAAkB,CAAC;CAC7B;AAED,MAAM,WAAW,eAAe;IAC9B,oDAAoD;IACpD,aAAa,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,+CAA+C;IAC/C,OAAO,EAAE,kBAAkB,CAAC;IAC5B,qFAAqF;IACrF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CAC7C;AAaD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC;;;OAGG;gBACS,UAAU,SAAQ;IAI9B,2EAA2E;IAC3E,MAAM,CACJ,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,OAAO,EAChB,MAAM,CAAC,EAAE,MAAM,GACd,IAAI;IASP,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,cAAc;IAgBtB;;;OAGG;IACH,OAAO,CAAC,YAAY;IAapB;;;;;;;OAOG;IACH,QAAQ,IAAI,eAAe;IA0D3B,mCAAmC;IACnC,KAAK,IAAI,IAAI;IAIb,uDAAuD;IACvD,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
@@ -0,0 +1,131 @@
1
+ /**
2
+ * FastFetch lightweight metrics collector.
3
+ *
4
+ * Tracks request latencies, success/error rates, and per-endpoint breakdowns
5
+ * using a rolling window of the last N samples.
6
+ */
7
+ // ---------------------------------------------------------------------------
8
+ // MetricsCollector
9
+ // ---------------------------------------------------------------------------
10
+ export class MetricsCollector {
11
+ /**
12
+ * @param windowSize Maximum number of samples to retain (default: 1 000).
13
+ * Older samples are evicted when the window is full.
14
+ */
15
+ constructor(windowSize = 1000) {
16
+ this.samples = [];
17
+ this.windowSize = windowSize;
18
+ }
19
+ /** Record a completed request. Called automatically by FastFetchClient. */
20
+ record(url, duration, success, status) {
21
+ this.samples.push({ url, duration, success, status });
22
+ if (this.samples.length > this.windowSize) {
23
+ this.samples.shift(); // evict oldest
24
+ }
25
+ }
26
+ // ── Percentile computation ───────────────────────────────────────────────
27
+ percentile(sorted, p) {
28
+ if (sorted.length === 0)
29
+ return 0;
30
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
31
+ return sorted[Math.max(0, idx)];
32
+ }
33
+ computeLatency(durations) {
34
+ if (durations.length === 0) {
35
+ return { p50: 0, p95: 0, p99: 0, min: 0, max: 0, mean: 0 };
36
+ }
37
+ const sorted = [...durations].sort((a, b) => a - b);
38
+ const sum = sorted.reduce((s, v) => s + v, 0);
39
+ return {
40
+ p50: this.percentile(sorted, 50),
41
+ p95: this.percentile(sorted, 95),
42
+ p99: this.percentile(sorted, 99),
43
+ min: sorted[0],
44
+ max: sorted[sorted.length - 1],
45
+ mean: Math.round(sum / sorted.length),
46
+ };
47
+ }
48
+ /**
49
+ * Normalise a URL for grouping — strips query params and replaces numeric
50
+ * path segments with `:id` so `/users/1` and `/users/2` are the same bucket.
51
+ */
52
+ normalizeUrl(url) {
53
+ try {
54
+ const u = new URL(url);
55
+ // Replace numeric-only path segments with :id
56
+ const path = u.pathname.replace(/\/\d+(?=\/|$)/g, "/:id");
57
+ return u.hostname + path;
58
+ }
59
+ catch {
60
+ return url.replace(/\/\d+(?=\/|$)/g, "/:id");
61
+ }
62
+ }
63
+ // ── Public API ───────────────────────────────────────────────────────────
64
+ /**
65
+ * Return a serialisable metrics snapshot of the current rolling window.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * console.table(api.metrics.snapshot().byEndpoint);
70
+ * ```
71
+ */
72
+ snapshot() {
73
+ const total = this.samples.length;
74
+ const emptyLatency = {
75
+ p50: 0,
76
+ p95: 0,
77
+ p99: 0,
78
+ min: 0,
79
+ max: 0,
80
+ mean: 0,
81
+ };
82
+ if (total === 0) {
83
+ return {
84
+ totalRequests: 0,
85
+ successRate: 1,
86
+ errorRate: 0,
87
+ latency: emptyLatency,
88
+ byEndpoint: {},
89
+ };
90
+ }
91
+ const errors = this.samples.filter((s) => !s.success).length;
92
+ const allDurations = this.samples.map((s) => s.duration);
93
+ // Group samples by normalised URL path
94
+ const groups = new Map();
95
+ for (const sample of this.samples) {
96
+ const key = this.normalizeUrl(sample.url);
97
+ const existing = groups.get(key);
98
+ if (existing) {
99
+ existing.push(sample);
100
+ }
101
+ else {
102
+ groups.set(key, [sample]);
103
+ }
104
+ }
105
+ const byEndpoint = {};
106
+ for (const [key, bucket] of groups) {
107
+ const bucketErrors = bucket.filter((s) => !s.success).length;
108
+ byEndpoint[key] = {
109
+ count: bucket.length,
110
+ errors: bucketErrors,
111
+ successRate: parseFloat(((bucket.length - bucketErrors) / bucket.length).toFixed(4)),
112
+ latency: this.computeLatency(bucket.map((s) => s.duration)),
113
+ };
114
+ }
115
+ return {
116
+ totalRequests: total,
117
+ successRate: parseFloat(((total - errors) / total).toFixed(4)),
118
+ errorRate: parseFloat((errors / total).toFixed(4)),
119
+ latency: this.computeLatency(allDurations),
120
+ byEndpoint,
121
+ };
122
+ }
123
+ /** Reset all collected samples. */
124
+ reset() {
125
+ this.samples = [];
126
+ }
127
+ /** Current number of samples in the rolling window. */
128
+ get size() {
129
+ return this.samples.length;
130
+ }
131
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * FastFetch Koa-style middleware (plugin) system.
3
+ *
4
+ * Middleware functions receive a `FastFetchContext` object and a `next()`
5
+ * callback. They can:
6
+ * - Mutate `ctx.init` (headers, body, options) before the request is sent.
7
+ * - Await `next()` and then inspect / transform `ctx.response` after.
8
+ * - Attach data to `ctx.meta` for consumption by later middleware.
9
+ *
10
+ * Execution order follows the classic onion model:
11
+ * mw[0] wraps mw[1] wraps mw[2] wraps … wraps the actual fetch.
12
+ */
13
+ import type { FastFetchOptions } from "./fastFetch.js";
14
+ /**
15
+ * Mutable request/response context passed through the middleware pipeline.
16
+ */
17
+ export interface FastFetchContext {
18
+ /** Fully-resolved URL (baseURL + path). */
19
+ url: string;
20
+ /** Uppercase HTTP method (GET, POST, …). */
21
+ method: string;
22
+ /**
23
+ * Mutable request init — mutate `ctx.init.headers`, `ctx.init.body`, etc.
24
+ * inside request middleware.
25
+ */
26
+ init: RequestInit & FastFetchOptions;
27
+ /**
28
+ * Set to the raw `Response` after the request completes.
29
+ * Available inside response middleware (i.e., code after `await next()`).
30
+ */
31
+ response?: Response;
32
+ /**
33
+ * Request duration in milliseconds.
34
+ * Available after `await next()` completes.
35
+ */
36
+ duration?: number;
37
+ /**
38
+ * Free-form metadata bag. Attach anything here and read it in later
39
+ * middleware or after the final `await next()`.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * ctx.meta.requestId = crypto.randomUUID();
44
+ * await next();
45
+ * console.log(`${ctx.meta.requestId} — ${ctx.duration}ms`);
46
+ * ```
47
+ */
48
+ meta: Record<string, unknown>;
49
+ }
50
+ export type MiddlewareFn = (ctx: FastFetchContext, next: () => Promise<void>) => Promise<void>;
51
+ /**
52
+ * Compose an array of middleware functions into a single callable.
53
+ *
54
+ * Works identically to Koa's `koa-compose`:
55
+ * - Middleware runs in insertion order during the "down" pass.
56
+ * - Code after `await next()` runs in reverse order during the "up" pass.
57
+ * - Calling `next()` more than once throws an Error.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const run = compose([loggerMiddleware, authMiddleware]);
62
+ * await run(ctx);
63
+ * ```
64
+ */
65
+ export declare function compose(middlewares: MiddlewareFn[]): (ctx: FastFetchContext) => Promise<void>;
66
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAMvD;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,4CAA4C;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,IAAI,EAAE,WAAW,GAAG,gBAAgB,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;OAUG;IACH,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAMD,MAAM,MAAM,YAAY,GAAG,CACzB,GAAG,EAAE,gBAAgB,EACrB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KACtB,OAAO,CAAC,IAAI,CAAC,CAAC;AAMnB;;;;;;;;;;;;;GAaG;AACH,wBAAgB,OAAO,CACrB,WAAW,EAAE,YAAY,EAAE,GAC1B,CAAC,GAAG,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAoB1C"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * FastFetch Koa-style middleware (plugin) system.
3
+ *
4
+ * Middleware functions receive a `FastFetchContext` object and a `next()`
5
+ * callback. They can:
6
+ * - Mutate `ctx.init` (headers, body, options) before the request is sent.
7
+ * - Await `next()` and then inspect / transform `ctx.response` after.
8
+ * - Attach data to `ctx.meta` for consumption by later middleware.
9
+ *
10
+ * Execution order follows the classic onion model:
11
+ * mw[0] wraps mw[1] wraps mw[2] wraps … wraps the actual fetch.
12
+ */
13
+ // ---------------------------------------------------------------------------
14
+ // Compose
15
+ // ---------------------------------------------------------------------------
16
+ /**
17
+ * Compose an array of middleware functions into a single callable.
18
+ *
19
+ * Works identically to Koa's `koa-compose`:
20
+ * - Middleware runs in insertion order during the "down" pass.
21
+ * - Code after `await next()` runs in reverse order during the "up" pass.
22
+ * - Calling `next()` more than once throws an Error.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const run = compose([loggerMiddleware, authMiddleware]);
27
+ * await run(ctx);
28
+ * ```
29
+ */
30
+ export function compose(middlewares) {
31
+ return async function (ctx) {
32
+ let lastIndex = -1;
33
+ async function dispatch(i) {
34
+ if (i <= lastIndex) {
35
+ throw new Error("FastFetch middleware: next() was called more than once in the same middleware.");
36
+ }
37
+ lastIndex = i;
38
+ if (i >= middlewares.length)
39
+ return; // reached the end of the chain
40
+ const fn = middlewares[i];
41
+ await fn(ctx, () => dispatch(i + 1));
42
+ }
43
+ await dispatch(0);
44
+ };
45
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * FastFetch offline request queue.
3
+ *
4
+ * When the environment reports `navigator.onLine === false`, mutating requests
5
+ * (POST / PUT / PATCH / DELETE) are held in memory and replayed automatically
6
+ * once the `"online"` event fires.
7
+ *
8
+ * Notes:
9
+ * - Only mutating HTTP methods are queued; GET / HEAD / OPTIONS are always
10
+ * passed through so read operations fail fast with a network error.
11
+ * - This is intentionally an in-memory queue. For durable offline support
12
+ * (e.g. across page refreshes) consider combining with Service Workers or
13
+ * IndexedDB persistence on top of this class.
14
+ * - In non-browser environments (Node.js, Deno) the queue is always disabled
15
+ * (`isOffline` returns false, `start()` is a no-op).
16
+ */
17
+ export interface OfflineRequest {
18
+ url: string;
19
+ init: RequestInit;
20
+ queuedAt: number;
21
+ }
22
+ export interface ReplayResult {
23
+ url: string;
24
+ method: string;
25
+ success: boolean;
26
+ status?: number;
27
+ error?: string;
28
+ }
29
+ export declare class OfflineQueue {
30
+ private _queue;
31
+ private _listening;
32
+ private _boundOnline?;
33
+ /**
34
+ * Start listening for browser `online` / `offline` events.
35
+ * Safe to call multiple times (idempotent).
36
+ */
37
+ start(): void;
38
+ /**
39
+ * Stop listening and discard all queued requests.
40
+ * Call this when you no longer need the queue (e.g. component unmount).
41
+ */
42
+ stop(): void;
43
+ private get isBrowser();
44
+ /**
45
+ * `true` when the environment reports no network connectivity.
46
+ * Always `false` in non-browser environments (Node.js / Deno).
47
+ */
48
+ get isOffline(): boolean;
49
+ /**
50
+ * Returns `true` if this request should be queued (mutating method + offline).
51
+ */
52
+ shouldQueue(method: string): boolean;
53
+ /** Add a request to the offline queue. */
54
+ enqueue(url: string, init: RequestInit): void;
55
+ /**
56
+ * Replay all queued requests in chronological order.
57
+ * Requests that fail are returned in the result with `success: false`.
58
+ */
59
+ replay(): Promise<ReplayResult[]>;
60
+ /** Number of requests currently queued. */
61
+ get size(): number;
62
+ /** Snapshot of queued requests (read-only). */
63
+ get queue(): ReadonlyArray<OfflineRequest>;
64
+ }
65
+ //# sourceMappingURL=offline-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"offline-queue.d.ts","sourceRoot":"","sources":["../src/offline-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAUH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,WAAW,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD,qBAAa,YAAY;IACvB,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,YAAY,CAAC,CAAa;IAIlC;;;OAGG;IACH,KAAK,IAAI,IAAI;IAOb;;;OAGG;IACH,IAAI,IAAI,IAAI;IAaZ,OAAO,KAAK,SAAS,GAEpB;IAED;;;OAGG;IACH,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED;;OAEG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO;IAMpC,0CAA0C;IAC1C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI;IAI7C;;;OAGG;IACG,MAAM,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAgCvC,2CAA2C;IAC3C,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,+CAA+C;IAC/C,IAAI,KAAK,IAAI,aAAa,CAAC,cAAc,CAAC,CAEzC;CACF"}