From 45b9958967e6b7ea16f63d6e7a5eb9bf2b40ab19 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 23 Jun 2026 17:32:49 -0400 Subject: [PATCH 1/2] Add background preview servers --- .changeset/giant-pets-take.md | 5 + packages/astro/src/cli/agent.ts | 12 + packages/astro/src/cli/dev/background.ts | 164 +------- packages/astro/src/cli/dev/index.ts | 13 +- packages/astro/src/cli/dev/logs.ts | 83 +--- packages/astro/src/cli/dev/status.ts | 45 +-- packages/astro/src/cli/dev/stop.ts | 57 +-- packages/astro/src/cli/preview/index.ts | 90 ++++- packages/astro/src/cli/server.ts | 365 ++++++++++++++++++ packages/astro/src/core/dev/lockfile.ts | 37 +- .../src/core/preview/static-preview-server.ts | 2 + packages/astro/src/types/public/preview.ts | 2 + .../astro/test/units/dev/dev-output.test.ts | 62 +++ 13 files changed, 585 insertions(+), 352 deletions(-) create mode 100644 .changeset/giant-pets-take.md create mode 100644 packages/astro/src/cli/agent.ts create mode 100644 packages/astro/src/cli/server.ts diff --git a/.changeset/giant-pets-take.md b/.changeset/giant-pets-take.md new file mode 100644 index 000000000000..564edcb3917b --- /dev/null +++ b/.changeset/giant-pets-take.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Adds the `astro preview --background` flag to start preview servers as background processes, including automatic background mode for AI coding agents diff --git a/packages/astro/src/cli/agent.ts b/packages/astro/src/cli/agent.ts new file mode 100644 index 000000000000..9312d05c365b --- /dev/null +++ b/packages/astro/src/cli/agent.ts @@ -0,0 +1,12 @@ +import { detectAgenticEnvironment } from 'am-i-vibing'; + +export function isRunByAgent(): boolean { + try { + // Only treat direct "agent" types as auto-background-worthy. + // "hybrid" environments (e.g. Warp terminal) may not actually be running + // an AI agent, so we avoid false positives by excluding them. + return detectAgenticEnvironment().type === 'agent'; + } catch { + return false; + } +} diff --git a/packages/astro/src/cli/dev/background.ts b/packages/astro/src/cli/dev/background.ts index 2a277d526551..6b426360e436 100644 --- a/packages/astro/src/cli/dev/background.ts +++ b/packages/astro/src/cli/dev/background.ts @@ -1,55 +1,22 @@ -import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, openSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; import type { AstroLogger } from '../../core/logger/core.js'; import type { Flags } from '../flags.js'; +import type { LockFileData } from '../../core/dev/lockfile.js'; import { - checkExistingServer, - getLogFileURL, - readLockFile, - removeLockFile, - isProcessAlive, - GRACEFUL_SHUTDOWN_TIMEOUT, - type LockFileData, -} from '../../core/dev/lockfile.js'; -import { resolveRoot } from '../../core/config/config.js'; + background as startBackgroundServer, + devServerCommand, + formatBackgroundOutput, + formatServerRunningMessage as formatServerMessage, + type BackgroundErrorResult, + type BackgroundResult, +} from '../server.js'; -export interface BackgroundResult { - pid: number; - url: string; - existing?: boolean; -} - -export interface BackgroundErrorResult { - error: string; - message: string; -} - -export function formatBackgroundOutput(result: BackgroundResult | BackgroundErrorResult): string { - return JSON.stringify(result); -} +export { formatBackgroundOutput, type BackgroundErrorResult, type BackgroundResult }; -/** - * Build the human-readable message shown when a background dev server is running. - * Lists every network address (when `--host` exposed any) so the output matches - * the foreground dev server, then appends the management command hints. - */ export function formatServerRunningMessage( data: LockFileData, { existing = false }: { existing?: boolean } = {}, ): string { - const lines = [ - `Dev server ${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: astro dev stop', ' Status: astro dev status', ' Logs: astro dev logs'); - return lines.join('\n'); + return formatServerMessage(data, devServerCommand, { existing }); } export async function background({ @@ -59,114 +26,5 @@ export async function background({ flags: Flags; logger: AstroLogger; }): Promise { - const root = pathToFileURL(resolveRoot(flags.root) + '/'); - - // Check for existing server - const existing = checkExistingServer(root); - if (existing && !flags.force) { - logger.info('SKIP_FORMAT', formatServerRunningMessage(existing, { existing: true })); - return; - } - - // If --force, kill the existing server first - if (existing && flags.force) { - try { - process.kill(existing.pid, 'SIGTERM'); - } catch { - // Already dead - } - // Wait for graceful shutdown before escalating to SIGKILL - const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT; - while (Date.now() < deadline) { - if (!isProcessAlive(existing.pid)) break; - await new Promise((r) => setTimeout(r, 100)); - } - // If still alive after timeout, force kill - if (isProcessAlive(existing.pid)) { - try { - process.kill(existing.pid, 'SIGKILL'); - } catch { - // Already dead - } - } - removeLockFile(root); - } - - // Build the args for the child process: plain `astro dev` (no --background) - const args: string[] = ['dev']; - 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'); - - // Open the log file for writing, ensuring the .astro directory exists - const logFileURL = getLogFileURL(root); - const logFilePath = fileURLToPath(logFileURL); - const dotAstroDir = fileURLToPath(new URL('.astro/', root)); - if (!existsSync(dotAstroDir)) { - mkdirSync(dotAstroDir, { recursive: true }); - } - const logFd = openSync(logFilePath, 'w'); - - // Spawn node directly with astro's entry point, bypassing the .bin shim. - // On Windows, .bin shims are .cmd batch files that cannot be spawned without - // a shell, so we avoid the shim entirely for cross-platform compatibility. - const rootPath = fileURLToPath(root); - const astroBin = resolve(rootPath, 'node_modules', 'astro', 'bin', 'astro.mjs'); - - // Spawn the dev server as a detached child process - const child = spawn(process.execPath, [astroBin, ...args], { - detached: true, - stdio: ['ignore', logFd, logFd], - cwd: rootPath, - env: { ...process.env, ASTRO_DEV_BACKGROUND: '1' }, - }); - - child.unref(); - - const childPid = child.pid; - if (!childPid) { - logger.error('SKIP_FORMAT', 'Failed to spawn background dev server process.'); - process.exit(1); - } - - // Poll the lock file to detect when the server is ready - const timeout = 30000; - const deadline = Date.now() + timeout; - - while (Date.now() < deadline) { - // Check if child is still alive - if (!isProcessAlive(childPid)) { - logger.error('SKIP_FORMAT', 'Dev server process exited before becoming ready.'); - process.exit(1); - } - - // Check for the lock file (written by the child's dev server) - const lockData = readLockFile(root); - if (lockData && lockData.pid === childPid) { - logger.info('SKIP_FORMAT', formatServerRunningMessage(lockData)); - return; - } - - await new Promise((r) => setTimeout(r, 200)); - } - - // Timeout: kill the child and report failure - try { - process.kill(childPid, 'SIGTERM'); - } catch { - // Already dead - } - removeLockFile(root); - - logger.error('SKIP_FORMAT', `Dev server failed to start within ${timeout / 1000}s.`); - process.exit(1); + await startBackgroundServer({ flags, logger, config: devServerCommand }); } diff --git a/packages/astro/src/cli/dev/index.ts b/packages/astro/src/cli/dev/index.ts index abd596edbc58..d8c23858ad4b 100644 --- a/packages/astro/src/cli/dev/index.ts +++ b/packages/astro/src/cli/dev/index.ts @@ -1,27 +1,16 @@ -import { detectAgenticEnvironment } from 'am-i-vibing'; import colors from 'piccolore'; import devServer from '../../core/dev/index.js'; import { pathToFileURL } from 'node:url'; import { checkExistingServer, removeLockFile, writeLockFile } from '../../core/dev/lockfile.js'; import { resolveRoot } from '../../core/config/config.js'; import { printHelp } from '../../core/messages/runtime.js'; +import { isRunByAgent } from '../agent.js'; import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js'; interface DevOptions { flags: Flags; } -function isRunByAgent(): boolean { - try { - // Only treat direct "agent" types as auto-background-worthy. - // "hybrid" environments (e.g. Warp terminal) may not actually be running - // an AI agent, so we avoid false positives by excluding them. - return detectAgenticEnvironment().type === 'agent'; - } catch { - return false; - } -} - export async function dev({ flags }: DevOptions) { if (flags.help || flags.h) { printHelp({ diff --git a/packages/astro/src/cli/dev/logs.ts b/packages/astro/src/cli/dev/logs.ts index 116842c99758..f586ce795f00 100644 --- a/packages/astro/src/cli/dev/logs.ts +++ b/packages/astro/src/cli/dev/logs.ts @@ -1,9 +1,6 @@ -import { readFileSync, existsSync, statSync, createReadStream, watch } from 'node:fs'; -import { fileURLToPath, pathToFileURL } from 'node:url'; import type { AstroLogger } from '../../core/logger/core.js'; import type { Flags } from '../flags.js'; -import { checkExistingServer, getLogFileURL, isProcessAlive } from '../../core/dev/lockfile.js'; -import { resolveRoot } from '../../core/config/config.js'; +import { devServerCommand, logs as serverLogs } from '../server.js'; export async function logs({ flags, @@ -12,81 +9,5 @@ export async function logs({ flags: Flags; logger: AstroLogger; }): Promise { - const root = pathToFileURL(resolveRoot(flags.root) + '/'); - const existing = checkExistingServer(root); - - if (!existing) { - logger.error('SKIP_FORMAT', 'No dev server is running.'); - process.exit(1); - } - - if (!existing.background) { - logger.error( - 'SKIP_FORMAT', - 'The running dev server was not started with `astro dev --background`. View logs in the terminal where it was started.', - ); - process.exit(1); - } - - const logFileURL = getLogFileURL(root); - 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; - } - - // --follow mode: print existing content, then watch for new data - let offset = statSync(logFilePath).size; - - // Print existing content - if (offset > 0) { - const content = readFileSync(logFilePath, 'utf-8'); - process.stdout.write(content); - } - - // Watch the file for changes and stream new bytes - 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; - }); - } - }); - - // Periodically check if the server process is still alive - const aliveCheck = setInterval(() => { - if (!isProcessAlive(existing.pid)) { - // Read any final bytes - 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); - } - }, 1000); - - // Clean up on termination signals - const cleanup = () => { - watcher.close(); - clearInterval(aliveCheck); - process.exit(0); - }; - process.on('SIGINT', cleanup); - process.on('SIGTERM', cleanup); - - // Keep the process alive - await new Promise(() => {}); + await serverLogs({ flags, logger, config: devServerCommand }); } diff --git a/packages/astro/src/cli/dev/status.ts b/packages/astro/src/cli/dev/status.ts index f5976e0594a4..c85b443fa762 100644 --- a/packages/astro/src/cli/dev/status.ts +++ b/packages/astro/src/cli/dev/status.ts @@ -1,21 +1,13 @@ import type { AstroLogger } from '../../core/logger/core.js'; import type { Flags } from '../flags.js'; -import { pathToFileURL } from 'node:url'; -import { checkExistingServer } from '../../core/dev/lockfile.js'; -import { resolveRoot } from '../../core/config/config.js'; +import { + devServerCommand, + formatStatusOutput, + status as serverStatus, + type StatusResult, +} from '../server.js'; -export interface StatusResult { - running: boolean; - pid?: number; - url?: string; - port?: number; - background?: boolean; - uptime?: number; -} - -export function formatStatusOutput(result: StatusResult): string { - return JSON.stringify(result); -} +export { formatStatusOutput, type StatusResult }; export async function status({ flags, @@ -24,26 +16,5 @@ export async function status({ flags: Flags; logger: AstroLogger; }): Promise { - const root = pathToFileURL(resolveRoot(flags.root) + '/'); - const existing = checkExistingServer(root); - - if (!existing) { - logger.info('SKIP_FORMAT', 'No dev server is running.'); - return; - } - - const startedAt = new Date(existing.startedAt).getTime(); - const uptime = Math.floor((Date.now() - startedAt) / 1000); - - const lines = [ - `Dev server 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')); + await serverStatus({ flags, logger, config: devServerCommand }); } diff --git a/packages/astro/src/cli/dev/stop.ts b/packages/astro/src/cli/dev/stop.ts index 556f16c14bb6..a42825427059 100644 --- a/packages/astro/src/cli/dev/stop.ts +++ b/packages/astro/src/cli/dev/stop.ts @@ -1,23 +1,13 @@ import type { AstroLogger } from '../../core/logger/core.js'; import type { Flags } from '../flags.js'; -import { pathToFileURL } from 'node:url'; import { - checkExistingServer, - removeLockFile, - isProcessAlive, - GRACEFUL_SHUTDOWN_TIMEOUT, -} from '../../core/dev/lockfile.js'; -import { resolveRoot } from '../../core/config/config.js'; + devServerCommand, + formatStopOutput, + stop as stopServer, + type StopResult, +} from '../server.js'; -export interface StopResult { - stopped: boolean; - pid?: number; - reason?: string; -} - -export function formatStopOutput(result: StopResult): string { - return JSON.stringify(result); -} +export { formatStopOutput, type StopResult }; export async function stop({ flags, @@ -26,38 +16,5 @@ export async function stop({ flags: Flags; logger: AstroLogger; }): Promise { - const root = pathToFileURL(resolveRoot(flags.root) + '/'); - const existing = checkExistingServer(root); - - if (!existing) { - logger.info('SKIP_FORMAT', 'No dev server is running.'); - return; - } - - try { - process.kill(existing.pid, 'SIGTERM'); - } catch { - // Process may have already exited between check and kill - } - - // Wait for graceful shutdown before escalating to SIGKILL - const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT; - while (Date.now() < deadline) { - if (!isProcessAlive(existing.pid)) break; - await new Promise((r) => setTimeout(r, 100)); - } - - // If still alive after timeout, force kill - if (isProcessAlive(existing.pid)) { - try { - process.kill(existing.pid, 'SIGKILL'); - } catch { - // Already dead - } - } - - // Clean up the lock file in case the process didn't remove it - removeLockFile(root); - - logger.info('SKIP_FORMAT', `Stopped dev server (pid ${existing.pid}).`); + await stopServer({ flags, logger, config: devServerCommand }); } diff --git a/packages/astro/src/cli/preview/index.ts b/packages/astro/src/cli/preview/index.ts index 5c6a08f097e2..3e1770986e3d 100644 --- a/packages/astro/src/cli/preview/index.ts +++ b/packages/astro/src/cli/preview/index.ts @@ -1,7 +1,12 @@ import colors from 'piccolore'; +import { pathToFileURL } from 'node:url'; +import { checkExistingServer, removeLockFile, writeLockFile } from '../../core/dev/lockfile.js'; +import { resolveRoot } from '../../core/config/config.js'; import { printHelp } from '../../core/messages/runtime.js'; import previewServer from '../../core/preview/index.js'; -import { type Flags, flagsToAstroInlineConfig } from '../flags.js'; +import { isRunByAgent } from '../agent.js'; +import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js'; +import { background, logs, previewServerCommand, status, stop } from '../server.js'; interface PreviewOptions { flags: Flags; @@ -11,9 +16,15 @@ export async function preview({ flags }: PreviewOptions) { if (flags?.help || flags?.h) { printHelp({ commandName: 'astro preview', - usage: '[...flags]', + usage: '[command] [...flags]', tables: { + Commands: [ + ['stop', 'Stop a running background preview server.'], + ['status', 'Check if a preview server is running.'], + ['logs [--follow]', 'View logs from a background preview server.'], + ], Flags: [ + ['--background', 'Start the preview server as a background process.'], ['--port', `Specify which port to run on. Defaults to 4321.`], ['--host', `Listen on all addresses, including LAN and public addresses.`], ['--host ', `Expose on a network IP address at `], @@ -32,7 +43,80 @@ export async function preview({ flags }: PreviewOptions) { return; } + const agentDetected = !process.env.ASTRO_PREVIEW_BACKGROUND && isRunByAgent(); + if (agentDetected) { + flags.json = true; + } + + const logger = createLoggerFromFlags(flags); + const subcommand = flags._[3]?.toString(); + + if (subcommand === 'stop') { + await stop({ flags, logger, config: previewServerCommand }); + return; + } + + if (subcommand === 'status') { + await status({ flags, logger, config: previewServerCommand }); + return; + } + + if (subcommand === 'logs') { + await logs({ flags, logger, config: previewServerCommand }); + return; + } + + if (flags.background || agentDetected) { + await background({ flags, logger, config: previewServerCommand }); + return; + } + + if (subcommand) { + logger.error( + 'SKIP_FORMAT', + `Unknown command: astro preview ${subcommand}\n\nRun \`astro preview --help\` to see available commands.`, + ); + process.exit(1); + } + + const root = pathToFileURL(resolveRoot(flags.root) + '/'); + const existingServer = checkExistingServer(root, 'preview'); + if (existingServer) { + const message = [ + 'Another astro preview server is already running.', + '', + ` URL: ${existingServer.url}`, + ` PID: ${existingServer.pid}`, + '', + `Run \`astro preview stop\` to stop it, or use \`astro preview --force\` to replace it.`, + ].join('\n'); + throw new Error(message); + } + const inlineConfig = flagsToAstroInlineConfig(flags); + const server = await previewServer(inlineConfig); + const serverUrl = server.urls?.local[0] + ? new URL(server.urls.local[0]).origin + : `http://${server.host ?? 'localhost'}:${server.port}`; + + writeLockFile( + root, + { + pid: process.pid, + port: server.port, + url: serverUrl, + urls: server.urls, + background: !!process.env.ASTRO_PREVIEW_BACKGROUND, + startedAt: new Date().toISOString(), + }, + 'preview', + ); + + const originalStop = server.stop.bind(server); + server.stop = async () => { + removeLockFile(root, 'preview'); + await originalStop(); + }; - return await previewServer(inlineConfig); + return server; } diff --git a/packages/astro/src/cli/server.ts b/packages/astro/src/cli/server.ts new file mode 100644 index 000000000000..2b084752dbf0 --- /dev/null +++ b/packages/astro/src/cli/server.ts @@ -0,0 +1,365 @@ +import { spawn } from 'node:child_process'; +import { + createReadStream, + existsSync, + mkdirSync, + openSync, + readFileSync, + statSync, + watch, +} from 'node:fs'; +import { 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, + type LockFileData, + type ServerCommand, +} from '../core/dev/lockfile.js'; +import type { AstroLogger } from '../core/logger/core.js'; +import type { Flags } from './flags.js'; + +export interface BackgroundCommandConfig { + command: ServerCommand; + displayName: string; + envVar: 'ASTRO_DEV_BACKGROUND' | 'ASTRO_PREVIEW_BACKGROUND'; +} + +export const devServerCommand: BackgroundCommandConfig = { + command: 'dev', + displayName: 'Dev server', + envVar: 'ASTRO_DEV_BACKGROUND', +}; + +export const previewServerCommand: BackgroundCommandConfig = { + command: 'preview', + displayName: 'Preview server', + envVar: 'ASTRO_PREVIEW_BACKGROUND', +}; + +export interface BackgroundResult { + pid: number; + url: string; + existing?: boolean; +} + +export interface BackgroundErrorResult { + error: string; + message: string; +} + +export interface StopResult { + stopped: boolean; + pid?: number; + reason?: string; +} + +export interface StatusResult { + running: boolean; + pid?: number; + url?: string; + port?: number; + background?: boolean; + uptime?: number; +} + +export function formatBackgroundOutput(result: BackgroundResult | BackgroundErrorResult): string { + return JSON.stringify(result); +} + +export function formatStopOutput(result: StopResult): string { + return JSON.stringify(result); +} + +export function formatStatusOutput(result: StatusResult): string { + return JSON.stringify(result); +} + +export function formatServerRunningMessage( + data: LockFileData, + config: BackgroundCommandConfig, + { existing = false }: { existing?: boolean } = {}, +): string { + 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: Flags): URL { + return pathToFileURL(resolveRoot(flags.root) + '/'); +} + +export function buildBackgroundArgs(command: ServerCommand, flags: Flags): string[] { + const args: string[] = [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; +} + +export async function stopExistingServer( + root: URL, + command: ServerCommand, + pid: number, +): Promise { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Process may have already exited between check and kill. + } + + 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 { + // Already dead. + } + } + + removeLockFile(root, command); +} + +export async function background({ + flags, + logger, + config, +}: { + flags: Flags; + logger: AstroLogger; + config: BackgroundCommandConfig; +}): Promise { + const root = getRootURL(flags); + + const existing = 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(rootPath, 'node_modules', 'astro', 'bin', 'astro.mjs'); + + const child = spawn(process.execPath, [astroBin, ...args], { + detached: 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 = 30000; + 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 { + // Already dead. + } + removeLockFile(root, config.command); + + logger.error('SKIP_FORMAT', `${config.displayName} failed to start within ${timeout / 1000}s.`); + process.exit(1); +} + +export async function stop({ + flags, + logger, + config, +}: { + flags: Flags; + logger: AstroLogger; + config: BackgroundCommandConfig; +}): Promise { + const root = getRootURL(flags); + const existing = 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, +}: { + flags: Flags; + logger: AstroLogger; + config: BackgroundCommandConfig; +}): Promise { + const root = getRootURL(flags); + const existing = 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) / 1000); + + 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, +}: { + flags: Flags; + logger: AstroLogger; + config: BackgroundCommandConfig; +}): Promise { + const root = getRootURL(flags); + const existing = 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); + } + }, 1000); + + const cleanup = () => { + watcher.close(); + clearInterval(aliveCheck); + process.exit(0); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + await new Promise(() => {}); +} diff --git a/packages/astro/src/core/dev/lockfile.ts b/packages/astro/src/core/dev/lockfile.ts index 4bb5f0b10bb6..6b06b4702eff 100644 --- a/packages/astro/src/core/dev/lockfile.ts +++ b/packages/astro/src/core/dev/lockfile.ts @@ -2,6 +2,8 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from ' import { fileURLToPath } from 'node:url'; import type { ResolvedServerUrls } from 'vite'; +export type ServerCommand = 'dev' | 'preview'; + /** Maximum time (ms) to wait for a process to exit after SIGTERM before escalating to SIGKILL. */ export const GRACEFUL_SHUTDOWN_TIMEOUT = 5000; @@ -20,17 +22,17 @@ export interface ExistingServer { } /** - * Get the URL of the dev lock file for a given project root. + * Get the URL of a server lock file for a given project root. */ -function getLockFileURL(root: URL): URL { - return new URL('.astro/dev.json', root); +function getLockFileURL(root: URL, command: ServerCommand = 'dev'): URL { + return new URL(`.astro/${command}.json`, root); } /** - * Get the URL of the dev log file for a given project root. + * Get the URL of a server log file for a given project root. */ -export function getLogFileURL(root: URL): URL { - return new URL('.astro/dev.log', root); +export function getLogFileURL(root: URL, command: ServerCommand = 'dev'): URL { + return new URL(`.astro/${command}.log`, root); } function isStringArray(value: unknown): value is string[] { @@ -94,8 +96,8 @@ export function isProcessAlive(pid: number): boolean { /** * Read the lock file from disk. Returns null if it doesn't exist or is invalid. */ -export function readLockFile(root: URL): LockFileData | null { - const lockFileURL = getLockFileURL(root); +export function readLockFile(root: URL, command: ServerCommand = 'dev'): LockFileData | null { + const lockFileURL = getLockFileURL(root, command); try { const content = readFileSync(lockFileURL, 'utf-8'); return parseLockFile(content); @@ -107,8 +109,8 @@ export function readLockFile(root: URL): LockFileData | null { /** * Write the lock file to disk. */ -export function writeLockFile(root: URL, data: LockFileData): void { - const lockFileURL = getLockFileURL(root); +export function writeLockFile(root: URL, data: LockFileData, command: ServerCommand = 'dev'): void { + const lockFileURL = getLockFileURL(root, command); const dirPath = fileURLToPath(new URL('.astro/', root)); try { if (!existsSync(dirPath)) { @@ -124,8 +126,8 @@ export function writeLockFile(root: URL, data: LockFileData): void { /** * Remove the lock file from disk. No-op if it doesn't exist. */ -export function removeLockFile(root: URL): void { - const lockFileURL = getLockFileURL(root); +export function removeLockFile(root: URL, command: ServerCommand = 'dev'): void { + const lockFileURL = getLockFileURL(root, command); try { unlinkSync(lockFileURL); } catch (err: any) { @@ -152,18 +154,21 @@ export function evaluateExistingServer( } /** - * Check for an existing dev server by reading the lock file and checking process liveness. + * Check for an existing server by reading the lock file and checking process liveness. * Automatically cleans up stale lock files. * Returns the server info if a live server is found, null otherwise. */ -export function checkExistingServer(root: URL): LockFileData | null { - const data = readLockFile(root); +export function checkExistingServer( + root: URL, + command: ServerCommand = 'dev', +): LockFileData | null { + const data = readLockFile(root, command); const result = evaluateExistingServer(data, data !== null && isProcessAlive(data.pid)); if (result === null) { return null; } if (result.stale) { - removeLockFile(root); + removeLockFile(root, command); return null; } return result.data; diff --git a/packages/astro/src/core/preview/static-preview-server.ts b/packages/astro/src/core/preview/static-preview-server.ts index e55fc96f2c93..de723d219f04 100644 --- a/packages/astro/src/core/preview/static-preview-server.ts +++ b/packages/astro/src/core/preview/static-preview-server.ts @@ -15,6 +15,7 @@ import { piccoloreTextStyler } from '../../cli/infra/piccolore-text-styler.js'; interface PreviewServer { host?: string; port: number; + urls?: vite.ResolvedServerUrls; server: http.Server; closed(): Promise; stop(): Promise; @@ -118,6 +119,7 @@ export default async function createStaticPreviewServer( return { host: getResolvedHostForHttpServer(settings.config.server.host), port: actualPort, + urls: previewServer.resolvedUrls ?? { local: [], network: [] }, closed, server: previewServer.httpServer as http.Server, stop: previewServer.close.bind(previewServer), diff --git a/packages/astro/src/types/public/preview.ts b/packages/astro/src/types/public/preview.ts index 85fc7e0e99fc..cfc4db16a3c4 100644 --- a/packages/astro/src/types/public/preview.ts +++ b/packages/astro/src/types/public/preview.ts @@ -1,9 +1,11 @@ import type { OutgoingHttpHeaders } from 'node:http'; +import type { ResolvedServerUrls } from 'vite'; import type { AstroIntegrationLogger } from '../../core/logger/core.js'; export interface PreviewServer { host?: string; port: number; + urls?: ResolvedServerUrls; closed(): Promise; stop(): Promise; } diff --git a/packages/astro/test/units/dev/dev-output.test.ts b/packages/astro/test/units/dev/dev-output.test.ts index cb13a8397409..7869812e8a24 100644 --- a/packages/astro/test/units/dev/dev-output.test.ts +++ b/packages/astro/test/units/dev/dev-output.test.ts @@ -6,6 +6,12 @@ import { formatBackgroundOutput, formatServerRunningMessage, } from '../../../dist/cli/dev/background.js'; +import { + buildBackgroundArgs, + formatServerRunningMessage as formatGenericServerRunningMessage, + previewServerCommand, +} from '../../../dist/cli/server.js'; +import { getLogFileURL } from '../../../dist/core/dev/lockfile.js'; // #region formatStopOutput describe('formatStopOutput', () => { @@ -158,3 +164,59 @@ describe('formatServerRunningMessage', () => { }); }); // #endregion + +// #region shared server utilities +describe('shared server utilities', () => { + const base = { + pid: 54576, + port: 4321, + url: 'http://localhost:4321', + background: true, + startedAt: '2026-05-05T10:00:00.000Z', + }; + + it('formats preview server management commands', () => { + const output = formatGenericServerRunningMessage(base, previewServerCommand); + assert.equal( + output, + 'Preview server running at http://localhost:4321 (pid 54576)\n' + + ' Stop: astro preview stop\n' + + ' Status: astro preview status\n' + + ' Logs: astro preview logs', + ); + }); + + it('builds preview child process args without --background', () => { + assert.deepEqual( + buildBackgroundArgs('preview', { + _: [], + port: 3000, + host: true, + config: 'astro.config.mjs', + root: '.', + allowedHosts: 'example.com', + json: true, + }), + [ + 'preview', + '--port', + '3000', + '--host', + '--config', + 'astro.config.mjs', + '--root', + '.', + '--allowed-hosts', + 'example.com', + '--json', + ], + ); + }); + + it('uses separate dev and preview log files', () => { + const root = new URL('file:///project/'); + assert.equal(getLogFileURL(root).href, 'file:///project/.astro/dev.log'); + assert.equal(getLogFileURL(root, 'preview').href, 'file:///project/.astro/preview.log'); + }); +}); +// #endregion From 31ebcc8b2d999636bbae6dbf0d68668fea5c566f Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 23 Jun 2026 17:36:09 -0400 Subject: [PATCH 2/2] Improve preview background changeset --- .changeset/giant-pets-take.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.changeset/giant-pets-take.md b/.changeset/giant-pets-take.md index 564edcb3917b..81bcfe8555b7 100644 --- a/.changeset/giant-pets-take.md +++ b/.changeset/giant-pets-take.md @@ -2,4 +2,23 @@ 'astro': minor --- -Adds the `astro preview --background` flag to start preview servers as background processes, including automatic background mode for AI coding agents +Adds the `astro preview --background` flag to start preview servers as background processes. + +This makes preview servers easier to manage from scripts and AI coding agents because the command returns after the server is ready instead of keeping the terminal attached to the long-running process. + +```sh +astro preview --background +``` + +When a preview server is running in the background, you can inspect or stop it with new `astro preview` subcommands: + +```sh +astro preview status +astro preview logs +astro preview logs --follow +astro preview stop +``` + +If Astro detects that `astro preview` is being run by an AI coding agent, background mode is enabled automatically. This matches the existing behavior for `astro dev`, allowing agents to continue working after the preview server starts while still receiving the server URL and process ID. + +To opt out of automatic background mode for preview servers, set `ASTRO_PREVIEW_BACKGROUND=0` before running `astro preview`.