vitest-dev-server
Version:
Starts a server before your Vitest tests and tears it down after.
207 lines (204 loc) • 6.68 kB
JavaScript
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 };