Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fresh-styles-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes component styles rendered from content entries remaining stale until a second save when an adapter uses Astro's fallback development environment
5 changes: 5 additions & 0 deletions .changeset/little-walls-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes incremental builds dropping optimized images for cached pages when using a `collectStaticImages` prerenderer (e.g. `@astrojs/cloudflare` with compile-time image optimization)
5 changes: 5 additions & 0 deletions .changeset/lovely-papayas-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes intermittent `ImageNotFound` errors during build on projects with many images. The build now limits concurrent image file reads to avoid exhausting OS file descriptors (EMFILE) and retries transient I/O errors with backoff. Non-transient errors are no longer silently swallowed.
5 changes: 5 additions & 0 deletions .changeset/prerendered-endpoint-404.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/node': patch
---

Return a 404 instead of a 500 for unknown parameters that match a prerendered dynamic endpoint.
6 changes: 6 additions & 0 deletions .changeset/proud-turtles-see.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'astro': patch
---

Fixes the Fonts API breaking `experimental.incrementalBuild` caching by embedding a build-local, randomly-assigned server port in generated code used for the dependency hash

5 changes: 5 additions & 0 deletions .changeset/witty-ghosts-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes `astro dev` refusing to start after a Docker container restart when an unrelated process reuses the PID from a persisted lock file. Astro now checks the process command across platforms, so stale lock files are cleaned up and `--force` does not signal the unrelated process.
1 change: 1 addition & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
"dset": "^3.1.4",
"es-module-lexer": "^2.0.0",
"esbuild": "^0.28.0",
"find-process": "^2.1.1",
"flattie": "^1.1.1",
"fontace": "~0.4.1",
"get-tsconfig": "5.0.0-beta.4",
Expand Down
9 changes: 9 additions & 0 deletions packages/astro/src/assets/fonts/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,12 @@ export const GENERIC_FALLBACK_NAMES = [
] as const;

export const FONTS_TYPES_FILE = 'fonts.d.ts';

/**
* Variable name used in the font-file-url-resolver virtual module to hold
* the ephemeral font HTTP server address. The incremental build plugin
* strips the variable declaration (which contains an OS-assigned port that
* changes every build) from the module source before hashing so that the
* dependency hash is deterministic across builds.
*/
export const FONTS_SERVER_ADDRESS_PLACEHOLDER = '__ASTRO_FONTS_SERVER_ADDRESS__';
4 changes: 3 additions & 1 deletion packages/astro/src/assets/fonts/vite-plugin-fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ASSETS_DIR,
CACHE_DIR,
DEFAULTS,
FONTS_SERVER_ADDRESS_PLACEHOLDER,
RESOLVED_RUNTIME_FONT_FILE_URL_RESOLVER_VIRTUAL_MODULE_ID,
RESOLVED_RUNTIME_VIRTUAL_MODULE_ID,
RESOLVED_VIRTUAL_MODULE_ID,
Expand Down Expand Up @@ -341,9 +342,10 @@ export function fontsPlugin({ settings, sync, logger }: Options): Plugin {
return {
code: `
import { RemoteRuntimeFontFileUrlResolver } from ${JSON.stringify(new URL('./infra/remote-runtime-font-file-url-resolver.js', import.meta.url))};
const ${FONTS_SERVER_ADDRESS_PLACEHOLDER} = ${JSON.stringify(serverAddress)};
export const runtimeFontFileUrlResolver = new RemoteRuntimeFontFileUrlResolver({
urls: new Set(${JSON.stringify(urls)}),
address: ${JSON.stringify(serverAddress)},
address: ${FONTS_SERVER_ADDRESS_PLACEHOLDER},
});
`,
};
Expand Down
51 changes: 48 additions & 3 deletions packages/astro/src/assets/utils/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,48 @@ async function handleSvgDeduplication(
}
}

const TRANSIENT_ERROR_CODES = new Set(['EMFILE', 'ENFILE', 'EAGAIN', 'EBUSY']);

// Limits concurrent fs.readFile calls to avoid EMFILE when the bundler loads
// thousands of images in parallel. 200 is well below typical OS defaults
// (1024 on Linux, ~8000 on macOS) while leaving headroom for other I/O.
const MAX_CONCURRENT_READS = 200;
let activeReads = 0;
const readQueue: Array<() => void> = [];

