Skip to content

Commit 48966f1

Browse files
committed
feat: implement offline request buffering
1 parent e7c906b commit 48966f1

File tree

1 file changed

+150
-0
lines changed

1 file changed

+150
-0
lines changed

src/offline-queue.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)