UNPKG

vitest-dev-server

Version:
207 lines (204 loc) 6.68 kB
import { Transform } from 'node:stream'; import { createServer } from 'node:net'; import { promisify } from 'node:util'; import chalk from 'chalk'; import { spawnd } from 'spawnd'; import cwd from 'cwd'; import waitOn from 'wait-on'; import findProcess from 'find-process'; import treeKill from 'tree-kill'; import prompts from 'prompts'; const DEFAULT_CONFIG = { debug: false, options: {}, launchTimeout: 5000, host: undefined, port: undefined, protocol: "tcp", usedPortAction: "ask", waitOnScheme: undefined }; const resolveConfig = (config)=>{ return { ...DEFAULT_CONFIG, ...config }; }; const pTreeKill = promisify(treeKill); const serverLogPrefixer = new Transform({ transform (chunk, _encoding, callback) { this.push(chalk.magentaBright(`[vitest-dev-server] ${chunk.toString()}`)); callback(); } }); const ERROR_TIMEOUT = "ERROR_TIMEOUT"; const ERROR_PORT_USED = "ERROR_PORT_USED"; const ERROR_NO_COMMAND = "ERROR_NO_COMMAND"; class JestDevServerError extends Error { code; constructor(message, options){ // @ts-ignore - cause is not part of the Error constructor (yet) super(message, options?.cause ? { cause: options.cause } : undefined); this.code = options?.code; } } const logProcDetection = (name, port)=>{ console.log(chalk.blue(`🕵️ Detecting a process "${name}" running on port "${port}"`)); }; const killProc = async (proc)=>{ console.log(chalk.yellow(`Killing process ${proc.name}...`)); await pTreeKill(proc.pid); console.log(chalk.green(`Successfully killed process ${proc.name}`)); }; const spawnServer = (config)=>{ if (!config.command) { throw new JestDevServerError("You must define a `command`", { code: ERROR_NO_COMMAND }); } const proc = spawnd(config.command, { shell: true, env: process.env, cwd: cwd(), ...config.options }); if (config.debug) { console.log(chalk.magentaBright("\nJest dev-server output:")); proc.stdout.pipe(serverLogPrefixer).pipe(process.stdout); } else { // eslint-disable-next-line @typescript-eslint/no-empty-function proc.stdout.on("data", ()=>{}); } return proc; }; const outOfStin = async (run)=>{ const { stdin } = process; const listeners = stdin.listeners("data"); const result = await run(); // @ts-ignore listeners.forEach((listener)=>stdin.on("data", listener)); stdin.setRawMode(true); stdin.setEncoding("utf8"); stdin.resume(); return result; }; const checkIsPortBusy = async (config)=>{ return new Promise((resolve)=>{ const server = createServer().once("error", (err)=>{ if (err.code === "EADDRINUSE") { resolve(true); } else { resolve(false); } }).once("listening", ()=>{ server.once("close", ()=>resolve(false)).close(); }).listen(config.port, config.host); }); }; const usedPortHandlers = { error: (port)=>{ throw new JestDevServerError(`Port ${port} is in use`, { code: ERROR_PORT_USED }); }, kill: async (port)=>{ console.log(""); console.log(`Killing process listening to ${port}. On linux, this may require you to enter your password.`); const [portProcess] = await findProcess("port", port); logProcDetection(portProcess.name, port); await killProc(portProcess); return true; }, ask: async (port)=>{ console.log(""); const answers = await outOfStin(()=>prompts({ type: "confirm", name: "kill", message: `Another process is listening on ${port}. Should I kill it for you? On linux, this may require you to enter your password.`, initial: true })); if (answers.kill) { const [portProcess] = await findProcess("port", port); logProcDetection(portProcess.name, port); await killProc(portProcess); return true; } process.exit(1); }, ignore: (port)=>{ console.log(""); console.log(`Port ${port} is already used. Assuming server is already running.`); return false; } }; const handleUsedPort = async (config)=>{ if (config.port === undefined) return true; if (!config.usedPortAction) { throw new JestDevServerError(`Port ${config.port} is in use, but no action was provided to handle it. Please provide a "usedPortAction" in your config.`); } const isPortBusy = await checkIsPortBusy(config); if (isPortBusy) { const usedPortHandler = usedPortHandlers[config.usedPortAction]; return await usedPortHandler(config.port); } return true; }; const checkIsTimeoutError = (err)=>{ return Boolean(err?.message?.startsWith("Timed out waiting for")); }; const waitForServerToBeReady = async (config)=>{ if (config.port === undefined) return; const { launchTimeout, protocol, host, port, path, waitOnScheme } = config; let resource = `${host ?? "0.0.0.0"}:${port}`; if (path) { resource = `${resource}/${path}`; } let url; if (protocol === "tcp" || protocol === "socket") { url = `${protocol}:${resource}`; } else { url = `${protocol}://${resource}`; } const opts = { resources: [ url ], timeout: launchTimeout, ...waitOnScheme }; try { await waitOn(opts); } catch (err) { if (checkIsTimeoutError(err)) { throw new JestDevServerError(`Server has taken more than ${launchTimeout}ms to start.`, { code: ERROR_TIMEOUT }); } throw err; } }; const setupJestServer = async (providedConfig)=>{ const config = resolveConfig(providedConfig); const shouldRunServer = await handleUsedPort(config); if (shouldRunServer) { const proc = spawnServer(config); await waitForServerToBeReady(config); return proc; } return null; }; async function setup(providedConfigs) { const configs = Array.isArray(providedConfigs) ? providedConfigs : [ providedConfigs ]; const procs = await Promise.all(configs.map((config)=>setupJestServer(config))); return procs.filter(Boolean); } async function teardown(procs) { if (procs.length) { await Promise.all(procs.map((proc)=>proc.destroy())); } } export { ERROR_NO_COMMAND, ERROR_PORT_USED, ERROR_TIMEOUT, JestDevServerError, setup, teardown };