// Shared reconnect policy for the WebSockets (page/banner editors, // analytics view, the activity channel). Two things trip a browser's // WebSocket throttling, after which every socket to the host sits // "pending" (never opens, never closes) for minutes: // // 1. A burst of simultaneous attempts — page load opens Vite's HMR // socket plus several of ours at the same moment, and every refresh // repeats the burst. socketSlot() spaces new sockets out. // 2. Too-frequent retries — so failed attempts back off exponentially // (a few seconds, doubling to half a minute), reset only after a // connection stayed open long enough to count as healthy. A socket // that closes right after opening must NOT reset the backoff. import { ApiError, NetworkError, apiJson, fetchJson } from 'paskia' export function reconnectPolicy({ min = 2000, max = 30000, healthyAfter = 30000 } = {}) { let delay = min let openedAt = 0 return { // Stamp a socket that just opened. opened() { openedAt = Date.now() }, // The socket closed: the wait before the next attempt (up to 50% // jitter; the base doubles per failure). A healthy streak resets it. closed() { if (openedAt && Date.now() - openedAt >= healthyAfter) delay = min openedAt = 0 const wait = Math.round(delay * (1 + Math.random() * 0.5)) delay = Math.min(delay * 2, max) return wait }, } } // Sockets created at the same moment (page load: Vite's HMR socket plus // ours) read as one burst to the browser's throttling. Space new sockets // out: each call reserves a slot a beat after the previous one. let nextSlot = 0 export function socketSlot() { const now = Date.now() const wait = Math.max(0, nextSlot - now) nextSlot = Math.max(now, nextSlot) + 300 return wait } // A socket still CONNECTING after this long counts as a failed attempt: // browser throttling leaves sockets "pending" (no open, no close) for // minutes, and without a watchdog the app would wait on one forever (the // recurring empty editor). Closing it fires onclose, which reschedules // through the policy's backoff — it never reconnects aggressively itself. export function watchConnecting(ws, label) { return setTimeout(() => { if (ws.readyState === WebSocket.CONNECTING) { console.warn(`[pagerite] ${label} socket stuck connecting — closing it, retrying with backoff`) ws.close() } }, 10_000) } // Auth-aware close handling for the admin sockets (/_api/...). An expired // session fails the WS handshake exactly like a dead network, but retrying // the socket loops forever — the WS API cannot open the login dialog. So // probe /_api first (plain fetchJson, which never pops a dialog) and: // // 'retry' — the session is fine or the trouble is a network/server one: // take the policy's paced backoff. // 'auth' — the session was gone, so the paskia login flow ran (apiJson // pops the auth iframe and retries through it) and completed: // reconnect at once, no backoff. // 'abort' — the login was declined or the proxy offers no paskia flow: // stop retrying; the caller decides what its UI does next. // // onLogin fires when the login flow starts, so the caller can say so. export async function authReconnect(onLogin) { try { await fetchJson('/_api/settings') return 'retry' } catch (e) { if (!(e instanceof ApiError && (e.status === 401 || e.status === 403))) return 'retry' } onLogin?.() try { await apiJson('/_api/settings') return 'auth' } catch (e) { return e instanceof NetworkError || (e instanceof ApiError && e.status !== 401 && e.status !== 403) ? 'retry' : 'abort' } }