// Copyright (C) 2026 Whiterun LLC, // This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. // A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html import { debug, Scope, LogScope } from "./log.js"; import { EventEmitter } from "eventemitter3"; async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export interface VisibilityChangeContext { client: TClient; state: "hidden" | "visible"; setPaused: (_value: boolean) => void; isPaused: () => boolean; startConnectionLoop: () => void; } export interface ConnectionManagerOptions< TClient extends ConnectionClient = ConnectionClient, > { reconnectInterval?: number; maxReconnectAttempts?: number; enableVisibilityHandling?: boolean; scope?: LogScope; onVisibilityChange?: ( _context: VisibilityChangeContext, ) => void | Promise; } export interface ConnectionClient extends EventEmitter { connect(): Promise; disconnect(..._args: any[]): Promise; } export interface ConnectionManagerResult { cleanup: () => Promise; startConnectionLoop: () => void; } export const createConnectionManager = ( client: TClient, callbacks: { onConnected: (_client: TClient) => void; onReconnecting: (_client: TClient, _reason: string | null) => void; onDisconnected: (_client: TClient) => void; onError?: (_client: TClient, _error: any) => void; }, events: { connected: string; disconnected: string; error?: string; }, options: ConnectionManagerOptions = {}, ): ConnectionManagerResult => { const { reconnectInterval = 5000, maxReconnectAttempts = Infinity, enableVisibilityHandling = true, scope = Scope.Network, onVisibilityChange, } = options; let isPaused = false; let reconnectLoop: Promise | null = null; let visibilityChangeHandler: (() => void) | null = null; const triggerReconnect = (reason: string | null) => { if (!isPaused) { callbacks.onReconnecting(client, reason); reconnectLoop = null; startConnectionLoop(); } }; const onConnected = () => callbacks.onConnected(client); client.on(events.connected, onConnected); const onDisconnected = (...args: any[]) => { const err = args[0] instanceof Error ? args[0].message : null; debug(scope, "Disconnected event received", err); triggerReconnect(err); }; const onError = (err: any) => { const errorMsg = err?.message || String(err); debug(scope, "Error event received", errorMsg); triggerReconnect(errorMsg); if (callbacks.onError) { callbacks.onError(client, err); } }; client.on(events.disconnected, onDisconnected); if (events.error) { client.on(events.error, onError); } const setupVisibilityHandling = () => { if (typeof document === "undefined") { return; } visibilityChangeHandler = () => { const state = document.visibilityState as "hidden" | "visible"; if (onVisibilityChange) { const context: VisibilityChangeContext = { client, state, setPaused: (value: boolean) => { isPaused = value; }, isPaused: () => isPaused, startConnectionLoop, }; (async () => { try { await onVisibilityChange(context); } catch (err) { debug(scope, "Error in custom visibility change handler:", err); } })(); return; } if (state === "hidden") { isPaused = true; (async () => { try { await client.disconnect(); callbacks.onDisconnected(client); } catch (err) { debug(scope, "Error disconnecting on visibility change:", err); } })(); } else if (state === "visible") { if (isPaused) { isPaused = false; startConnectionLoop(); } } }; document.addEventListener("visibilitychange", visibilityChangeHandler); }; const startConnectionLoop = () => { if (reconnectLoop) { return; } reconnectLoop = (async () => { let reconnectAttempts = 0; let wasConnected = false; while (true) { if (isPaused) { await sleep(1000); continue; } try { await client.connect(); // onConnected is fired via the "connection" event emitted inside client.connect() // — do NOT call callbacks.onConnected here too, that would double-fire it. reconnectAttempts = 0; wasConnected = true; reconnectLoop = null; return; } catch (e) { reconnectAttempts++; if (wasConnected || reconnectAttempts === 1) { callbacks.onReconnecting(client, `${e}`); wasConnected = false; } if (reconnectAttempts > maxReconnectAttempts) { callbacks.onDisconnected(client); reconnectLoop = null; return; } try { await client.disconnect(); } catch (disconnectError) { debug(scope, "Failed to disconnect client", disconnectError); } await sleep(reconnectInterval); } } })(); }; if (enableVisibilityHandling) { setupVisibilityHandling(); } const cleanup = async () => { isPaused = true; if (typeof document !== "undefined" && visibilityChangeHandler !== null) { document.removeEventListener("visibilitychange", visibilityChangeHandler); } client.off(events.connected, onConnected); client.off(events.disconnected, onDisconnected); if (events.error) { client.off(events.error, onError); } callbacks.onDisconnected(client); try { await client.disconnect(); } catch (e) { debug(scope, "Failed to disconnect client during cleanup", e); } }; return { cleanup, startConnectionLoop, }; };