astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
246 lines (245 loc) • 8.81 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 require = createRequire(import.meta.url);
export const devServerCommand = {
command: "dev",
displayName: "Dev server",
envVar: "ASTRO_DEV_BACKGROUND"
};
export const previewServerCommand = {
command: "preview",
displayName: "Preview server",
envVar: "ASTRO_PREVIEW_BACKGROUND"
};
export function formatBackgroundOutput(result) {
return JSON.stringify(result);
}
export function formatStopOutput(result) {
return JSON.stringify(result);
}
export function formatStatusOutput(result) {
return JSON.stringify(result);
}
export 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) + "/");
}
export 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");
if (flags.mode) args.push("--mode", String(flags.mode));
if (flags.site) args.push("--site", String(flags.site));
if (flags.base) args.push("--base", String(flags.base));
if (flags.outDir) args.push("--out-dir", String(flags.outDir));
if (flags.verbose) args.push("--verbose");
if (flags.silent) args.push("--silent");
if (flags.open != null) {
if (typeof flags.open === "string") {
args.push("--open", flags.open);
} else if (flags.open) {
args.push("--open");
}
}
return args;
}
export 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);
}
export 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);
// Resolve astro's entry from its own package location rather than the project
// root: in a hoisted monorepo (e.g. bun workspaces) `astro` is deduped to the
// workspace-root node_modules, so a project-relative path does not exist.
// `astro/bin/astro.mjs` is not exported, so resolve via the package manifest.
const astroBin = resolve(dirname(require.resolve("astro/package.json")), "bin", "astro.mjs");
// `windowsHide: true` prevents a console window from popping up: on Windows
// `detached: true` maps to DETACHED_PROCESS, so the child has no console and
// any console-subsystem grandchild it spawns (e.g. workerd.exe) gets a brand
// new, visible, focus-stealing window allocated by Windows Terminal.
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);
}
export 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}).`);
}
export 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"));
}
export 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(() => {});
}