From b9505ed097d60eadae665d4664199e3d4989c864 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 18 Oct 2023 08:28:13 +0000 Subject: [PATCH] fix(@angular-devkit/build-angular): generate a file containing a list of prerendered routes With this change when SSG is enabled a `prerendered-routes.json` file is emitted that contains all the prerendered routes. This is useful for Cloud providers and other server engines to have server rules to serve these files as static. (cherry picked from commit 2dc6566ad069c88ecdca62ef3c05e488de34960d) --- .../src/builders/application/execute-build.ts | 44 +++++++++++++------ .../application/execute-post-bundle.ts | 11 ++++- .../src/builders/application/i18n.ts | 39 ++++++++++------ .../src/utils/server-rendering/prerender.ts | 15 +++++-- .../prerender/discover-routes-standalone.ts | 19 +++++++- 5 files changed, 95 insertions(+), 33 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts index 963daa5e334e..3a74c43228b1 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-build.ts @@ -198,25 +198,41 @@ export async function executeBuild( } // Perform i18n translation inlining if enabled + let prerenderedRoutes: string[]; + let errors: string[]; + let warnings: string[]; if (i18nOptions.shouldInline) { - const { errors, warnings } = await inlineI18n(options, executionResult, initialFiles); - printWarningsAndErrorsToConsole(context, warnings, errors); + const result = await inlineI18n(options, executionResult, initialFiles); + errors = result.errors; + warnings = result.warnings; + prerenderedRoutes = result.prerenderedRoutes; } else { - const { errors, warnings, additionalAssets, additionalOutputFiles } = - await executePostBundleSteps( - options, - executionResult.outputFiles, - executionResult.assetFiles, - initialFiles, - // Set lang attribute to the defined source locale if present - i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, - ); + const result = await executePostBundleSteps( + options, + executionResult.outputFiles, + executionResult.assetFiles, + initialFiles, + // Set lang attribute to the defined source locale if present + i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, + ); - executionResult.outputFiles.push(...additionalOutputFiles); - executionResult.assetFiles.push(...additionalAssets); - printWarningsAndErrorsToConsole(context, warnings, errors); + errors = result.errors; + warnings = result.warnings; + prerenderedRoutes = result.prerenderedRoutes; + executionResult.outputFiles.push(...result.additionalOutputFiles); + executionResult.assetFiles.push(...result.additionalAssets); } + if (prerenderOptions) { + executionResult.addOutputFile( + 'prerendered-routes.json', + JSON.stringify({ routes: prerenderedRoutes.sort((a, b) => a.localeCompare(b)) }, null, 2), + BuildOutputFileType.Root, + ); + } + + printWarningsAndErrorsToConsole(context, warnings, errors); + logBuildStats(context, metafile, initialFiles, budgetFailures, estimatedTransferSizes); const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9; diff --git a/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts b/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts index cceb04d3b665..6a5d1869ebfa 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/execute-post-bundle.ts @@ -39,11 +39,13 @@ export async function executePostBundleSteps( warnings: string[]; additionalOutputFiles: BuildOutputFile[]; additionalAssets: BuildOutputAsset[]; + prerenderedRoutes: string[]; }> { const additionalAssets: BuildOutputAsset[] = []; const additionalOutputFiles: BuildOutputFile[] = []; const allErrors: string[] = []; const allWarnings: string[] = []; + const prerenderedRoutes: string[] = []; const { serviceWorker, @@ -105,7 +107,12 @@ export async function executePostBundleSteps( 'The "index" option is required when using the "ssg" or "appShell" options.', ); - const { output, warnings, errors } = await prerenderPages( + const { + output, + warnings, + errors, + prerenderedRoutes: generatedRoutes, + } = await prerenderPages( workspaceRoot, appShellOptions, prerenderOptions, @@ -119,6 +126,7 @@ export async function executePostBundleSteps( allErrors.push(...errors); allWarnings.push(...warnings); + prerenderedRoutes.push(...Array.from(generatedRoutes)); for (const [path, content] of Object.entries(output)) { additionalOutputFiles.push( @@ -155,6 +163,7 @@ export async function executePostBundleSteps( errors: allErrors, warnings: allWarnings, additionalAssets, + prerenderedRoutes, additionalOutputFiles, }; } diff --git a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts index f48c958e9608..e871c7298790 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/i18n.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/i18n.ts @@ -7,7 +7,7 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import { join } from 'node:path'; +import { join, posix } from 'node:path'; import { InitialFileRecord } from '../../tools/esbuild/bundler-context'; import { ExecutionResult } from '../../tools/esbuild/bundler-execution-result'; import { I18nInliner } from '../../tools/esbuild/i18n-inliner'; @@ -29,7 +29,7 @@ export async function inlineI18n( options: NormalizedApplicationBuildOptions, executionResult: ExecutionResult, initialFiles: Map, -): Promise<{ errors: string[]; warnings: string[] }> { +): Promise<{ errors: string[]; warnings: string[]; prerenderedRoutes: string[] }> { // Create the multi-threaded inliner with common options and the files generated from the build. const inliner = new I18nInliner( { @@ -40,9 +40,10 @@ export async function inlineI18n( maxWorkers, ); - const inlineResult: { errors: string[]; warnings: string[] } = { + const inlineResult: { errors: string[]; warnings: string[]; prerenderedRoutes: string[] } = { errors: [], warnings: [], + prerenderedRoutes: [], }; // For each active locale, use the inliner to process the output files of the build. @@ -59,17 +60,22 @@ export async function inlineI18n( const baseHref = getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref; - const { errors, warnings, additionalAssets, additionalOutputFiles } = - await executePostBundleSteps( - { - ...options, - baseHref, - }, - localeOutputFiles, - executionResult.assetFiles, - initialFiles, - locale, - ); + const { + errors, + warnings, + additionalAssets, + additionalOutputFiles, + prerenderedRoutes: generatedRoutes, + } = await executePostBundleSteps( + { + ...options, + baseHref, + }, + localeOutputFiles, + executionResult.assetFiles, + initialFiles, + locale, + ); localeOutputFiles.push(...additionalOutputFiles); inlineResult.errors.push(...errors); @@ -87,7 +93,12 @@ export async function inlineI18n( destination: join(locale, assetFile.destination), }); } + + inlineResult.prerenderedRoutes.push( + ...generatedRoutes.map((route) => posix.join('/', locale, route)), + ); } else { + inlineResult.prerenderedRoutes.push(...generatedRoutes); executionResult.assetFiles.push(...additionalAssets); } diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts index d0b8c59b7ccd..cd1f91f0c0f3 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts @@ -41,6 +41,7 @@ export async function prerenderPages( output: Record; warnings: string[]; errors: string[]; + prerenderedRoutes: Set; }> { const output: Record = {}; const warnings: string[] = []; @@ -92,6 +93,7 @@ export async function prerenderPages( errors, warnings, output, + prerenderedRoutes: allRoutes, }; } @@ -114,7 +116,7 @@ export async function prerenderPages( try { const renderingPromises: Promise[] = []; - const appShellRoute = appShellOptions.route && removeLeadingSlash(appShellOptions.route); + const appShellRoute = appShellOptions.route && addLeadingSlash(appShellOptions.route); for (const route of allRoutes) { const isAppShellRoute = appShellRoute === route; @@ -123,7 +125,9 @@ export async function prerenderPages( const render: Promise = renderWorker.run({ route, serverContext }); const renderResult: Promise = render.then(({ content, warnings, errors }) => { if (content !== undefined) { - const outPath = isAppShellRoute ? 'index.html' : posix.join(route, 'index.html'); + const outPath = isAppShellRoute + ? 'index.html' + : removeLeadingSlash(posix.join(route, 'index.html')); output[outPath] = content; } @@ -148,12 +152,13 @@ export async function prerenderPages( errors, warnings, output, + prerenderedRoutes: allRoutes, }; } class RoutesSet extends Set { override add(value: string): this { - return super.add(removeLeadingSlash(value)); + return super.add(addLeadingSlash(value)); } } @@ -213,6 +218,10 @@ async function getAllRoutes( return { routes, warnings }; } +function addLeadingSlash(value: string): string { + return value.charAt(0) === '/' ? value : '/' + value; +} + function removeLeadingSlash(value: string): string { return value.charAt(0) === '/' ? value.slice(1) : value; } diff --git a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts index 49674fe2b2c4..7c2ac56f097b 100644 --- a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts +++ b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts @@ -1,9 +1,10 @@ import { join } from 'path'; import { getGlobalVariable } from '../../../utils/env'; -import { expectFileToMatch, rimraf, writeFile } from '../../../utils/fs'; +import { expectFileToMatch, readFile, rimraf, writeFile } from '../../../utils/fs'; import { installWorkspacePackages } from '../../../utils/packages'; import { ng } from '../../../utils/process'; import { useSha } from '../../../utils/project'; +import { deepStrictEqual } from 'node:assert'; export default async function () { const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; @@ -111,5 +112,21 @@ export default async function () { for (const [filePath, fileMatch] of Object.entries(expects)) { await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); } + + if (!useWebpackBuilder) { + // prerendered-routes.json file is only generated when using esbuild. + const generatedRoutesStats = await readFile('dist/test-project/prerendered-routes.json'); + deepStrictEqual(JSON.parse(generatedRoutesStats), { + routes: [ + '/', + '/lazy-one', + '/lazy-one/lazy-one-child', + '/lazy-two', + '/two', + '/two/two-child-one', + '/two/two-child-two', + ], + }); + } } }