Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/afraid-coins-wear.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 10 additions & 7 deletions packages/astro/src/core/build/static-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -419,8 +418,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,
},
Expand Down
18 changes: 17 additions & 1 deletion packages/astro/src/core/build/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,23 @@ export function shouldAppendForwardSlash(
}
}

export function encodeName(name: string): string {
/**
* 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, '_'));
}

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.
Expand Down
11 changes: 11 additions & 0 deletions packages/astro/test/fixtures/ssr-script/src/pages/dynamic.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<html>
<head>
<title>Dynamic import</title>
</head>
<body>
<h1>Dynamic import</h1>
<script>
import('../scripts/confetti.js').then(m => m.celebrate());
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function celebrate() {
console.log('confetti!');
}
12 changes: 12 additions & 0 deletions packages/astro/test/special-chars-in-component-imports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ describe('Special chars in component import paths', () => {
assert.equal(html.includes('<html>'), 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);
Expand Down
39 changes: 39 additions & 0 deletions packages/astro/test/ssr-script.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/astro/test/units/build/static-build.test.ts
Original file line number Diff line number Diff line change
@@ -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[] = [
{
Expand Down
Loading