/**
* Reads a file with concurrency limiting and retry logic for transient OS errors
* like EMFILE (too many open files). Large projects can exhaust file descriptors
* when the bundler loads thousands of images concurrently.
*/
async function readFileWithRetry(url: URL, maxRetries = 5): Promise<Buffer> {
// Wait for a slot if at the concurrency limit
if (activeReads >= MAX_CONCURRENT_READS) {
await new Promise<void>((resolve) => readQueue.push(resolve));
}
activeReads++;
try {
for (let attempt = 0; ; attempt++) {
try {
return await fs.readFile(url);
} catch (err) {
const code =
err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
if (code && TRANSIENT_ERROR_CODES.has(code) && attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt));
continue;
}
throw err;
}
}
} finally {
activeReads--;
if (readQueue.length > 0) {
readQueue.shift()!();
}
}
}

/**
* Processes an image file and emits its metadata and optionally its contents. This function supports both build and development modes.
*
Expand All @@ -79,9 +121,12 @@ export async function emitImageMetadata(
const url = pathToFileURL(id);
let fileData: Buffer;
try {
fileData = await fs.readFile(url);
} catch {
return undefined;
fileData = await readFileWithRetry(url);
} catch (err) {
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
return undefined;
}
throw err;
}

const fileMetadata = await imageMetadata(fileData, id);
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/cli/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export async function dev({ flags }: DevOptions) {
// an existing server, and it won't be tracked by `astro dev stop`/`status`/`logs`.
// We still do a read-only check purely to give the user a heads-up.
if (ignoreLock) {
const existingServer = checkExistingServer(root);
const existingServer = await checkExistingServer(root);
if (existingServer) {
logger.info(
'SKIP_FORMAT',
Expand All @@ -204,7 +204,7 @@ export async function dev({ flags }: DevOptions) {
return await devServer(inlineConfig);
}

const existingServer = checkExistingServer(root);
const existingServer = await checkExistingServer(root);
if (existingServer) {
if (flags.force) {
// --force: kill the existing server and replace it
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/cli/preview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export async function preview({ flags }: PreviewOptions) {
}

const root = pathToFileURL(resolveRoot(flags.root) + '/');
const existingServer = checkExistingServer(root, 'preview');
const existingServer = await checkExistingServer(root, 'preview');
if (existingServer) {
const message = [
'Another astro preview server is already running.',
Expand Down
8 changes: 4 additions & 4 deletions packages/astro/src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export async function background({
}): Promise<void> {
const root = getRootURL(flags);

const existing = checkExistingServer(root, config.command);
const existing = await checkExistingServer(root, config.command);
if (existing && !flags.force) {
logger.info('SKIP_FORMAT', formatServerRunningMessage(existing, config, { existing: true }));
return;
Expand Down Expand Up @@ -251,7 +251,7 @@ export async function stop({
config: BackgroundCommandConfig;
}): Promise<void> {
const root = getRootURL(flags);
const existing = checkExistingServer(root, config.command);
const existing = await checkExistingServer(root, config.command);

if (!existing) {
logger.info('SKIP_FORMAT', `No ${config.command} server is running.`);
Expand All @@ -272,7 +272,7 @@ export async function status({
config: BackgroundCommandConfig;
}): Promise<void> {
const root = getRootURL(flags);
const existing = checkExistingServer(root, config.command);
const existing = await checkExistingServer(root, config.command);

if (!existing) {
logger.info('SKIP_FORMAT', `No ${config.command} server is running.`);
Expand Down Expand Up @@ -305,7 +305,7 @@ export async function logs({
config: BackgroundCommandConfig;
}): Promise<void> {
const root = getRootURL(flags);
const existing = checkExistingServer(root, config.command);
const existing = await checkExistingServer(root, config.command);

if (!existing) {
logger.error('SKIP_FORMAT', `No ${config.command} server is running.`);
Expand Down
13 changes: 12 additions & 1 deletion packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,18 @@ export async function generatePages(
if (prerenderer.collectStaticImages) {
const adapterImages = await prerenderer.collectStaticImages();
for (const [path, entry] of adapterImages) {
staticImageList.set(path, entry);
const existing = staticImageList.get(path);
if (existing) {
// Merge adapter transforms into existing entries so that transforms
// restored from the incremental cache are preserved.
for (const [hash, transform] of entry.transforms) {
if (!existing.transforms.has(hash)) {
existing.transforms.set(hash, transform);
}
}
} else {
staticImageList.set(path, entry);
}
}
}
} finally {
Expand Down
16 changes: 16 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-incremental.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import type { Plugin as VitePlugin } from 'vite';
import { FONTS_SERVER_ADDRESS_PLACEHOLDER } from '../../../assets/fonts/constants.js';
import { PROPAGATED_ASSET_FLAG } from '../../../content/consts.js';
import { hasContentFlag } from '../../../content/utils.js';
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js';
Expand Down Expand Up @@ -53,6 +54,18 @@ const ASSET_PLACEHOLDERS = [
{ token: '__VITE_ASSET__', pattern: /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g },
];

/**
* Strip the fonts server address variable declaration from module code. The
* fonts plugin assigns the ephemeral HTTP server's AddressInfo to a variable
* named {@link FONTS_SERVER_ADDRESS_PLACEHOLDER}; the value includes an
* OS-assigned port that differs between builds. Removing the declaration
* (but keeping the stable variable reference) prevents the volatile port
* from poisoning the dependency hash.
*/
const FONTS_ADDRESS_DECLARATION = new RegExp(
`(?:const|let|var)\\s+${FONTS_SERVER_ADDRESS_PLACEHOLDER}\\s*=[^;]+;`,
);

