Skip to content

Commit 10c7e63

Browse files
fix(build): replace manifest placeholder when SSR output is minified (#17874)
Rolldown's minifier rewrites string literals as template literals (backticks), but the regex matching the `@@ASTRO_MANIFEST_REPLACE@@` placeholder only accepted single and double quotes. Add backtick to the character class in the manifest and server islands replacement regexes. Fixes #17843 Co-authored-by: factory[bot] <factory[bot]@users.noreply.github.com>
1 parent 1870eea commit 10c7e63

4 files changed

Lines changed: 137 additions & 3 deletions

File tree

.changeset/beige-times-dream.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 SSR manifest placeholder not being replaced when the server build is minified, which caused a runtime `Invalid URL` crash at server boot

packages/astro/src/core/build/plugins/plugin-manifest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ import { sessionConfigToManifest } from '../../session/utils.js';
6262
*/
6363

6464
export const MANIFEST_REPLACE = '@@ASTRO_MANIFEST_REPLACE@@';
65-
const replaceExp = new RegExp(`['"]${MANIFEST_REPLACE}['"]`, 'g');
65+
// Backtick included: Rolldown's minifier may rewrite string literals as template literals.
66+
const replaceExp = new RegExp(`['"\`]${MANIFEST_REPLACE}['"\`]`, 'g');
6667

6768
/**
6869
* Post-build hook that injects the computed manifest into bundled chunks.

packages/astro/src/core/server-islands/vite-plugin-server-islands.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ const RESOLVED_SERVER_ISLAND_MANIFEST = '\0' + SERVER_ISLAND_MANIFEST;
1111
const serverIslandPlaceholderMap = "'$$server-islands-map$$'";
1212
const serverIslandPlaceholderNameMap = "'$$server-islands-name-map$$'";
1313
export const SERVER_ISLAND_MAP_MARKER = '$$server-islands-map$$';
14-
const serverIslandMapReplaceExp = /['"]\$\$server-islands-map\$\$['"]/g;
15-
const serverIslandNameMapReplaceExp = /['"]\$\$server-islands-name-map\$\$['"]/g;
14+
// Backtick included: Rolldown's minifier may rewrite string literals as template literals.
15+
const serverIslandMapReplaceExp = /['"`]\$\$server-islands-map\$\$['"`]/g;
16+
const serverIslandNameMapReplaceExp = /['"`]\$\$server-islands-name-map\$\$['"`]/g;
1617

1718
export function vitePluginServerIslands({
1819
settings,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import assert from 'node:assert/strict';
2+
import { promises as fs } from 'node:fs';
3+
import path from 'node:path';
4+
import { describe, it } from 'node:test';
5+
import { fileURLToPath } from 'node:url';
6+
import type { Plugin } from 'vite';
7+
import { AstroBuilder } from '../../../dist/core/build/index.js';
8+
import { MANIFEST_REPLACE } from '../../../dist/core/build/plugins/plugin-manifest.js';
9+
import { parseRoute } from '../../../dist/core/routing/parse-route.js';
10+
import { createBasicSettings, defaultLogger } from '../test-utils.ts';
11+
import { virtualAstroModules } from './test-helpers.ts';
12+
13+
async function readFilesRecursive(dir: string): Promise<string[]> {
14+
const entries = await fs.readdir(dir, { withFileTypes: true });
15+
const files = await Promise.all(
16+
entries.map(async (entry) => {
17+
const fullPath = path.join(dir, entry.name);
18+
if (entry.isDirectory()) {
19+
return readFilesRecursive(fullPath);
20+
}
21+
return [fullPath];
22+
}),
23+
);
24+
return files.flat();
25+
}
26+
27+
/**
28+
* Vite plugin that enables minification for the SSR environment.
29+
* This simulates what an integration would do via `astro:build:setup`.
30+
*/
31+
function enableSsrMinification(): Plugin {
32+
return {
33+
name: 'test-enable-ssr-minification',
34+
configEnvironment(environmentName, config) {
35+
if (environmentName === 'ssr') {
36+
config.build ??= {};
37+
config.build.minify = true;
38+
}
39+
},
40+
};
41+
}
42+
43+
describe('Build: Manifest injection', () => {
44+
it('replaces manifest placeholder when server build is minified', async () => {
45+
const root = new URL('./_temp-fixtures/', import.meta.url);
46+
47+
const settings = await createBasicSettings({
48+
root: fileURLToPath(root),
49+
output: 'server',
50+
adapter: {
51+
name: 'test-adapter',
52+
hooks: {
53+
'astro:config:done': ({ setAdapter }) => {
54+
setAdapter({
55+
name: 'test-adapter',
56+
serverEntrypoint: 'astro/app',
57+
exports: ['manifest', 'createApp'],
58+
supportedAstroFeatures: {
59+
serverOutput: 'stable',
60+
},
61+
adapterFeatures: {
62+
buildOutput: 'server',
63+
},
64+
});
65+
},
66+
},
67+
},
68+
vite: {
69+
plugins: [
70+
virtualAstroModules(root, {
71+
'src/pages/index.astro': [
72+
'---',
73+
'---',
74+
'<html>',
75+
'<head><title>Test</title></head>',
76+
'<body><h1>Hello</h1></body>',
77+
'</html>',
78+
].join('\n'),
79+
}),
80+
enableSsrMinification(),
81+
],
82+
},
83+
});
84+
85+
const routesList = {
86+
routes: [
87+
parseRoute('index.astro', settings, {
88+
component: 'src/pages/index.astro',
89+
prerender: false,
90+
}),
91+
],
92+
};
93+
94+
process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=';
95+
96+
try {
97+
const builder = new AstroBuilder(settings, {
98+
logger: defaultLogger,
99+
mode: 'production',
100+
runtimeMode: 'production',
101+
routesList,
102+
sync: false,
103+
});
104+
await builder.run();
105+
} finally {
106+
delete process.env.ASTRO_KEY;
107+
}
108+
109+
const serverOutputDir = fileURLToPath(settings.config.build.server);
110+
const outputFiles = await readFilesRecursive(serverOutputDir);
111+
112+
// Find all server output files and verify none contain the unsubstituted placeholder
113+
let foundManifestChunk = false;
114+
for (const file of outputFiles) {
115+
if (!file.endsWith('.mjs') && !file.endsWith('.js')) continue;
116+
const content = await fs.readFile(file, 'utf-8');
117+
if (content.includes('deserializeManifest') || content.includes('_deserializeManifest')) {
118+
foundManifestChunk = true;
119+
assert.ok(
120+
!content.includes(MANIFEST_REPLACE),
121+
`Manifest placeholder should be replaced in minified output but was found in ${path.basename(file)}`,
122+
);
123+
}
124+
}
125+
assert.ok(foundManifestChunk, 'Should find at least one chunk containing deserializeManifest');
126+
});
127+
});

0 commit comments

Comments
 (0)