UNPKG

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
/* 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;