/**
* Replace the emit handles of imported assets with the file names they resolve
* to. Handles are assigned by the bundler in the order modules finish
Expand All @@ -73,6 +86,9 @@ function resolveAssetPlaceholders(graph: ModuleGraph, code: string): string {
}
});
}
if (resolved.includes(FONTS_SERVER_ADDRESS_PLACEHOLDER)) {
resolved = resolved.replace(FONTS_ADDRESS_DECLARATION, '');
}
return resolved;
}

Expand Down
56 changes: 52 additions & 4 deletions packages/astro/src/core/dev/lockfile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import findProcess from 'find-process';
import type { ResolvedServerUrls } from 'vite';

export type ServerCommand = 'dev' | 'preview';
Expand Down Expand Up @@ -93,6 +94,50 @@ export function isProcessAlive(pid: number): boolean {
}
}

// 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: string): boolean {
return ASTRO_COMMAND_PATTERN.test(command);
}

interface ProcessInfo {
pid: number;
cmd?: string;
}

type ProcessLookup = (
by: 'pid',
value: number,
options: { logLevel: 'error' },
) => Promise<ProcessInfo[]>;

/**
* 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: LockFileData,
find: ProcessLookup = findProcess,
): Promise<boolean> {
if (!isProcessAlive(data.pid)) {
return false;
}

try {
const processInfo = (await find('pid', data.pid, { logLevel: 'error' })).find(
({ pid }) => pid === data.pid,
);
return processInfo?.cmd === undefined || isAstroCommand(processInfo.cmd);
} catch {
return true;
}
}

/**
* Read the lock file from disk. Returns null if it doesn't exist or is invalid.
*/
Expand Down Expand Up @@ -188,16 +233,19 @@ export async function killDevServer(root: URL, data: LockFileData): Promise<void
}

/**
* Check for an existing server by reading the lock file and checking process liveness.
* 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 function checkExistingServer(
export async function checkExistingServer(
root: URL,
command: ServerCommand = 'dev',
): LockFileData | null {
): Promise<LockFileData | null> {
const data = readLockFile(root, command);
const result = evaluateExistingServer(data, data !== null && isProcessAlive(data.pid));
const result = evaluateExistingServer(
data,
data !== null && (await isLockFileProcessAlive(data)),
);
if (result === null) {
return null;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/vite-plugin-css/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export function astroDevCssPlugin({
},
applyToEnvironment(env) {
return (
(command === 'dev' && env.name === ASTRO_VITE_ENVIRONMENT_NAMES.astro) ||
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
Expand Down Expand Up @@ -288,6 +289,7 @@ export function astroDevCssPlugin({
name: MODULE_DEV_CSS_ALL,
applyToEnvironment(env) {
return (
(command === 'dev' && env.name === ASTRO_VITE_ENVIRONMENT_NAMES.astro) ||
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.ssr ||
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.client ||
env.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig, fontProviders } from 'astro/config';

export default defineConfig({
experimental: {
incrementalBuild: true,
},
fonts: [
{
provider: fontProviders.google(),
name: 'Roboto',
cssVariable: '--font-test',
},
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/incremental-build-fonts",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Loading
Loading