Skip to content

Commit a6866a7

Browse files
authored
fix(core): clean chunk name (#16367)
* fix(core): clean chunk name * linting and tests
1 parent 811015d commit a6866a7

8 files changed

Lines changed: 121 additions & 8 deletions

File tree

.changeset/afraid-coins-wear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes an issue where build output files could contain special characters (`!`, `~`, `{`, `}`) in their names, causing deploy failures on platforms like Netlify.

packages/astro/src/core/build/static-build.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
} from './plugins/plugin-ssr.js';
3333
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js';
3434
import type { StaticBuildOptions } from './types.js';
35-
import { encodeName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js';
35+
import { cleanChunkName, getTimeStat, viteBuildReturnToRollupOutputs } from './util.js';
3636
import { NOOP_MODULE_ID } from './plugins/plugin-noop.js';
3737
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../constants.js';
3838
import type { InputOption } from 'rollup';
@@ -280,15 +280,14 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter
280280
// TODO: refactor our build logic to avoid this
281281
if (name.includes(ASTRO_PAGE_EXTENSION_POST_PATTERN)) {
282282
const [sanitizedName] = name.split(ASTRO_PAGE_EXTENSION_POST_PATTERN);
283-
return [prefix, sanitizedName, suffix].join('');
283+
return [prefix, cleanChunkName(sanitizedName), suffix].join('');
284284
}
285285
// Injected routes include "pages/[name].[ext]" already. Clean those up!
286286
if (name.startsWith('pages/')) {
287287
const sanitizedName = name.split('.')[0];
288-
return [prefix, sanitizedName, suffix].join('');
288+
return [prefix, cleanChunkName(sanitizedName), suffix].join('');
289289
}
290-
const encoded = encodeName(name);
291-
return [prefix, encoded, suffix].join('');
290+
return [prefix, cleanChunkName(name), suffix].join('');
292291
},
293292
assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
294293
...viteConfig.build?.rollupOptions?.output,
@@ -419,8 +418,12 @@ async function buildEnvironments(opts: StaticBuildOptions, internals: BuildInter
419418
rollupOptions: {
420419
preserveEntrySignatures: 'exports-only',
421420
output: {
422-
entryFileNames: `${settings.config.build.assets}/[name].[hash].js`,
423-
chunkFileNames: `${settings.config.build.assets}/[name].[hash].js`,
421+
entryFileNames(chunkInfo) {
422+
return `${settings.config.build.assets}/${cleanChunkName(chunkInfo.name)}.[hash].js`;
423+
},
424+
chunkFileNames(chunkInfo) {
425+
return `${settings.config.build.assets}/${cleanChunkName(chunkInfo.name)}.[hash].js`;
426+
},
424427
assetFileNames: `${settings.config.build.assets}/[name].[hash][extname]`,
425428
...viteConfig.environments?.client?.build?.rollupOptions?.output,
426429
},

packages/astro/src/core/build/util.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,23 @@ export function shouldAppendForwardSlash(
3131
}
3232
}
3333

34-
export function encodeName(name: string): string {
34+
/**
35+
* Matches any character that is NOT alphanumeric, underscore, dot, hyphen, or forward slash.
36+
* Rollup's built-in `sanitizeFileName` misses characters like `!` and `~` that can leak
37+
* from Vite module IDs into chunk names (e.g. `page.!{005}.js`).
38+
*/
39+
const UNSAFE_CHUNK_CHAR_RE = /[^\w.\-/]/g;
40+
41+
/**
42+
* Replaces characters in a chunk name that are not safe for filesystem paths or URLs.
43+
* Characters like `!` and `~` can leak from Vite module IDs into Rollup chunk names
44+
* and break deploys on platforms like Netlify.
45+
*/
46+
export function cleanChunkName(name: string): string {
47+
return encodeName(name.replace(UNSAFE_CHUNK_CHAR_RE, '_'));
48+
}
49+
50+
function encodeName(name: string): string {
3551
// Detect if the chunk name has as % sign that is not encoded.
3652
// This is borrowed from Node core: https://github.com/nodejs/node/blob/3838b579e44bf0c2db43171c3ce0da51eb6b05d5/lib/internal/url.js#L1382-L1391
3753
// We do this because you cannot import a module with this character in it.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<html>
2+
<head>
3+
<title>Dynamic import</title>
4+
</head>
5+
<body>
6+
<h1>Dynamic import</h1>
7+
<script>
8+
import('../scripts/confetti.js').then(m => m.celebrate());
9+
</script>
10+
</body>
11+
</html>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function celebrate() {
2+
console.log('confetti!');
3+
}

packages/astro/test/special-chars-in-component-imports.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ describe('Special chars in component import paths', () => {
3333
assert.equal(html.includes('<html>'), true);
3434
});
3535

36+
it('Output JS filenames do not contain unsafe characters', async () => {
37+
const files = await fixture.readdir('/_astro');
38+
const jsFiles = files.filter((f) => f.endsWith('.js'));
39+
for (const file of jsFiles) {
40+
assert.equal(
41+
/[!~#{}<>]/.test(file),
42+
false,
43+
`File "${file}" contains unsafe characters that break some hosting platforms`,
44+
);
45+
}
46+
});
47+
3648
it('Special chars in imports work from .astro files', async () => {
3749
const html = await fixture.readFile('/index.html');
3850
const $ = cheerioLoad(html);

packages/astro/test/ssr-script.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,45 @@ describe('Inline scripts in SSR', () => {
3737
const $ = cheerioLoad(html);
3838
assert.equal($('script').length, 1);
3939
});
40+
41+
it('server output filenames do not contain unsafe characters', async () => {
42+
const files = await fixture.glob('server/**/*.{js,mjs}');
43+
for (const file of files) {
44+
assert.equal(
45+
/[!~#{}<>]/.test(file),
46+
false,
47+
`File "${file}" contains characters that break hosting platforms like Netlify`,
48+
);
49+
}
50+
});
51+
});
52+
53+
describe('with assetQueryParams', () => {
54+
before(async () => {
55+
fixture = await loadFixture({
56+
...defaultFixtureOptions,
57+
outDir: './dist/inline-scripts-with-asset-query-params',
58+
adapter: testAdapter({
59+
extendAdapter: {
60+
client: {
61+
assetQueryParams: new URLSearchParams({ dpl: 'test123' }),
62+
},
63+
},
64+
}),
65+
});
66+
await fixture.build();
67+
});
68+
69+
it('client output filenames do not contain hash placeholders or unsafe characters', async () => {
70+
const files = await fixture.glob('client/**/*.{js,mjs}');
71+
for (const file of files) {
72+
assert.equal(
73+
/[!~{}]/.test(file),
74+
false,
75+
`File "${file}" contains unsafe characters (likely unresolved hash placeholders)`,
76+
);
77+
}
78+
});
4079
});
4180

4281
describe('with base path', () => {

packages/astro/test/units/build/static-build.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,33 @@
11
import * as assert from 'node:assert/strict';
22
import { describe, it } from 'node:test';
33
import { makeAstroPageEntryPointFileName } from '../../../dist/core/build/static-build.js';
4+
import { cleanChunkName } from '../../../dist/core/build/util.js';
45
import type { RouteData } from '../../../dist/types/public/internal.js';
56

67
describe('astro/src/core/build', () => {
8+
describe('cleanChunkName', () => {
9+
it('passes through safe names unchanged', () => {
10+
assert.equal(cleanChunkName('page'), 'page');
11+
assert.equal(cleanChunkName('my-component'), 'my-component');
12+
assert.equal(cleanChunkName('pages/index'), 'pages/index');
13+
assert.equal(cleanChunkName('chunk_abc123'), 'chunk_abc123');
14+
});
15+
16+
it('replaces ! and ~ characters', () => {
17+
assert.equal(cleanChunkName('page.!{005}'), 'page.__005_');
18+
assert.equal(cleanChunkName('~something'), '_something');
19+
});
20+
21+
it('replaces other unsafe characters', () => {
22+
assert.equal(cleanChunkName('name@scope'), 'name_scope');
23+
assert.equal(cleanChunkName('file#hash'), 'file_hash');
24+
});
25+
26+
it('replaces % character', () => {
27+
assert.equal(cleanChunkName('chunk%name'), 'chunk_name');
28+
});
29+
});
30+
731
describe('makeAstroPageEntryPointFileName', () => {
832
const routes: RouteData[] = [
933
{

0 commit comments

Comments
 (0)