206 lines
6.1 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
|