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
JavaScript
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;
}