import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting"; export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting"; export const WS_RECONNECT_INITIAL_DELAY_MS = 1_000; export const WS_RECONNECT_BACKOFF_FACTOR = 2; export const WS_RECONNECT_MAX_DELAY_MS = 64_000; export const WS_RECONNECT_MAX_RETRIES = 7; export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1; export interface WsConnectionStatus { readonly attemptCount: number; readonly closeCode: number | null; readonly closeReason: string | null; readonly connectedAt: string | null; readonly disconnectedAt: string | null; readonly hasConnected: boolean; readonly lastError: string | null; readonly lastErrorAt: string | null; readonly nextRetryAt: string | null; readonly online: boolean; readonly phase: "idle" | "connecting" | "connected" | "disconnected"; readonly reconnectAttemptCount: number; readonly reconnectMaxAttempts: number; readonly reconnectPhase: WsReconnectPhase; readonly socketUrl: string | null; } const INITIAL_WS_CONNECTION_STATUS = Object.freeze({ attemptCount: 0, closeCode: null, closeReason: null, connectedAt: null, disconnectedAt: null, hasConnected: false, lastError: null, lastErrorAt: null, nextRetryAt: null, online: typeof navigator === "undefined" ? true : navigator.onLine !== false, phase: "idle", reconnectAttemptCount: 0, reconnectMaxAttempts: WS_RECONNECT_MAX_ATTEMPTS, reconnectPhase: "idle", socketUrl: null, }); export const wsConnectionStatusAtom = Atom.make(INITIAL_WS_CONNECTION_STATUS).pipe( Atom.keepAlive, Atom.withLabel("ws-connection-status"), ); function isoNow() { return new Date().toISOString(); } function updateWsConnectionStatus( updater: (current: WsConnectionStatus) => WsConnectionStatus, ): WsConnectionStatus { const nextStatus = updater(getWsConnectionStatus()); appAtomRegistry.set(wsConnectionStatusAtom, nextStatus); return nextStatus; } export function getWsConnectionStatus(): WsConnectionStatus { return appAtomRegistry.get(wsConnectionStatusAtom); } export function getWsConnectionUiState(status: WsConnectionStatus): WsConnectionUiState { if (status.phase === "connected") { return "connected"; } if (!status.online && (status.disconnectedAt !== null || status.phase === "disconnected")) { return "offline"; } if (!status.hasConnected) { return status.phase === "disconnected" ? "error" : "connecting"; } return "reconnecting"; } export function recordWsConnectionAttempt(socketUrl: string): WsConnectionStatus { return updateWsConnectionStatus((current) => ({ ...current, attemptCount: current.attemptCount + 1, nextRetryAt: null, phase: "connecting", reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1, reconnectPhase: "attempting", socketUrl, })); } export function recordWsConnectionOpened(): WsConnectionStatus { return updateWsConnectionStatus((current) => ({ ...current, closeCode: null, closeReason: null, connectedAt: isoNow(), disconnectedAt: null, hasConnected: true, nextRetryAt: null, phase: "connected", reconnectAttemptCount: 0, reconnectPhase: "idle", })); } export function recordWsConnectionErrored(message?: string | null): WsConnectionStatus { return updateWsConnectionStatus((current) => applyDisconnectState(current, { lastError: message?.trim() ? message : current.lastError, lastErrorAt: isoNow(), }), ); } export function recordWsConnectionClosed(details?: { readonly code?: number; readonly reason?: string; }): WsConnectionStatus { return updateWsConnectionStatus((current) => applyDisconnectState(current, { closeCode: details?.code ?? current.closeCode, closeReason: details?.reason?.trim() ? details.reason : current.closeReason, }), ); } export function setBrowserOnlineStatus(online: boolean): WsConnectionStatus { return updateWsConnectionStatus((current) => ({ ...current, online, })); } export function resetWsReconnectBackoff(): WsConnectionStatus { return updateWsConnectionStatus((current) => ({ ...current, nextRetryAt: null, reconnectAttemptCount: 0, reconnectPhase: "idle", })); } export function resetWsConnectionStateForTests(): void { appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS); } export function useWsConnectionStatus(): WsConnectionStatus { return useAtomValue(wsConnectionStatusAtom); } export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null { if (!Number.isInteger(retryIndex) || retryIndex < 0 || retryIndex >= WS_RECONNECT_MAX_RETRIES) { return null; } return Math.min( Math.round(WS_RECONNECT_INITIAL_DELAY_MS * WS_RECONNECT_BACKOFF_FACTOR ** retryIndex), WS_RECONNECT_MAX_DELAY_MS, ); } function applyDisconnectState( current: WsConnectionStatus, updates: Partial< Pick >, ): WsConnectionStatus { const disconnectedAt = current.disconnectedAt ?? isoNow(); const nextRetryDelayMs = current.nextRetryAt !== null || current.reconnectPhase === "exhausted" ? null : getWsReconnectDelayMsForRetry(Math.max(0, current.reconnectAttemptCount - 1)); return { ...current, ...updates, disconnectedAt, nextRetryAt: nextRetryDelayMs === null ? current.nextRetryAt : new Date(Date.now() + nextRetryDelayMs).toISOString(), phase: "disconnected", reconnectPhase: current.reconnectPhase === "waiting" || current.reconnectPhase === "exhausted" ? current.reconnectPhase : nextRetryDelayMs === null ? "exhausted" : "waiting", }; }