Files
pagerite/frontend/src/reconnect.js
T
LeoVasanko d4add54f10 Editor sockets: auth-aware reconnect, shared with the analytics view
An expired session failed the /_api/ws/editor handshake exactly like a dead
network, so the page/banner editors retried forever — the WS API cannot
open the login dialog. reconnect.js gains authReconnect(): on close, probe
/_api/settings (dialog-less fetchJson); a 401/403 runs the paskia login
flow and reconnects the moment it completes, a declined login holds at a
'session expired' ConnNote, and only transient failures take the paced
backoff. A paskia auth-success message from anywhere in the page
reconnects a waiting editor at once. AnalyticsView moves from its inline
copy of the same flow onto the shared helper.
2026-09-25 18:19:05 +00:00

92 lines
3.7 KiB
JavaScript

// 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'
}
}