Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/giant-pets-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'astro': minor
---

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`.
12 changes: 12 additions & 0 deletions packages/astro/src/cli/agent.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
164 changes: 11 additions & 153 deletions packages/astro/src/cli/dev/background.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -59,114 +26,5 @@ export async function background({
flags: Flags;
logger: AstroLogger;
}): Promise<void> {
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 });
}
13 changes: 1 addition & 12 deletions packages/astro/src/cli/dev/index.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
83 changes: 2 additions & 81 deletions packages/astro/src/cli/dev/logs.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,81 +9,5 @@ export async function logs({
flags: Flags;
logger: AstroLogger;
}): Promise<void> {
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 });
}
45 changes: 8 additions & 37 deletions packages/astro/src/cli/dev/status.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -24,26 +16,5 @@ export async function status({
flags: Flags;
logger: AstroLogger;
}): Promise<void> {
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 });
}
Loading
Loading