const DEFAULT_OPTIONS = { scheme: 'ws', host: '127.0.0.1', port: 8080, key: 'game-local-key', client: 'zombie-client', version: '1.0.0', }; export class SocketClient { constructor(options = {}) { this.opts = { ...DEFAULT_OPTIONS, ...options }; this.socket = null; this.socketId = null; this.connected = false; this.closedByUser = false; this.retries = 0; this.handlers = new Map(); this.queue = []; this.onConnectionChange = null; } get url() { const { scheme, host, port, key, client, version } = this.opts; return `${scheme}://${host}:${port}/app/${key}?protocol=7&client=${client}&version=${version}&flash=false`; } _connect() { if (this.socket) return; this.closedByUser = false; const socket = new WebSocket(this.url); this.socket = socket; socket.onopen = () => { this.retries = 0; }; socket.onmessage = (event) => { let msg; try { msg = JSON.parse(event.data); } catch { return; } const rawBytes = typeof event.data === 'string' ? new Blob([event.data]).size : 0; this._handle(msg, rawBytes); }; socket.onclose = () => { this.socket = null; if (this.socketId) { this.connected = false; this.socketId = null; this.onConnectionChange?.(false); } if (this.closedByUser) return; if (!this.handlers.size) return; const delay = Math.min(5000, 300 * 2 ** this.retries); this.retries += 1; setTimeout(() => this._connect(), delay); }; socket.onerror = () => {}; } connect() { if (this.socket) return; this._connect(); } close() { this.closedByUser = true; this.handlers.clear(); this.socket?.close(); this.socket = null; this.connected = false; this.socketId = null; } _handle(msg, rawBytes = 0) { const { event, channel } = msg; if (event === 'pusher:connection_established') { try { this.socketId = JSON.parse(msg.data).socket_id; } catch { this.socketId = null; } this.connected = true; this.onConnectionChange?.(true); for (const channel of this.handlers.keys()) { this._sendSubscribe(channel); } this.queue = []; return; } if (event === 'pusher_internal:subscription_succeeded') { if (channel) this.handlers.get(channel)?.onSubscribed?.(); return; } if (event === 'pusher:ping') { this.send({ event: 'pusher:pong', data: {} }); return; } if (event === 'pusher:pong') { return; } if (channel && this.handlers.has(channel)) { let payload = msg.data; if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { return; } } this.handlers.get(channel)?.onEvent?.(event, payload, rawBytes); } } _sendSubscribe(channel) { this.send({ event: 'pusher:subscribe', data: { channel } }); } subscribe(channel, { onEvent = null, onSubscribed = null } = {}) { this.handlers.set(channel, { onEvent, onSubscribed }); if (this.connected) { this._sendSubscribe(channel); } else { this.queue.push({ channel }); } } unsubscribe(channel) { this.handlers.delete(channel); this.queue = this.queue.filter((entry) => entry.channel !== channel); if (this.connected) { this.send({ event: 'pusher:unsubscribe', data: { channel } }); } } trigger(event, channel, data) { if (!this.connected) return; this.send({ event, channel, data }); } send(payload) { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(payload)); } } }