full initial implementation

This commit is contained in:
Shivering Ocelot committed 2026-09-03 23:43:33 -05:00
1 parent 65fc75e509
commit d9d9b7f463
15 files changed
+799 -4

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
// 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 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;
}
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;
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();
}
};
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);
}
};
socket.onerror = (event) => {
console.error('WebSocket error:', event.message || event.error || event);
};
}
close() {
this.stopped = true;
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
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:', raw);
}
}
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
// remote-center: connects local functions to a horde hub as remotes.
//
// Usage:
// node src/main.js run the center from the current directory
// node src/main.js init [dir] copy the center template into <dir>/.remote and exit
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { Horde } from './horde.mjs';
import { Remote, Watcher } from './runtime.mjs';
const APP_DIR = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const TEMPLATES_DIR = path.join(APP_DIR, 'templates');
const DEFAULT_HOST = 'wss://hub.hordelab.com';
const DEFAULT_NAME = 'remote-center';
// --- init command ---
function init(dir = '.') {
const remoteDir = path.resolve(dir, '.remote');
if (fs.existsSync(remoteDir)) {
console.error(`Refusing to overwrite existing ${remoteDir}`);
process.exit(1);
}
fs.cpSync(path.join(TEMPLATES_DIR, 'center'), remoteDir, { recursive: true });
console.log(`Initialized center remote at ${remoteDir}`);
console.log('Set your hub token in .remote/config.json, then run: node src/main.js');
}
// --- run ---
function loadConfig(workPath) {
try {
return JSON.parse(fs.readFileSync(path.join(workPath, '.remote', 'config.json'), 'utf8'));
} catch {
return {};
}
}
function run() {
const workPath = process.cwd();
const config = loadConfig(workPath);
const host = process.env.HORDE_HOST || config.host || DEFAULT_HOST;
const name = config.name || DEFAULT_NAME;
const token = process.env.HORDE_TOKEN || config.token || null;
const spacesDir = path.join(workPath, 'spaces');
console.log(`Running remote-center '${name}' on ${host}, working path ${workPath}`);
if (!token) console.warn('No token set in .remote/config.json');
const horde = new Horde({ host, token });
const center = new Remote({
horde,
name,
functionsDir: path.join(workPath, '.remote', 'functions'),
});
const remotes = new Map([[name, center]]);
function checkSpaceName(spaceName) {
if (typeof spaceName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(spaceName)) {
throw new Error(`Invalid space name: ${JSON.stringify(spaceName)}`);
}
}
async function createSpace(spaceName) {
checkSpaceName(spaceName);
const dir = path.join(spacesDir, spaceName);
if (fs.existsSync(dir)) throw new Error(`Space already exists: ${spaceName}`);
fs.cpSync(path.join(TEMPLATES_DIR, 'space'), path.join(dir, '.remote'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.remote', 'config.json'),
JSON.stringify({ name: spaceName }, null, 2) + '\n',
);
const remote = addSpaceRemote(spaceName, dir);
if (!(await remote.register())) throw new Error('Failed to register space with the hub');
console.log(`Created space '${spaceName}' at ${dir}`);
return { name: spaceName, dir };
}
async function cloneSpace(source, newName) {
const isUrl = /^https?:\/\//.test(source) || source.endsWith('.git') || /@[^@]+:/.test(source);
if (!newName) {
if (!isUrl) throw new Error('A new space name is required when cloning an existing space');
newName = path.basename(source).replace(/\.git$/, '');
}
checkSpaceName(newName);
const dir = path.join(spacesDir, newName);
if (fs.existsSync(dir)) throw new Error(`Space already exists: ${newName}`);
if (isUrl) {
await new Promise((resolve, reject) => {
execFile('git', ['clone', source, dir], (err, _out, stderr) => {
if (err) reject(new Error(`git clone failed: ${stderr.trim() || err.message}`));
else resolve();
});
});
if (!fs.existsSync(path.join(dir, '.remote'))) {
fs.cpSync(path.join(TEMPLATES_DIR, 'space'), path.join(dir, '.remote'), { recursive: true });
}
} else {
const sourceDir = path.join(spacesDir, source);
if (!fs.existsSync(sourceDir)) throw new Error(`Unknown space: ${source}`);
fs.cpSync(sourceDir, dir, { recursive: true });
}
const remote = addSpaceRemote(newName, dir);
if (!(await remote.register())) throw new Error('Failed to register space with the hub');
console.log(`Cloned space '${newName}' from ${source}`);
return { name: newName, dir };
}
const api = {
createSpace,
cloneSpace,
listSpaces: () => [...remotes.keys()].filter((n) => n !== name),
};
center.ctx = { dir: workPath, remoteName: name, api };
function addSpaceRemote(spaceName, dir) {
const remoteName = `${name}/${spaceName}`;
let remote = remotes.get(remoteName);
if (remote) return remote;
remote = new Remote({
horde,
name: remoteName,
functionsDir: path.join(dir, '.remote', 'functions'),
ctx: { dir, remoteName, spaceName, api },
fallback: center.functions, // spaces share the center's functions
});
remotes.set(remoteName, remote);
return remote;
}
// Registers new spaces and disconnects deleted ones. Safe to call repeatedly.
function reconcileSpaces() {
let entries = [];
try {
entries = fs.readdirSync(spacesDir, { withFileTypes: true });
} catch {
// spaces/ does not exist yet
}
const present = new Set();
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = path.join(spacesDir, entry.name);
if (!fs.existsSync(path.join(dir, '.remote'))) continue;
present.add(entry.name);
addSpaceRemote(entry.name, dir).register();
}
for (const [remoteName, remote] of remotes) {
if (remoteName === name) continue;
const spaceName = remoteName.slice(name.length + 1);
if (!present.has(spaceName)) {
remotes.delete(remoteName);
remote.disconnect();
console.log(`Space '${spaceName}' deleted; disconnected`);
}
}
}
horde.onAction('remote-call', (packet) => {
const body = packet.body ?? packet;
const remoteName = body.remotename;
const fnName = body.function;
const args = body.args ?? {};
const requestId = body.requestId ?? packet.requestId;
const respond = (fields) => {
try {
horde.send({
action: 'remote-call-result',
nonce: packet.nonce,
body: { requestId, ...fields },
});
} catch (err) {
console.error('Failed to send remote-call-result:', err.message);
}
};
Promise.resolve()
.then(() => {
const remote = remotes.get(remoteName);
if (!remote) throw new Error(`Unknown remote: ${remoteName}`);
return remote.call(fnName, args);
})
.then((result) => respond({ result: result ?? null }))
.catch((err) => respond({ error: String(err?.message || err) }));
});
horde.onConnect(() => {
// (Re)register every remote after each (re)connection
for (const remote of remotes.values()) remote.register();
});
reconcileSpaces();
new Watcher(spacesDir, () => reconcileSpaces()).start();
horde.connect();
process.on('SIGINT', async () => {
console.log('Shutting down...');
for (const remote of remotes.values()) await remote.disconnect();
horde.close();
process.exit(0);
});
}
// --- entry point ---
const [command, ...rest] = process.argv.slice(2);
if (command === 'init') {
init(rest[0]);
} else if (command) {
console.error(`Unknown command: ${command}`);
console.error('Usage: node src/main.js [init [directory]]');
process.exit(1);
} else {
run();
}
+179
View File
@@ -0,0 +1,179 @@
// Remote runtime: loads function modules from directories, watches them for
// changes, and registers remotes on a horde connection.
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const FUNCTION_EXTENSIONS = ['.mjs', '.js'];
// A directory of function modules. Each module exports `spec`
// ({ name, description, parameters }) and a default async function (args, ctx).
export class Functions {
constructor(dir) {
this.dir = dir;
this.map = new Map(); // spec.name -> { spec, fn, file }
}
async scan() {
const map = new Map();
let entries = [];
try {
entries = fs.readdirSync(this.dir, { withFileTypes: true });
} catch {
this.map = map; // no directory yet: no functions
return map;
}
// Load .mjs first so it wins over a same-named .js module
entries.sort((a, b) =>
FUNCTION_EXTENSIONS.indexOf(path.extname(a.name)) -
FUNCTION_EXTENSIONS.indexOf(path.extname(b.name)));
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!FUNCTION_EXTENSIONS.includes(path.extname(entry.name))) continue;
const file = path.join(this.dir, entry.name);
try {
// Cache-busting query: node caches modules by URL
const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
const { spec } = mod;
if (!spec || typeof spec.name !== 'string') {
throw new Error('module must export a `spec` object with a `name` string');
}
if (typeof mod.default !== 'function') {
throw new Error('module must export a default function');
}
if (map.has(spec.name)) continue;
map.set(spec.name, { spec, fn: mod.default, file });
} catch (err) {
console.error(`Failed to load function module ${file}: ${err.message}`);
}
}
this.map = map;
return map;
}
specs() {
return [...this.map.values()].map((f) => f.spec);
}
has(name) {
return this.map.has(name);
}
async call(name, args, ctx) {
const fn = this.map.get(name);
if (!fn) throw new Error(`Unknown function: ${name}`);
return await fn.fn(args, ctx);
}
}
// Watches a directory using fs.watch, with a polling fallback every 5 seconds.
export class Watcher {
constructor(dir, onChange, intervalMs = 5000) {
this.dir = dir;
this.onChange = onChange;
this.intervalMs = intervalMs;
this.fsWatcher = null;
this.timer = null;
this.signature = null;
this.debounce = null;
}
signatureOf() {
try {
return fs.readdirSync(this.dir, { withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => {
const st = fs.statSync(path.join(this.dir, e.name));
return `${e.name}:${st.mtimeMs}`;
})
.sort()
.join('|');
} catch {
return null;
}
}
check() {
const signature = this.signatureOf();
if (signature !== this.signature) {
const changed = this.signature !== null;
this.signature = signature;
if (changed) this.onChange();
}
}
start() {
this.signature = this.signatureOf();
try {
this.fsWatcher = fs.watch(this.dir, () => {
if (this.debounce) clearTimeout(this.debounce);
this.debounce = setTimeout(() => this.check(), 100);
});
} catch (err) {
console.warn(`fs.watch unavailable for ${this.dir}, relying on polling: ${err.message}`);
}
this.timer = setInterval(() => this.check(), this.intervalMs);
}
stop() {
if (this.fsWatcher) this.fsWatcher.close();
if (this.timer) clearInterval(this.timer);
if (this.debounce) clearTimeout(this.debounce);
}
}
// A named collection of functions in a directory, registered on the hub.
export class Remote {
constructor({ horde, name, functionsDir, ctx = null, fallback = null }) {
this.horde = horde;
this.name = name;
this.ctx = ctx;
this.fallback = fallback; // shared Functions instance (center functions)
this.functions = new Functions(functionsDir);
this.watcher = null;
}
// (Re)scans functions and (re)registers the remote. Returns success.
async register() {
await this.functions.scan();
try {
await this.horde.sendAction('remote-connect', {
remotename: this.name,
functions: this.functions.specs(),
});
console.log(`Registered remote '${this.name}' with ${this.functions.map.size} function(s)`);
} catch (err) {
console.error(`Failed to register remote '${this.name}': ${err.message}`);
return false;
}
if (!this.watcher) {
this.watcher = new Watcher(this.functions.dir, () => this.register());
this.watcher.start();
}
return true;
}
async disconnect() {
if (this.watcher) this.watcher.stop();
try {
await this.horde.sendAction('remote-disconnect', { remotename: this.name });
} catch {
// socket may already be gone
}
}
// Handles an incoming remote-call. Falls back to shared center functions.
async call(functionName, args) {
if (this.functions.has(functionName)) {
return await this.functions.call(functionName, args, this.ctx);
}
if (this.fallback) {
return await this.fallback.call(functionName, args, this.ctx);
}
throw new Error(`Unknown function '${functionName}' on remote '${this.name}'`);
}
}