run-script-webpack-plugin icon indicating copy to clipboard operation
run-script-webpack-plugin copied to clipboard

Wait till process stopped

Open nick4fake opened this issue 2 years ago • 0 comments

These features would be really nice to have:

  • Wait till process stopped during restart
  • Kill process with SIGKILL if it didn't stop during timeout

Example:

import {ChildProcess, fork} from 'child_process';
import {Compilation, Compiler, WebpackPluginInstance} from 'webpack';

const killProcess = async ({pid, signal = 'SIGTERM', timeout}) => {
  process.kill(pid, signal);
  let count = 0;
  do {
    try {
      process.kill(pid, 0);
    } catch (e) {
      return;
    }
    if ((count += 100) > timeout) {
      break;
    }

    await new Promise(cb => setTimeout(cb, 100));
  } while (true);

  try {
    process.kill(pid, 'SIGKILL');
  } catch (e) {
    return;
  }

  count = 0;
  do {
    try {
      process.kill(pid, 0);
    } catch (e) {
      return;
    }
    if ((count += 100) > timeout) {
      throw new Error('Timeout process kill');
    }

    await new Promise(cb => setTimeout(cb, 100));
  } while (true);

};

export type RunScriptWebpackPluginOptions = {
  autoRestart?: boolean;
  args: string[];
  cwd?: string;
  keyboard: boolean;
  name?: string;
  nodeArgs: string[];
  restartable?: boolean;
  signal: boolean | string;
  killTimeoutMs?: number;
};

function getSignal(signal: string | boolean) {
  // allow users to disable sending a signal by setting to `false`...
  if (signal === false) return;
  if (signal === true) return 'SIGUSR2';
  return signal;
}

export class RunScriptWebpackPlugin implements WebpackPluginInstance {
  private readonly options: RunScriptWebpackPluginOptions;

  private worker?: ChildProcess;

  private _entrypoint?: string;

  constructor(options: Partial<RunScriptWebpackPluginOptions> = {}) {
    this.options = {
      autoRestart: true,
      signal: false,
      killTimeoutMs: 5000,
      // Only listen on keyboard in development, so the server doesn't hang forever
      keyboard: process.env.NODE_ENV === 'development',
      ...options,
      args: [...(options.args || [])],
      nodeArgs: options.nodeArgs || process.execArgv,
    };

    if (this.options.restartable) {
      this._enableRestarting();
    }
  }

  private _enableRestarting(): void {
    if (this.options.keyboard) {
      process.stdin.setEncoding('utf8');
      process.stdin.on('data', (data: string) => {
        if (data.trim() === 'rs') {
          this._restartServer();
        }
      });
    }
  }

  private async _restartServer(): Promise<void> {
    console.log('Restarting app...');
    if (this.worker?.pid) {
      const signal = getSignal(this.options.signal);
      await killProcess({
        pid: this.worker.pid,
        signal,
        timeout: this.options.killTimeoutMs,
      });
    }
    this._startServer((worker) => {
      this.worker = worker;
    });
  }

  private afterEmit = (compilation: Compilation, cb: (err?: any) => void): void => {
    if (this.worker && this.worker.connected && this.worker?.pid) {
      if (this.options.autoRestart) {
        this._restartServer().then(() => cb()).catch(err => cb(err));
        return;
      }
      const signal = getSignal(this.options.signal);
      if (signal) {
        killProcess({
          pid: this.worker.pid,
          signal,
          timeout: this.options.killTimeoutMs,
        }).then(() => cb()).catch(err => cb(err));
      }
      cb();
      return;
    }

    this.startServer(compilation, cb);
  };

  apply = (compiler: Compiler): void => {
    compiler.hooks.afterEmit.tapAsync(
      {name: 'RunScriptPlugin'},
      this.afterEmit,
    );
  };

  private startServer = (compilation: Compilation, cb: () => void): void => {
    const {assets, compiler} = compilation;
    const {options} = this;
    let name;
    const names = Object.keys(assets);
    if (options.name) {
      name = options.name;
      if (!assets[name]) {
        console.error(
          `Entry ${name} not found. Try one of: ${names.join(' ')}`,
        );
      }
    } else {
      name = names[0];
      if (names.length > 1) {
        console.log(
          `More than one entry built, selected ${name}. All names: ${names.join(
            ' ',
          )}`,
        );
      }
    }
    if (!compiler.options.output || !compiler.options.output.path) {
      throw new Error('output.path should be defined in webpack config!');
    }

    this._entrypoint = `${compiler.options.output.path}/${name}`;
    this._startServer((worker) => {
      this.worker = worker;
      cb();
    });
  };

  private _startServer(cb: (arg0: ChildProcess) => void): void {
    const {args, nodeArgs, cwd} = this.options;
    if (!this._entrypoint) throw new Error('run-script-webpack-plugin requires an entrypoint.');

    const child = fork(this._entrypoint, args, {
      execArgv: nodeArgs,
      stdio: 'inherit',
      cwd,
    });
    setTimeout(() => cb(child), 0);
  }
}

nick4fake avatar Jan 07 '23 05:01 nick4fake