229 lines
6 KiB
TypeScript
229 lines
6 KiB
TypeScript
|
|
// 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<void> {
|
||
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface VisibilityChangeContext<TClient> {
|
||
|
|
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<TClient>,
|
||
|
|
) => void | Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ConnectionClient extends EventEmitter {
|
||
|
|
connect(): Promise<void>;
|
||
|
|
disconnect(..._args: any[]): Promise<void | boolean>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ConnectionManagerResult {
|
||
|
|
cleanup: () => Promise<void>;
|
||
|
|
startConnectionLoop: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const createConnectionManager = <TClient extends ConnectionClient>(
|
||
|
|
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<TClient> = {},
|
||
|
|
): ConnectionManagerResult => {
|
||
|
|
const {
|
||
|
|
reconnectInterval = 5000,
|
||
|
|
maxReconnectAttempts = Infinity,
|
||
|
|
enableVisibilityHandling = true,
|
||
|
|
scope = Scope.Network,
|
||
|
|
onVisibilityChange,
|
||
|
|
} = options;
|
||
|
|
|
||
|
|
let isPaused = false;
|
||
|
|
let reconnectLoop: Promise<void> | 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<TClient> = {
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
};
|