WizardConnect/packages/core/src/connection-manager.ts
2026-03-06 11:38:09 +01:00

228 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,
};
};