import type { WsEvent, WsStatus } from "./types"; type WsEventCallback = (event: WsEvent) => void; const REMOTE_WS = "wss://imphnen.asepharyana.my.id/ws"; function getWsUrl(): string { if (typeof window === "undefined") return REMOTE_WS; const hostname = window.location.hostname; // Always route WS through the remote server (even from local dev) if (hostname === "localhost" || hostname === "127.0.0.1") { return REMOTE_WS; } // Production: nginx proxies /ws/* to backend on the same host const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const port = window.location.port; return `${protocol}://${hostname}${port ? `:${port}` : ""}/ws`; } export class WsConnection { private ws: WebSocket | null = null; private url: string; private reconnectAttempt = 0; private maxReconnectAttempts = 20; private reconnectTimeout: ReturnType | null = null; private _status: WsStatus = "disconnected"; private statusListeners: Array<(status: WsStatus) => void> = []; private eventListeners: Array = []; private destroyed = false; constructor(url?: string) { this.url = url ?? getWsUrl(); } get status(): WsStatus { return this._status; } onStatusChange(listener: (status: WsStatus) => void): () => void { this.statusListeners.push(listener); return () => { this.statusListeners = this.statusListeners.filter((l) => l !== listener); }; } onEvent(listener: WsEventCallback): () => void { this.eventListeners.push(listener); return () => { this.eventListeners = this.eventListeners.filter((l) => l !== listener); }; } connect(): void { if (this.destroyed) return; if (this._status === "connected" || this._status === "connecting") return; this.setStatus("connecting"); try { this.ws = new WebSocket(this.url); } catch (_err) { this.setStatus("error"); this.scheduleReconnect(); return; } this.ws.onopen = () => { this.reconnectAttempt = 0; this.setStatus("connected"); }; this.ws.onclose = () => { this.setStatus("disconnected"); this.scheduleReconnect(); }; this.ws.onerror = () => { this.setStatus("error"); }; this.ws.onmessage = (msg: MessageEvent) => { if (typeof msg.data === "string") { this.dispatchEvent({ type: "text", data: msg.data }); } else if (msg.data instanceof ArrayBuffer) { this.dispatchEvent({ type: "binary", data: msg.data }); } else if (msg.data instanceof Blob) { // Blob — convert to ArrayBuffer msg.data.arrayBuffer().then((buffer) => { this.dispatchEvent({ type: "binary", data: buffer }); }); } }; } disconnect(): void { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } if (this.ws) { this.ws.onclose = null; // prevent reconnect this.ws.close(); this.ws = null; } this.setStatus("disconnected"); } destroy(): void { this.destroyed = true; this.disconnect(); this.statusListeners = []; this.eventListeners = []; } sendText(text: string): void { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(text); } } sendBinary(data: ArrayBufferLike): void { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(data); } } private setStatus(status: WsStatus): void { if (this._status === status) return; this._status = status; this.statusListeners.forEach((l) => l(status)); } private dispatchEvent(event: WsEvent): void { this.eventListeners.forEach((l) => l(event)); } private scheduleReconnect(): void { if (this.destroyed || this.reconnectAttempt >= this.maxReconnectAttempts) return; // Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5) const base = Math.min(1000 * 2 ** this.reconnectAttempt, 30000); const jitter = 0.5 + Math.random() * 0.5; const delay = Math.floor(base * jitter); this.reconnectAttempt++; this.reconnectTimeout = setTimeout(() => { this.connect(); }, delay); } }