jukebox/jsonws.js
Eric Villnow a796176a17 init
2025-10-03 21:29:17 -05:00

206 lines
6.1 KiB
JavaScript

/**
* 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);
}
}