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
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|
package/dist/metrics.js
ADDED
|
@@ -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"}
|