monex
Version:
Execute one or multiple scripts, interactively or in daemon mode, and restart them whenever they crash or a watched file changes.
179 lines (178 loc) • 6.86 kB
JavaScript
/* IMPORT */
import { debounce } from 'dettle';
import dirname from 'tiny-dirname';
import { spawn } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { color } from 'specialist';
import Watcher from 'watcher';
import whenExit from 'when-exit';
import zeptomatch from 'zeptomatch';
import Logger from './logger.js';
import PID from './pid.js';
import Stdin from './stdin.js';
/* MAIN */
class ControllerSingle {
/* CONSTRUCTOR */
constructor(options) {
/* HELPERS */
this._processKill = async () => {
const { process } = this;
if (!process)
return;
this.process = undefined;
await PID.tree.kill(process.pid, process.pids || [process.pid]);
};
this._watcherKill = async () => {
const { watcher } = this;
if (!watcher)
return;
this.watcher = undefined;
watcher.close();
};
/* API */
this.restart = async () => {
if (this.restarting)
return;
this.restarting = true;
await this._processKill();
await this.start();
this.restarting = false;
};
this.start = async () => {
if (this.process)
return;
this.restarts += 1;
console.log(`[monex] ${this.name ? `${color.bold(this.name)} - ` : ''}Starting...`);
const exec = this.options.exec.replace(/^npm:/, 'npm run ');
let proc;
if (this.cluster > 1) { // Spawning in cluster mode
const nodeRe = /^node\s+/;
const isNode = nodeRe.test(exec);
if (!isNode)
throw new Error('Only "node" scripts support cluster mode');
const execArgsRe = /"([^"]*)"|'([^']*)'|([\w\/\\-]+)/g;
const execArgs = [...exec.matchAll(execArgsRe)].map(match => match[1] || match[2] || match[3]);
const execPath = execArgs[1];
const args = execArgs.slice(2);
const name = this.options.name;
const delay = this.options.delay ?? 1000;
const size = this.cluster;
const options = { name, exec: execPath, args, delay, size };
const controllerPath = path.join(dirname(import.meta.url), 'controller_cluster.js');
proc = this.process = spawn('node', [controllerPath, JSON.stringify(options)], {
stdio: ['ignore', null, null],
shell: false
});
}
else { // Spawn in single mode
proc = this.process = spawn(exec, {
stdio: ['ignore', null, null],
shell: true
});
}
const updatePids = async () => {
if (this.process !== proc)
return;
const pids = await PID.tree.get(proc.pid);
proc.pids = pids || proc.pids;
};
const kill = () => {
stdinDisposer();
clearInterval(pidsInterval);
PID.tree.kill(proc.pid, proc.pids || [proc.pid]);
};
const closed = (code) => {
kill();
if (code === 0 && this.options.watch)
return;
restart();
};
const restart = debounce(() => {
if (this.process !== proc)
return kill();
this.restart();
}, 500);
const stdinDisposer = (this.options.stdin !== false) ? Stdin.onRestart(restart) : () => { };
const pidsInterval = setInterval(updatePids, 1000);
updatePids();
proc.on('close', closed);
proc.on('error', restart);
proc.on('exit', closed);
const log = (data) => {
if (this.options.prefix) {
Logger.log(data, this.options.name, this.options.color);
}
else {
Logger.log(data);
}
};
const onStdout = (data) => {
this.stdout += data.toString();
this.stdout = this.stdout.slice(-128000);
log(data);
};
const onStderr = (data) => {
this.stderr += data.toString();
this.stderr = this.stderr.slice(-128000);
log(data);
};
proc.stdout.on('data', onStdout);
proc.stderr.on('data', onStderr);
this.watch();
};
this.stat = async () => {
const pid = this.process?.pid;
const usage = await PID.tree.usage(pid, [pid]);
return {
pid: pid || -1,
name: this.options.name || '',
online: !!pid,
cluster: this.cluster,
restarts: this.restarts,
birthtime: usage?.birthtime || 0,
uptime: usage?.uptime || 0,
cpu: (usage?.cpu || 0) / 100,
memory: usage?.memory || 0,
stdout: this.stdout,
stderr: this.stderr
};
};
this.stop = async () => {
await this._processKill();
await this._watcherKill();
};
this.watch = () => {
if (this.watcher)
return this;
if (!this.options.watch)
return this;
const targetPaths = this.options.watch.map(targetPath => path.resolve(process.cwd(), targetPath));
const ignoreGlobs = this.options.ignore || [];
const ignore = (targetPath) => !!ignoreGlobs.length && zeptomatch(ignoreGlobs, targetPath);
const delay = this.options.delay ?? 1000;
const halfDelay = Math.floor(delay / 2);
const restart = debounce(this.restart, halfDelay);
const options = {
native: true,
recursive: true,
ignoreInitial: true,
debounce: delay,
ignore
};
this.watcher = new Watcher(targetPaths, options, restart);
return this;
};
this.options = options;
this.name = options.name || '';
this.cluster = (typeof options.cluster === 'number' && options.cluster >= 0 && options.cluster !== 1) ? options.cluster || os.cpus().length : 1;
this.restarting = false;
this.restarts = -1;
this.stdout = '';
this.stderr = '';
whenExit(this.stop);
}
}
/* EXPORT */
export default ControllerSingle;