Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

154 linesCodeBlameRaw
1/**
2 * A page left open refreshes itself (lib/refresh.ts, lib/use-repo-live.ts),
3 * and any of those reads can meet a moment without a network: Wi-Fi
4 * changing, a laptop waking, a connection cut during a deploy. The browser
5 * says only "Failed to fetch", and the router would show the error page in
6 * place of a page that was fine. So the router's own reads (its `.data`
7 * requests and route manifest) are asked again until they arrive: a little
8 * later each time, sooner once the browser says it is back online. While
9 * one is waiting, `useReconnecting` says so (components/connection-toast.tsx).
10 *
11 * A refresh of the page already showing keeps trying for as long as it
12 * takes: the page stays as it is meanwhile, and a click elsewhere cancels
13 * it. A read for another page (a click) tries for `PATIENCE_MS`, then
14 * reaches the error page, which keeps trying on its own (lib/reconnect.ts).
15 * A read the router gave up on itself (its signal aborted) stops at once.
16 * Writes are never sent again: one that failed may have arrived, and
17 * sending it twice could do it twice.
18 */
19import { useSyncExternalStore } from "react";
20
21/** How long a read keeps trying; under lib/stuck.ts's `STUCK_NAVIGATION_MS`, so a click still lands on an error page that recovers rather than a whole-page load that cannot. */
22export const PATIENCE_MS = 45_000;
23
24/** The wait before try `attempt` (1 is the first retry): 0.25 s, 0.5 s, 1 s, 2 s, then every 5 s. */
25export function retryDelay(attempt: number): number {
26 return Math.min(250 * 2 ** (attempt - 1), 5000);
27}
28
29/** What browsers throw when a request never got an answer, by browser. */
30const NETWORK_MESSAGES = new Set([
31 "failed to fetch", // Chromium
32 "networkerror when attempting to fetch resource.", // Firefox
33 "load failed", // Safari
34 "network connection was lost.", // Safari
35 "the network connection was lost.",
36 "the internet connection appears to be offline.",
37]);
38
39/** Whether `error` means the request got no answer at all (not an error the server sent, nor an abort). */
40export function isNetworkFailure(error: unknown): boolean {
41 if (!(error instanceof TypeError)) return false;
42 return NETWORK_MESSAGES.has(error.message.trim().toLowerCase());
43}
44
45/** Whether a request is one of the router's reads, safe to send again. */
46export function retriable(url: URL, method: string, origin: string): boolean {
47 if (method.toUpperCase() !== "GET" || url.origin !== origin) return false;
48 return url.pathname.endsWith(".data") || url.pathname === "/__manifest";
49}
50
51/**
52 * Whether a `.data` read is for the page already showing (a refresh), not
53 * one being navigated to: the same path (the root's is `/_root.data`) and
54 * the same query, besides the router's own `_routes`.
55 */
56export function refreshesCurrent(url: URL, current: { pathname: string; search: string }): boolean {
57 if (!url.pathname.endsWith(".data")) return false;
58 const path = url.pathname.slice(0, -".data".length);
59 if ((path === "/_root" ? "/" : path.replace(/\/$/, "")) !== (current.pathname.replace(/\/$/, "") || "/")) return false;
60 const query = (search: string) => {
61 const params = new URLSearchParams(search);
62 params.delete("_routes");
63 params.sort();
64 return params.toString();
65 };
66 return query(url.search) === query(current.search);
67}
68
69// ── Whether a read is waiting on the network ─────────────────────────────
70
71let waiting = 0;
72const listeners = new Set<() => void>();
73function setWaiting(change: number) {
74 waiting += change;
75 for (const listener of listeners) listener();
76}
77
78/** True while any of the router's reads is waiting for the network to come back. */
79export function useReconnecting(): boolean {
80 return useSyncExternalStore(
81 (listener) => {
82 listeners.add(listener);
83 return () => listeners.delete(listener);
84 },
85 () => waiting > 0,
86 () => false,
87 );
88}
89
90/** Waits `ms`, or less once the browser is back online; rejects when `signal` aborts. */
91function pause(ms: number, signal: AbortSignal | null | undefined): Promise<void> {
92 return new Promise((resolve, reject) => {
93 if (signal?.aborted) return reject(signal.reason);
94 const done = () => {
95 clearTimeout(timer);
96 window.removeEventListener("online", done);
97 signal?.removeEventListener("abort", stop);
98 resolve();
99 };
100 const stop = () => {
101 clearTimeout(timer);
102 window.removeEventListener("online", done);
103 reject(signal?.reason);
104 };
105 const timer = setTimeout(done, ms);
106 window.addEventListener("online", done, { once: true });
107 signal?.addEventListener("abort", stop, { once: true });
108 });
109}
110
111let installed = false;
112
113/** Wraps the browser's `fetch` so the router's reads survive a moment offline. Browser only; once. */
114export function installResilientFetch() {
115 if (installed || typeof window === "undefined") return;
116 installed = true;
117 const original = window.fetch.bind(window);
118 window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
119 const request = input instanceof Request ? input : null;
120 let url: URL;
121 try {
122 url = new URL(request ? request.url : String(input), window.location.href);
123 } catch {
124 return original(input, init);
125 }
126 const method = init?.method ?? request?.method ?? "GET";
127 if (!retriable(url, method, window.location.origin)) return original(input, init);
128 const signal = init?.signal ?? request?.signal;
129 const patient = refreshesCurrent(url, window.location);
130 const started = Date.now();
131 let counted = false;
132 try {
133 for (let attempt = 1; ; attempt++) {
134 try {
135 // A Request's body is read once; a GET has none, but clone so each try is whole.
136 return await original(request ? (request.clone() as RequestInfo) : input, init);
137 } catch (error) {
138 const wait = retryDelay(attempt);
139 if (!isNetworkFailure(error) || signal?.aborted) throw error;
140 if (!patient && Date.now() - started + wait > PATIENCE_MS) throw error;
141 // The first miss may be nothing; from the second, say so.
142 if (attempt === 2 && !counted) {
143 counted = true;
144 setWaiting(1);
145 }
146 if (attempt === 1) console.warn(`g1t: ${url.pathname} got no answer; trying again`);
147 await pause(wait, signal);
148 }
149 }
150 } finally {
151 if (counted) setWaiting(-1);
152 }
153 };
154}