theseus/bundled-addons/pithos/core/daemon.js
Local Dev 4ad95b3c7a Theseus: bundle the Pithos add-on
Ships Pithos (the s3d control panel) as a built-in extension: the dock
menu opens it in a tab served from a loopback port, and "Stop s3d"
shuts the gateway down. Generated by Pithos/scripts/build-theseus-addon.mjs.

yaml is vendored under vendor/yaml/lib rather than node_modules/ or
dist/, because Theseus git-ignores both and a clean-worktree release
build would otherwise ship the add-on without its only dependency.
2026-10-03 19:03:05 +02:00

117 lines
4 KiB
JavaScript

// Owns the s3d process: start/stop, a log ring buffer, and one-shot CLI runs
// (users/keys/version) against the same config file the daemon uses.
import { spawn, execFile } from 'node:child_process';
import { EventEmitter } from 'node:events';
import path from 'node:path';
const LOG_LINES = 2000;
const ANSI = /\x1b\[[0-9;]*m/g;
export class Daemon extends EventEmitter {
constructor({ binary, configFile }) {
super();
this.binary = binary;
this.configFile = configFile;
this.child = null;
this.state = 'stopped'; // stopped | starting | running | stopping | crashed
this.exitCode = null;
this.startedAt = null;
this.logs = [];
this.seq = 0;
}
env() {
// S3D_CONFIG_FILE pins every invocation to the config this GUI edits; the
// cwd is the config's folder because s3d also searches ./s3d.yml first.
return { ...process.env, S3D_CONFIG_FILE: this.configFile };
}
cwd() { return path.dirname(this.configFile); }
status() {
return {
state: this.state,
pid: this.child?.pid ?? null,
exitCode: this.exitCode,
startedAt: this.startedAt,
binary: this.binary,
needsLogin: !!this.needsLogin,
configFile: this.configFile,
};
}
setState(state) {
this.state = state;
this.emit('status', this.status());
}
pushLog(stream, chunk) {
for (const raw of chunk.toString('utf8').split(/\r?\n/)) {
const line = raw.replace(ANSI, '');
if (!line) continue;
const entry = { seq: ++this.seq, t: Date.now(), stream, line };
// s3d logs "server started" once every listener is up.
if (this.state === 'starting' && /server started/.test(line)) this.setState('running');
if (/No app key found/.test(line)) this.needsLogin = true;
this.logs.push(entry);
if (this.logs.length > LOG_LINES) this.logs.shift();
this.emit('log', entry);
}
}
logsSince(seq = 0) { return this.logs.filter((l) => l.seq > seq); }
start() {
if (!this.binary) throw new Error('s3d binary not found');
if (this.child) return this.status();
this.exitCode = null;
this.needsLogin = false;
this.setState('starting');
const child = spawn(this.binary, [], {
cwd: this.cwd(), env: this.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
this.child = child;
this.startedAt = Date.now();
child.stdout.on('data', (d) => this.pushLog('out', d));
child.stderr.on('data', (d) => this.pushLog('err', d));
child.on('error', (err) => {
this.pushLog('err', `failed to start s3d: ${err.message}`);
// A spawn failure never emits 'exit'.
if (child.pid === undefined) { this.child = null; this.setState('crashed'); }
});
child.on('exit', (code, signal) => {
this.child = null;
this.exitCode = code;
this.pushLog('sys', `s3d exited (${signal || `code ${code}`})`);
this.setState(this.state === 'stopping' ? 'stopped' : 'crashed');
});
return this.status();
}
async stop() {
const child = this.child;
if (!child) return this.status();
this.setState('stopping');
const exited = new Promise((r) => child.once('exit', r));
child.kill(); // SIGTERM on unix; TerminateProcess on Windows
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 10_000);
await exited;
clearTimeout(timer);
return this.status();
}
// One-shot CLI run. Resolves { code, stdout, stderr }; never rejects on a
// non-zero exit so callers can surface s3d's own error text.
run(args, { timeout = 30_000 } = {}) {
if (!this.binary) return Promise.reject(new Error('s3d binary not found'));
return new Promise((resolve, reject) => {
execFile(this.binary, args, {
cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20,
}, (err, stdout, stderr) => {
if (err && typeof err.code !== 'number') return reject(err);
resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') });
});
});
}
}