Skip to content

Commit 15cce9a

Browse files
committed
fix(@angular/build): serialize server manifest asset paths
Serialize route-derived asset keys, base paths, hashes, and App Engine entry points before embedding them in executable ESM manifests. Generate bounded ASCII asset chunk names with a path digest so filesystem-sensitive and URL-significant characters remain filename data without creating chunk-name collisions. Add focused manifest coverage and exercise an apostrophe-bearing prerender route end to end.
1 parent 37fd640 commit 15cce9a

3 files changed

Lines changed: 149 additions & 8 deletions

File tree

packages/angular/build/src/utils/server-rendering/manifest.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
BuildOutputFileType,
1616
createOutputFile,
1717
} from '../../tools/esbuild/bundler-files';
18+
import { calculateHash } from '../hash';
1819

1920
export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs';
2021
export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs';
@@ -60,6 +61,37 @@ function escapeUnsafeChars(str: string): string {
6061
return str.replace(/[$`\\]/g, (c) => UNSAFE_CHAR_MAP[c]);
6162
}
6263

64+
/**
65+
* Matches every character which is not safe in the name of a generated server asset chunk.
66+
*/
67+
const UNSAFE_CHUNK_NAME_CHARACTER_REGEXP = /[^a-zA-Z0-9_-]/g;
68+
69+
/**
70+
* The maximum number of characters of an asset path kept in the name of its generated chunk.
71+
* The appended digest is what makes the name unique, so the readable part can be truncated to
72+
* stay well within the file name length limits of all supported platforms.
73+
*/
74+
const MAX_CHUNK_NAME_LENGTH = 128;
75+
76+
/**
77+
* Builds the path of the generated chunk which holds the content of a server asset.
78+
*
79+
* Asset paths are derived from route paths and can therefore contain characters which are unusable
80+
* in a file name (`?`, `:` and `*` are invalid on Windows) or which change how the generated
81+
* dynamic import is resolved (`?`, `#` and `%` are URL syntax). Those characters are replaced, and
82+
* a digest of the asset path is appended so that two asset paths never share a chunk.
83+
*
84+
* @param assetPath - The path of the asset, for example `store/summer sale/index.html`.
85+
* @returns The path of the chunk to generate for the asset.
86+
*/
87+
function generateServerAssetChunkPath(assetPath: string): string {
88+
const name = assetPath
89+
.replace(UNSAFE_CHUNK_NAME_CHARACTER_REGEXP, '_')
90+
.slice(0, MAX_CHUNK_NAME_LENGTH);
91+
92+
return `assets-chunks/${name}-${calculateHash(assetPath)}.mjs`;
93+
}
94+
6395
/**
6496
* Generates the server manifest for the App Engine environment.
6597
*
@@ -85,7 +117,7 @@ export function generateAngularServerAppEngineManifest(
85117
for (const locale of i18nOptions.inlineLocales) {
86118
const { subPath } = i18nOptions.locales[locale];
87119
const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`;
88-
entryPoints[subPath] = `() => import('./${importPath}')`;
120+
entryPoints[subPath] = `() => import(${JSON.stringify(`./${importPath}`)})`;
89121
supportedLocales[locale] = subPath;
90122
}
91123
} else {
@@ -101,12 +133,12 @@ export function generateAngularServerAppEngineManifest(
101133

102134
const manifestContent = `
103135
export default {
104-
basePath: '${basePath}',
136+
basePath: ${JSON.stringify(basePath)},
105137
allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)},
106138
supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)},
107139
entryPoints: {
108140
${Object.entries(entryPoints)
109-
.map(([key, value]) => `'${key}': ${value}`)
141+
.map(([key, value]) => `${JSON.stringify(key)}: ${value}`)
110142
.join(',\n ')}
111143
},
112144
};
@@ -163,7 +195,7 @@ export function generateAngularServerAppManifest(
163195
for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) {
164196
const extension = extname(file.path);
165197
if (extension === '.html' || (inlineCriticalCss && extension === '.css')) {
166-
const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`;
198+
const jsChunkFilePath = generateServerAssetChunkPath(file.path);
167199
const escapedContent = escapeUnsafeChars(file.text);
168200

169201
serverAssetsChunks.push(
@@ -183,8 +215,11 @@ export function generateAngularServerAppManifest(
183215
pos = file.text.indexOf('\r\n', pos + 2);
184216
}
185217

218+
// Asset paths are derived from route paths and can contain arbitrary characters, so they are
219+
// serialized rather than interpolated into the generated executable manifest.
186220
serverAssets[file.path] =
187-
`{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`;
221+
`{size: ${size}, hash: ${JSON.stringify(file.hash)}, ` +
222+
`text: () => import(${JSON.stringify(`./${jsChunkFilePath}`)}).then(m => m.default)}`;
188223
}
189224
}
190225

@@ -197,13 +232,13 @@ export function generateAngularServerAppManifest(
197232
export default {
198233
bootstrap: () => import('./main.server.mjs').then(m => m.default),
199234
inlineCriticalCss: ${inlineCriticalCss},
200-
baseHref: '${baseHref}',
235+
baseHref: ${JSON.stringify(baseHref)},
201236
locale: ${JSON.stringify(locale)},
202237
routes: ${JSON.stringify(routes, undefined, 2)},
203238
entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)},
204239
assets: {
205240
${Object.entries(serverAssets)
206-
.map(([key, value]) => `'${key}': ${value}`)
241+
.map(([key, value]) => `${JSON.stringify(key)}: ${value}`)
207242
.join(',\n ')}
208243
},
209244
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { BuildOutputFileType, createOutputFile } from '../../tools/esbuild/bundler-files';
10+
import { initializeHash } from '../hash';
11+
import { generateAngularServerAppManifest } from './manifest';
12+
13+
/**
14+
* Evaluates a generated manifest, which both asserts that it is syntactically valid JavaScript and
15+
* gives access to the values it declares. The dynamic imports it contains are never invoked.
16+
*/
17+
function evaluateManifest(manifestContent: string): Record<string, unknown> {
18+
return new Function(manifestContent.replace('export default', 'return'))() as Record<
19+
string,
20+
unknown
21+
>;
22+
}
23+
24+
function generateManifest(
25+
htmlOutputFiles: Record<string, string>,
26+
baseHref = '/',
27+
): ReturnType<typeof generateAngularServerAppManifest> {
28+
const additionalHtmlOutputFiles = new Map(
29+
Object.entries(htmlOutputFiles).map(([path, content]) => [
30+
path,
31+
createOutputFile(path, content, BuildOutputFileType.Browser),
32+
]),
33+
);
34+
35+
return generateAngularServerAppManifest(
36+
additionalHtmlOutputFiles,
37+
[],
38+
false,
39+
undefined,
40+
undefined,
41+
baseHref,
42+
new Set(),
43+
{ inputs: {}, outputs: {} },
44+
undefined,
45+
);
46+
}
47+
48+
describe('generateAngularServerAppManifest', () => {
49+
beforeAll(async () => {
50+
await initializeHash();
51+
});
52+
53+
it('serializes asset paths which contain JavaScript string delimiters', () => {
54+
const assetPath = "catalog/customer's-choice/index.html";
55+
const { manifestContent } = generateManifest({ [assetPath]: '<main>Featured</main>' });
56+
57+
const assets = evaluateManifest(manifestContent)['assets'] as Record<string, unknown>;
58+
expect(Object.keys(assets)).toEqual([assetPath]);
59+
});
60+
61+
it('serializes a base href which contains JavaScript string delimiters', () => {
62+
const { manifestContent } = generateManifest({ 'index.html': '<main></main>' }, "/o'brien/");
63+
64+
expect(evaluateManifest(manifestContent)['baseHref']).toBe("/o'brien/");
65+
});
66+
67+
it('generates chunk names which are usable as a file name and as a module specifier', () => {
68+
const assetPath = "catalog/customer's#featured?ratio=50%/index.html";
69+
const { serverAssetsChunks } = generateManifest({ [assetPath]: '<main>Featured</main>' });
70+
71+
expect(serverAssetsChunks).toHaveSize(1);
72+
expect(serverAssetsChunks[0].path).toMatch(/^assets-chunks\/[a-zA-Z0-9_-]+\.mjs$/);
73+
});
74+
75+
it('generates a dynamic import which resolves back to the emitted chunk', () => {
76+
// The in-memory ESM loader used while prerendering resolves the specifier as a URL and looks the
77+
// result up by output file path, so the two have to match exactly.
78+
const assetPath = "catalog/customer's#featured?ratio=50%/index.html";
79+
const { manifestContent, serverAssetsChunks } = generateManifest({
80+
[assetPath]: '<main>Featured</main>',
81+
});
82+
83+
const assets = evaluateManifest(manifestContent)['assets'] as Record<
84+
string,
85+
{ text: () => Promise<string> }
86+
>;
87+
const specifier = /import\("(.+?)"\)/.exec(assets[assetPath].text.toString())?.[1];
88+
89+
const root = 'file:///virtual/root/';
90+
expect(specifier).toBeDefined();
91+
expect(new URL(specifier as string, root).href.slice(root.length)).toBe(
92+
serverAssetsChunks[0].path,
93+
);
94+
});
95+
96+
it('generates a distinct chunk for asset paths which map to the same name', () => {
97+
const { serverAssetsChunks } = generateManifest({
98+
'foo/bar/index.html': '<main>nested</main>',
99+
'foo_bar/index.html': '<main>flat</main>',
100+
});
101+
102+
expect(serverAssetsChunks).toHaveSize(2);
103+
expect(serverAssetsChunks[0].path).not.toBe(serverAssetsChunks[1].path);
104+
});
105+
});

tests/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export default async function () {
7777
path: 'ssg/:id',
7878
renderMode: RenderMode.Prerender,
7979
headers: { 'x-custom': 'ssg-with-params' },
80-
getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}],
80+
getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}, {id: "customer's-choice"}],
8181
},
8282
{
8383
path: 'ssr',
@@ -115,6 +115,7 @@ export default async function () {
115115
'ssg/index.html': 'ssg works!',
116116
'ssg/one/index.html': 'ssg-with-params works!',
117117
'ssg/two/index.html': 'ssg-with-params works!',
118+
"ssg/customer's-choice/index.html": 'ssg-with-params works!',
118119
};
119120

120121
for (const [filePath, fileMatch] of Object.entries(expects)) {

0 commit comments

Comments
 (0)