345 lines
10 KiB
JavaScript
345 lines
10 KiB
JavaScript
// 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.
|
|
|
|
// Mirrors the browser Stream: accumulates live content and exposes it as a
|
|
// string via getContent() once the server-side stream reaches eof.
|
|
export class Stream {
|
|
constructor(id) {
|
|
this.id = id;
|
|
this.partialContent = "";
|
|
this.eof = false;
|
|
this.listeners = { data: [], end: [] };
|
|
this.contentPromise = new Promise((resolve) => {
|
|
this.on('end', () => resolve(this.partialContent));
|
|
});
|
|
}
|
|
|
|
feed(chunk) {
|
|
if (this.eof) return;
|
|
this.partialContent += chunk;
|
|
this.emit('data', chunk);
|
|
}
|
|
|
|
close() {
|
|
if (this.eof) return;
|
|
this.eof = true;
|
|
this.emit('end', this.partialContent);
|
|
}
|
|
|
|
getContent() {
|
|
return this.contentPromise;
|
|
}
|
|
|
|
emit(event, ...args) {
|
|
for (const fn of this.listeners[event]) fn(...args);
|
|
}
|
|
|
|
on(event, callback) {
|
|
this.listeners[event].push(callback);
|
|
if (event === 'data' && this.partialContent) callback(this.partialContent);
|
|
if (event === 'end' && this.eof) callback(this.partialContent);
|
|
}
|
|
}
|
|
|
|
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 -> single { resolve, reject } or streaming { run, deferred }
|
|
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 entry of this.pending.values()) {
|
|
const reject = entry.streaming ? entry.deferred.reject : entry.reject;
|
|
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));
|
|
}
|
|
|
|
// Sends an action. Without a callback this is a single-response request.
|
|
// With a callback it is a multiple-result action (like the browser client's
|
|
// sendAction): the callback runs once per packet carrying this nonce, and the
|
|
// returned promise settles only when the callback calls deferred.resolve()
|
|
// (or deferred.reject()). This is the shape used by read-stream and
|
|
// prompt-agent, which both emit many packets per request.
|
|
sendAction(action, body = {}, callback = null) {
|
|
const nonce = this.nextNonce++;
|
|
|
|
if (callback) {
|
|
let resolveInner, rejectInner;
|
|
const promise = new Promise((res, rej) => { resolveInner = res; rejectInner = rej; });
|
|
const deferred = { promise, resolve: resolveInner, reject: rejectInner };
|
|
this.pending.set(nonce, {
|
|
streaming: true,
|
|
run: (packet) => callback(packet, deferred),
|
|
deferred,
|
|
});
|
|
// Ensure cleanup runs whichever way the action settles, and swallow the
|
|
// rejection on the derived chain so it never surfaces as unhandled.
|
|
promise.catch(() => {}).finally(() => this.pending.delete(nonce));
|
|
this.send({ action, nonce, body });
|
|
return promise;
|
|
}
|
|
|
|
const promise = new Promise((resolve, reject) => {
|
|
this.pending.set(nonce, { streaming: false, resolve, reject });
|
|
});
|
|
promise.catch(() => {}); // avoid unhandled rejections when nobody awaits
|
|
this.send({ action, nonce, body });
|
|
return promise;
|
|
}
|
|
|
|
// Reads a server-side stream into a Stream object. Content arrives as a
|
|
// sequence of read-stream packets sharing one nonce; eof closes the stream.
|
|
readStream(streamId) {
|
|
const stream = new Stream(streamId);
|
|
this.sendAction("read-stream", { streamId }, (packet, deferred) => {
|
|
if (packet.eof) {
|
|
stream.close();
|
|
deferred.resolve(stream.partialContent);
|
|
} else if (packet.data != null) {
|
|
stream.feed(packet.data);
|
|
}
|
|
}).catch(() => stream.close());
|
|
return stream;
|
|
}
|
|
|
|
// Runs a prompt and yields every generated inference message in arrival
|
|
// order. prompt-agent is a multiple-result action; assistant/reasoning
|
|
// content is an open stream ({streamId}) which is read to completion before
|
|
// the message is yielded, so callers always see plain string content.
|
|
async *prompt(agentId, body) {
|
|
const queue = [];
|
|
let notify = null;
|
|
let finished = false;
|
|
let failure = null;
|
|
|
|
const wake = () => {
|
|
if (notify) { const resolve = notify; notify = null; resolve(); }
|
|
};
|
|
|
|
this.sendAction(
|
|
"prompt-agent",
|
|
{ agentId, ...body },
|
|
(packet, deferred) => {
|
|
if (packet.eof) {
|
|
finished = true;
|
|
deferred.resolve(packet);
|
|
wake();
|
|
} else {
|
|
queue.push(packet.data);
|
|
wake();
|
|
}
|
|
}
|
|
).catch((err) => {
|
|
failure = err;
|
|
finished = true;
|
|
wake();
|
|
});
|
|
|
|
while (true) {
|
|
if (failure) throw failure;
|
|
if (queue.length) {
|
|
yield await this.resolveStreams(queue.shift());
|
|
continue;
|
|
}
|
|
if (finished) return;
|
|
await new Promise((resolve) => { notify = resolve; });
|
|
}
|
|
}
|
|
|
|
// Resolves any open Stream in a message's content to its final text.
|
|
async resolveStreams(msg) {
|
|
const content = msg && msg.content;
|
|
if (content && typeof content === 'object' && content.streamId != null) {
|
|
const stream = this.readStream(content.streamId);
|
|
const full = await stream.getContent();
|
|
return { ...msg, content: full };
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
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 entry = this.pending.get(packet.nonce);
|
|
if (!entry) {
|
|
console.warn(`Orphan response for nonce ${packet.nonce}`);
|
|
return;
|
|
}
|
|
|
|
if (entry.streaming) {
|
|
// Multiple-result action: keep the entry alive and hand every packet
|
|
// (including the terminal eof) to the callback. The callback settles
|
|
// the promise once the action is complete.
|
|
if (packet.error) entry.deferred.reject(new Error(packet.error));
|
|
else entry.run(packet);
|
|
return;
|
|
}
|
|
|
|
this.pending.delete(packet.nonce);
|
|
if (packet.error) entry.reject(new Error(packet.error));
|
|
else entry.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);
|
|
}
|
|
} |