p2p-media-loader-core
Version:
P2P Media Loader core functionality
187 lines (156 loc) • 4.96 kB
text/typescript
import { EventTarget } from "../../utils/event-target.js";
export type WebSocketClientEventMap = {
connected: () => void;
disconnected: () => void;
reconnecting: () => void;
error: (error: Event) => void;
message: (data: ArrayBuffer | string) => void;
};
export type WebSocketClientState =
| "connecting"
| "connected"
| "reconnecting"
| "disconnected"
| "disposed";
export interface WebSocketClientConfig {
url: string;
initialDelay?: number;
maxDelay?: number;
jitterMultiplier?: number;
}
export class WebSocketClient {
readonly #config: Required<WebSocketClientConfig>;
#state: WebSocketClientState = "disconnected";
#ws: WebSocket | null = null;
#backoffCount = 0;
#reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null;
readonly #eventTarget = new EventTarget<WebSocketClientEventMap>();
constructor(config: WebSocketClientConfig) {
const initialDelay = Math.max(100, config.initialDelay ?? 1000);
this.#config = {
url: config.url,
initialDelay,
// Ensure maxDelay is never less than initialDelay
maxDelay: Math.max(initialDelay, config.maxDelay ?? 30000),
jitterMultiplier: Math.max(0, config.jitterMultiplier ?? 0.2),
};
}
public get state(): WebSocketClientState {
return this.#state;
}
public addEventListener<K extends keyof WebSocketClientEventMap>(
eventName: K,
listener: WebSocketClientEventMap[K],
): void {
this.#eventTarget.addEventListener(eventName, listener);
}
public removeEventListener<K extends keyof WebSocketClientEventMap>(
eventName: K,
listener: WebSocketClientEventMap[K],
): void {
this.#eventTarget.removeEventListener(eventName, listener);
}
public connect(): void {
if (
this.#state === "connected" ||
this.#state === "connecting" ||
this.#state === "disposed"
) {
return;
}
this.#state = "connecting";
this.#clearReconnectTimeout();
try {
this.#ws = new WebSocket(this.#config.url);
this.#ws.binaryType = "arraybuffer";
this.#ws.onopen = this.#onOpen;
this.#ws.onclose = this.#onClose;
this.#ws.onerror = this.#onError;
this.#ws.onmessage = this.#onMessage;
} catch (error) {
this.#state = "disconnected";
const errorEvent = new ErrorEvent("error", {
message:
error instanceof Error
? error.message
: "Unknown WebSocket creation error",
error,
});
this.#eventTarget.dispatchEvent("error", errorEvent);
}
}
public send(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
data: string | ArrayBuffer | Blob | ArrayBufferView<ArrayBuffer>,
): void {
if (this.#state !== "connected" || !this.#ws) {
throw new Error("WebSocketClient: Cannot send data when not connected");
}
this.#ws.send(data);
}
public dispose(): void {
this.#state = "disposed";
this.#clearReconnectTimeout();
if (this.#ws) {
this.#ws.onopen = null;
this.#ws.onclose = null;
this.#ws.onerror = null;
this.#ws.onmessage = null;
this.#ws.close();
this.#ws = null;
}
this.#eventTarget.clear();
}
#onOpen = (): void => {
if (this.#state === "disposed") return;
this.#state = "connected";
this.#backoffCount = 0;
this.#eventTarget.dispatchEvent("connected");
};
#onClose = (): void => {
if (this.#state === "disposed") return;
if (this.#ws) {
this.#ws.onopen = null;
this.#ws.onclose = null;
this.#ws.onerror = null;
this.#ws.onmessage = null;
this.#ws = null;
}
this.#scheduleReconnect();
this.#eventTarget.dispatchEvent("disconnected");
};
#onError = (event: Event): void => {
if (this.#state === "disposed") return;
this.#eventTarget.dispatchEvent("error", event);
};
#onMessage = (event: MessageEvent<ArrayBuffer | string>): void => {
if (this.#state === "disposed") return;
this.#eventTarget.dispatchEvent("message", event.data);
};
#scheduleReconnect(): void {
if (this.#state === "disposed") return;
this.#state = "reconnecting";
const baseDelay = Math.min(
this.#config.initialDelay * Math.pow(2, this.#backoffCount),
this.#config.maxDelay,
);
const jitter = baseDelay * this.#config.jitterMultiplier;
const randomJitter = Math.random() * 2 * jitter - jitter;
const delay = Math.max(0, baseDelay + randomJitter);
if (baseDelay < this.#config.maxDelay) {
this.#backoffCount++;
}
this.#reconnectTimeoutId = setTimeout(() => {
if (this.#state !== "disposed") {
this.connect();
}
}, delay);
this.#eventTarget.dispatchEvent("reconnecting");
}
#clearReconnectTimeout(): void {
if (this.#reconnectTimeoutId !== null) {
clearTimeout(this.#reconnectTimeoutId);
this.#reconnectTimeoutId = null;
}
}
}