initial implementation
This commit is contained in:
1 parent
536b308096
commit
a08c41a426
7 files changed
+949
-20
No files matched your search
+204
@@ -0,0 +1,204 @@
|
||||
// Horde hub WebSocket client for Node.js (>= 22, uses the global WebSocket).
|
||||
// Modeled on the browser client in reference/backend.js: nonce-tracked actions,
|
||||
// action listeners, orphan handlers for the auth response, auto-reconnect.
|
||||
|
||||
export default class Horde {
|
||||
constructor({ host, token = null }) {
|
||||
this.host = host;
|
||||
this.token = token;
|
||||
this.socket = null;
|
||||
this.nextNonce = 0;
|
||||
this.pending = new Map(); // nonce -> { resolve, reject }
|
||||
this.actionListeners = new Map(); // action -> [callback(packet)]
|
||||
this.orphanHandlers = []; // callbacks for nonce-less messages (auth replies)
|
||||
this.connectListeners = [];
|
||||
this.reconnectTimeout = null;
|
||||
this.stopped = false;
|
||||
this.pingTimeout = null; // Tracks the heartbeat interval
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.stopped) return;
|
||||
if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) return;
|
||||
|
||||
const socket = new WebSocket(this.host + "/ws");
|
||||
this.socket = socket;
|
||||
|
||||
let promiseInner = {};
|
||||
const connectPromise = new Promise((resolve, reject) => {
|
||||
promiseInner = {resolve, reject};
|
||||
});
|
||||
|
||||
socket.onopen = async () => {
|
||||
console.log(`Connected to ${this.host}`);
|
||||
try {
|
||||
if (this.token) {
|
||||
socket.send(JSON.stringify({ token: this.token }));
|
||||
const response = await this.waitOrphan();
|
||||
if (response && response.error) {
|
||||
console.error('Hub authentication failed:', response.error);
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.warn('No auth token configured; the hub may reject this session');
|
||||
}
|
||||
for (const cb of this.connectListeners) {
|
||||
try { cb(); } catch (err) { console.error('Connect listener failed:', err); }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error during hub login:', err);
|
||||
socket.close();
|
||||
}
|
||||
|
||||
// Start heartbeat
|
||||
this.schedulePing();
|
||||
|
||||
if (promiseInner) {
|
||||
promiseInner.resolve();
|
||||
promiseInner = null;
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') this.handleMessage(event.data);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this.clearPending();
|
||||
if (!this.stopped) {
|
||||
console.log('Disconnected from hub. Reconnecting in 1s...');
|
||||
this.reconnectTimeout = setTimeout(() => this.connect(), 1000);
|
||||
}
|
||||
this.pingTimeout = null;
|
||||
};
|
||||
|
||||
socket.onerror = (event) => {
|
||||
console.error('WebSocket error:', event.message || event.error || event);
|
||||
|
||||
if (promiseInner) {
|
||||
promiseInner.reject(event.message || event.error || event);
|
||||
promiseInner = null;
|
||||
}
|
||||
};
|
||||
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.stopped = true;
|
||||
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
|
||||
if (this.pingTimeout) clearTimeout(this.pingTimeout);
|
||||
if (this.socket) {
|
||||
this.socket.onclose = null;
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
this.clearPending();
|
||||
}
|
||||
|
||||
clearPending() {
|
||||
for (const { reject } of this.pending.values()) {
|
||||
reject(new Error('Connection lost'));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
onConnect(cb) {
|
||||
this.connectListeners.push(cb);
|
||||
}
|
||||
|
||||
onAction(action, cb) {
|
||||
let list = this.actionListeners.get(action);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this.actionListeners.set(action, list);
|
||||
}
|
||||
list.push(cb);
|
||||
}
|
||||
|
||||
waitOrphan() {
|
||||
return new Promise((resolve) => this.orphanHandlers.push(resolve));
|
||||
}
|
||||
|
||||
send(value) {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('WebSocket is not open');
|
||||
}
|
||||
this.socket.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
sendAction(action, body = {}) {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error('WebSocket is not open'));
|
||||
}
|
||||
const nonce = this.nextNonce++;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
this.pending.set(nonce, { resolve, reject });
|
||||
});
|
||||
promise.catch(() => {}); // avoid unhandled rejections when nobody awaits
|
||||
this.send({ action, nonce, body });
|
||||
return promise;
|
||||
}
|
||||
|
||||
handleMessage(raw) {
|
||||
let packet;
|
||||
try {
|
||||
packet = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse hub message:', raw, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.nonce != null) {
|
||||
const pending = this.pending.get(packet.nonce);
|
||||
if (!pending) {
|
||||
console.warn(`Orphan response for nonce ${packet.nonce}`);
|
||||
return;
|
||||
}
|
||||
this.pending.delete(packet.nonce);
|
||||
if (packet.error) pending.reject(new Error(packet.error));
|
||||
else pending.resolve(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.action) {
|
||||
const listeners = this.actionListeners.get(packet.action);
|
||||
if (listeners && listeners.length) {
|
||||
for (const cb of listeners) {
|
||||
try { cb(packet); } catch (err) {
|
||||
console.error(`Listener for '${packet.action}' failed:`, err);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.orphanHandlers.length) {
|
||||
this.orphanHandlers.shift()(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.error) console.error('Hub error:', packet.error);
|
||||
else {
|
||||
//console.warn('Ignored hub message:', packet.action);
|
||||
}
|
||||
}
|
||||
|
||||
schedulePing() {
|
||||
if (this.pingTimeout) clearTimeout(this.pingTimeout);
|
||||
this.pingTimeout = setTimeout(() => {
|
||||
if (this.stopped) {
|
||||
this.pingTimeout = null;
|
||||
return;
|
||||
}
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendAction("ping");
|
||||
this.schedulePing();
|
||||
} else {
|
||||
this.pingTimeout = null;
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user