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,150 @@
|
|
|
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
|
+
|
|
18
|
+
import fetch from "cross-fetch";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
25
|
+
|
|
26
|
+
export interface OfflineRequest {
|
|
27
|
+
url: string;
|
|
28
|
+
init: RequestInit;
|
|
29
|
+
queuedAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReplayResult {
|
|
33
|
+
url: string;
|
|
34
|
+
method: string;
|
|
35
|
+
success: boolean;
|
|
36
|
+
status?: number;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// OfflineQueue
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export class OfflineQueue {
|
|
45
|
+
private _queue: OfflineRequest[] = [];
|
|
46
|
+
private _listening = false;
|
|
47
|
+
private _boundOnline?: () => void;
|
|
48
|
+
|
|
49
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start listening for browser `online` / `offline` events.
|
|
53
|
+
* Safe to call multiple times (idempotent).
|
|
54
|
+
*/
|
|
55
|
+
start(): void {
|
|
56
|
+
if (this._listening || !this.isBrowser) return;
|
|
57
|
+
this._listening = true;
|
|
58
|
+
this._boundOnline = () => void this.replay();
|
|
59
|
+
window.addEventListener("online", this._boundOnline);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Stop listening and discard all queued requests.
|
|
64
|
+
* Call this when you no longer need the queue (e.g. component unmount).
|
|
65
|
+
*/
|
|
66
|
+
stop(): void {
|
|
67
|
+
if (this._boundOnline) {
|
|
68
|
+
if (typeof window !== "undefined") {
|
|
69
|
+
window.removeEventListener("online", this._boundOnline);
|
|
70
|
+
}
|
|
71
|
+
this._boundOnline = undefined;
|
|
72
|
+
}
|
|
73
|
+
this._listening = false;
|
|
74
|
+
this._queue = [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Status ─────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
private get isBrowser(): boolean {
|
|
80
|
+
return typeof window !== "undefined" && typeof navigator !== "undefined";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `true` when the environment reports no network connectivity.
|
|
85
|
+
* Always `false` in non-browser environments (Node.js / Deno).
|
|
86
|
+
*/
|
|
87
|
+
get isOffline(): boolean {
|
|
88
|
+
return this.isBrowser && !navigator.onLine;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns `true` if this request should be queued (mutating method + offline).
|
|
93
|
+
*/
|
|
94
|
+
shouldQueue(method: string): boolean {
|
|
95
|
+
return this.isOffline && MUTATING_METHODS.has(method.toUpperCase());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Queue management ───────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** Add a request to the offline queue. */
|
|
101
|
+
enqueue(url: string, init: RequestInit): void {
|
|
102
|
+
this._queue.push({ url, init, queuedAt: Date.now() });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Replay all queued requests in chronological order.
|
|
107
|
+
* Requests that fail are returned in the result with `success: false`.
|
|
108
|
+
*/
|
|
109
|
+
async replay(): Promise<ReplayResult[]> {
|
|
110
|
+
const pending = this._queue.splice(0); // atomic drain
|
|
111
|
+
const results: ReplayResult[] = [];
|
|
112
|
+
|
|
113
|
+
for (const req of pending) {
|
|
114
|
+
const method = (req.init.method ?? "GET").toUpperCase();
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(req.url, req.init);
|
|
117
|
+
results.push({
|
|
118
|
+
url: req.url,
|
|
119
|
+
method,
|
|
120
|
+
success: res.ok,
|
|
121
|
+
status: res.status,
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
// Non-2xx — don't re-queue, just report
|
|
125
|
+
}
|
|
126
|
+
} catch (err: unknown) {
|
|
127
|
+
// Network error during replay — re-enqueue at front
|
|
128
|
+
this._queue.unshift(req);
|
|
129
|
+
results.push({
|
|
130
|
+
url: req.url,
|
|
131
|
+
method,
|
|
132
|
+
success: false,
|
|
133
|
+
error: err instanceof Error ? err.message : String(err),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Number of requests currently queued. */
|
|
142
|
+
get size(): number {
|
|
143
|
+
return this._queue.length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Snapshot of queued requests (read-only). */
|
|
147
|
+
get queue(): ReadonlyArray<OfflineRequest> {
|
|
148
|
+
return this._queue;
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/queue.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FastFetch concurrency-limited async request queue with priority support.
|
|
3
|
+
*
|
|
4
|
+
* Ensures at most `maxConcurrent` requests are in-flight at any time.
|
|
5
|
+
* Excess requests are queued and dispatched as slots become free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export type QueuePriority = "high" | "normal" | "low";
|
|
13
|
+
|
|
14
|
+
const PRIORITY_WEIGHT: Record<QueuePriority, number> = {
|
|
15
|
+
high: 0,
|
|
16
|
+
normal: 1,
|
|
17
|
+
low: 2,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface QueueEntry<T> {
|
|
21
|
+
fn: () => Promise<T>;
|
|
22
|
+
priority: QueuePriority;
|
|
23
|
+
resolve: (value: T) => void;
|
|
24
|
+
reject: (reason: unknown) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// RequestQueue
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export class RequestQueue {
|
|
32
|
+
private readonly max: number;
|
|
33
|
+
private _active = 0;
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
private readonly waiting: QueueEntry<any>[] = [];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param maxConcurrent Maximum number of concurrently running requests.
|
|
39
|
+
* @throws RangeError if `maxConcurrent` < 1.
|
|
40
|
+
*/
|
|
41
|
+
constructor(maxConcurrent: number) {
|
|
42
|
+
if (maxConcurrent < 1) {
|
|
43
|
+
throw new RangeError(`maxConcurrent must be ≥ 1, got ${maxConcurrent}`);
|
|
44
|
+
}
|
|
45
|
+
this.max = maxConcurrent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Enqueue a request factory function. If a slot is free it runs immediately;
|
|
50
|
+
* otherwise it waits until one becomes available.
|
|
51
|
+
*
|
|
52
|
+
* @param fn A zero-argument factory that returns a Promise.
|
|
53
|
+
* @param priority Execution priority. High-priority jobs skip ahead of normal/low ones.
|
|
54
|
+
*/
|
|
55
|
+
enqueue<T>(
|
|
56
|
+
fn: () => Promise<T>,
|
|
57
|
+
priority: QueuePriority = "normal",
|
|
58
|
+
): Promise<T> {
|
|
59
|
+
return new Promise<T>((resolve, reject) => {
|
|
60
|
+
this.waiting.push({ fn, priority, resolve, reject });
|
|
61
|
+
|
|
62
|
+
// Sort descending by weight so index 0 is the highest-priority entry.
|
|
63
|
+
// Use a stable insertion to avoid reordering equal-priority entries.
|
|
64
|
+
this.waiting.sort(
|
|
65
|
+
(a, b) => PRIORITY_WEIGHT[a.priority] - PRIORITY_WEIGHT[b.priority],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
this.drain();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Internal ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
private drain(): void {
|
|
75
|
+
while (this._active < this.max && this.waiting.length > 0) {
|
|
76
|
+
const entry = this.waiting.shift()!;
|
|
77
|
+
this._active++;
|
|
78
|
+
|
|
79
|
+
entry
|
|
80
|
+
.fn()
|
|
81
|
+
.then(
|
|
82
|
+
(value) => {
|
|
83
|
+
this._active--;
|
|
84
|
+
entry.resolve(value);
|
|
85
|
+
this.drain();
|
|
86
|
+
},
|
|
87
|
+
(err) => {
|
|
88
|
+
this._active--;
|
|
89
|
+
entry.reject(err);
|
|
90
|
+
this.drain();
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Observability ────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** Number of requests currently queued (not yet started). */
|
|
99
|
+
get pending(): number {
|
|
100
|
+
return this.waiting.length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Number of requests currently running. */
|
|
104
|
+
get active(): number {
|
|
105
|
+
return this._active;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Configured maximum concurrency. */
|
|
109
|
+
get concurrency(): number {
|
|
110
|
+
return this.max;
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/streaming.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FastFetch Server-Sent Events (SSE) streaming support.
|
|
3
|
+
*
|
|
4
|
+
* Consumes a `ReadableStream` from a `fetch()` Response body and calls
|
|
5
|
+
* `handler` for each well-formed SSE event received.
|
|
6
|
+
*
|
|
7
|
+
* SSE spec reference: https://html.spec.whatwg.org/#server-sent-events
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface SSEEvent {
|
|
15
|
+
/** Event type field (defaults to `"message"` if not specified by server). */
|
|
16
|
+
type: string;
|
|
17
|
+
/** The `data` field value. Multi-line data fields are joined with `\n`. */
|
|
18
|
+
data: string;
|
|
19
|
+
/** Optional `id` field from the server. */
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Optional `retry` field in milliseconds. */
|
|
22
|
+
retry?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SSEHandler = (event: SSEEvent) => void | Promise<void>;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// consumeSSE
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Consume a Server-Sent Events stream and invoke `handler` for each event.
|
|
33
|
+
*
|
|
34
|
+
* The function resolves when the stream ends (or `signal` is aborted).
|
|
35
|
+
* The underlying reader is always released in the `finally` block.
|
|
36
|
+
*
|
|
37
|
+
* @param reader A `ReadableStreamDefaultReader<Uint8Array>` from `response.body.getReader()`.
|
|
38
|
+
* @param handler Called for each complete SSE event.
|
|
39
|
+
* @param signal Optional `AbortSignal` to stop consuming early.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* const res = await fetch('/api/stream');
|
|
44
|
+
* if (!res.body) throw new Error('No body');
|
|
45
|
+
* await consumeSSE(res.body.getReader(), (event) => {
|
|
46
|
+
* console.log(event.type, event.data);
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export async function consumeSSE(
|
|
51
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
52
|
+
handler: SSEHandler,
|
|
53
|
+
signal?: AbortSignal,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const decoder = new TextDecoder("utf-8");
|
|
56
|
+
let buffer = "";
|
|
57
|
+
|
|
58
|
+
function parseAndEmit(block: string): void {
|
|
59
|
+
// SSE field defaults
|
|
60
|
+
let eventType = "message";
|
|
61
|
+
let data = "";
|
|
62
|
+
let id: string | undefined;
|
|
63
|
+
let retry: number | undefined;
|
|
64
|
+
|
|
65
|
+
for (const line of block.split("\n")) {
|
|
66
|
+
const colon = line.indexOf(":");
|
|
67
|
+
if (colon === -1) continue; // ignore malformed lines
|
|
68
|
+
|
|
69
|
+
const field = line.slice(0, colon).trim();
|
|
70
|
+
// A space immediately after ":" is part of the spec and must be stripped.
|
|
71
|
+
const value = line.slice(colon + 1).replace(/^ /, "");
|
|
72
|
+
|
|
73
|
+
switch (field) {
|
|
74
|
+
case "event":
|
|
75
|
+
eventType = value;
|
|
76
|
+
break;
|
|
77
|
+
case "data":
|
|
78
|
+
data = data ? `${data}\n${value}` : value;
|
|
79
|
+
break;
|
|
80
|
+
case "id":
|
|
81
|
+
id = value;
|
|
82
|
+
break;
|
|
83
|
+
case "retry": {
|
|
84
|
+
const ms = parseInt(value, 10);
|
|
85
|
+
if (!isNaN(ms)) retry = ms;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
// "comment" lines (field === "") are silently ignored per spec
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Dispatch only if a data field was present (spec §9.2.6)
|
|
93
|
+
if (data !== "") {
|
|
94
|
+
void handler({ type: eventType, data, id, retry });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
while (true) {
|
|
100
|
+
if (signal?.aborted) break;
|
|
101
|
+
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done) break;
|
|
104
|
+
|
|
105
|
+
buffer += decoder.decode(value, { stream: true });
|
|
106
|
+
|
|
107
|
+
// Events are separated by two newlines (\n\n or \r\n\r\n)
|
|
108
|
+
// Split on double-newline — keep trailing incomplete chunk in buffer.
|
|
109
|
+
const parts = buffer.split(/\n\n|\r\n\r\n/);
|
|
110
|
+
buffer = parts.pop() ?? "";
|
|
111
|
+
|
|
112
|
+
for (const block of parts) {
|
|
113
|
+
const trimmed = block.trim();
|
|
114
|
+
if (trimmed) {
|
|
115
|
+
parseAndEmit(trimmed);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Flush any remaining data in the buffer
|
|
121
|
+
if (buffer.trim()) {
|
|
122
|
+
parseAndEmit(buffer.trim());
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
reader.releaseLock();
|
|
126
|
+
}
|
|
127
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "NodeNext",
|
|
4
|
+
"moduleResolution": "NodeNext",
|
|
5
|
+
"target": "ES2020",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"rootDir": "src",
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["node_modules", "**/__tests__"]
|
|
17
|
+
}
|
|
18
|
+
|