From 9bc3207fdbcdf8991596d0caeb66b707405aad07 Mon Sep 17 00:00:00 2001 From: thelazylama <67310144+thelazylamaGit@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:25:38 +1000 Subject: [PATCH 1/6] fix(dev): apply the dev CSS plugin to the fallback Astro environment (#17611) * fix(dev): collect fresh CSS in fallback environments * add changeset * improvements * fix tests --- .changeset/fresh-styles-fallback.md | 5 ++++ packages/astro/src/vite-plugin-css/index.ts | 2 ++ .../units/dev/dev-css-virtual-modules.test.ts | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 .changeset/fresh-styles-fallback.md diff --git a/.changeset/fresh-styles-fallback.md b/.changeset/fresh-styles-fallback.md new file mode 100644 index 000000000000..31ecad843b60 --- /dev/null +++ b/.changeset/fresh-styles-fallback.md @@ -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 diff --git a/packages/astro/src/vite-plugin-css/index.ts b/packages/astro/src/vite-plugin-css/index.ts index 42afd9fbf324..30f60f1811fa 100644 --- a/packages/astro/src/vite-plugin-css/index.ts +++ b/packages/astro/src/vite-plugin-css/index.ts @@ -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 @@ -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 diff --git a/packages/astro/test/units/dev/dev-css-virtual-modules.test.ts b/packages/astro/test/units/dev/dev-css-virtual-modules.test.ts index bc23014bcd4a..6074dbe40509 100644 --- a/packages/astro/test/units/dev/dev-css-virtual-modules.test.ts +++ b/packages/astro/test/units/dev/dev-css-virtual-modules.test.ts @@ -1,6 +1,33 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { wrapId } from '../../../dist/core/util.js'; +import { astroDevCssPlugin } from '../../../dist/vite-plugin-css/index.js'; + +describe('dev CSS plugin environments', () => { + it('applies both plugins to the fallback Astro environment in dev', () => { + const plugins = astroDevCssPlugin({ + routesList: {} as never, + command: 'dev', + cssContentCache: new Map(), + }); + + for (const plugin of plugins) { + assert.equal(plugin.applyToEnvironment?.({ name: 'astro' } as never), true); + } + }); + + it('does not apply either plugin to the Astro environment during builds', () => { + const plugins = astroDevCssPlugin({ + routesList: {} as never, + command: 'build', + cssContentCache: new Map(), + }); + + for (const plugin of plugins) { + assert.equal(plugin.applyToEnvironment?.({ name: 'astro' } as never), false); + } + }); +}); /** * Tests for the cache key alignment in the dev CSS collection pipeline. From 90c6ea4641e2ca9362c4ab0ea7a8590d07bd1868 Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:13:53 -0700 Subject: [PATCH 2/6] fix(fonts): strip volatile server address from incremental build hash (#17659) The fonts plugin embeds the ephemeral HTTP server's AddressInfo (including an OS-assigned port) into the font-file-url-resolver virtual module. Since the port changes every build, the incremental build plugin's dependency hash was non-deterministic, silently defeating all caching for any route that imports . Fix: assign the address to a variable named with a known token (__ASTRO_FONTS_SERVER_ADDRESS__) and have the incremental plugin strip the variable declaration before hashing, following the same pattern as resolveAssetPlaceholders for volatile asset emit handles. Fixes #17626 Co-authored-by: Matthew Phillips Co-authored-by: Emanuele Stoppa --- .changeset/proud-turtles-see.md | 6 +++ packages/astro/src/assets/fonts/constants.ts | 9 ++++ .../src/assets/fonts/vite-plugin-fonts.ts | 4 +- .../core/build/plugins/plugin-incremental.ts | 16 +++++++ .../incremental-build-fonts/astro.config.mjs | 14 ++++++ .../incremental-build-fonts/package.json | 8 ++++ .../src/pages/[slug].astro | 20 +++++++++ .../test/incremental-build-fonts.test.ts | 44 +++++++++++++++++++ pnpm-lock.yaml | 6 +++ 9 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 .changeset/proud-turtles-see.md create mode 100644 packages/astro/test/fixtures/incremental-build-fonts/astro.config.mjs create mode 100644 packages/astro/test/fixtures/incremental-build-fonts/package.json create mode 100644 packages/astro/test/fixtures/incremental-build-fonts/src/pages/[slug].astro create mode 100644 packages/astro/test/incremental-build-fonts.test.ts diff --git a/.changeset/proud-turtles-see.md b/.changeset/proud-turtles-see.md new file mode 100644 index 000000000000..81886b9f3a0e --- /dev/null +++ b/.changeset/proud-turtles-see.md @@ -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 + diff --git a/packages/astro/src/assets/fonts/constants.ts b/packages/astro/src/assets/fonts/constants.ts index 577e0b8f5979..83ec91857b28 100644 --- a/packages/astro/src/assets/fonts/constants.ts +++ b/packages/astro/src/assets/fonts/constants.ts @@ -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__'; diff --git a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts index 105080ec80eb..d6278ab644c2 100644 --- a/packages/astro/src/assets/fonts/vite-plugin-fonts.ts +++ b/packages/astro/src/assets/fonts/vite-plugin-fonts.ts @@ -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, @@ -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}, }); `, }; diff --git a/packages/astro/src/core/build/plugins/plugin-incremental.ts b/packages/astro/src/core/build/plugins/plugin-incremental.ts index 676d99a80e6c..93509c0fabc1 100644 --- a/packages/astro/src/core/build/plugins/plugin-incremental.ts +++ b/packages/astro/src/core/build/plugins/plugin-incremental.ts @@ -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'; @@ -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 @@ -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; } diff --git a/packages/astro/test/fixtures/incremental-build-fonts/astro.config.mjs b/packages/astro/test/fixtures/incremental-build-fonts/astro.config.mjs new file mode 100644 index 000000000000..0b6de9b0830b --- /dev/null +++ b/packages/astro/test/fixtures/incremental-build-fonts/astro.config.mjs @@ -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', + }, + ], +}); diff --git a/packages/astro/test/fixtures/incremental-build-fonts/package.json b/packages/astro/test/fixtures/incremental-build-fonts/package.json new file mode 100644 index 000000000000..deacab150cea --- /dev/null +++ b/packages/astro/test/fixtures/incremental-build-fonts/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/incremental-build-fonts", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/incremental-build-fonts/src/pages/[slug].astro b/packages/astro/test/fixtures/incremental-build-fonts/src/pages/[slug].astro new file mode 100644 index 000000000000..a9cc01dec502 --- /dev/null +++ b/packages/astro/test/fixtures/incremental-build-fonts/src/pages/[slug].astro @@ -0,0 +1,20 @@ +--- +import { Font } from 'astro:assets'; + +export async function getStaticPaths() { + return [ + { params: { slug: 'page-1' }, props: { title: 'Page 1' }, cacheKey: 'v1' }, + { params: { slug: 'page-2' }, props: { title: 'Page 2' }, cacheKey: 'v1' }, + ]; +} + +const { title } = Astro.props; +--- + + + + + +

{title}

+ + diff --git a/packages/astro/test/incremental-build-fonts.test.ts b/packages/astro/test/incremental-build-fonts.test.ts new file mode 100644 index 000000000000..d63d81aaf26d --- /dev/null +++ b/packages/astro/test/incremental-build-fonts.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { after, before, describe, it } from 'node:test'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('incremental build + fonts API', () => { + const root = new URL('./fixtures/incremental-build-fonts/', import.meta.url); + const cacheFile = new URL('node_modules/.astro/incremental-build.json', root); + let fixture: Fixture; + + before(async () => { + fs.rmSync(new URL('dist/', root), { recursive: true, force: true }); + fs.rmSync(cacheFile, { force: true }); + fixture = await loadFixture({ root }); + }); + + after(async () => { + fs.rmSync(new URL('dist/', root), { recursive: true, force: true }); + fs.rmSync(cacheFile, { force: true }); + }); + + it('produces a stable dependencyHash across two builds when using the Fonts API', async () => { + // First build — populates the cache + await fixture.build(); + assert.ok(fs.existsSync(cacheFile), 'Cache manifest should exist after first build'); + const cache1 = JSON.parse(fs.readFileSync(cacheFile, 'utf-8')); + const route1 = cache1.routes['src/pages/[slug].astro']; + assert.ok(route1, 'Route should be tracked in cache'); + const hash1 = route1.dependencyHash; + assert.ok(hash1, 'Should have a dependency hash after first build'); + + // Second build — no changes; dependencyHash should be identical + await fixture.build(); + const cache2 = JSON.parse(fs.readFileSync(cacheFile, 'utf-8')); + const route2 = cache2.routes['src/pages/[slug].astro']; + const hash2 = route2.dependencyHash; + + assert.equal( + hash2, + hash1, + `dependencyHash should be stable across builds, but got:\n build 1: ${hash1}\n build 2: ${hash2}`, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2553bc367a72..336349a9fa54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3540,6 +3540,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/incremental-build-fonts: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/incremental-build-headers: dependencies: astro: From 4cdf12873970dc542a18188fca1a9289ca1b0368 Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:15:50 -0700 Subject: [PATCH 3/6] fix(assets): prevent EMFILE errors on large image builds with concurrency limiting and retry (#17650) Add a concurrency-limited file reader (`readFileWithRetry`) to `emitImageMetadata` that caps concurrent `fs.readFile` calls to 200 and retries transient OS errors (EMFILE, ENFILE, EAGAIN, EBUSY) with exponential backoff. The bare `catch` block that previously swallowed all errors now only returns `undefined` for ENOENT (file not found), re-throwing other errors with the real OS message instead of the misleading "Could not find requested image... Does it exist?" Fixes #17649 --- .changeset/lovely-papayas-listen.md | 5 ++ packages/astro/src/assets/utils/node.ts | 51 +++++++++++++++++-- .../units/assets/emit-image-metadata.test.ts | 48 +++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 .changeset/lovely-papayas-listen.md create mode 100644 packages/astro/test/units/assets/emit-image-metadata.test.ts diff --git a/.changeset/lovely-papayas-listen.md b/.changeset/lovely-papayas-listen.md new file mode 100644 index 000000000000..033b95ce7974 --- /dev/null +++ b/.changeset/lovely-papayas-listen.md @@ -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. diff --git a/packages/astro/src/assets/utils/node.ts b/packages/astro/src/assets/utils/node.ts index 578eafbf5422..d93dbb0c5ad6 100644 --- a/packages/astro/src/assets/utils/node.ts +++ b/packages/astro/src/assets/utils/node.ts @@ -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 { + // Wait for a slot if at the concurrency limit + if (activeReads >= MAX_CONCURRENT_READS) { + await new Promise((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. * @@ -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); diff --git a/packages/astro/test/units/assets/emit-image-metadata.test.ts b/packages/astro/test/units/assets/emit-image-metadata.test.ts new file mode 100644 index 000000000000..d43ea5bf9f95 --- /dev/null +++ b/packages/astro/test/units/assets/emit-image-metadata.test.ts @@ -0,0 +1,48 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { emitImageMetadata } from '../../../dist/assets/utils/node.js'; + +// Minimal valid 1×1 JPEG +const TINY_JPEG = Buffer.from([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, + 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12, + 0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20, 0x24, 0x2e, 0x27, 0x20, + 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29, 0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, + 0x39, 0x3d, 0x38, 0x32, 0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, + 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, + 0x00, 0x7b, 0x40, 0x1f, 0xff, 0xd9, +]); + +describe('emitImageMetadata', () => { + it('returns undefined for undefined id', async () => { + const result = await emitImageMetadata(undefined); + assert.equal(result, undefined); + }); + + it('returns undefined when image file does not exist (ENOENT)', async () => { + const result = await emitImageMetadata('/tmp/nonexistent-image-abc123.jpg'); + assert.equal(result, undefined); + }); + + it('returns metadata for an existing image', async () => { + const dir = join(tmpdir(), `astro-test-${Date.now()}`); + const file = join(dir, 'test.jpg'); + await mkdir(dir, { recursive: true }); + try { + await writeFile(file, TINY_JPEG); + const result = await emitImageMetadata(file); + assert.ok(result, 'expected metadata to be returned'); + assert.equal(result.width, 1); + assert.equal(result.height, 1); + assert.equal(result.format, 'jpg'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); From 2267eeec7e88a47013465682d5278d7ea9253e5b Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:18:38 -0700 Subject: [PATCH 4/6] fix: preserve restored image transforms when merging adapter-collected images during incremental builds (#17634) --- .changeset/little-walls-drive.md | 5 + packages/astro/src/core/build/generate.ts | 13 +- .../units/build/incremental-images.test.ts | 132 ++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 .changeset/little-walls-drive.md create mode 100644 packages/astro/test/units/build/incremental-images.test.ts diff --git a/.changeset/little-walls-drive.md b/.changeset/little-walls-drive.md new file mode 100644 index 000000000000..b1fec2c0a2f4 --- /dev/null +++ b/.changeset/little-walls-drive.md @@ -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) diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index b38ffbd7acaf..c3644ca89416 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -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 { diff --git a/packages/astro/test/units/build/incremental-images.test.ts b/packages/astro/test/units/build/incremental-images.test.ts new file mode 100644 index 000000000000..0abf8f8efdbf --- /dev/null +++ b/packages/astro/test/units/build/incremental-images.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; +import { getStaticImageList, restoreStaticImages } from '../../../dist/assets/build/generate.js'; +import type { AssetsGlobalStaticImagesList } from '../../../dist/assets/types.js'; + +describe('collectStaticImages merge with restored incremental images', () => { + afterEach(() => { + // Clean up the global static images list between tests. + if (globalThis.astroAsset) { + delete globalThis.astroAsset.staticImages; + } + }); + + it('preserves restored transforms when adapter images share the same source path', () => { + // Simulate the incremental build flow: + // 1. A cached page restores its 200px transform via restoreStaticImages + // 2. The adapter's collectStaticImages returns a 100px transform for the + // same source image (from a rendered page) + // 3. The merge loop should keep both transforms + + const originalPath = '/_astro/photo.abc123.png'; + + // Step 1: Restore cached page's image transform + restoreStaticImages([ + { + originalPath, + originalSrcPath: '/src/assets/photo.png', + hash: 'hash200', + finalPath: '/_astro/photo.abc123_hash200.webp', + transform: { + src: { src: originalPath, width: 1000, height: 800, format: 'png' }, + width: 200, + format: 'webp', + }, + }, + ]); + + const listBefore = getStaticImageList(); + assert.equal(listBefore.get(originalPath)?.transforms.size, 1); + assert.ok(listBefore.get(originalPath)?.transforms.has('hash200')); + + // Step 2: Simulate adapter returning images for only the rendered page + const adapterImages: AssetsGlobalStaticImagesList = new Map([ + [ + originalPath, + { + originalSrcPath: '/src/assets/photo.png', + transforms: new Map([ + [ + 'hash100', + { + finalPath: '/_astro/photo.abc123_hash100.webp', + transform: { + src: { src: originalPath, width: 1000, height: 800, format: 'png' }, + width: 100, + format: 'webp', + }, + }, + ], + ]), + }, + ], + ]); + + // Step 3: Merge using the fixed logic (same as generatePages) + const staticImageList = getStaticImageList(); + for (const [path, entry] of adapterImages) { + const existing = staticImageList.get(path); + if (existing) { + for (const [hash, transform] of entry.transforms) { + if (!existing.transforms.has(hash)) { + existing.transforms.set(hash, transform); + } + } + } else { + staticImageList.set(path, entry); + } + } + + // Both transforms should be present + const entry = staticImageList.get(originalPath); + assert.ok(entry, 'entry for original path should exist'); + assert.equal(entry.transforms.size, 2, 'both transforms should be preserved'); + assert.ok(entry.transforms.has('hash200'), 'restored 200px transform should be kept'); + assert.ok(entry.transforms.has('hash100'), 'adapter 100px transform should be added'); + }); + + it('adds new source paths from adapter images when no restored entry exists', () => { + const originalPath = '/_astro/photo.abc123.png'; + + const adapterImages: AssetsGlobalStaticImagesList = new Map([ + [ + originalPath, + { + originalSrcPath: '/src/assets/photo.png', + transforms: new Map([ + [ + 'hashA', + { + finalPath: '/_astro/photo.abc123_hashA.webp', + transform: { + src: { src: originalPath, width: 500, height: 400, format: 'png' }, + width: 100, + format: 'webp', + }, + }, + ], + ]), + }, + ], + ]); + + const staticImageList = getStaticImageList(); + for (const [path, entry] of adapterImages) { + const existing = staticImageList.get(path); + if (existing) { + for (const [hash, transform] of entry.transforms) { + if (!existing.transforms.has(hash)) { + existing.transforms.set(hash, transform); + } + } + } else { + staticImageList.set(path, entry); + } + } + + const entry = staticImageList.get(originalPath); + assert.ok(entry, 'new entry should be added'); + assert.equal(entry.transforms.size, 1); + assert.ok(entry.transforms.has('hashA')); + }); +}); From c1cf11037797da70196f9a9ff20744ce6c2524d3 Mon Sep 17 00:00:00 2001 From: tianrking <10758833+tianrking@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:47:11 +0800 Subject: [PATCH 5/6] fix(node): return 404 for unknown prerendered endpoints (#17400) Co-authored-by: tianrking --- .changeset/prerendered-endpoint-404.md | 5 +++++ packages/integrations/node/src/serve-app.ts | 7 +++++-- .../prerender/src/pages/api/dogs/[dog].ts | 9 +++++++++ packages/integrations/node/test/prerender.test.ts | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 .changeset/prerendered-endpoint-404.md create mode 100644 packages/integrations/node/test/fixtures/prerender/src/pages/api/dogs/[dog].ts diff --git a/.changeset/prerendered-endpoint-404.md b/.changeset/prerendered-endpoint-404.md new file mode 100644 index 000000000000..1d0283e0d2db --- /dev/null +++ b/.changeset/prerendered-endpoint-404.md @@ -0,0 +1,5 @@ +--- +'@astrojs/node': patch +--- + +Return a 404 instead of a 500 for unknown parameters that match a prerendered dynamic endpoint. diff --git a/packages/integrations/node/src/serve-app.ts b/packages/integrations/node/src/serve-app.ts index 8a91ac4f6c6b..8407b71728ce 100644 --- a/packages/integrations/node/src/serve-app.ts +++ b/packages/integrations/node/src/serve-app.ts @@ -7,10 +7,13 @@ import { writeResponse, getAbortControllerCleanup, } from 'astro/app/node'; +import type { RouteType } from 'astro'; import type { BaseApp } from 'astro/app'; import { resolveClientDir } from './shared.js'; import type { Options, RequestHandler } from './types.js'; +const PRERENDERED_ROUTE_TYPES: ReadonlyArray = ['page', 'endpoint']; + /** * Read a prerendered error page from disk and return it as a Response. * Returns undefined if the file doesn't exist or can't be read. @@ -108,9 +111,9 @@ export function createAppHandler(app: BaseApp, options: Options): RequestHandler // Include prerendered routes so static-mode redirects remain dynamic. let routeData = app.match(request, true); - // Normal matching can select a lower-priority on-demand route when a prerendered page + // Normal matching can select a lower-priority on-demand route when a prerendered route // matches first. - if (routeData?.type === 'page' && routeData.prerender) { + if (routeData?.prerender && PRERENDERED_ROUTE_TYPES.includes(routeData.type)) { routeData = app.match(request); } if (routeData) { diff --git a/packages/integrations/node/test/fixtures/prerender/src/pages/api/dogs/[dog].ts b/packages/integrations/node/test/fixtures/prerender/src/pages/api/dogs/[dog].ts new file mode 100644 index 000000000000..d160b03df4a3 --- /dev/null +++ b/packages/integrations/node/test/fixtures/prerender/src/pages/api/dogs/[dog].ts @@ -0,0 +1,9 @@ +export const prerender = true; + +export function getStaticPaths() { + return [{ params: { dog: 'rover' } }]; +} + +export function GET({ params }: { params: { dog: string } }) { + return Response.json({ dog: params.dog }); +} diff --git a/packages/integrations/node/test/prerender.test.ts b/packages/integrations/node/test/prerender.test.ts index 9a873ab0a3b6..9f7bf8c5cd84 100644 --- a/packages/integrations/node/test/prerender.test.ts +++ b/packages/integrations/node/test/prerender.test.ts @@ -22,6 +22,7 @@ describe('Prerendering', () => { output: 'server', outDir: './dist/with-base', adapter: nodejs({ mode: 'standalone' }), + redirects: { '/old-two': '/two' }, }); await fixture.build(); const { startServer } = await fixture.loadAdapterEntryModule(); @@ -96,6 +97,20 @@ describe('Prerendering', () => { assert.equal(res.status, 404); }); + + it('Can render 404 matching a prerendered dynamic endpoint pattern', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/api/dogs/unknown`); + + assert.equal(res.status, 404); + }); + + it('Can render redirects alongside prerendered dynamic endpoints', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/old-two`, { + redirect: 'manual', + }); + + assert.equal(res.status, 301); + }); }); describe('Without base', async () => { From 09f0dc7f90ef92f8520e13b7ba130e4b8aad31bd Mon Sep 17 00:00:00 2001 From: Tarik Ermis Date: Wed, 12 Aug 2026 23:07:42 +0200 Subject: [PATCH 6/6] Detect stale dev server lock files after PID reuse (#17671) * fix: detect stale dev server lock files when the recorded PID was reused The lock file liveness check relied on process.kill(pid, 0), which only proves that some process owns the PID. After a Docker container restart the PID is quickly reused by an unrelated process while the lock file persists on the volume mount, so astro refused to start. The lock file now records the kernel start time from /proc//stat alongside the PID, and the check verifies both. Lock files written by older versions are only treated as stale when the recorded process clearly started after the lock file was written (5s margin for soft clocks). Platforms without /proc keep the previous PID-existence check. Closes #17656 * fix: verify lock file process identity across platforms --------- Co-authored-by: Matthew Phillips --- .changeset/witty-ghosts-restart.md | 5 + packages/astro/package.json | 1 + packages/astro/src/cli/dev/index.ts | 4 +- packages/astro/src/cli/preview/index.ts | 2 +- packages/astro/src/cli/server.ts | 8 +- packages/astro/src/core/dev/lockfile.ts | 56 ++++++++++- .../astro/test/units/dev/lockfile.test.ts | 94 +++++++++++++++++++ pnpm-lock.yaml | 25 +++++ 8 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 .changeset/witty-ghosts-restart.md diff --git a/.changeset/witty-ghosts-restart.md b/.changeset/witty-ghosts-restart.md new file mode 100644 index 000000000000..6960f4f38134 --- /dev/null +++ b/.changeset/witty-ghosts-restart.md @@ -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. diff --git a/packages/astro/package.json b/packages/astro/package.json index 6f4f9bb34cfa..a25961462f6d 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", diff --git a/packages/astro/src/cli/dev/index.ts b/packages/astro/src/cli/dev/index.ts index 2ce151a7f0ab..bff493ef5ec2 100644 --- a/packages/astro/src/cli/dev/index.ts +++ b/packages/astro/src/cli/dev/index.ts @@ -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', @@ -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 diff --git a/packages/astro/src/cli/preview/index.ts b/packages/astro/src/cli/preview/index.ts index 3e1770986e3d..0228ed7fa304 100644 --- a/packages/astro/src/cli/preview/index.ts +++ b/packages/astro/src/cli/preview/index.ts @@ -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.', diff --git a/packages/astro/src/cli/server.ts b/packages/astro/src/cli/server.ts index 91720170ed0c..8f37fe9ffa88 100644 --- a/packages/astro/src/cli/server.ts +++ b/packages/astro/src/cli/server.ts @@ -166,7 +166,7 @@ export async function background({ }): Promise { 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; @@ -251,7 +251,7 @@ export async function stop({ config: BackgroundCommandConfig; }): Promise { 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.`); @@ -272,7 +272,7 @@ export async function status({ config: BackgroundCommandConfig; }): Promise { 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.`); @@ -305,7 +305,7 @@ export async function logs({ config: BackgroundCommandConfig; }): Promise { 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.`); diff --git a/packages/astro/src/core/dev/lockfile.ts b/packages/astro/src/core/dev/lockfile.ts index 5d9fab85daf3..b27f9bcacd26 100644 --- a/packages/astro/src/core/dev/lockfile.ts +++ b/packages/astro/src/core/dev/lockfile.ts @@ -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'; @@ -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; + +/** + * 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 { + 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. */ @@ -188,16 +233,19 @@ export async function killDevServer(root: URL, data: LockFileData): Promise { 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; } diff --git a/packages/astro/test/units/dev/lockfile.test.ts b/packages/astro/test/units/dev/lockfile.test.ts index f1a756747bec..87bdfac92017 100644 --- a/packages/astro/test/units/dev/lockfile.test.ts +++ b/packages/astro/test/units/dev/lockfile.test.ts @@ -13,6 +13,9 @@ import { writeLockFile, readLockFile, isProcessAlive, + isAstroCommand, + isLockFileProcessAlive, + checkExistingServer, type LockFileData, } from '../../../dist/core/dev/lockfile.js'; @@ -189,6 +192,97 @@ describe('evaluateExistingServer', () => { }); // #endregion +describe('isAstroCommand', () => { + it('recognizes Astro CLI commands on Unix', () => { + assert.equal(isAstroCommand('node /workspace/node_modules/astro/bin/astro.mjs dev'), true); + assert.equal(isAstroCommand('node ./node_modules/.bin/astro preview'), true); + }); + + it('recognizes Astro CLI commands on Windows', () => { + assert.equal( + isAstroCommand( + '"C:\\Program Files\\nodejs\\node.exe" "C:\\project\\node_modules\\astro\\bin\\astro.mjs" dev', + ), + true, + ); + assert.equal( + isAstroCommand('cmd.exe /d /s /c "C:\\project\\node_modules\\.bin\\astro.cmd dev"'), + true, + ); + }); + + it('does not mistake an unrelated command for Astro', () => { + assert.equal(isAstroCommand('node /home/astro/server.mjs'), false); + assert.equal(isAstroCommand('node /workspace/astronomy.mjs'), false); + assert.equal(isAstroCommand('node ./astro.js'), false); + assert.equal(isAstroCommand('npm run dev'), false); + }); +}); + +describe('isLockFileProcessAlive', () => { + it('returns true when the recorded process command is Astro', async () => { + const data = { ...validData, pid: process.pid }; + const findProcess = async () => [ + { + pid: process.pid, + ppid: process.ppid, + name: 'node', + cmd: 'node /workspace/node_modules/astro/bin/astro.mjs dev', + }, + ]; + + assert.equal(await isLockFileProcessAlive(data, findProcess), true); + }); + + it('returns false when the PID belongs to another command', async () => { + const data = { ...validData, pid: process.pid }; + const findProcess = async () => [ + { + pid: process.pid, + ppid: process.ppid, + name: 'node', + cmd: 'node /app/server.mjs', + }, + ]; + + assert.equal(await isLockFileProcessAlive(data, findProcess), false); + }); + + it('keeps the PID-only result when the command cannot be inspected', async () => { + const data = { ...validData, pid: process.pid }; + + assert.equal(await isLockFileProcessAlive(data, async () => []), true); + assert.equal( + await isLockFileProcessAlive(data, async () => { + throw new Error('Process lookup failed'); + }), + true, + ); + }); +}); + +describe('checkExistingServer', () => { + let tempDir: string; + let root: URL; + + before(() => { + tempDir = mkdtempSync(join(tmpdir(), 'astro-lockfile-')); + root = pathToFileURL(tempDir + '/'); + }); + + after(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('cleans up a lock file when a live PID belongs to another command', async () => { + const data = { ...validData, pid: process.pid }; + writeLockFile(root, data); + + assert.equal(await checkExistingServer(root), null); + assert.equal(readLockFile(root), null); + }); +}); + // #region killDevServer describe('killDevServer', () => { let tempDir: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 336349a9fa54..c4914fd6068f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -778,6 +778,9 @@ importers: esbuild: specifier: ^0.28.0 version: 0.28.0 + find-process: + specifier: ^2.1.1 + version: 2.1.1 flattie: specifier: ^1.1.1 version: 1.1.1 @@ -11530,6 +11533,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -12356,6 +12363,10 @@ packages: resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} + find-process@2.1.1: + resolution: {integrity: sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA==} + hasBin: true + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -13361,6 +13372,10 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -21251,6 +21266,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@6.2.1: {} @@ -22173,6 +22190,12 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.1.1 + find-process@2.1.1: + dependencies: + chalk: 4.1.2 + commander: 14.0.3 + loglevel: 1.9.2 + find-up-simple@1.0.1: {} find-up@4.1.0: @@ -23298,6 +23321,8 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 + loglevel@1.9.2: {} + long@5.3.2: {} longest-streak@3.1.0: {}