From 6b50fc6c3e84e745cca1cbaf460ff78a56a42212 Mon Sep 17 00:00:00 2001 From: ematipico Date: Thu, 16 Apr 2026 17:29:54 +0100 Subject: [PATCH 1/2] fix(core): clean chunk name --- .changeset/afraid-coins-wear.md | 5 +++ packages/astro/src/core/build/static-build.ts | 23 +++++++---- packages/astro/src/core/build/util.ts | 16 ++++++++ .../ssr-script/src/pages/dynamic.astro | 11 ++++++ .../ssr-script/src/scripts/confetti.js | 3 ++ ...special-chars-in-component-imports.test.js | 12 ++++++ packages/astro/test/ssr-script.test.js | 39 +++++++++++++++++++ .../test/units/build/static-build.test.ts | 24 ++++++++++++ 8 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 .changeset/afraid-coins-wear.md create mode 100644 packages/astro/test/fixtures/ssr-script/src/pages/dynamic.astro create mode 100644 packages/astro/test/fixtures/ssr-script/src/scripts/confetti.js diff --git a/.changeset/afraid-coins-wear.md b/.changeset/afraid-coins-wear.md new file mode 100644 index 000000000000..6b8dfc2a4111 --- /dev/null +++ b/.changeset/afraid-coins-wear.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where build output files could contain special characters (`!`, `~`, `{`, `}`) in their names, causing deploy failures on platforms like Netlify. diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 97112de36244..d62ffb328803 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -32,7 +32,7 @@ import { } from './plugins/plugin-ssr.js'; import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; import type { StaticBuildOptions } from './types.js'; -import { encodeName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js'; +import { cleanChunkName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js'; import { NOOP_MODULE_ID } from './plugins/plugin-noop.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js'; import type { InputOption } from 'rollup'; @@ -280,15 +280,14 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter // TODO: refactor our build logic to avoid this if (name.includes(ASTRO_PAGE_EXTENSION_POST_PATTERN)) { const [sanitizedName] = name.split(ASTRO_PAGE_EXTENSION_POST_PATTERN); - return [prefix, sanitizedName, suffix].join(''); + return [prefix, cleanChunkName(sanitizedName), suffix].join(''); } // Injected routes include "pages/[name].[ext]" already. Clean those up! if (name.startsWith('pages/')) { const sanitizedName = name.split('.')[0]; - return [prefix, sanitizedName, suffix].join(''); + return [prefix, cleanChunkName(sanitizedName), suffix].join(''); } - const encoded = encodeName(name); - return [prefix, encoded, suffix].join(''); + return [prefix, cleanChunkName(name), suffix].join(''); }, assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`, ...viteConfig.build?.rollupOptions?.output, @@ -401,6 +400,9 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter : { input: 'astro/entrypoints/prerender' }), output: { entryFileNames: `${PRERENDER_ENTRY_FILENAME_PREFIX}.[hash].mjs`, + chunkFileNames(chunkInfo) { + return `${cleanChunkName(chunkInfo.name)}-[hash].js`; + }, format: 'esm', ...viteConfig.environments?.prerender?.build?.rollupOptions?.output, }, @@ -419,8 +421,12 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter rollupOptions: { preserveEntrySignatures: 'exports-only', output: { - entryFileNames: `${settings.config.build.assets}/[name].[hash].js`, - chunkFileNames: `${settings.config.build.assets}/[name].[hash].js`, + entryFileNames(chunkInfo) { + return `${settings.config.build.assets}/${cleanChunkName(chunkInfo.name)}.[hash].js`; + }, + chunkFileNames(chunkInfo) { + return `${settings.config.build.assets}/${cleanChunkName(chunkInfo.name)}.[hash].js`; + }, assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`, ...viteConfig.environments?.client?.build?.rollupOptions?.output, }, @@ -432,6 +438,9 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter outDir: fileURLToPath(getServerOutputDirectory(settings)), rollupOptions: { output: { + chunkFileNames(chunkInfo) { + return `${cleanChunkName(chunkInfo.name)}-[hash].js`; + }, ...viteConfig.environments?.ssr?.build?.rollupOptions?.output, }, }, diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts index 9a0655b65b11..093eeb67ba16 100644 --- a/packages/astro/src/core/build/util.ts +++ b/packages/astro/src/core/build/util.ts @@ -31,6 +31,22 @@ export function shouldAppendForwardSlash( } } +/** + * Matches any character that is NOT alphanumeric, underscore, dot, hyphen, or forward slash. + * Rollup's built-in `sanitizeFileName` misses characters like `!` and `~` that can leak + * from Vite module IDs into chunk names (e.g. `page.!{005}.js`). + */ +const UNSAFE_CHUNK_CHAR_RE = /[^\w.\-/]/g; + +/** + * Replaces characters in a chunk name that are not safe for filesystem paths or URLs. + * Characters like `!` and `~` can leak from Vite module IDs into Rollup chunk names + * and break deploys on platforms like Netlify. + */ +export function cleanChunkName(name: string): string { + return encodeName(name.replace(UNSAFE_CHUNK_CHAR_RE, '_')); +} + export function encodeName(name: string): string { // Detect if the chunk name has as % sign that is not encoded. // This is borrowed from Node core: https://github.com/nodejs/node/blob/3838b579e44bf0c2db43171c3ce0da51eb6b05d5/lib/internal/url.js#L1382-L1391 diff --git a/packages/astro/test/fixtures/ssr-script/src/pages/dynamic.astro b/packages/astro/test/fixtures/ssr-script/src/pages/dynamic.astro new file mode 100644 index 000000000000..4a98fcb693a9 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-script/src/pages/dynamic.astro @@ -0,0 +1,11 @@ + + + Dynamic import + + +

Dynamic import

+ + + diff --git a/packages/astro/test/fixtures/ssr-script/src/scripts/confetti.js b/packages/astro/test/fixtures/ssr-script/src/scripts/confetti.js new file mode 100644 index 000000000000..467452d00507 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-script/src/scripts/confetti.js @@ -0,0 +1,3 @@ +export function celebrate() { + console.log('confetti!'); +} diff --git a/packages/astro/test/special-chars-in-component-imports.test.js b/packages/astro/test/special-chars-in-component-imports.test.js index 64301741adff..33834b7d5d3a 100644 --- a/packages/astro/test/special-chars-in-component-imports.test.js +++ b/packages/astro/test/special-chars-in-component-imports.test.js @@ -33,6 +33,18 @@ describe('Special chars in component import paths', () => { assert.equal(html.includes(''), true); }); + it('Output JS filenames do not contain unsafe characters', async () => { + const files = await fixture.readdir('/_astro'); + const jsFiles = files.filter((f) => f.endsWith('.js')); + for (const file of jsFiles) { + assert.equal( + /[!~#{}<>]/.test(file), + false, + `File "${file}" contains unsafe characters that break some hosting platforms`, + ); + } + }); + it('Special chars in imports work from .astro files', async () => { const html = await fixture.readFile('/index.html'); const $ = cheerioLoad(html); diff --git a/packages/astro/test/ssr-script.test.js b/packages/astro/test/ssr-script.test.js index 755c5061f526..b42115cf4068 100644 --- a/packages/astro/test/ssr-script.test.js +++ b/packages/astro/test/ssr-script.test.js @@ -37,6 +37,45 @@ describe('Inline scripts in SSR', () => { const $ = cheerioLoad(html); assert.equal($('script').length, 1); }); + + it('server output filenames do not contain unsafe characters', async () => { + const files = await fixture.glob('server/**/*.{js,mjs}'); + for (const file of files) { + assert.equal( + /[!~#{}<>]/.test(file), + false, + `File "${file}" contains characters that break hosting platforms like Netlify`, + ); + } + }); + }); + + describe('with assetQueryParams', () => { + before(async () => { + fixture = await loadFixture({ + ...defaultFixtureOptions, + outDir: './dist/inline-scripts-with-asset-query-params', + adapter: testAdapter({ + extendAdapter: { + client: { + assetQueryParams: new URLSearchParams({ dpl: 'test123' }), + }, + }, + }), + }); + await fixture.build(); + }); + + it('client output filenames do not contain hash placeholders or unsafe characters', async () => { + const files = await fixture.glob('client/**/*.{js,mjs}'); + for (const file of files) { + assert.equal( + /[!~{}]/.test(file), + false, + `File "${file}" contains unsafe characters (likely unresolved hash placeholders)`, + ); + } + }); }); describe('with base path', () => { diff --git a/packages/astro/test/units/build/static-build.test.ts b/packages/astro/test/units/build/static-build.test.ts index 50268232fdd3..8e1bbdac8446 100644 --- a/packages/astro/test/units/build/static-build.test.ts +++ b/packages/astro/test/units/build/static-build.test.ts @@ -1,9 +1,33 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { makeAstroPageEntryPointFileName } from '../../../dist/core/build/static-build.js'; +import { cleanChunkName } from '../../../dist/core/build/util.js'; import type { RouteData } from '../../../dist/types/public/internal.js'; describe('astro/src/core/build', () => { + describe('cleanChunkName', () => { + it('passes through safe names unchanged', () => { + assert.equal(cleanChunkName('page'), 'page'); + assert.equal(cleanChunkName('my-component'), 'my-component'); + assert.equal(cleanChunkName('pages/index'), 'pages/index'); + assert.equal(cleanChunkName('chunk_abc123'), 'chunk_abc123'); + }); + + it('replaces ! and ~ characters', () => { + assert.equal(cleanChunkName('page.!{005}'), 'page.__005_'); + assert.equal(cleanChunkName('~something'), '_something'); + }); + + it('replaces other unsafe characters', () => { + assert.equal(cleanChunkName('name@scope'), 'name_scope'); + assert.equal(cleanChunkName('file#hash'), 'file_hash'); + }); + + it('replaces % character', () => { + assert.equal(cleanChunkName('chunk%name'), 'chunk_name'); + }); + }); + describe('makeAstroPageEntryPointFileName', () => { const routes: RouteData[] = [ { From 872335b50eb166f3c42cae67242f6b7852d409e9 Mon Sep 17 00:00:00 2001 From: ematipico Date: Thu, 16 Apr 2026 17:44:16 +0100 Subject: [PATCH 2/2] linting and tests --- packages/astro/src/core/build/static-build.ts | 6 ------ packages/astro/src/core/build/util.ts | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index d62ffb328803..c0b11f9ad77e 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -400,9 +400,6 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter : { input: 'astro/entrypoints/prerender' }), output: { entryFileNames: `${PRERENDER_ENTRY_FILENAME_PREFIX}.[hash].mjs`, - chunkFileNames(chunkInfo) { - return `${cleanChunkName(chunkInfo.name)}-[hash].js`; - }, format: 'esm', ...viteConfig.environments?.prerender?.build?.rollupOptions?.output, }, @@ -438,9 +435,6 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter outDir: fileURLToPath(getServerOutputDirectory(settings)), rollupOptions: { output: { - chunkFileNames(chunkInfo) { - return `${cleanChunkName(chunkInfo.name)}-[hash].js`; - }, ...viteConfig.environments?.ssr?.build?.rollupOptions?.output, }, }, diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts index 093eeb67ba16..e668e2a0c982 100644 --- a/packages/astro/src/core/build/util.ts +++ b/packages/astro/src/core/build/util.ts @@ -47,7 +47,7 @@ export function cleanChunkName(name: string): string { return encodeName(name.replace(UNSAFE_CHUNK_CHAR_RE, '_')); } -export function encodeName(name: string): string { +function encodeName(name: string): string { // Detect if the chunk name has as % sign that is not encoded. // This is borrowed from Node core: https://github.com/nodejs/node/blob/3838b579e44bf0c2db43171c3ce0da51eb6b05d5/lib/internal/url.js#L1382-L1391 // We do this because you cannot import a module with this character in it.