// @ts-check /** * The connection to the relay. It knows nothing about pages or encryption: * it delivers bytes to peers and reports who is present. It reconnects by * itself when the connection drops. */ const RETRY_DELAYS_MS = [500, 1000, 2000, 5000]; /** Close codes after which retrying is pointless (see the relay). */ const FINAL_CLOSE_CODES = new Map([ [4001, 'The session is full.'], [4002, 'The relay is full.'], ]); /** * @typedef {object} RelayHandlers * @property {(peer: number, peers: number[]) => void} onWelcome * Connected (or reconnected): our peer number and the peers present. * @property {(peer: number) => void} onJoin * @property {(peer: number) => void} onLeave * @property {(peer: number, body: Uint8Array) => void} onMessage * @property {(status: 'connecting' | 'connected' | 'failed', detail?: string) => void} onStatus */ export class RelayConnection { /** * @param {string} relay Address of the relay, ws:// or wss://. * @param {string} roomId * @param {RelayHandlers} handlers */ constructor(relay, roomId, handlers) { this.url = `${relay}/room/${roomId}`; this.handlers = handlers; /** @type {WebSocket | null} */ this.socket = null; this.attempt = 0; this.closed = false; } connect() { if (this.closed) return; this.handlers.onStatus('connecting'); const socket = new WebSocket(this.url); socket.binaryType = 'arraybuffer'; this.socket = socket; socket.onmessage = (event) => { if (socket !== this.socket) return; if (typeof event.data === 'string') { const control = JSON.parse(event.data); if (control.type === 'welcome') { this.attempt = 0; this.handlers.onStatus('connected'); this.handlers.onWelcome(control.peer, control.peers); } else if (control.type === 'join') { this.handlers.onJoin(control.peer); } else if (control.type === 'leave') { this.handlers.onLeave(control.peer); } } else { const bytes = new Uint8Array(event.data); if (bytes.length < 4) return; const peer = new DataView(event.data).getUint32(0); this.handlers.onMessage(peer, bytes.subarray(4)); } }; socket.onclose = (event) => { if (socket !== this.socket) return; this.socket = null; if (this.closed) return; const final = FINAL_CLOSE_CODES.get(event.code); if (final) { this.closed = true; this.handlers.onStatus('failed', final); return; } this.handlers.onStatus('connecting'); const delay = RETRY_DELAYS_MS[Math.min(this.attempt, RETRY_DELAYS_MS.length - 1)]; this.attempt += 1; setTimeout(() => this.connect(), delay); }; } /** @returns {boolean} */ get connected() { return this.socket !== null && this.socket.readyState === WebSocket.OPEN; } /** * @param {number} destination A peer number, or 0 for everyone else. * @param {Uint8Array} body */ send(destination, body) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; const message = new Uint8Array(4 + body.length); new DataView(message.buffer).setUint32(0, destination); message.set(body, 4); this.socket.send(message); } close() { this.closed = true; if (this.socket) this.socket.close(); } }