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/.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/.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/.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/.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/.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/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/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/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/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/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/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/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/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/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 }); + } + }); +}); 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')); + }); +}); 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. 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/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 () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2553bc367a72..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 @@ -3540,6 +3543,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: @@ -11524,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==} @@ -12350,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'} @@ -13355,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==} @@ -21245,6 +21266,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@6.2.1: {} @@ -22167,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: @@ -23292,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: {}