| 1 | /** |
| 2 | * connection-state.ts — the typed connection model behind the signed-in |
| 3 | * shell's offline/reconnect banner (`components/connection-banner.tsx`). |
| 4 | * |
| 5 | * Pure: a reducer over browser and probe events, plus the retry schedule. |
| 6 | * The component owns timers and `fetch`; this module owns the meaning of |
| 7 | * each state so it can be unit-tested without a DOM and so no surface can |
| 8 | * invent a fourth kind of "online". |
| 9 | * |
| 10 | * online — the browser reports a network and the last probe (if any) |
| 11 | * succeeded. No banner. |
| 12 | * offline — `navigator.onLine` is false. Banner; probes wait for the |
| 13 | * browser's `online` event before retrying. |
| 14 | * reconnecting — the browser reports a network but the server has not |
| 15 | * answered a probe yet. Banner with the attempt count; |
| 16 | * probes retry on a capped exponential backoff. |
| 17 | * degraded — a probe failed while the browser still reports a network. |
| 18 | * Banner; the next probe is scheduled. |
| 19 | */ |
| 20 | |
| 21 | export type ConnectionStatus = "online" | "offline" | "reconnecting" | "degraded"; |
| 22 | |
| 23 | export interface ConnectionState { |
| 24 | status: ConnectionStatus; |
| 25 | /** Failed or pending probes since the last success. 0 while online. */ |
| 26 | attempt: number; |
| 27 | /** Epoch ms of the last completed probe, success or failure. */ |
| 28 | lastCheckedAt: number | null; |
| 29 | /** True for one render cycle after a recovery, so the banner can say so. */ |
| 30 | restored: boolean; |
| 31 | } |
| 32 | |
| 33 | export type ConnectionEvent = |
| 34 | | { type: "browser-online" } |
| 35 | | { type: "browser-offline" } |
| 36 | | { type: "probe-start" } |
| 37 | | { type: "probe-ok"; at: number } |
| 38 | | { type: "probe-failed"; at: number } |
| 39 | | { type: "restored-seen" }; |
| 40 | |
| 41 | export const INITIAL_CONNECTION_STATE: ConnectionState = { |
| 42 | status: "online", |
| 43 | attempt: 0, |
| 44 | lastCheckedAt: null, |
| 45 | restored: false, |
| 46 | }; |
| 47 | |
| 48 | /** Base delay and ceiling for the reconnect schedule, in milliseconds. */ |
| 49 | export const RETRY_BASE_MS = 2_000; |
| 50 | export const RETRY_MAX_MS = 30_000; |
| 51 | |
| 52 | /** |
| 53 | * Delay before the next probe for a given attempt count: 2s, 4s, 8s, 16s, |
| 54 | * then 30s forever. Attempt 0 (first failure) retries after the base delay. |
| 55 | */ |
| 56 | export function backoffDelayMs(attempt: number): number { |
| 57 | const exponent = Math.max(0, Math.min(attempt, 10)); |
| 58 | return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** exponent); |
| 59 | } |
| 60 | |
| 61 | export function nextConnectionState( |
| 62 | state: ConnectionState, |
| 63 | event: ConnectionEvent, |
| 64 | ): ConnectionState { |
| 65 | switch (event.type) { |
| 66 | case "browser-offline": |
| 67 | return { ...state, status: "offline", restored: false }; |
| 68 | |
| 69 | case "browser-online": |
| 70 | // The browser thinks it has a network; the server has not confirmed. |
| 71 | if (state.status === "online") return state; |
| 72 | return { ...state, status: "reconnecting", restored: false }; |
| 73 | |
| 74 | case "probe-start": |
| 75 | if (state.status === "offline") return state; |
| 76 | return { |
| 77 | ...state, |
| 78 | status: state.status === "online" ? "online" : "reconnecting", |
| 79 | }; |
| 80 | |
| 81 | case "probe-ok": { |
| 82 | // A success that lands after the browser went `offline` belongs to a |
| 83 | // stale probe: the browser emits no second event, so honoring it would |
| 84 | // hide the banner with no network to back it. Record the check, keep |
| 85 | // the banner. |
| 86 | if (state.status === "offline") { |
| 87 | return { ...state, lastCheckedAt: event.at }; |
| 88 | } |
| 89 | const wasDown = state.status !== "online"; |
| 90 | return { |
| 91 | status: "online", |
| 92 | attempt: 0, |
| 93 | lastCheckedAt: event.at, |
| 94 | restored: wasDown, |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | case "probe-failed": |
| 99 | if (state.status === "offline") { |
| 100 | return { ...state, lastCheckedAt: event.at }; |
| 101 | } |
| 102 | return { |
| 103 | status: "degraded", |
| 104 | attempt: state.attempt + 1, |
| 105 | lastCheckedAt: event.at, |
| 106 | restored: false, |
| 107 | }; |
| 108 | |
| 109 | case "restored-seen": |
| 110 | return state.restored ? { ...state, restored: false } : state; |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /** Whether a probe should be scheduled from this state. */ |
| 115 | export function shouldProbe(state: ConnectionState): boolean { |
| 116 | return state.status === "reconnecting" || state.status === "degraded"; |
| 117 | } |
| 118 |