astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
275 lines (274 loc) • 8.21 kB
JavaScript
import { spawn } from "node:child_process";
import {
createReadStream,
existsSync,
mkdirSync,
openSync,
readFileSync,
statSync,
watch
} from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveRoot } from "../core/config/config.js";
import {
GRACEFUL_SHUTDOWN_TIMEOUT,
checkExistingServer,
getLogFileURL,
isProcessAlive,
readLockFile,
removeLockFile
} from "../core/dev/lockfile.js";
const require2 = createRequire(import.meta.url);
const devServerCommand = {
command: "dev",
displayName: "Dev server",
envVar: "ASTRO_DEV_BACKGROUND"
};
const previewServerCommand = {
command: "preview",
displayName: "Preview server",
envVar: "ASTRO_PREVIEW_BACKGROUND"
};
function formatBackgroundOutput(result) {
return JSON.stringify(result);
}
function formatStopOutput(result) {
return JSON.stringify(result);
}
function formatStatusOutput(result) {
return JSON.stringify(result);
}
function formatServerRunningMessage(data, config, { existing = false } = {}) {
const command = `astro ${config.command}`;
const lines = [
`${config.displayName} ${existing ? "already running" : "running"} at ${data.url} (pid ${data.pid})`
];
if (data.urls && data.urls.network.length > 0) {
lines.push(" Network:");
for (const url of data.urls.network) {
lines.push(` ${url}`);
}
}
lines.push(
` Stop: ${command} stop`,
` Status: ${command} status`,
` Logs: ${command} logs`
);
return lines.join("\n");
}
function getRootURL(flags) {
return pathToFileURL(resolveRoot(flags.root) + "/");
}
function buildBackgroundArgs(command, flags) {
const args = [command];
if (flags.port) args.push("--port", String(flags.port));
if (flags.host != null) {
if (typeof flags.host === "string") {
args.push("--host", flags.host);
} else {
args.push("--host");
}
}
if (flags.config) args.push("--config", String(flags.config));
if (flags.root) args.push("--root", String(flags.root));
if (flags.allowedHosts) args.push("--allowed-hosts", String(flags.allowedHosts));
if (flags.json) args.push("--json");
return args;
}
async function stopExistingServer(root, command, pid) {
try {
process.kill(pid, "SIGTERM");
} catch {
}
const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) break;
await new Promise((r) => setTimeout(r, 100));
}
if (isProcessAlive(pid)) {
try {
process.kill(pid, "SIGKILL");
} catch {
}
}
removeLockFile(root, command);
}
async function background({
flags,
logger,
config
}) {
const root = getRootURL(flags);
const existing = await checkExistingServer(root, config.command);
if (existing && !flags.force) {
logger.info("SKIP_FORMAT", formatServerRunningMessage(existing, config, { existing: true }));
return;
}
if (existing && flags.force) {
await stopExistingServer(root, config.command, existing.pid);
}
const args = buildBackgroundArgs(config.command, flags);
const logFileURL = getLogFileURL(root, config.command);
const logFilePath = fileURLToPath(logFileURL);
const dotAstroDir = fileURLToPath(new URL(".astro/", root));
if (!existsSync(dotAstroDir)) {
mkdirSync(dotAstroDir, { recursive: true });
}
const logFd = openSync(logFilePath, "w");
const rootPath = fileURLToPath(root);
const astroBin = resolve(dirname(require2.resolve("astro/package.json")), "bin", "astro.mjs");
const child = spawn(process.execPath, [astroBin, ...args], {
detached: true,
windowsHide: true,
stdio: ["ignore", logFd, logFd],
cwd: rootPath,
env: { ...process.env, [config.envVar]: "1" }
});
child.unref();
const childPid = child.pid;
if (!childPid) {
logger.error("SKIP_FORMAT", `Failed to spawn background ${config.command} server process.`);
process.exit(1);
}
const timeout = 3e4;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (!isProcessAlive(childPid)) {
logger.error("SKIP_FORMAT", `${config.displayName} process exited before becoming ready.`);
process.exit(1);
}
const lockData = readLockFile(root, config.command);
if (lockData && lockData.pid === childPid) {
logger.info("SKIP_FORMAT", formatServerRunningMessage(lockData, config));
return;
}
await new Promise((r) => setTimeout(r, 200));
}
try {
process.kill(childPid, "SIGTERM");
} catch {
}
removeLockFile(root, config.command);
logger.error("SKIP_FORMAT", `${config.displayName} failed to start within ${timeout / 1e3}s.`);
process.exit(1);
}
async function stop({
flags,
logger,
config
}) {
const root = getRootURL(flags);
const existing = await checkExistingServer(root, config.command);
if (!existing) {
logger.info("SKIP_FORMAT", `No ${config.command} server is running.`);
return;
}
await stopExistingServer(root, config.command, existing.pid);
logger.info("SKIP_FORMAT", `Stopped ${config.command} server (pid ${existing.pid}).`);
}
async function status({
flags,
logger,
config
}) {
const root = getRootURL(flags);
const existing = await checkExistingServer(root, config.command);
if (!existing) {
logger.info("SKIP_FORMAT", `No ${config.command} server is running.`);
return;
}
const startedAt = new Date(existing.startedAt).getTime();
const uptime = Math.floor((Date.now() - startedAt) / 1e3);
const lines = [
`${config.displayName} running at ${existing.url} (pid ${existing.pid}, uptime ${uptime}s${existing.background ? ", background" : ""})`
];
if (existing.urls && existing.urls.network.length > 0) {
lines.push(" Network:");
for (const url of existing.urls.network) {
lines.push(` ${url}`);
}
}
logger.info("SKIP_FORMAT", lines.join("\n"));
}
async function logs({
flags,
logger,
config
}) {
const root = getRootURL(flags);
const existing = await checkExistingServer(root, config.command);
if (!existing) {
logger.error("SKIP_FORMAT", `No ${config.command} server is running.`);
process.exit(1);
}
if (!existing.background) {
logger.error(
"SKIP_FORMAT",
`The running ${config.command} server was not started with \`astro ${config.command} --background\`. View logs in the terminal where it was started.`
);
process.exit(1);
}
const logFileURL = getLogFileURL(root, config.command);
const logFilePath = fileURLToPath(logFileURL);
if (!existsSync(logFilePath)) {
logger.error("SKIP_FORMAT", "No log file found.");
process.exit(1);
}
const follow = flags.follow || flags.f;
if (!follow) {
const content = readFileSync(logFilePath, "utf-8");
process.stdout.write(content);
return;
}
let offset = statSync(logFilePath).size;
if (offset > 0) {
const content = readFileSync(logFilePath, "utf-8");
process.stdout.write(content);
}
const watcher = watch(logFilePath, () => {
const currentSize = statSync(logFilePath).size;
if (currentSize > offset) {
const stream = createReadStream(logFilePath, { start: offset, encoding: "utf-8" });
stream.on("data", (chunk) => process.stdout.write(chunk));
stream.on("end", () => {
offset = currentSize;
});
}
});
const aliveCheck = setInterval(() => {
if (!isProcessAlive(existing.pid)) {
const currentSize = statSync(logFilePath).size;
if (currentSize > offset) {
const remaining = readFileSync(logFilePath, { encoding: "utf-8" }).slice(offset);
process.stdout.write(remaining);
}
watcher.close();
clearInterval(aliveCheck);
}
}, 1e3);
const cleanup = () => {
watcher.close();
clearInterval(aliveCheck);
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
await new Promise(() => {
});
}
export {
background,
buildBackgroundArgs,
devServerCommand,
formatBackgroundOutput,
formatServerRunningMessage,
formatStatusOutput,
formatStopOutput,
logs,
previewServerCommand,
status,
stop,
stopExistingServer
};