/** * Lightweight JSON-RPC over WebSocket client (vanilla JS). * * Usage: * const client = new JsonRpcWsClient({ url: 'wss://vill.now/mopidy/ws/' }); * client.on('open', () => console.log('connected')); * client.call('core.playback.get_state').then(result => console.log(result)); * client.on('notification', (notif) => console.log('notif', notif)); * client.connect(); * * Works with wss:// if your page is https. If you pass ws:// on an https page, * the browser will block it (mixed content). Use a reverse proxy or wss. */ class JsonRpcWsClient { constructor(opts = {}) { this.url = opts.url || this._defaultUrl(); // ws/wss endpoint this.reconnect = opts.reconnect ?? true; this.reconnectDelay = opts.reconnectDelay ?? 1000; // initial ms this.maxReconnectDelay = opts.maxReconnectDelay ?? 30000; // max ms this.requestTimeout = opts.requestTimeout ?? 15000; // ms this._ws = null; this._nextId = 1; this._pending = new Map(); // id -> {resolve,reject,timeout} this._listeners = new Map(); // event -> [fn,...] this._sendQueue = []; // queue while disconnected this._backoff = this.reconnectDelay; this._manuallyClosed = false; } // ---------- public API ---------- connect() { if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return; this._manuallyClosed = false; this._openWebSocket(); } disconnect() { this._manuallyClosed = true; if (this._ws) { try { this._ws.close(1000, 'client disconnect'); } catch (e) {} } this._clearPending(new Error('Client disconnected')); } /** * Send a JSON-RPC request. Returns a Promise which resolves with result or rejects on error/timeout. * method: string, params: array or object or undefined. */ call(method, params) { const id = this._nextId++; const payload = { jsonrpc: '2.0', method, params, id }; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this._pending.delete(id); reject(new Error('JSON-RPC request timed out')); }, this.requestTimeout); this._pending.set(id, { resolve, reject, timeout }); const str = JSON.stringify(payload); if (this._isWsOpen()) { this._ws.send(str); } else { // queue until connected this._sendQueue.push(str); } }); } /** * Send a raw notification (no id expected). */ notify(method, params) { const payload = { jsonrpc: '2.0', method, params }; const str = JSON.stringify(payload); if (this._isWsOpen()) this._ws.send(str); else this._sendQueue.push(str); } on(event, fn) { if (!this._listeners.has(event)) this._listeners.set(event, []); this._listeners.get(event).push(fn); return () => this.off(event, fn); } off(event, fn) { if (!this._listeners.has(event)) return; this._listeners.set(event, this._listeners.get(event).filter(f => f !== fn)); } // ---------- internal helpers ---------- _defaultUrl() { // if page is https, prefer wss, else ws. const loc = window.location; const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'; // default path used by Mopidy is often /mopidy/ws/ — but we don't assume that; user can pass url. return `${proto}//${loc.host}/mopidy/ws/`; } _emit(event, ...args) { const list = this._listeners.get(event) || []; for (const fn of list.slice()) { try { fn(...args); } catch (e) { console.error('listener error', e); } } } _openWebSocket() { try { this._ws = new WebSocket(this.url); } catch (err) { // e.g. invalid URL or blocked this._emit('error', err); this._scheduleReconnect(); return; } this._ws.addEventListener('open', () => { this._backoff = this.reconnectDelay; this._emit('open'); // flush queue while (this._sendQueue.length && this._isWsOpen()) { this._ws.send(this._sendQueue.shift()); } }); this._ws.addEventListener('message', (ev) => this._handleMessage(ev.data)); this._ws.addEventListener('close', (ev) => { this._emit('close', ev); // reject pending requests this._clearPending(new Error(`WebSocket closed (code=${ev.code})`)); if (!this._manuallyClosed && this.reconnect) this._scheduleReconnect(); }); this._ws.addEventListener('error', (err) => { this._emit('error', err); // errors are followed by close; clear pending on close }); } _isWsOpen() { return this._ws && this._ws.readyState === WebSocket.OPEN; } _scheduleReconnect() { if (!this.reconnect) return; const delay = this._backoff; this._emit('reconnect_scheduled', delay); setTimeout(() => { if (this._manuallyClosed) return; this._openWebSocket(); }, delay); this._backoff = Math.min(this._backoff * 1.8, this.maxReconnectDelay); } _clearPending(err) { for (const [id, p] of this._pending) { clearTimeout(p.timeout); p.reject(err); } this._pending.clear(); } _handleMessage(raw) { let msg; try { msg = JSON.parse(raw); } catch (err) { // not JSON at all this._emit('raw', raw); return; } // JSON-RPC response (has id) if (msg && Object.prototype.hasOwnProperty.call(msg, 'id')) { const id = msg.id; const pending = this._pending.get(id); if (pending) { clearTimeout(pending.timeout); this._pending.delete(id); if (msg.error !== undefined) { pending.reject(msg.error); this._emit('response', { id, error: msg.error }); } else { pending.resolve(msg.result); this._emit('response', { id, result: msg.result }); } } else { // response to unknown request this._emit('response', msg); } return; } // JSON-RPC notification (has method but no id) if (msg && Object.prototype.hasOwnProperty.call(msg, 'method')) { this._emit('notification', msg); return; } // Fallback: just emit as plain message this._emit('message', msg); } }