UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

197 lines (196 loc) • 6.19 kB
import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { getProcessInfo } from "tinyps"; /** Maximum time (ms) to wait for a process to exit after SIGTERM before escalating to SIGKILL. */ export const GRACEFUL_SHUTDOWN_TIMEOUT = 5e3; /** * Get the URL of a server lock file for a given project root. */ function getLockFileURL(root, command = "dev") { return new URL(`.astro/${command}.json`, root); } /** * Get the URL of a server log file for a given project root. */ export function getLogFileURL(root, command = "dev") { return new URL(`.astro/${command}.log`, root); } function isStringArray(value) { return Array.isArray(value) && value.every((item) => typeof item === "string"); } function isResolvedServerUrls(value) { if (typeof value !== "object" || value === null) { return false; } const { local, network } = value; return isStringArray(local) && isStringArray(network); } /** * Parse a lock file JSON string into a LockFileData object. * Returns null if the content is invalid. */ export function parseLockFile(content) { try { const data = JSON.parse(content); if (typeof data.pid !== "number" || typeof data.port !== "number" || typeof data.url !== "string" || typeof data.background !== "boolean" || typeof data.startedAt !== "string") { return null; } // `urls` is optional, but if present it must have the expected shape. if (data.urls !== undefined && !isResolvedServerUrls(data.urls)) { return null; } return data; } catch { return null; } } /** * Serialize lock file data to a JSON string. */ export function serializeLockFile(data) { return JSON.stringify(data, null, " "); } /** * Check if a process with the given PID is alive. * Signal 0 does not kill the process — it only checks whether the process exists. */ export function isProcessAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } // Match Astro's published CLI entry and package-manager shims, not arbitrary files named astro. const ASTRO_COMMAND_PATTERN = /(?:^|[\\/\s"'])(?:astro[\\/]bin[\\/]astro\.mjs|\.bin[\\/]astro(?:\.cmd)?)(?=$|[\s"'])/i; /** * Check whether a process command points to the Astro CLI. */ export function isAstroCommand(command) { return ASTRO_COMMAND_PATTERN.test(command); } /** * Check whether the live process recorded in a lock file is still Astro. * If the command cannot be inspected, keep the existing PID-only behavior. */ export async function isLockFileProcessAlive(data, find = getProcessInfo) { // The current process cannot be the server recorded in the lock file — it hasn't // started one yet. In Docker containers the PID namespace resets on restart, so the // new `astro dev` process often inherits the same PID the old one had. Without this // guard, the process detects itself as the "already running" server. (#17744) if (data.pid === process.pid) { return false; } if (!isProcessAlive(data.pid)) { return false; } try { const processInfo = await find(data.pid); if (processInfo === undefined) { return false; } return processInfo.command === undefined || isAstroCommand(processInfo.command); } catch { return true; } } /** * Read the lock file from disk. Returns null if it doesn't exist or is invalid. */ export function readLockFile(root, command = "dev") { const lockFileURL = getLockFileURL(root, command); try { const content = readFileSync(lockFileURL, "utf-8"); return parseLockFile(content); } catch { return null; } } /** * Write the lock file to disk. */ export function writeLockFile(root, data, command = "dev") { const lockFileURL = getLockFileURL(root, command); const dirPath = fileURLToPath(new URL(".astro/", root)); try { if (!existsSync(dirPath)) { mkdirSync(dirPath, { recursive: true }); } writeFileSync(lockFileURL, serializeLockFile(data), "utf-8"); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to write lock file: ${message}`); } } /** * Remove the lock file from disk. No-op if it doesn't exist. */ export function removeLockFile(root, command = "dev") { const lockFileURL = getLockFileURL(root, command); try { unlinkSync(lockFileURL); } catch (err) { // ENOENT means the file doesn't exist, which is fine. // Any other error (permissions, etc.) should be surfaced. if (err?.code !== "ENOENT") { throw err; } } } /** * Given lock file data and a liveness result, determine the state of the existing server. * This is the pure decision logic, separated from I/O for testability. */ export function evaluateExistingServer(data, alive) { if (data === null) { return null; } return { data, stale: !alive }; } /** * Kill the dev server identified by `data` and clean up its lock file. * * Sends SIGTERM and waits up to {@link GRACEFUL_SHUTDOWN_TIMEOUT} for the * process to exit, escalating to SIGKILL if it is still alive. The lock file * is always removed afterwards so a new server can start. */ export async function killDevServer(root, data) { try { process.kill(data.pid, "SIGTERM"); } catch {} // Wait for graceful shutdown before escalating to SIGKILL const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT; while (Date.now() < deadline) { if (!isProcessAlive(data.pid)) break; await new Promise((r) => setTimeout(r, 100)); } // If still alive after timeout, force kill if (isProcessAlive(data.pid)) { try { process.kill(data.pid, "SIGKILL"); } catch {} } // Clean up the lock file in case the process didn't remove it removeLockFile(root); } /** * Check for an existing server by reading the lock file and checking process identity. * Automatically cleans up stale lock files. * Returns the server info if a live server is found, null otherwise. */ export async function checkExistingServer(root, command = "dev") { const data = readLockFile(root, command); const result = evaluateExistingServer(data, data !== null && await isLockFileProcessAlive(data)); if (result === null) { return null; } if (result.stale) { removeLockFile(root, command); return null; } return result.data; }