UNPKG

@raven-js/fledge

Version:

From nestling to flight-ready - Build & bundle tool for modern JavaScript apps

508 lines (431 loc) 13.5 kB
/** * @author Anonyfox <max@anonyfox.com> * @license MIT * @see {@link https://ravenjs.dev} * @see {@link https://github.com/Anonyfox/ravenjs} * @see {@link https://anonyfox.com} */ /** * @file Server process management with blackbox detection. * * Manages child server processes with OS-native port allocation, TCP connection probing * for readiness detection, and bulletproof cleanup on process termination. */ import { spawn } from "node:child_process"; import { createServer, Socket } from "node:net"; /** * @typedef {function({ port: number }): Promise<void>} ServerBootFunction */ /** * Server process manager with blackbox detection and lifecycle management */ export class Server { /** @type {Set<Server>} Global set of all Server instances for cleanup */ static #instances = new Set(); /** @type {boolean} Track if global cleanup handlers are installed */ static #cleanupInstalled = false; /** @type {ServerBootFunction} User's server boot function */ #handler; /** @type {import("node:child_process").ChildProcess | null} Spawned server process */ #childProcess = null; /** @type {number | null} Allocated port number */ #port = null; /** @type {string | null} Server origin URL */ #origin = null; /** @type {boolean} Boot state tracking */ #isBooted = false; /** @type {boolean} Prevent concurrent boot operations */ #isBooting = false; /** @type {boolean} Track if server crashed during boot */ #crashed = false; /** @type {string | null} Crash reason for debugging */ #crashReason = null; /** * Install global cleanup handlers for all Server instances */ static #installGlobalCleanup() { // DISABLE GLOBAL CLEANUP IN TESTS to prevent hanging if (process.env.NODE_ENV === "test" || this.#cleanupInstalled) return; const cleanupAll = () => { for (const server of Server.#instances) { try { if (server.#childProcess && !server.#childProcess.killed) { server.#childProcess.kill("SIGTERM"); } } catch { // Process might already be dead } } }; process.once("exit", cleanupAll); process.once("SIGINT", cleanupAll); this.#cleanupInstalled = true; } /** * Create server manager * @param {ServerBootFunction} handler - Async function that starts server on given port */ constructor(handler) { if (typeof handler !== "function") { throw new Error("Server handler must be a function"); } this.#handler = handler; Server.#installGlobalCleanup(); Server.#instances.add(this); } /** * Find free port using OS allocation * @returns {Promise<number>} Available port number * @throws {Error} If port allocation fails */ async findFreePort() { return new Promise((resolve, reject) => { const server = createServer(); server.listen(0, (/** @type {Error} */ error) => { if (error) { reject(new Error(`Port allocation failed: ${error.message}`)); return; } const address = server.address(); if (!address || typeof address === "string") { reject(new Error("Failed to get port from server address")); return; } const port = address.port; server.close((closeError) => { if (closeError) { reject(new Error(`Failed to release port: ${closeError.message}`)); return; } resolve(port); }); }); server.on("error", (error) => { reject(new Error(`Port allocation error: ${error.message}`)); }); }); } /** * Test if port is ready for connections * @param {number} port - Port number to test * @param {number} [timeout=200] - Connection timeout in milliseconds * @returns {Promise<boolean>} True if port accepts connections */ async isPortReady(port, timeout = 200) { return new Promise((resolve) => { const socket = new Socket(); let done = false; const finish = (/** @type {boolean} */ ok) => { if (done) return; done = true; clearTimeout(timer); socket.removeAllListeners(); if (!socket.destroyed) socket.destroy(); resolve(ok); }; // Hard wall; don't rely on socket idle timeout const timer = setTimeout(() => finish(false), timeout); socket.once("connect", () => finish(true)); socket.once("error", () => finish(false)); socket.once("timeout", () => finish(false)); try { // Prefer IPv4 to avoid dual-stack delays on 'localhost' socket.connect({ port, host: "127.0.0.1" }); } catch { finish(false); } }); } /** * Wait for server to become ready via TCP probing * @param {number} port - Port to probe * @param {object} [options] - Wait options * @param {number} [options.timeout=5000] - Maximum wait time in milliseconds * @param {number} [options.interval=50] - Polling interval in milliseconds * @param {number} [options.probeTimeout=200] - Maximum time per probe * @returns {Promise<void>} Resolves when server is ready * @throws {Error} If server crashes or timeout exceeded */ async waitForTcpReady(port, options = {}) { const { timeout = 5000, interval = 50, probeTimeout = 200 } = options; const deadline = Date.now() + timeout; while (!this.#crashed) { const remaining = deadline - Date.now(); if (remaining <= 0) break; const ok = await this.isPortReady( port, Math.min(probeTimeout, remaining), ); if (ok) return; // Sleep, but never longer than the remaining budget await new Promise((resolve) => setTimeout( resolve, Math.min(interval, Math.max(0, deadline - Date.now())), ), ); } if (this.#crashed) { throw new Error(`Server crashed during boot: ${this.#crashReason}`); } throw new Error( `Server boot timeout after ${timeout}ms - no response on port ${port}`, ); } /** * Sleep for specified milliseconds * @param {number} ms - Milliseconds to sleep * @returns {Promise<void>} Resolves after delay */ async sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Boot server process with retry logic * @param {object} [options] - Boot options * @param {number} [options.timeout=30000] - Boot timeout in milliseconds * @param {number} [options.maxAttempts=3] - Maximum retry attempts for port conflicts * @returns {Promise<void>} Resolves when server is ready * @throws {Error} If boot fails or times out */ async boot(options = {}) { if (this.#isBooted) { throw new Error("Server is already booted"); } if (this.#isBooting) { throw new Error("Server is already booting"); } const { timeout = 5000, maxAttempts = 3 } = options; this.#isBooting = true; try { let attempts = 0; while (attempts < maxAttempts) { try { // OS-native port allocation this.#port = await this.findFreePort(); this.#origin = `http://localhost:${this.#port}`; // Small delay to ensure OS fully releases port await this.sleep(10); // Spawn child process immediately await this.#spawnServerProcess(); // Wait for server readiness via TCP probing with fast fail in tests const probeTimeout = timeout < 1000 ? 20 : 200; // Use 20ms probes for fast tests await this.waitForTcpReady(this.#port, { timeout, probeTimeout }); // Success! this.#isBooted = true; return; } catch (error) { const err = /** @type {any} */ (error); // Clean up failed attempt - Don't wait for exit, just kill and nullify if (this.#childProcess) { this.#childProcess.kill("SIGKILL"); // Set to null immediately instead of waiting for exit event // The process will die but we don't need to block on it this.#childProcess = null; } attempts++; // Retry on port conflicts if ( err.message?.includes("EADDRINUSE") || err.message?.includes("port") || attempts < maxAttempts ) { await this.sleep(100 * attempts); // Exponential backoff continue; } throw error; } } throw new Error(`Failed to boot server after ${maxAttempts} attempts`); } finally { this.#isBooting = false; this.#crashed = false; this.#crashReason = null; } } /** * Spawn server process and set up monitoring * @returns {Promise<void>} Resolves when process is spawned */ async #spawnServerProcess() { if (!this.#port) { throw new Error("No port allocated for server"); } // Create executable script for child process that keeps it alive const serverScript = ` const http = require('node:http'); const net = require('node:net'); (async () => { try { const handler = ${this.#handler.toString()}; await handler({ port: ${this.#port} }); // Keep process alive by listening for SIGTERM process.on('SIGTERM', () => { process.exit(0); }); // Keep event loop alive with a simple interval const keepAlive = setInterval(() => { // Just keep the process alive }, 1000); // Cleanup on exit process.on('exit', () => { clearInterval(keepAlive); }); } catch (error) { console.error('Server handler error:', error); process.exit(1); } })(); `; // Spawn child process this.#childProcess = spawn("node", ["-e", serverScript], { stdio: ["pipe", "pipe", "pipe"], detached: false, // Keep linked to parent }); // Monitor process lifecycle this.#childProcess.on("exit", (code, signal) => { if (!this.#isBooted && code !== 0) { this.#crashed = true; this.#crashReason = `Process exited with code ${code}, signal ${signal}`; } this.#childProcess = null; }); this.#childProcess.on("error", (error) => { if (!this.#isBooted) { this.#crashed = true; this.#crashReason = `Process error: ${error.message}`; } }); // Optional: Capture stdout/stderr for debugging this.#childProcess.stdout?.on("data", (_data) => { // Could forward to main process logger if needed // console.log(`Server stdout: ${_data}`); }); this.#childProcess.stderr?.on("data", (_data) => { // Could forward to main process logger if needed // console.error(`Server stderr: ${_data}`); }); } /** * Kill server process with graceful shutdown * @param {object} [options] - Kill options * @param {number} [options.gracefulTimeout=5000] - Time to wait for graceful shutdown * @returns {Promise<void>} Resolves when server is killed */ async kill(options = {}) { if (!this.#childProcess) { this.#isBooted = false; return; } const { gracefulTimeout = 5000 } = options; try { // Attach listeners first, then signal to avoid race condition const waitPromise = this.#waitForProcessExit(gracefulTimeout); this.#childProcess.kill("SIGTERM"); await waitPromise; } catch { // Force kill if graceful shutdown failed if (this.#childProcess && !this.#childProcess.killed) { const waitPromise = this.#waitForProcessExit(1000); this.#childProcess.kill("SIGKILL"); await waitPromise; } } finally { // Remove from global instances and clear state Server.#instances.delete(this); this.#childProcess = null; this.#isBooted = false; this.#port = null; this.#origin = null; } } /** * Wait for child process to exit * @param {number} timeout - Maximum wait time * @returns {Promise<void>} Resolves when process exits * @throws {Error} If timeout exceeded */ async #waitForProcessExit(timeout) { if (!this.#childProcess) return; return new Promise((resolve, reject) => { const timer = setTimeout(() => { // Clean up event listener on timeout if (this.#childProcess) { this.#childProcess.removeAllListeners("exit"); } reject(new Error("Process exit timeout")); }, timeout); // Create one-time exit handler that cleans itself up const exitHandler = () => { clearTimeout(timer); if (this.#childProcess) { this.#childProcess.removeListener("exit", exitHandler); } resolve(); }; this.#childProcess?.on("exit", exitHandler); // If process is already dead if (this.#childProcess?.killed || this.#childProcess?.exitCode !== null) { clearTimeout(timer); resolve(); } }); } /** * Check if server is currently booted * @returns {boolean} True if server is running */ isBooted() { return this.#isBooted && !!this.#childProcess && !this.#childProcess.killed; } /** * Get server origin URL * @returns {string | null} Origin URL or null if not booted */ getOrigin() { return this.#origin; } /** * Get allocated port number * @returns {number | null} Port number or null if not booted */ getPort() { return this.#port; } /** * Get child process PID * @returns {number | null} Process ID or null if not running */ getPid() { return this.#childProcess?.pid ?? null; } /** * Check if server is still responsive * @returns {Promise<boolean>} True if server responds to TCP connection */ async isAlive() { if (!this.#isBooted || !this.#port) { return false; } // Check if child process is still running if (!this.#childProcess || this.#childProcess.killed) { return false; } // Check if port is still responsive return await this.isPortReady(this.#port); } /** * Convert server state to JSON representation * @returns {object} JSON representation */ toJSON() { return { isBooted: this.#isBooted, port: this.#port, origin: this.#origin, pid: this.getPid(), crashed: this.#crashed, crashReason: this.#crashReason, }; } }