Consolidate MangaCircus app, scraper, and Compose baseline
This commit is contained in:
1 parent
cfb2d79103
commit
eef4ffa07c
103 files changed
+9316
-172
No files matched your search
@@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
function secret(name) {
|
||||
if (process.env[`${name}_FILE`]) return fs.readFileSync(process.env[`${name}_FILE`], 'utf8').trim();
|
||||
return process.env[name] || '';
|
||||
}
|
||||
module.exports = { secret };
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
const { Supervisor } = require('./supervisor');
|
||||
const supervisor = new Supervisor(process.argv[2] || 'reader');
|
||||
supervisor.start();
|
||||
const keepalive = setInterval(() => {}, 60000);
|
||||
let closing = false;
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, async () => {
|
||||
if (closing) return; closing = true; clearInterval(keepalive); await supervisor.close(); process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
const fs = require('fs'), path = require('path'), { spawn } = require('child_process');
|
||||
const SPECS = {
|
||||
reader: { script: 'server.js', port: () => process.env.PORT || 8080, file: () => path.join(process.env.DATA || 'data', 'runtime-reader.json') },
|
||||
scraper: { script: 'scraper/daemon.js', port: () => process.env.SCRAPER_PORT || 8091, file: () => path.join(process.env.SCRAPER_DATA || path.join(process.env.DATA || 'data', 'scraper'), 'runtime.json') },
|
||||
sentinel: { script: 'sentinel/daemon.js', port: () => process.env.SENTINEL_PORT || 8092, file: () => path.join(process.env.SENTINEL_DATA || path.join(process.env.DATA || 'data', 'sentinel'), 'runtime.json') },
|
||||
};
|
||||
class Supervisor {
|
||||
constructor(name, options = {}) {
|
||||
this.name = name; this.spec = SPECS[name]; if (!this.spec) throw new Error('Unknown service');
|
||||
this.root = options.root || path.resolve(__dirname, '..'); this.child = null; this.stopped = false; this.timer = null;
|
||||
this.healthFailures = 0; this.restarting = false; this.startedAt = 0;
|
||||
this.spawn = options.spawn || spawn; this.retryBaseMs = options.retryBaseMs ?? 5000;
|
||||
this.file = options.file || path.resolve(this.root, this.spec.file()); fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
||||
this.state = { failures: [], status: 'starting' };
|
||||
if (fs.existsSync(this.file)) this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
}
|
||||
save() { fs.writeFileSync(this.file + '.tmp', JSON.stringify(this.state, null, 2) + '\n'); fs.renameSync(this.file + '.tmp', this.file); }
|
||||
log(event, extra = {}) { console.log(JSON.stringify({ service: 'supervisor', target: this.name, event, ...extra })); }
|
||||
start() {
|
||||
if (this.stopped) return;
|
||||
this.state.failures = this.state.failures.filter(at => Date.now() - at < 3600000);
|
||||
if (this.state.failures.length >= 3) { this.state.status = 'parked'; this.save(); this.log('repair-required', { reason: 'Three failures within one hour' }); return; }
|
||||
this.child = this.spawn(process.execPath, [this.spec.script], { cwd: this.root, env: process.env, stdio: 'inherit', windowsHide: true });
|
||||
this.state.status = 'running'; this.state.pid = this.child.pid; this.startedAt = Date.now(); this.healthFailures = 0; this.restarting = false; this.save();
|
||||
this.log('started', { pid: this.child.pid });
|
||||
const child = this.child;
|
||||
child.on('error', e => this.log('spawn-error', { message: e.message }));
|
||||
child.once('close', (code, signal) => {
|
||||
if (this.child !== child) return;
|
||||
this.child = null; clearTimeout(this.killTimer);
|
||||
if (this.stopped) return;
|
||||
clearTimeout(this.timer);
|
||||
this.state.failures.push(Date.now()); this.state.status = 'retry_wait'; this.state.lastExit = { at: new Date().toISOString(), code, signal }; this.save();
|
||||
this.log('exited', { code, signal });
|
||||
this.timer = setTimeout(() => this.start(), Math.min(60000, this.retryBaseMs * 2 ** (this.state.failures.length - 1)));
|
||||
});
|
||||
this.timer = setTimeout(() => this.check(), 30000);
|
||||
}
|
||||
async check() {
|
||||
if (this.stopped || !this.child) return;
|
||||
const observedChild = this.child;
|
||||
const healthy = await fetch(`http://127.0.0.1:${this.spec.port()}/health`, { signal: AbortSignal.timeout(5000) }).then(async r => r.ok && (await r.json()).ok !== false).catch(() => false);
|
||||
if (this.stopped || this.child !== observedChild) return;
|
||||
this.healthFailures = healthy ? 0 : this.healthFailures + 1;
|
||||
if (this.healthFailures >= 3 && Date.now() - this.startedAt > 90000 && !this.restarting) {
|
||||
this.restarting = true; this.log('liveness-recovery');
|
||||
const child = this.child; child.kill('SIGTERM');
|
||||
this.killTimer = setTimeout(() => { if (this.child === child) child.kill('SIGKILL'); }, 65000);
|
||||
}
|
||||
if (!this.stopped) this.timer = setTimeout(() => this.check(), 30000);
|
||||
}
|
||||
async close() {
|
||||
this.stopped = true; clearTimeout(this.timer); clearTimeout(this.killTimer);
|
||||
if (!this.child) return;
|
||||
const child = this.child;
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 65000);
|
||||
child.once('exit', () => { clearTimeout(timer); resolve(); }); child.kill('SIGTERM');
|
||||
});
|
||||
this.state.status = 'stopped'; this.save();
|
||||
}
|
||||
}
|
||||
module.exports = { Supervisor, SPECS };
|
||||
Reference in new issue
Block a user