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,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
+ }
@@ -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
+