diff --git a/.changeset/config.json b/.changeset/config.json index 902828cfe8b7..be86265817aa 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -4,7 +4,7 @@ "commit": false, "linked": [], "access": "public", - "baseBranch": "origin/next", + "baseBranch": "origin/main", "updateInternalDependencies": "patch", "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true diff --git a/.changeset/fix-dev-toolbar-optimize-deps.md b/.changeset/fix-dev-toolbar-optimize-deps.md new file mode 100644 index 000000000000..04247b08995d --- /dev/null +++ b/.changeset/fix-dev-toolbar-optimize-deps.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Prebundle `astro/toolbar` in dev when custom dev toolbar apps are registered, preventing re-optimization reloads that can hide or break the toolbar. diff --git a/.changeset/fix-i18n-redirect-double-slash.md b/.changeset/fix-i18n-redirect-double-slash.md new file mode 100644 index 000000000000..170a821ea40f --- /dev/null +++ b/.changeset/fix-i18n-redirect-double-slash.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `redirectToDefaultLocale` producing a protocol-relative URL (`//locale`) instead of an absolute path (`/locale`) when `base` is `'/'`. diff --git a/.changeset/fix-server-islands-prerender.md b/.changeset/fix-server-islands-prerender.md new file mode 100644 index 000000000000..fe2c1142fc1d --- /dev/null +++ b/.changeset/fix-server-islands-prerender.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes server islands (`server:defer`) not working when only used in prerendered pages with `output: 'server'`. diff --git a/.changeset/new-routes-dev-css.md b/.changeset/new-routes-dev-css.md new file mode 100644 index 000000000000..46b7cfbbe188 --- /dev/null +++ b/.changeset/new-routes-dev-css.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix a dev server bug where newly created pages could miss layout-imported CSS until restart. diff --git a/.changeset/sparkly-peas-mix.md b/.changeset/sparkly-peas-mix.md new file mode 100644 index 000000000000..a50932f638be --- /dev/null +++ b/.changeset/sparkly-peas-mix.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +--- + +Fixes images not working in dev mode when using the `cloudflare` option diff --git a/.changeset/swift-terms-lose.md b/.changeset/swift-terms-lose.md new file mode 100644 index 000000000000..7e47a712e74f --- /dev/null +++ b/.changeset/swift-terms-lose.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a warning when using `prefetchAll` diff --git a/.changeset/tricky-otters-go.md b/.changeset/tricky-otters-go.md new file mode 100644 index 000000000000..923730522e83 --- /dev/null +++ b/.changeset/tricky-otters-go.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a bug where a directory at the project root sharing the same name as a page route would cause the dev server to return a 404 instead of serving the page. diff --git a/.changeset/wise-cats-check.md b/.changeset/wise-cats-check.md new file mode 100644 index 000000000000..560fddeb3f07 --- /dev/null +++ b/.changeset/wise-cats-check.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Update the unknown file extension error hint to recommend `vite.resolve.noExternal`, which is the correct Vite 7 config key. diff --git a/.changeset/wise-turkeys-tan.md b/.changeset/wise-turkeys-tan.md new file mode 100644 index 000000000000..dfc33b50020b --- /dev/null +++ b/.changeset/wise-turkeys-tan.md @@ -0,0 +1,6 @@ +--- +'@astrojs/netlify': patch +'@astrojs/node': patch +--- + +Fixes an issue where the adapter would cause a series of warnings during the build. diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 54383a76a41f..dce2b3ec6e77 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -92,9 +92,19 @@ interface AstroBuilderOptions extends BuildOptions { logger: Logger; mode: string; runtimeMode: RuntimeMode; + /** + * Provide a pre-built routes list to skip filesystem route scanning. + * Useful for testing builds with in-memory virtual modules. + */ + routesList?: RoutesList; + /** + * Whether to run `syncInternal` during setup. Defaults to true. + * Set to false for in-memory builds that don't need type generation. + */ + sync?: boolean; } -class AstroBuilder { +export class AstroBuilder { private settings: AstroSettings; private logger: Logger; private mode: string; @@ -103,6 +113,7 @@ class AstroBuilder { private routesList: RoutesList; private timer: Record; private teardownCompiler: boolean; + private sync: boolean; constructor(settings: AstroSettings, options: AstroBuilderOptions) { this.mode = options.mode; @@ -110,10 +121,11 @@ class AstroBuilder { this.settings = settings; this.logger = options.logger; this.teardownCompiler = options.teardownCompiler ?? true; + this.sync = options.sync ?? true; this.origin = settings.config.site ? new URL(settings.config.site).origin : `http://localhost:${settings.config.server.port}`; - this.routesList = { routes: [] }; + this.routesList = options.routesList ?? { routes: [] }; this.timer = {}; } @@ -128,7 +140,11 @@ class AstroBuilder { logger: logger, }); this.settings.buildOutput = getPrerenderDefault(this.settings.config) ? 'static' : 'server'; - this.routesList = await createRoutesList({ settings: this.settings }, this.logger); + + // Skip filesystem route scanning if routesList was pre-populated (e.g. in-memory builds) + if (this.routesList.routes.length === 0) { + this.routesList = await createRoutesList({ settings: this.settings }, this.logger); + } await runHookConfigDone({ settings: this.settings, logger: logger, command: 'build' }); @@ -155,14 +171,16 @@ class AstroBuilder { }, ); - const { syncInternal } = await import('../sync/index.js'); - await syncInternal({ - mode: this.mode, - settings: this.settings, - logger, - fs, - command: 'build', - }); + if (this.sync) { + const { syncInternal } = await import('../sync/index.js'); + await syncInternal({ + mode: this.mode, + settings: this.settings, + logger, + fs, + command: 'build', + }); + } return { viteConfig }; } diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 2e70352fd791..ed20c6aa0a09 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -10,7 +10,11 @@ import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js'; import { appendForwardSlash, prependForwardSlash } from '../../core/path.js'; import { runHookBuildSetup } from '../../integrations/hooks.js'; import { SERIALIZED_MANIFEST_RESOLVED_ID } from '../../manifest/serialized.js'; -import { getClientOutputDirectory, getServerOutputDirectory } from '../../prerender/utils.js'; +import { + getClientOutputDirectory, + getPrerenderOutputDirectory, + getServerOutputDirectory, +} from '../../prerender/utils.js'; import type { RouteData } from '../../types/public/internal.js'; import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../../vite-plugin-pages/const.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; @@ -33,6 +37,7 @@ import { NOOP_MODULE_ID } from './plugins/plugin-noop.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js'; import type { InputOption } from 'rollup'; import { getSSRAssets } from './internal.js'; +import { serverIslandPlaceholderMap } from '../server-islands/vite-plugin-server-islands.js'; const PRERENDER_ENTRY_FILENAME_PREFIX = 'prerender-entry'; @@ -47,6 +52,11 @@ export interface ExtractedChunk { prerender: boolean; } +type BuildPostHook = (params: { + chunks: ExtractedChunk[]; + mutate: (fileName: string, code: string, prerender: boolean) => void; +}) => void | Promise; + /** * Extracts only the chunks that need post-build injection from RollupOutput. * This allows releasing the full RollupOutput to reduce memory usage. @@ -63,8 +73,9 @@ function extractRelevantChunks( const needsContentInjection = chunk.code.includes(LINKS_PLACEHOLDER); const needsManifestInjection = chunk.moduleIds.includes(SERIALIZED_MANIFEST_RESOLVED_ID); + const needsServerIslandInjection = chunk.code.includes(serverIslandPlaceholderMap); - if (needsContentInjection || needsManifestInjection) { + if (needsContentInjection || needsManifestInjection || needsServerIslandInjection) { extracted.push({ fileName: chunk.fileName, code: chunk.code, @@ -159,6 +170,7 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter const flatPlugins = buildPlugins.flat().filter(Boolean); const plugins = [...flatPlugins, ...(viteConfig.plugins || [])]; let currentRollupInput: InputOption | undefined = undefined; + let buildPostHooks: BuildPostHook[] = []; plugins.push({ name: 'astro:resolve-input', // When the rollup input is safe to update, we normalize it to always be an object @@ -187,10 +199,15 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter order: 'post', async handler() { // Inject manifest and content placeholders into extracted chunks - await runManifestInjection(opts, internals, internals.extractedChunks ?? []); + await runManifestInjection( + opts, + internals, + internals.extractedChunks ?? [], + buildPostHooks, + ); // Generation and cleanup - const prerenderOutputDir = new URL('./.prerender/', getServerOutputDirectory(settings)); + const prerenderOutputDir = getPrerenderOutputDirectory(settings); // TODO: The `static` and `server` branches below are nearly identical now. // Consider refactoring to remove the else-if and unify the logic. @@ -335,6 +352,14 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter const prerenderChunks = extractRelevantChunks(prerenderOutputs, true); prerenderOutput = undefined as any; + const ssrPlugins = + builder.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]?.config.plugins ?? []; + buildPostHooks = ssrPlugins + .map((plugin) => + typeof plugin.api?.buildPostHook === 'function' ? plugin.api.buildPostHook : undefined, + ) + .filter(Boolean) as BuildPostHook[]; + // Build client environment // We must discover client inputs after SSR build because hydration/client-only directives // are only detected during SSR. We mutate the config here since the builder was already created @@ -364,7 +389,7 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter [ASTRO_VITE_ENVIRONMENT_NAMES.prerender]: { build: { emitAssets: true, - outDir: fileURLToPath(new URL('./.prerender/', getServerOutputDirectory(settings))), + outDir: fileURLToPath(getPrerenderOutputDirectory(settings)), rollupOptions: { // Only skip the default prerender entrypoint if an adapter with `entrypointResolution: 'self'` is used // AND provides a custom prerenderer. Otherwise, use the default. @@ -470,6 +495,7 @@ async function runManifestInjection( opts: StaticBuildOptions, internals: BuildInternals, chunks: ExtractedChunk[], + buildPostHooks: BuildPostHook[], ) { const mutations = new Map(); @@ -484,6 +510,11 @@ async function runManifestInjection( internals, { chunks, mutate }, ); + + for (const buildPostHook of buildPostHooks) { + await buildPostHook({ chunks, mutate }); + } + await writeMutatedChunks(opts, mutations); } @@ -498,14 +529,13 @@ async function writeMutatedChunks( ) { const { settings } = opts; const config = settings.config; - const serverOutputDir = getServerOutputDirectory(settings); for (const [fileName, mutation] of mutations) { let root: URL; if (mutation.prerender) { // Write to prerender directory - root = new URL('./.prerender/', serverOutputDir); + root = getPrerenderOutputDirectory(settings); } else if (settings.buildOutput === 'server') { root = config.build.server; } else { diff --git a/packages/astro/src/core/errors/dev/utils.ts b/packages/astro/src/core/errors/dev/utils.ts index c55a639c130e..61ad0e371d06 100644 --- a/packages/astro/src/core/errors/dev/utils.ts +++ b/packages/astro/src/core/errors/dev/utils.ts @@ -140,7 +140,7 @@ function generateHint(err: ErrorWithMetadata): string | undefined { const commonBrowserAPIs = ['document', 'window']; if (/Unknown file extension "\.(?:jsx|vue|svelte|astro|css)" for /.test(err.message)) { - return 'You likely need to add this package to `vite.ssr.noExternal` in your astro config file.'; + return 'You likely need to add this package to `vite.resolve.noExternal` in your astro config file.'; } else if (commonBrowserAPIs.some((api) => err.toString().includes(api))) { const hint = `Browser APIs are not available on the server. diff --git a/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts b/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts index 421e9160b143..2f51e7c20823 100644 --- a/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts +++ b/packages/astro/src/core/server-islands/vite-plugin-server-islands.ts @@ -1,22 +1,54 @@ -import MagicString from 'magic-string'; +import fs from 'node:fs'; import type { ConfigEnv, DevEnvironment, Plugin as VitePlugin } from 'vite'; +import { getPrerenderOutputDirectory, getServerOutputDirectory } from '../../prerender/utils.js'; import type { AstroPluginOptions } from '../../types/astro.js'; import type { AstroPluginMetadata } from '../../vite-plugin-astro/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; +import { appendForwardSlash } from '../path.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js'; export const SERVER_ISLAND_MANIFEST = 'virtual:astro:server-island-manifest'; const RESOLVED_SERVER_ISLAND_MANIFEST = '\0' + SERVER_ISLAND_MANIFEST; -const serverIslandPlaceholderMap = "'$$server-islands-map$$'"; +export const serverIslandPlaceholderMap = "'$$server-islands-map$$'"; const serverIslandPlaceholderNameMap = "'$$server-islands-name-map$$'"; +function createServerIslandImportMapSource( + entries: Iterable<[string, string]>, + toImportPath: (fileName: string) => string, +) { + const mappings = Array.from(entries, ([islandName, fileName]) => { + const importPath = toImportPath(fileName); + return `\t[${JSON.stringify(islandName)}, () => import(${JSON.stringify(importPath)})],`; + }); + + return `new Map([\n${mappings.join('\n')}\n])`; +} + +function createNameMapSource(entries: Iterable<[string, string]>) { + return `new Map(${JSON.stringify(Array.from(entries), null, 2)})`; +} + export function vitePluginServerIslands({ settings }: AstroPluginOptions): VitePlugin { let command: ConfigEnv['command'] = 'serve'; let ssrEnvironment: DevEnvironment | null = null; const referenceIdMap = new Map(); - const serverIslandMap = new Map(); - const serverIslandNameMap = new Map(); + + // Maps populated during transform to discover server island components. + // serverIslandMap: displayName → resolvedPath (e.g. 'Island' → '/abs/path/Island.astro') + // serverIslandNameMap: resolvedPath → displayName (reverse of above) + const serverIslandMap = new Map(); + const serverIslandNameMap = new Map(); + + // Resolved island chunk filenames discovered across SSR + prerender builds. + // Map of displayName → chunk fileName (e.g. 'Island' → 'chunks/Island_abc123.mjs') + const resolvedIslandImports = new Map(); + + // Reference to the SSR manifest chunk, saved during SSR's generateBundle. + // Patched in-memory during prerender's generateBundle so test capture plugins + // and other post plugins observe final code without placeholders. + let ssrManifestChunk: { code: string; fileName: string } | null = null; + return { name: 'astro:server-islands', enforce: 'post', @@ -78,13 +110,12 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP name += idx++; } - // Append the name map, for prod + // Track the island component for later build/dev use serverIslandNameMap.set(comp.resolvedPath, name); serverIslandMap.set(name, comp.resolvedPath); - // Build mode if (command === 'build') { - let referenceId = this.emitFile({ + const referenceId = this.emitFile({ type: 'chunk', id: comp.specifier, importer: id, @@ -115,16 +146,16 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP } if (serverIslandNameMap.size > 0 && serverIslandMap.size > 0) { - let mapSource = 'new Map([\n\t'; - for (let [name, path] of serverIslandMap) { - mapSource += `\n\t['${name}', () => import('${path}')],`; - } - mapSource += ']);'; + const mapSource = createServerIslandImportMapSource( + serverIslandMap, + (fileName) => fileName, + ); + const nameMapSource = createNameMapSource(serverIslandNameMap); return { code: ` export const serverIslandMap = ${mapSource}; - \n\nexport const serverIslandNameMap = new Map(${JSON.stringify(Array.from(serverIslandNameMap.entries()), null, 2)}); + \n\nexport const serverIslandNameMap = ${nameMapSource}; `, }; } @@ -134,42 +165,148 @@ export function vitePluginServerIslands({ settings }: AstroPluginOptions): ViteP renderChunk(code, chunk) { if (code.includes(serverIslandPlaceholderMap)) { - if (referenceIdMap.size === 0) { - // If there's no reference, we can fast-path to an empty map replacement - // without sourcemaps as it doesn't shift rows + if (command === 'build') { + if (referenceIdMap.size === 0) { + // SSR may not discover islands if they only appear in prerendered pages. + // Leave placeholders for post-build patching in that case. + return; + } + + const isRelativeChunk = !chunk.isEntry; + const dots = isRelativeChunk ? '..' : '.'; + const mapEntries: Array<[string, string]> = []; + for (const [resolvedPath, referenceId] of referenceIdMap) { + const fileName = this.getFileName(referenceId); + const islandName = serverIslandNameMap.get(resolvedPath); + if (!islandName) continue; + if (!resolvedIslandImports.has(islandName)) { + resolvedIslandImports.set(islandName, fileName); + } + mapEntries.push([islandName, fileName]); + } + const mapSource = createServerIslandImportMapSource( + mapEntries, + (fileName) => `${dots}/${fileName}`, + ); + const nameMapSource = createNameMapSource(serverIslandNameMap); + return { code: code - .replace(serverIslandPlaceholderMap, 'new Map();') - .replace(serverIslandPlaceholderNameMap, 'new Map()'), + .replace(serverIslandPlaceholderMap, mapSource) + .replace(serverIslandPlaceholderNameMap, nameMapSource), map: null, }; } - // The server island modules are in chunks/ - // This checks if this module is also in chunks/ and if so - // make the import like import('../chunks/name.mjs') - // TODO we could possibly refactor this to not need to emit separate chunks. - const isRelativeChunk = !chunk.isEntry; - const dots = isRelativeChunk ? '..' : '.'; - let mapSource = 'new Map(['; - let nameMapSource = 'new Map('; - for (let [resolvedPath, referenceId] of referenceIdMap) { - const fileName = this.getFileName(referenceId); - const islandName = serverIslandNameMap.get(resolvedPath)!; - mapSource += `\n\t['${islandName}', () => import('${dots}/${fileName}')],`; - } - nameMapSource += `${JSON.stringify(Array.from(serverIslandNameMap.entries()), null, 2)}`; - mapSource += '\n])'; - nameMapSource += '\n)'; - referenceIdMap.clear(); - - const ms = new MagicString(code); - ms.replace(serverIslandPlaceholderMap, mapSource); - ms.replace(serverIslandPlaceholderNameMap, nameMapSource); + // Dev mode: fast-path to empty map replacement return { - code: ms.toString(), - map: ms.generateMap({ hires: 'boundary' }), + code: code + .replace(serverIslandPlaceholderMap, 'new Map();') + .replace(serverIslandPlaceholderNameMap, 'new Map()'), + map: null, }; } }, + + generateBundle(_options, bundle) { + const envName = this.environment?.name; + + if (envName === ASTRO_VITE_ENVIRONMENT_NAMES.ssr) { + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk' && chunk.code.includes(serverIslandPlaceholderMap)) { + ssrManifestChunk = chunk; + break; + } + } + } + + if (envName === ASTRO_VITE_ENVIRONMENT_NAMES.prerender && ssrManifestChunk) { + if (resolvedIslandImports.size > 0) { + const isRelativeChunk = ssrManifestChunk.fileName.includes('/'); + const dots = isRelativeChunk ? '..' : '.'; + const mapSource = createServerIslandImportMapSource( + resolvedIslandImports, + (fileName) => `${dots}/${fileName}`, + ); + const nameMapSource = createNameMapSource(serverIslandNameMap); + + ssrManifestChunk.code = ssrManifestChunk.code + .replace(serverIslandPlaceholderMap, mapSource) + .replace(serverIslandPlaceholderNameMap, nameMapSource); + } else { + ssrManifestChunk.code = ssrManifestChunk.code + .replace(serverIslandPlaceholderMap, 'new Map()') + .replace(serverIslandPlaceholderNameMap, 'new Map()'); + } + } + }, + + api: { + /** + * Post-build hook that patches SSR chunks containing server island placeholders. + * + * During build, SSR can run before all server islands are discovered (e.g. islands + * only used in prerendered pages). This hook runs after SSR + prerender builds and: + * 1) replaces placeholders with the complete map of discovered islands + * 2) copies island chunks emitted in prerender into the SSR output directory + * + * Two cases: + * 1. Islands were discovered: Replace placeholders with real import maps. + * 2. No islands found: Replace placeholders with empty maps. + */ + async buildPostHook({ + chunks, + mutate, + }: { + chunks: Array<{ fileName: string; code: string; prerender: boolean }>; + mutate: (fileName: string, code: string, prerender: boolean) => void; + }) { + // Find SSR chunks that still have the placeholder (not prerender chunks) + const ssrChunkWithPlaceholder = chunks.find( + (c) => !c.prerender && c.code.includes(serverIslandPlaceholderMap), + ); + + if (!ssrChunkWithPlaceholder) { + return; + } + + if (resolvedIslandImports.size > 0) { + // Server islands were discovered across SSR/prerender builds. + // Construct import paths relative to the SSR chunk's location. + const isRelativeChunk = ssrChunkWithPlaceholder.fileName.includes('/'); + const dots = isRelativeChunk ? '..' : '.'; + + const mapSource = createServerIslandImportMapSource( + resolvedIslandImports, + (fileName) => `${dots}/${fileName}`, + ); + const nameMapSource = createNameMapSource(serverIslandNameMap); + + const newCode = ssrChunkWithPlaceholder.code + .replace(serverIslandPlaceholderMap, mapSource) + .replace(serverIslandPlaceholderNameMap, nameMapSource); + + mutate(ssrChunkWithPlaceholder.fileName, newCode, false); + + const serverOutputDir = getServerOutputDirectory(settings); + const prerenderOutputDir = getPrerenderOutputDirectory(settings); + for (const [, fileName] of resolvedIslandImports) { + const srcPath = new URL(fileName, appendForwardSlash(prerenderOutputDir.toString())); + const destPath = new URL(fileName, appendForwardSlash(serverOutputDir.toString())); + + if (!fs.existsSync(srcPath)) continue; + const destDir = new URL('./', destPath); + await fs.promises.mkdir(destDir, { recursive: true }); + await fs.promises.copyFile(srcPath, destPath); + } + } else { + // No server islands found — replace placeholders with empty maps + const newCode = ssrChunkWithPlaceholder.code + .replace(serverIslandPlaceholderMap, 'new Map()') + .replace(serverIslandPlaceholderNameMap, 'new Map()'); + + mutate(ssrChunkWithPlaceholder.fileName, newCode, false); + } + }, + }, }; } diff --git a/packages/astro/src/i18n/router.ts b/packages/astro/src/i18n/router.ts index 612770cf1b3f..2aa26698e454 100644 --- a/packages/astro/src/i18n/router.ts +++ b/packages/astro/src/i18n/router.ts @@ -151,9 +151,11 @@ export class I18nRouter { if (isRoot) { // Redirect root to default locale + // When base is '/', avoid producing '//locale' (a protocol-relative URL) + const basePrefix = this.#base === '/' ? '' : this.#base; return { type: 'redirect', - location: `${this.#base}/${this.#defaultLocale}`, + location: `${basePrefix}/${this.#defaultLocale}`, }; } diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts index eedbdd47543a..351e40706331 100644 --- a/packages/astro/src/prerender/utils.ts +++ b/packages/astro/src/prerender/utils.ts @@ -15,6 +15,13 @@ export function getServerOutputDirectory(settings: AstroSettings): URL { : getOutDirWithinCwd(settings.config.outDir); } +/** + * Returns the output directory used by the prerender environment. + */ +export function getPrerenderOutputDirectory(settings: AstroSettings): URL { + return new URL('./.prerender/', getServerOutputDirectory(settings)); +} + /** * Returns the correct output directory of the client build based on the configuration */ diff --git a/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts b/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts index ab46509751bc..aaac1d5fcce7 100644 --- a/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts +++ b/packages/astro/src/toolbar/vite-plugin-dev-toolbar.ts @@ -16,7 +16,11 @@ export default function astroDevToolbar({ settings, logger }: AstroPluginOptions return { optimizeDeps: { // Optimize CJS dependencies used by the dev toolbar - include: ['astro > aria-query', 'astro > axobject-query'], + include: [ + 'astro > aria-query', + 'astro > axobject-query', + ...(settings.devToolbarApps.length > 0 ? ['astro/toolbar'] : []), + ], }, }; }, diff --git a/packages/astro/src/vite-plugin-astro-server/route-guard.ts b/packages/astro/src/vite-plugin-astro-server/route-guard.ts index 66ec8c77a9e3..9f921e2544c7 100644 --- a/packages/astro/src/vite-plugin-astro-server/route-guard.ts +++ b/packages/astro/src/vite-plugin-astro-server/route-guard.ts @@ -68,12 +68,18 @@ export function routeGuardMiddleware(settings: AstroSettings): vite.Connect.Next return next(); } - // Check if the file exists at project root (outside srcDir/publicDir) + // Check if a file exists at project root (outside srcDir/publicDir) + // Only block files, not directories — directories may share names with valid page routes const rootFilePath = new URL('.' + pathname, config.root); - if (fs.existsSync(rootFilePath)) { - // File exists at root but not in srcDir or publicDir - block it - const html = notFoundTemplate(pathname); - return writeHtmlResponse(res, 404, html); + try { + const stat = fs.statSync(rootFilePath); + if (stat.isFile()) { + // File exists at root but not in srcDir or publicDir - block it + const html = notFoundTemplate(pathname); + return writeHtmlResponse(res, 404, html); + } + } catch { + // Path doesn't exist, continue to next middleware } // File doesn't exist anywhere, let other middleware handle it diff --git a/packages/astro/src/vite-plugin-environment/index.ts b/packages/astro/src/vite-plugin-environment/index.ts index 32e8ece90946..d2002a5e6de0 100644 --- a/packages/astro/src/vite-plugin-environment/index.ts +++ b/packages/astro/src/vite-plugin-environment/index.ts @@ -90,7 +90,7 @@ export function vitePluginEnvironment({ // For the dev toolbar 'astro > html-escaper', ], - exclude: ['astro:*', 'virtual:astro:*'], + exclude: ['astro:*', 'virtual:astro:*', 'astro/virtual-modules/prefetch.js'], // Astro files can't be rendered on the client entries: [`${srcDirPattern}**/*.{jsx,tsx,vue,svelte,html}`], }; diff --git a/packages/astro/src/vite-plugin-routes/index.ts b/packages/astro/src/vite-plugin-routes/index.ts index b3b260951b82..039f492ba94b 100644 --- a/packages/astro/src/vite-plugin-routes/index.ts +++ b/packages/astro/src/vite-plugin-routes/index.ts @@ -16,6 +16,7 @@ import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js'; import type { PluginMetadata } from '../vite-plugin-astro/types.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; import { isAstroServerEnvironment } from '../environments.js'; +import { RESOLVED_MODULE_DEV_CSS_ALL } from '../vite-plugin-css/const.js'; import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; type Payload = { @@ -125,6 +126,12 @@ export default async function astroPluginRoutes({ if (!virtualMod) continue; environment.moduleGraph.invalidateModule(virtualMod); + + const cssMod = environment.moduleGraph.getModuleById(RESOLVED_MODULE_DEV_CSS_ALL); + if (cssMod) { + environment.moduleGraph.invalidateModule(cssMod); + } + // Signal that routes have changed so running apps can update // NOTE: Consider adding debouncing here if rapid file changes cause performance issues environment.hot.send('astro:routes-updated', {}); diff --git a/packages/astro/templates/env.mjs b/packages/astro/templates/env.mjs index 0681b96f6e0e..7546fff1208f 100644 --- a/packages/astro/templates/env.mjs +++ b/packages/astro/templates/env.mjs @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ // @ts-check import { schema } from 'virtual:astro:env/internal'; import { diff --git a/packages/astro/test/fixtures/route-guard/src/pages/test.astro b/packages/astro/test/fixtures/route-guard/src/pages/test.astro new file mode 100644 index 000000000000..2fe331eb897a --- /dev/null +++ b/packages/astro/test/fixtures/route-guard/src/pages/test.astro @@ -0,0 +1,8 @@ +--- +--- + +Test Page + +

