From a50e3c9f472cbac12272326cec0e90f27f475bb2 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:09:22 -0500 Subject: [PATCH] fix(@angular/build): serialize server manifest asset paths Serialize the values embedded in the generated server manifests with `JSON.stringify`, so a prerendered route such as `ssg/customer's-choice` no longer produces an invalid manifest. --- .../src/utils/server-rendering/manifest.ts | 15 ++-- .../utils/server-rendering/manifest_spec.ts | 77 +++++++++++++++++++ .../server-routes-output-mode-server.ts | 3 +- 3 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 packages/angular/build/src/utils/server-rendering/manifest_spec.ts diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index 975f7718715d..605792e4fb1e 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -69,7 +69,7 @@ export function generateAngularServerAppEngineManifest( for (const locale of i18nOptions.inlineLocales) { const { subPath } = i18nOptions.locales[locale]; const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`; - entryPoints[subPath] = `() => import('./${importPath}')`; + entryPoints[subPath] = `() => import(${JSON.stringify(`./${importPath}`)})`; supportedLocales[locale] = subPath; } } else { @@ -85,12 +85,12 @@ export function generateAngularServerAppEngineManifest( const manifestContent = ` export default { - basePath: '${basePath}', + basePath: ${JSON.stringify(basePath)}, allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)}, supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)}, entryPoints: { ${Object.entries(entryPoints) - .map(([key, value]) => `'${key}': ${value}`) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) .join(',\n ')} }, }; @@ -162,8 +162,11 @@ export function generateAngularServerAppManifest( // which can result in an incorrect byte length. const size = runInThisContext(`new TextEncoder().encode(\`${escapedContent}\`).byteLength`); + // Asset paths are derived from route paths and can contain arbitrary characters, so they are + // serialized rather than interpolated into the generated executable manifest. serverAssets[file.path] = - `{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`; + `{size: ${size}, hash: ${JSON.stringify(file.hash)}, ` + + `text: () => import(${JSON.stringify(`./${jsChunkFilePath}`)}).then(m => m.default)}`; } } @@ -178,13 +181,13 @@ export function generateAngularServerAppManifest( export default { bootstrap: () => import('./main.server.mjs').then(m => m.default), inlineCriticalCss: ${inlineCriticalCss}, - baseHref: '${baseHref}', + baseHref: ${JSON.stringify(baseHref)}, locale: ${JSON.stringify(locale)}, routes: ${JSON.stringify(routes, undefined, 2)}, entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)}, assets: { ${Object.entries(serverAssets) - .map(([key, value]) => `'${key}': ${value}`) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) .join(',\n ')} }, }; diff --git a/packages/angular/build/src/utils/server-rendering/manifest_spec.ts b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts new file mode 100644 index 000000000000..a65e4c69c8d7 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { BuildOutputFileType } from '../../tools/esbuild/bundler-context'; +import { createOutputFile } from '../../tools/esbuild/utils'; +import { generateAngularServerAppManifest } from './manifest'; + +describe('generateAngularServerAppManifest', () => { + const dummyMetafile = { inputs: {}, outputs: {} } as unknown as Metafile; + + /** + * Evaluates a generated manifest, which both asserts that it is syntactically valid JavaScript and + * gives access to the values it declares. The dynamic imports it contains are never invoked. + */ + function evaluateManifest(manifestContent: string): unknown { + return new Function(manifestContent.replace('export default', 'return'))(); + } + + function generateManifest( + htmlOutputFiles: Record, + baseHref = '/', + ): ReturnType { + const additionalHtmlOutputFiles = new Map( + Object.entries(htmlOutputFiles).map(([path, content]) => [ + path, + createOutputFile(path, content, BuildOutputFileType.Browser), + ]), + ); + + return generateAngularServerAppManifest( + additionalHtmlOutputFiles, + [], + false, + undefined, + undefined, + baseHref, + new Set(), + dummyMetafile, + undefined, + ); + } + + it('should serialize asset paths which contain JavaScript string delimiters', () => { + const assetPath = "catalog/customer's-choice/index.html"; + const { manifestContent } = generateManifest({ + [assetPath]: '
Featured
', + }); + + expect(evaluateManifest(manifestContent)).toEqual( + jasmine.objectContaining({ + assets: { + [assetPath]: jasmine.objectContaining({ + size: 21, + hash: jasmine.any(String), + text: jasmine.any(Function), + }), + }, + }), + ); + }); + + it('should serialize a base href which contains JavaScript string delimiters', () => { + const { manifestContent } = generateManifest({ 'index.html': '
' }, "/o'brien/"); + + expect(evaluateManifest(manifestContent)).toEqual( + jasmine.objectContaining({ + baseHref: "/o'brien/", + }), + ); + }); +}); diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts index ff72cb8e8df2..a922d9e142e7 100644 --- a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts @@ -77,7 +77,7 @@ export default async function () { path: 'ssg/:id', renderMode: RenderMode.Prerender, headers: { 'x-custom': 'ssg-with-params' }, - getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}], + getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}, {id: "customer's-choice"}], }, { path: 'ssr', @@ -115,6 +115,7 @@ export default async function () { 'ssg/index.html': 'ssg works!', 'ssg/one/index.html': 'ssg-with-params works!', 'ssg/two/index.html': 'ssg-with-params works!', + "ssg/customer's-choice/index.html": 'ssg-with-params works!', }; for (const [filePath, fileMatch] of Object.entries(expects)) {