init
This commit is contained in:
commit
a796176a17
8 changed files with 947 additions and 0 deletions
206
jsonws.js
Normal file
206
jsonws.js
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue