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.

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