monex
Version:
Execute one or multiple scripts, interactively or in daemon mode, and restart them whenever they crash or a watched file changes.
262 lines (178 loc) • 6.32 kB
text/typescript
/* 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 '~/interactive/logger';
import PID from '~/interactive/pid';
import Stdin from '~/interactive/stdin';
import type {Buffer} from 'node:buffer';
import type {OptionsSingle, OptionsCluster, Process, Stat} from '~/types';
/* MAIN */
class ControllerSingle {
/* VARIABLES */
private options: OptionsSingle;
private name: string;
private cluster: number;
private restarting: boolean;
private restarts: number;
private stdout: string;
private stderr: string;
private process?: Process;
private watcher?: Watcher;
/* CONSTRUCTOR */
constructor ( options: OptionsSingle ) {
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 );
}
/* HELPERS */
private _processKill = async (): Promise<void> => {
const {process} = this;
if ( !process ) return;
this.process = undefined;
await PID.tree.kill ( process.pid, process.pids || [process.pid] );
}
private _watcherKill = async (): Promise<void> => {
const {watcher} = this;
if ( !watcher ) return;
this.watcher = undefined;
watcher.close ();
}
/* API */
restart = async (): Promise<void> => {
if ( this.restarting ) return;
this.restarting = true;
await this._processKill ();
await this.start ();
this.restarting = false;
}
start = async (): Promise<void> => {
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: Process;
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: OptionsCluster = { 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 (): Promise<void> => {
if ( this.process !== proc ) return;
const pids = await PID.tree.get ( proc.pid );
proc.pids = pids || proc.pids;
};
const kill = (): void => {
stdinDisposer ();
clearInterval ( pidsInterval );
PID.tree.kill ( proc.pid, proc.pids || [proc.pid] );
};
const closed = ( code: number | null ): void => {
kill ();
if ( code === 0 && this.options.watch ) return;
restart ();
};
const restart = debounce ( (): void => {
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: Buffer | string ) => {
if ( this.options.prefix ) {
Logger.log ( data, this.options.name, this.options.color );
} else {
Logger.log ( data );
}
};
const onStdout = ( data: Buffer | string ) => {
this.stdout += data.toString ();
this.stdout = this.stdout.slice ( -128_000 );
log ( data );
};
const onStderr = ( data: Buffer | string ) => {
this.stderr += data.toString ();
this.stderr = this.stderr.slice ( -128_000 );
log ( data );
};
proc.stdout.on ( 'data', onStdout );
proc.stderr.on ( 'data', onStderr );
this.watch ();
}
stat = async (): Promise<Stat> => {
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
};
}
stop = async (): Promise<void> => {
await this._processKill ();
await this._watcherKill ();
}
watch = (): this => {
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: string ): boolean => !!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;
}
}
/* EXPORT */
export default ControllerSingle;