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.

203 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 the page as it is while it
12 * tries, and a click elsewhere cancels it. It waits out the browser being
13 * offline for as long as that lasts, but not a network that is up and
14 * still never answers (an extension or proxy blocking g1t, broken DNS):
15 * that is no blip, so after `REFRESH_PATIENCE_MS` of it the error is shown.
16 * A read for another page (a click) tries for `PATIENCE_MS`. Either way the
17 * error page then keeps trying on its own, on its own slower schedule
18 * (lib/reconnect.ts): while it shows, reads are not retried here as well.
19 * Nothing is retried from a tab out of view; it waits to be looked at.
20 * A read the router gave up on itself (its signal aborted) stops at once.
21 * Writes are never sent again: one that failed may have arrived, and
22 * sending it twice could do it twice.
23 */
24import { useSyncExternalStore } from "react";
25
26/** 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. */
27export const PATIENCE_MS = 45_000;
28
29/** How long a refresh keeps the page while the browser says it is online and nothing answers. */
30export const REFRESH_PATIENCE_MS = 120_000;
31
32/** The wait before try `attempt` (1 is the first retry): 0.25 s, 0.5 s, 1 s, 2 s, then every 5 s. */
33export function retryDelay(attempt: number): number {
34 return Math.min(250 * 2 ** (attempt - 1), 5000);
35}
36
37/** What browsers throw when a request never got an answer, by browser. */
38const NETWORK_MESSAGES = new Set([
39 "failed to fetch", // Chromium
40 "networkerror when attempting to fetch resource.", // Firefox
41 "load failed", // Safari
42 "network connection was lost.", // Safari
43 "the network connection was lost.",
44 "the internet connection appears to be offline.",
45]);
46
47/** Whether `error` means the request got no answer at all (not an error the server sent, nor an abort). */
48export function isNetworkFailure(error: unknown): boolean {
49 if (!(error instanceof TypeError)) return false;
50 return NETWORK_MESSAGES.has(error.message.trim().toLowerCase());
51}
52
53/** Whether a request is one of the router's reads, safe to send again. */
54export function retriable(url: URL, method: string, origin: string): boolean {
55 if (method.toUpperCase() !== "GET" || url.origin !== origin) return false;
56 return url.pathname.endsWith(".data") || url.pathname === "/__manifest";
57}
58
59/**
60 * Whether a `.data` read is for the page already showing (a refresh), not
61 * one being navigated to: the same path (the root's is `/_root.data`) and
62 * the same query, besides the router's own `_routes`.
63 */
64export function refreshesCurrent(url: URL, current: { pathname: string; search: string }): boolean {
65 if (!url.pathname.endsWith(".data")) return false;
66 const path = url.pathname.slice(0, -".data".length);
67 if ((path === "/_root" ? "/" : path.replace(/\/$/, "")) !== (current.pathname.replace(/\/$/, "") || "/")) return false;
68 const query = (search: string) => {
69 const params = new URLSearchParams(search);
70 params.delete("_routes");
71 params.sort();
72 return params.toString();
73 };
74 return query(url.search) === query(current.search);
75}
76
77/** What a read has been through, to decide whether to try it again after `wait`. */
78export type Tried = {
79 /** A refresh of the page already showing, not a navigation. */
80 refresh: boolean;
81 /** Since the read started. */
82 elapsedMs: number;
83 /** Waited between tries while the browser said it was online. */
84 onlineMs: number;
85 /** Whether the error page is showing, trying on its own. */
86 erroring: boolean;
87};
88
89/** Whether to try a read again after `wait`: never twice over, never forever on a network that is up. */
90export function keepTrying(tried: Tried, wait: number): boolean {
91 if (tried.erroring) return false;
92 if (tried.refresh) return tried.onlineMs + wait <= REFRESH_PATIENCE_MS;
93 return tried.elapsedMs + wait <= PATIENCE_MS;
94}
95
96/** Set by the error page while it tries on its own (lib/reconnect.ts). */
97let erroring = false;
98export function setErroring(on: boolean) {
99 erroring = on;
100}
101
102// ── Whether a read is waiting on the network ─────────────────────────────
103
104let waiting = 0;
105const listeners = new Set<() => void>();
106function setWaiting(change: number) {
107 waiting += change;
108 for (const listener of listeners) listener();
109}
110
111/** True while any of the router's reads is waiting for the network to come back. */
112export function useReconnecting(): boolean {
113 return useSyncExternalStore(
114 (listener) => {
115 listeners.add(listener);
116 return () => listeners.delete(listener);
117 },
118 () => waiting > 0,
119 () => false,
120 );
121}
122
123/**
124 * Waits `ms`, or less once the browser is back online, and then for as long
125 * as the tab is out of view; rejects when `signal` aborts.
126 */
127function pause(ms: number, signal: AbortSignal | null | undefined): Promise<void> {
128 return new Promise((resolve, reject) => {
129 if (signal?.aborted) return reject(signal.reason);
130 const cleanup = () => {
131 clearTimeout(timer);
132 window.removeEventListener("online", due);
133 document.removeEventListener("visibilitychange", shown);
134 signal?.removeEventListener("abort", stop);
135 };
136 let elapsed = false;
137 function due() {
138 elapsed = true;
139 if (document.visibilityState !== "visible") return;
140 cleanup();
141 resolve();
142 }
143 // Only once the wait is over: coming into view does not cut it short.
144 function shown() {
145 if (elapsed) due();
146 }
147 function stop() {
148 cleanup();
149 reject(signal?.reason);
150 }
151 const timer = setTimeout(due, ms);
152 window.addEventListener("online", due);
153 document.addEventListener("visibilitychange", shown);
154 signal?.addEventListener("abort", stop, { once: true });
155 });
156}
157
158let installed = false;
159
160/** Wraps the browser's `fetch` so the router's reads survive a moment offline. Browser only; once. */
161export function installResilientFetch() {
162 if (installed || typeof window === "undefined") return;
163 installed = true;
164 const original = window.fetch.bind(window);
165 window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
166 const request = input instanceof Request ? input : null;
167 let url: URL;
168 try {
169 url = new URL(request ? request.url : String(input), window.location.href);
170 } catch {
171 return original(input, init);
172 }
173 const method = init?.method ?? request?.method ?? "GET";
174 if (!retriable(url, method, window.location.origin)) return original(input, init);
175 const signal = init?.signal ?? request?.signal;
176 const refresh = refreshesCurrent(url, window.location);
177 const started = Date.now();
178 let onlineMs = 0;
179 let counted = false;
180 try {
181 for (let attempt = 1; ; attempt++) {
182 try {
183 // A Request's body is read once; a GET has none, but clone so each try is whole.
184 return await original(request ? (request.clone() as RequestInfo) : input, init);
185 } catch (error) {
186 const wait = retryDelay(attempt);
187 if (!isNetworkFailure(error) || signal?.aborted) throw error;
188 if (!keepTrying({ refresh, elapsedMs: Date.now() - started, onlineMs, erroring }, wait)) throw error;
189 if (navigator.onLine) onlineMs += wait;
190 // The first miss may be nothing; from the second, say so.
191 if (attempt === 2 && !counted) {
192 counted = true;
193 setWaiting(1);
194 }
195 if (attempt === 1) console.warn(`g1t: ${url.pathname} got no answer; trying again`);
196 await pause(wait, signal);
197 }
198 }
199 } finally {
200 if (counted) setWaiting(-1);
201 }
202 };
203}