Test Page

+ + diff --git a/packages/astro/test/fixtures/route-guard/test-1/.gitkeep b/packages/astro/test/fixtures/route-guard/test-1/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/astro/test/fixtures/route-guard/test/.gitkeep b/packages/astro/test/fixtures/route-guard/test/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/astro/test/route-guard.test.js b/packages/astro/test/route-guard.test.js index 7f6303fb60d2..6c2bd10d2352 100644 --- a/packages/astro/test/route-guard.test.js +++ b/packages/astro/test/route-guard.test.js @@ -63,6 +63,22 @@ describe('Route Guard - Dev Server', () => { }); }); + describe('Directories at project root should not conflict with routes', () => { + const browserHeaders = { headers: { Accept: 'text/html' } }; + + it('200 when loading /test (route exists and directory exists at project root)', async () => { + const response = await fixture.fetch('/test', browserHeaders); + assert.equal(response.status, 200); + const html = await response.text(); + assert.match(html, /Test Page/); + }); + + it('404 when loading /test-1 (no route exists and directory exists at project root)', async () => { + const response = await fixture.fetch('/test-1', browserHeaders); + assert.equal(response.status, 404); + }); + }); + describe('Nonexistent files should 404 normally', () => { it('404 when loading /nonexistent.md (file does not exist)', async () => { const response = await fixture.fetch('/nonexistent.md'); diff --git a/packages/astro/test/units/build/server-islands.test.js b/packages/astro/test/units/build/server-islands.test.js new file mode 100644 index 000000000000..56a9cfdd15d1 --- /dev/null +++ b/packages/astro/test/units/build/server-islands.test.js @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { AstroBuilder } from '../../../dist/core/build/index.js'; +import { parseRoute } from '../../../dist/core/routing/parse-route.js'; +import { createBasicSettings, defaultLogger } from '../test-utils.js'; +import { virtualAstroModules } from './test-helpers.js'; + +async function readFilesRecursive(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return readFilesRecursive(fullPath); + } + return [fullPath]; + }), + ); + return files.flat(); +} + +describe('Build: Server islands in prerendered pages', () => { + it('builds successfully when server:defer is only used in prerendered pages', async () => { + const root = new URL('./_temp-fixtures/', import.meta.url); + + const settings = await createBasicSettings({ + root: fileURLToPath(root), + output: 'server', + adapter: { + name: 'test-adapter', + hooks: { + 'astro:config:done': ({ setAdapter }) => { + setAdapter({ + name: 'test-adapter', + serverEntrypoint: 'astro/app', + exports: ['manifest', 'createApp'], + supportedAstroFeatures: { + serverOutput: 'stable', + }, + adapterFeatures: { + buildOutput: 'server', + }, + }); + }, + }, + }, + vite: { + plugins: [ + virtualAstroModules(root, { + 'src/components/Island.astro': [ + '---', + '---', + '

I am a server island

', + ].join('\n'), + 'src/pages/index.astro': [ + '---', + "import Island from '../components/Island.astro';", + 'export const prerender = true;', + '---', + '', + 'Test', + '', + '', + '', + '', + ].join('\n'), + }), + ], + }, + }); + + const routesList = { + routes: [ + parseRoute('index.astro', settings, { + component: 'src/pages/index.astro', + prerender: true, + }), + ], + }; + + // Inject the server island route — normally createRoutesList does this + const { injectServerIslandRoute } = await import( + '../../../dist/core/server-islands/endpoint.js' + ); + injectServerIslandRoute(settings.config, routesList); + + process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M='; + + try { + const builder = new AstroBuilder(settings, { + logger: defaultLogger, + mode: 'production', + runtimeMode: 'production', + teardownCompiler: false, + routesList, + sync: false, + }); + await builder.run(); + } finally { + delete process.env.ASTRO_KEY; + } + + const serverOutputDir = fileURLToPath(settings.config.build.server); + const outputFiles = await readFilesRecursive(serverOutputDir); + const manifestFilePath = outputFiles.find((file) => file.includes('server-island-manifest')); + const manifestContent = manifestFilePath ? await fs.readFile(manifestFilePath, 'utf-8') : null; + + assert.ok(manifestContent, 'Server island manifest chunk should be emitted'); + + assert.ok( + /["']Island["']/.test(manifestContent), + `Server island manifest should contain Island component but got:\n${manifestContent}`, + ); + assert.ok( + !manifestContent.includes('$$server-islands-map$$'), + `Server island manifest should not include placeholders but got:\n${manifestContent}`, + ); + }); +}); diff --git a/packages/astro/test/units/build/test-helpers.js b/packages/astro/test/units/build/test-helpers.js index 582706e9cae2..2059d827e7b1 100644 --- a/packages/astro/test/units/build/test-helpers.js +++ b/packages/astro/test/units/build/test-helpers.js @@ -1,4 +1,5 @@ // @ts-check +import { fileURLToPath } from 'node:url'; /** * @param {object} options @@ -29,3 +30,52 @@ export function createSettings({ }, }; } + +/** + * A Vite plugin that provides in-memory .astro source files as virtual modules. + * This allows running a full Astro build without any files on disk. + * + * @param {URL} root - The project root URL + * @param {Record} files - Map of relative paths (e.g. 'src/pages/index.astro') to source content + */ +export function virtualAstroModules(root, files) { + const virtualFiles = new Map(); + for (const [relativePath, source] of Object.entries(files)) { + const absolute = fileURLToPath(new URL(relativePath, root)); + virtualFiles.set(absolute, source); + } + + return { + name: 'virtual-astro-modules', + enforce: 'pre', + resolveId: { + handler(id, importer) { + // Handle absolute paths (used by server island manifest dynamic imports) + if (virtualFiles.has(id)) { + return id; + } + // Handle bare paths like "/src/pages/index.astro" + if (id.startsWith('/')) { + const absolute = fileURLToPath(new URL('.' + id, root)); + if (virtualFiles.has(absolute)) { + return absolute; + } + } + // Handle relative imports from within virtual files + if (importer && virtualFiles.has(importer) && id.startsWith('.')) { + const resolved = fileURLToPath(new URL(id, 'file://' + importer)); + if (virtualFiles.has(resolved)) { + return resolved; + } + } + }, + }, + load: { + handler(id) { + if (virtualFiles.has(id)) { + return { code: virtualFiles.get(id) }; + } + }, + }, + }; +} diff --git a/packages/astro/test/units/i18n/router.test.js b/packages/astro/test/units/i18n/router.test.js index bdcafaa2ac35..f95743f5c666 100644 --- a/packages/astro/test/units/i18n/router.test.js +++ b/packages/astro/test/units/i18n/router.test.js @@ -88,6 +88,45 @@ describe('I18nRouter', () => { assert.equal(result.type, 'notFound'); }); }); + + describe('with base "/" (root base path)', () => { + let routerWithSlashBase; + + before(() => { + const config = makeI18nRouterConfig({ + strategy: 'pathname-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + }); + routerWithSlashBase = new I18nRouter(config); + }); + + it('redirects root to /defaultLocale, not //defaultLocale (#15844)', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'redirect'); + assert.equal(result.location, '/en'); + }); + + it('continues for paths with valid locale prefix', () => { + const context = makeRouterContext({ currentLocale: 'es' }); + + const result = routerWithSlashBase.match('/es/about', context); + + assert.equal(result.type, 'continue'); + }); + + it('returns 404 for paths without locale prefix', () => { + const context = makeRouterContext({ currentLocale: undefined }); + + const result = routerWithSlashBase.match('/about', context); + + assert.equal(result.type, 'notFound'); + }); + }); }); describe('strategy: pathname-prefix-other-locales', () => { @@ -211,6 +250,22 @@ describe('I18nRouter', () => { assert.equal(result.type, 'continue'); }); + + it('continues for root path when base is "/" (#15844)', () => { + const routerWithSlashBase = new I18nRouter( + makeI18nRouterConfig({ + strategy: 'pathname-prefix-always-no-redirect', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + }), + ); + const context = makeRouterContext({ currentLocale: undefined }); + + const result = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'continue'); + }); }); describe('strategy: domains-prefix-always', () => { @@ -263,6 +318,30 @@ describe('I18nRouter', () => { assert.equal(result.type, 'notFound'); }); + + it('redirects root to /locale, not //locale, when base is "/" (#15844)', () => { + const routerWithSlashBase = new I18nRouter( + makeI18nRouterConfig({ + strategy: 'domains-prefix-always', + defaultLocale: 'en', + locales: ['en', 'es'], + base: '/', + domains: { + 'en.example.com': ['en'], + 'es.example.com': ['es'], + }, + }), + ); + const context = makeRouterContext({ + currentLocale: 'en', + currentDomain: 'en.example.com', + }); + + const result = routerWithSlashBase.match('/', context); + + assert.equal(result.type, 'redirect'); + assert.equal(result.location, '/en'); + }); }); describe('strategy: domains-prefix-other-locales', () => { diff --git a/packages/integrations/cloudflare/src/utils/image-config.ts b/packages/integrations/cloudflare/src/utils/image-config.ts index a86db46234f9..473bd6993718 100644 --- a/packages/integrations/cloudflare/src/utils/image-config.ts +++ b/packages/integrations/cloudflare/src/utils/image-config.ts @@ -65,6 +65,16 @@ export function setImageConfig( }; case 'cloudflare': + // The external Cloudflare image service generates `/cdn-cgi/image/...` URLs, + // which only work on Cloudflare's production edge network. In dev mode, + // fall back to passthrough so images render normally without transformation. + if (command === 'dev') { + return { + ...config, + service: passthroughImageService(), + endpoint: GENERIC_ENDPOINT, + }; + } return { ...config, service: { entrypoint: '@astrojs/cloudflare/image-service' }, diff --git a/packages/integrations/cloudflare/test/external-image-service.test.js b/packages/integrations/cloudflare/test/external-image-service.test.js index 6b22ebd54969..8662df507130 100644 --- a/packages/integrations/cloudflare/test/external-image-service.test.js +++ b/packages/integrations/cloudflare/test/external-image-service.test.js @@ -33,3 +33,25 @@ describe('ExternalImageService', () => { assert.equal(outFileToCheck.includes('cdn-cgi/image'), true); }); }); + +describe('ExternalImageService dev mode', () => { + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/external-image-service/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('does not generate /cdn-cgi/image/ URLs in dev mode', async () => { + const res = await fixture.fetch('/'); + const html = await res.text(); + assert.ok(!html.includes('/cdn-cgi/image/'), 'expected no cdn-cgi URL in dev mode'); + }); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/external-image-service/src/assets/placeholder.jpg b/packages/integrations/cloudflare/test/fixtures/external-image-service/src/assets/placeholder.jpg new file mode 100644 index 000000000000..f4fc88e293c2 Binary files /dev/null and b/packages/integrations/cloudflare/test/fixtures/external-image-service/src/assets/placeholder.jpg differ diff --git a/packages/integrations/cloudflare/test/fixtures/external-image-service/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/external-image-service/src/pages/index.astro new file mode 100644 index 000000000000..109e11fdf776 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/external-image-service/src/pages/index.astro @@ -0,0 +1,14 @@ +--- +import { Image } from 'astro:assets'; +import placeholder from '../assets/placeholder.jpg'; +--- + + + + + External Image Service Test + + + test + + diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index d1397f6f817e..d0541eac7cd5 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -664,9 +664,6 @@ export default function netlifyIntegration( cacheOnDemandPages: !!integrationConfig?.cacheOnDemandPages, }), ], - ssr: { - noExternal: ['@astrojs/netlify'], - }, server: { watch: { ignored: [fileURLToPath(new URL('./.netlify/**', rootDir))], diff --git a/packages/integrations/netlify/src/vite-plugin-config.ts b/packages/integrations/netlify/src/vite-plugin-config.ts index 23a756f29c42..6737422c0bb1 100644 --- a/packages/integrations/netlify/src/vite-plugin-config.ts +++ b/packages/integrations/netlify/src/vite-plugin-config.ts @@ -8,9 +8,20 @@ export interface Config { cacheOnDemandPages: boolean; } +const SERVER_ENVIRONMENTS = ['ssr', 'prerender', 'astro']; + export function createConfigPlugin(config: Config): PluginOption { return { name: VIRTUAL_CONFIG_ID, + configEnvironment(environmentName) { + if (SERVER_ENVIRONMENTS.includes(environmentName)) { + return { + resolve: { + noExternal: ['@astrojs/netlify'], + }, + }; + } + }, resolveId: { filter: { id: new RegExp(`^${VIRTUAL_CONFIG_ID}$`), diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index 4591d81e5585..84126b923722 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -54,6 +54,9 @@ "@fastify/static": "^9.0.0", "node-mocks-http": "^1.17.2" }, + "astro": { + "external": true + }, "publishConfig": { "provenance": true } diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 7a2e4c767afd..349ece144668 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -67,9 +67,6 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr }, session, vite: { - ssr: { - noExternal: ['@astrojs/node'], - }, plugins: [ createConfigPlugin({ ...userOptions, @@ -79,6 +76,7 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr port: _config.server.port, staticHeaders: userOptions.staticHeaders ?? false, bodySizeLimit: userOptions.bodySizeLimit ?? 1024 * 1024 * 1024, + experimentalDisableStreaming: userOptions.experimentalDisableStreaming ?? false, }), ], }, diff --git a/packages/integrations/node/src/vite-plugin-config.ts b/packages/integrations/node/src/vite-plugin-config.ts index 76a025e9168e..971fe4127b6b 100644 --- a/packages/integrations/node/src/vite-plugin-config.ts +++ b/packages/integrations/node/src/vite-plugin-config.ts @@ -4,11 +4,22 @@ import type { Options } from './types.js'; const VIRTUAL_CONFIG_ID = 'virtual:astro-node:config'; const RESOLVED_VIRTUAL_CONFIG_ID = '\0' + VIRTUAL_CONFIG_ID; +const SERVER_ENVIRONMENTS = ['ssr', 'prerender', 'astro']; + export function createConfigPlugin( config: Options, ): NonNullable[number] { return { name: VIRTUAL_CONFIG_ID, + configEnvironment(environmentName) { + if (SERVER_ENVIRONMENTS.includes(environmentName)) { + return { + resolve: { + noExternal: ['@astrojs/node'], + }, + }; + } + }, resolveId: { filter: { id: new RegExp(`^${VIRTUAL_CONFIG_ID}$`),