From 8f9a0d70cdf692b19574410cebb4d029056263fc Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 18 Sep 2023 08:33:35 +0000 Subject: [PATCH] feat(@angular-devkit/build-angular): support standalone apps route discovery during prerendering This fixes an issue were routes could not be discovered automatically in a standalone application. This is a total overhaul of the route extraction process as instead of using `guess-parser` NPM package, we now use the Angular Router. This enables a number of exciting possibilities for the future which were not possible before. # How it works? The application is bootstrapped and through DI injection we get the injector and router config instance and recursively build the routes tree. --- package.json | 1 - .../files/server-builder/server.ts.template | 2 +- .../ssr/schematics/ng-add/index_spec.ts | 2 +- packages/angular/ssr/src/common-engine.ts | 2 +- .../angular_devkit/build_angular/BUILD.bazel | 2 +- .../angular_devkit/build_angular/package.json | 1 - .../src/builders/app-shell/render-worker.ts | 2 +- .../src/builders/application/execute-build.ts | 4 +- .../src/builders/application/options.ts | 8 +- .../src/builders/application/schema.json | 12 +- .../src/builders/prerender/index.ts | 83 ++++++++-- .../src/builders/prerender/render-worker.ts | 2 +- .../prerender/routes-extractor-worker.ts | 80 ++++++++++ .../src/builders/prerender/schema.json | 4 +- .../src/builders/prerender/utils.ts | 69 -------- .../src/builders/prerender/works_spec.ts | 6 +- .../server/platform-server-exports-loader.ts | 14 +- .../tools/esbuild/application-code-bundle.ts | 52 +++--- .../src/utils/routes-extractor/BUILD.bazel | 27 ++++ .../src/utils/routes-extractor/extractor.ts | 111 +++++++++++++ .../esm-in-memory-file-loader.ts | 6 +- .../server-rendering/main-bundle-exports.ts | 28 ++++ .../src/utils/server-rendering/prerender.ts | 116 +++++++++----- .../src/utils/server-rendering/render-page.ts | 20 +-- .../utils/server-rendering/render-worker.ts | 6 +- .../routes-extractor-worker.ts | 73 +++++++++ tests/legacy-cli/e2e.bzl | 1 + .../prerender/discover-routes-ngmodule.ts | 114 ++++++++++++++ .../prerender/discover-routes-standalone.ts | 148 ++++++++++++++++++ 29 files changed, 802 insertions(+), 194 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts delete mode 100644 packages/angular_devkit/build_angular/src/builders/prerender/utils.ts create mode 100644 packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel create mode 100644 packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts create mode 100644 packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts create mode 100644 packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts create mode 100644 tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts create mode 100644 tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts diff --git a/package.json b/package.json index 57e73c1f3b8f..8b47445e7d21 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,6 @@ "eslint-plugin-import": "2.28.1", "express": "4.18.2", "fast-glob": "3.3.1", - "guess-parser": "0.4.22", "http-proxy": "^1.18.1", "http-proxy-middleware": "2.0.6", "https-proxy-agent": "7.0.2", diff --git a/packages/angular/ssr/schematics/ng-add/files/server-builder/server.ts.template b/packages/angular/ssr/schematics/ng-add/files/server-builder/server.ts.template index d028b46fd8bc..1c35b29e8500 100644 --- a/packages/angular/ssr/schematics/ng-add/files/server-builder/server.ts.template +++ b/packages/angular/ssr/schematics/ng-add/files/server-builder/server.ts.template @@ -64,4 +64,4 @@ if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { run(); } -<% if (isStandalone) { %>export default bootstrap;<% } else { %>export * from './src/main.server';<% } %> +export default <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %>; diff --git a/packages/angular/ssr/schematics/ng-add/index_spec.ts b/packages/angular/ssr/schematics/ng-add/index_spec.ts index 865e3df4faaf..cb66e4360a9d 100644 --- a/packages/angular/ssr/schematics/ng-add/index_spec.ts +++ b/packages/angular/ssr/schematics/ng-add/index_spec.ts @@ -194,7 +194,7 @@ describe('SSR Schematic', () => { const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); const content = tree.readContent('/projects/test-app/server.ts'); - expect(content).toContain(`export * from './src/main.server'`); + expect(content).toContain(`export default AppServerModule`); }); it(`should add correct value to 'distFolder'`, async () => { diff --git a/packages/angular/ssr/src/common-engine.ts b/packages/angular/ssr/src/common-engine.ts index 9a2b0fbe0f81..6fa95b551c94 100644 --- a/packages/angular/ssr/src/common-engine.ts +++ b/packages/angular/ssr/src/common-engine.ts @@ -162,7 +162,7 @@ async function exists(path: fs.PathLike): Promise { } function isBootstrapFn(value: unknown): value is () => Promise { - // We can differentiate between a module and a bootstrap function by reading `cmp`: + // We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property: return typeof value === 'function' && !('ɵmod' in value); } diff --git a/packages/angular_devkit/build_angular/BUILD.bazel b/packages/angular_devkit/build_angular/BUILD.bazel index 8d1f1147d466..70c753060de5 100644 --- a/packages/angular_devkit/build_angular/BUILD.bazel +++ b/packages/angular_devkit/build_angular/BUILD.bazel @@ -122,6 +122,7 @@ ts_library( module_root = "src/index.d.ts", deps = [ "//packages/angular_devkit/architect", + "//packages/angular_devkit/build_angular/src/utils/routes-extractor", "//packages/angular_devkit/build_webpack", "//packages/angular_devkit/core", "//packages/angular_devkit/core/node", @@ -168,7 +169,6 @@ ts_library( "@npm//esbuild", "@npm//esbuild-wasm", "@npm//fast-glob", - "@npm//guess-parser", "@npm//http-proxy-middleware", "@npm//https-proxy-agent", "@npm//inquirer", diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index 189338f11ede..9f7a92b3f378 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -34,7 +34,6 @@ "css-loader": "6.8.1", "esbuild-wasm": "0.19.3", "fast-glob": "3.3.1", - "guess-parser": "0.4.22", "https-proxy-agent": "7.0.2", "http-proxy-middleware": "2.0.6", "inquirer": "8.2.6", diff --git a/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts b/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts index 3f8e59a17cb6..a2434a5219b0 100644 --- a/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts +++ b/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts @@ -102,7 +102,7 @@ async function render({ serverBundlePath, document, url }: RenderRequest): Promi } function isBootstrapFn(value: unknown): value is () => Promise { - // We can differentiate between a module and a bootstrap function by reading `cmp`: + // We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property: return typeof value === 'function' && !('ɵmod' in value); } 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 4d7f3cb0f43a..fce939058857 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 @@ -53,6 +53,7 @@ export async function executeBuild( prerenderOptions, appShellOptions, ssrOptions, + verbose, } = options; const browsers = getSupportedBrowsers(projectRoot, context.logger); @@ -182,13 +183,13 @@ export async function executeBuild( const { output, warnings, errors } = await prerenderPages( workspaceRoot, - options.tsconfig, appShellOptions, prerenderOptions, executionResult.outputFiles, indexContentOutputNoCssInlining, optimizationOptions.styles.inlineCritical, maxWorkers, + verbose, ); printWarningsAndErrorsToConsole(context, warnings, errors); @@ -242,6 +243,7 @@ export async function executeBuild( if (optimizationOptions.scripts || optimizationOptions.styles.minify) { estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles); } + logBuildStats(context, metafile, initialFiles, estimatedTransferSizes); const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9; diff --git a/packages/angular_devkit/build_angular/src/builders/application/options.ts b/packages/angular_devkit/build_angular/src/builders/application/options.ts index 98f7ace920eb..e04da118f23e 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts @@ -181,15 +181,11 @@ export async function normalizeOptions( let prerenderOptions; if (options.prerender) { - const { - discoverRoutes = true, - routes = [], - routesFile = undefined, - } = options.prerender === true ? {} : options.prerender; + const { discoverRoutes = true, routesFile = undefined } = + options.prerender === true ? {} : options.prerender; prerenderOptions = { discoverRoutes, - routes, routesFile: routesFile && path.join(workspaceRoot, routesFile), }; } diff --git a/packages/angular_devkit/build_angular/src/builders/application/schema.json b/packages/angular_devkit/build_angular/src/builders/application/schema.json index e3e4411afb52..d5f646d7de32 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/application/schema.json @@ -423,19 +423,9 @@ "type": "string", "description": "The path to a file containing routes separated by newlines." }, - "routes": { - "type": "array", - "description": "The routes to render.", - "items": { - "minItems": 1, - "type": "string", - "uniqueItems": true - }, - "default": [] - }, "discoverRoutes": { "type": "boolean", - "description": "Whether the builder should statically discover routes.", + "description": "Whether the builder should discover routers using the Angular Router.", "default": true } }, diff --git a/packages/angular_devkit/build_angular/src/builders/prerender/index.ts b/packages/angular_devkit/build_angular/src/builders/prerender/index.ts index efbff737afb5..deba6c67e5e1 100644 --- a/packages/angular_devkit/build_angular/src/builders/prerender/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/prerender/index.ts @@ -14,6 +14,7 @@ import { } from '@angular-devkit/architect'; import { json } from '@angular-devkit/core'; import * as fs from 'fs'; +import { readFile } from 'node:fs/promises'; import ora from 'ora'; import * as path from 'path'; import Piscina from 'piscina'; @@ -21,16 +22,70 @@ import { normalizeOptimization } from '../../utils'; import { maxWorkers } from '../../utils/environment-options'; import { assertIsError } from '../../utils/error'; import { augmentAppWithServiceWorker } from '../../utils/service-worker'; +import { getIndexOutputFile } from '../../utils/webpack-browser-config'; import { BrowserBuilderOutput } from '../browser'; import { Schema as BrowserBuilderOptions } from '../browser/schema'; import { ServerBuilderOutput } from '../server'; import type { RenderOptions, RenderResult } from './render-worker'; +import { RoutesExtractorWorkerData } from './routes-extractor-worker'; import { Schema } from './schema'; -import { getIndexOutputFile, getRoutes } from './utils'; type PrerenderBuilderOptions = Schema & json.JsonObject; type PrerenderBuilderOutput = BuilderOutput; +class RoutesSet extends Set { + override add(value: string): this { + return super.add(value.charAt(0) === '/' ? value.slice(1) : value); + } +} + +async function getRoutes( + indexFile: string, + outputPath: string, + serverBundlePath: string, + options: PrerenderBuilderOptions, + workspaceRoot: string, +): Promise { + const { routes: extraRoutes = [], routesFile, discoverRoutes } = options; + const routes = new RoutesSet(extraRoutes); + + if (routesFile) { + const routesFromFile = (await readFile(path.join(workspaceRoot, routesFile), 'utf8')).split( + /\r?\n/, + ); + for (const route of routesFromFile) { + routes.add(route); + } + } + + if (discoverRoutes) { + const renderWorker = new Piscina({ + filename: require.resolve('./routes-extractor-worker'), + maxThreads: 1, + workerData: { + indexFile, + outputPath, + serverBundlePath, + zonePackage: require.resolve('zone.js', { paths: [workspaceRoot] }), + } as RoutesExtractorWorkerData, + }); + + const extractedRoutes: string[] = await renderWorker + .run({}) + .finally(() => void renderWorker.destroy()); + + for (const route of extractedRoutes) { + routes.add(route); + } + } + + if (routes.size === 0) { + throw new Error('Could not find any routes to prerender.'); + } + + return [...routes]; +} + /** * Schedules the server and browser builds and returns their results if both builds are successful. */ @@ -80,7 +135,7 @@ async function _scheduleBuilds( * /index.html for each output path in the browser result. */ async function _renderUniversal( - routes: string[], + options: PrerenderBuilderOptions, context: BuilderContext, browserResult: BrowserBuilderOutput, serverResult: ServerBuilderOutput, @@ -98,7 +153,7 @@ async function _renderUniversal( ); // Users can specify a different base html file e.g. "src/home.html" - const indexFile = getIndexOutputFile(browserOptions); + const indexFile = getIndexOutputFile(browserOptions.index); const { styles: normalizedStylesOptimization } = normalizeOptimization( browserOptions.optimization, ); @@ -112,15 +167,26 @@ async function _renderUniversal( workerData: { zonePackage }, }); + let routes: string[] | undefined; + try { // We need to render the routes for each locale from the browser output. for (const { path: outputPath } of browserResult.outputs) { const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath); const serverBundlePath = path.join(baseOutputPath, localeDirectory, 'main.js'); + if (!fs.existsSync(serverBundlePath)) { throw new Error(`Could not find the main bundle: ${serverBundlePath}`); } + routes ??= await getRoutes( + indexFile, + outputPath, + serverBundlePath, + options, + context.workspaceRoot, + ); + const spinner = ora(`Prerendering ${routes.length} route(s) to ${outputPath}...`).start(); try { @@ -197,21 +263,14 @@ export async function execute( const browserOptions = (await context.getTargetOptions( browserTarget, )) as unknown as BrowserBuilderOptions; - const tsConfigPath = - typeof browserOptions.tsConfig === 'string' ? browserOptions.tsConfig : undefined; - - const routes = await getRoutes(options, tsConfigPath, context); - if (!routes.length) { - throw new Error(`Could not find any routes to prerender.`); - } - const result = await _scheduleBuilds(options, context); const { success, error, browserResult, serverResult } = result; + if (!success || !browserResult || !serverResult) { return { success, error } as BuilderOutput; } - return _renderUniversal(routes, context, browserResult, serverResult, browserOptions); + return _renderUniversal(options, context, browserResult, serverResult, browserOptions); } export default createBuilder(execute); diff --git a/packages/angular_devkit/build_angular/src/builders/prerender/render-worker.ts b/packages/angular_devkit/build_angular/src/builders/prerender/render-worker.ts index f26cc8fef3c8..c9b0c9bf7343 100644 --- a/packages/angular_devkit/build_angular/src/builders/prerender/render-worker.ts +++ b/packages/angular_devkit/build_angular/src/builders/prerender/render-worker.ts @@ -148,7 +148,7 @@ async function render({ } function isBootstrapFn(value: unknown): value is () => Promise { - // We can differentiate between a module and a bootstrap function by reading `cmp`: + // We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property: return typeof value === 'function' && !('ɵmod' in value); } diff --git a/packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts b/packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts new file mode 100644 index 000000000000..1932f8f0ef1f --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/prerender/routes-extractor-worker.ts @@ -0,0 +1,80 @@ +/** + * @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.io/license + */ + +import type { ApplicationRef, Type } from '@angular/core'; +import assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { workerData } from 'node:worker_threads'; +import type { extractRoutes } from '../../utils/routes-extractor/extractor'; + +export interface RoutesExtractorWorkerData { + zonePackage: string; + indexFile: string; + outputPath: string; + serverBundlePath: string; +} + +interface ServerBundleExports { + /** NgModule to render. */ + AppServerModule?: Type; + + /** Standalone application bootstrapping function. */ + default?: (() => Promise) | Type; + + /** Method to extract routes from the router config. */ + extractRoutes: typeof extractRoutes; +} + +const { zonePackage, serverBundlePath, outputPath, indexFile } = + workerData as RoutesExtractorWorkerData; + +async function extract(): Promise { + const { + AppServerModule, + extractRoutes, + default: bootstrapAppFn, + } = (await import(serverBundlePath)) as ServerBundleExports; + + const browserIndexInputPath = path.join(outputPath, indexFile); + const document = await fs.promises.readFile(browserIndexInputPath, 'utf8'); + + const bootstrapAppFnOrModule = bootstrapAppFn || AppServerModule; + assert( + bootstrapAppFnOrModule, + `Neither an AppServerModule nor a bootstrapping function was exported from: ${serverBundlePath}.`, + ); + + const routes: string[] = []; + for await (const { route, success } of extractRoutes(bootstrapAppFnOrModule, document)) { + if (success) { + routes.push(route); + } + } + + return routes; +} + +/** + * Initializes the worker when it is first created by loading the Zone.js package + * into the worker instance. + * + * @returns A promise resolving to the extract function of the worker. + */ +async function initialize() { + // Setup Zone.js + await import(zonePackage); + + return extract; +} + +/** + * The default export will be the promise returned by the initialize function. + * This is awaited by piscina prior to using the Worker. + */ +export default initialize(); diff --git a/packages/angular_devkit/build_angular/src/builders/prerender/schema.json b/packages/angular_devkit/build_angular/src/builders/prerender/schema.json index 4e52a772153d..5867481d9bf1 100644 --- a/packages/angular_devkit/build_angular/src/builders/prerender/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/prerender/schema.json @@ -27,9 +27,9 @@ }, "default": [] }, - "guessRoutes": { + "discoverRoutes": { "type": "boolean", - "description": "Whether or not the builder should extract routes and guess which paths to render.", + "description": "Whether the builder should discover routers using the Angular Router.", "default": true } }, diff --git a/packages/angular_devkit/build_angular/src/builders/prerender/utils.ts b/packages/angular_devkit/build_angular/src/builders/prerender/utils.ts deleted file mode 100644 index 0f06504b4ca9..000000000000 --- a/packages/angular_devkit/build_angular/src/builders/prerender/utils.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @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.io/license - */ - -import { BuilderContext } from '@angular-devkit/architect'; -import { BrowserBuilderOptions } from '@angular-devkit/build-angular'; -import { JsonObject, json } from '@angular-devkit/core'; -import * as fs from 'fs'; -import { parseAngularRoutes } from 'guess-parser'; -import * as path from 'path'; -import { assertIsError } from '../../utils/error'; -import { Schema } from './schema'; - -type PrerenderBuilderOptions = Schema & json.JsonObject; - -/** - * Returns the union of routes, the contents of routesFile if given, - * and the static routes extracted if guessRoutes is set to true. - */ -export async function getRoutes( - options: PrerenderBuilderOptions, - tsConfigPath: string | undefined, - context: BuilderContext, -): Promise { - let routes = options.routes || []; - const { logger, workspaceRoot } = context; - if (options.routesFile) { - const routesFilePath = path.join(workspaceRoot, options.routesFile); - routes = routes.concat( - fs - .readFileSync(routesFilePath, 'utf8') - .split(/\r?\n/) - .filter((v) => !!v), - ); - } - - if (options.guessRoutes && tsConfigPath) { - try { - routes = routes.concat( - parseAngularRoutes(path.join(workspaceRoot, tsConfigPath)) - .map((routeObj) => routeObj.path) - .filter((route) => !route.includes('*') && !route.includes(':')), - ); - } catch (e) { - assertIsError(e); - - logger.error('Unable to extract routes from application.', { ...e } as unknown as JsonObject); - } - } - - routes = routes.map((r) => (r === '' ? '/' : r)); - - return [...new Set(routes)]; -} - -/** - * Returns the name of the index file outputted by the browser builder. - */ -export function getIndexOutputFile(options: BrowserBuilderOptions): string { - if (typeof options.index === 'string') { - return path.basename(options.index); - } else { - return options.index.output || 'index.html'; - } -} diff --git a/packages/angular_devkit/build_angular/src/builders/prerender/works_spec.ts b/packages/angular_devkit/build_angular/src/builders/prerender/works_spec.ts index edc8923d707e..cf8c809aba2b 100644 --- a/packages/angular_devkit/build_angular/src/builders/prerender/works_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/prerender/works_spec.ts @@ -66,7 +66,7 @@ describe('Prerender Builder', () => { afterEach(async () => host.restore().toPromise()); it('fails with error when no routes are provided', async () => { - const run = await architect.scheduleTarget(target, { routes: [], guessRoutes: false }); + const run = await architect.scheduleTarget(target, { routes: [], discoverRoutes: false }); await run.stop(); await expectAsync(run.result).toBeRejectedWith( @@ -155,10 +155,10 @@ describe('Prerender Builder', () => { ); }); - it('should guess routes to prerender when guessRoutes is set to true.', async () => { + it('should guess routes to prerender when discoverRoutes is set to true.', async () => { const run = await architect.scheduleTarget(target, { routes: [''], - guessRoutes: true, + discoverRoutes: true, }); const output = await run.result; diff --git a/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts b/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts index be0c96eedba8..c7bf0d895a0f 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts +++ b/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts @@ -6,6 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + /** * This loader is needed to add additional exports and is a workaround for a Webpack bug that doesn't * allow exports from multiple files in the same entry. @@ -16,11 +19,18 @@ export default function ( content: string, map: Parameters[1], ) { - const source = `${content} + const source = + `${content} // EXPORTS added by @angular-devkit/build-angular export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; - `; + ` + + // We do not import it directly so that node.js modules are resolved using the correct context. + // Remove source map URL comments from the code if a sourcemap is present as this will not match the file. + readFileSync(join(__dirname, '../../utils/routes-extractor/extractor.js'), 'utf-8').replace( + /^\/\/# sourceMappingURL=[^\r\n]*/gm, + '', + ); this.callback(null, source, map); diff --git a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts index 225bab797081..0499442ee7bf 100644 --- a/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular_devkit/build_angular/src/tools/esbuild/application-code-bundle.ts @@ -8,7 +8,8 @@ import type { BuildOptions } from 'esbuild'; import assert from 'node:assert'; -import path from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; import { allowMangle } from '../../utils/environment-options'; import { SourceFileCache, createCompilerPlugin } from './angular/compiler-plugin'; @@ -109,6 +110,7 @@ export function createServerCodeBundleOptions( ); const mainServerNamespace = 'angular:main-server'; + const routeExtractorNamespace = 'angular:prerender-route-extractor'; const ssrEntryNamespace = 'angular:ssr-entry'; const entryPoints: Record = { @@ -123,6 +125,8 @@ export function createServerCodeBundleOptions( const buildOptions: BuildOptions = { ...getEsBuildCommonOptions(options), platform: 'node', + // TODO: Invesigate why enabling `splitting` in JIT mode causes an "'@angular/compiler' is not available" error. + splitting: !jit, outExtension: { '.js': '.mjs' }, // Note: `es2015` is needed for RxJS v6. If not specified, `module` would // match and the ES5 distribution would be bundled and ends up breaking at @@ -159,8 +163,7 @@ export function createServerCodeBundleOptions( buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); } - const polyfills = [`import '@angular/platform-server/init';`]; - + const polyfills: string[] = []; if (options.polyfills?.includes('zone.js')) { polyfills.push(`import 'zone.js/node';`); } @@ -169,22 +172,35 @@ export function createServerCodeBundleOptions( polyfills.push(`import '@angular/compiler';`); } + polyfills.push(`import '@angular/platform-server/init';`); + buildOptions.plugins.push( createVirtualModulePlugin({ namespace: mainServerNamespace, - loadContent: () => { - const mainServerEntryPoint = path - .relative(workspaceRoot, serverEntryPoint) - .replace(/\\/g, '/'); + loadContent: async () => { + const mainServerEntryPoint = relative(workspaceRoot, serverEntryPoint).replace(/\\/g, '/'); + + const contents = [ + ...polyfills, + `import moduleOrBootstrapFn from './${mainServerEntryPoint}';`, + `export default moduleOrBootstrapFn;`, + `export * from './${mainServerEntryPoint}';`, + `export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';`, + ]; + + if (options.prerenderOptions?.discoverRoutes) { + // We do not import it directly so that node.js modules are resolved using the correct context. + const routesExtractorCode = await readFile( + join(__dirname, '../../utils/routes-extractor/extractor.js'), + 'utf-8', + ); + + // Remove source map URL comments from the code if a sourcemap is present as this will not match the file. + contents.push(routesExtractorCode.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, '')); + } return { - contents: [ - ...polyfills, - `import moduleOrBootstrapFn from './${mainServerEntryPoint}';`, - `export default moduleOrBootstrapFn;`, - `export * from './${mainServerEntryPoint}';`, - `export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';`, - ].join('\n'), + contents: contents.join('\n'), loader: 'js', resolveDir: workspaceRoot, }; @@ -197,15 +213,13 @@ export function createServerCodeBundleOptions( createVirtualModulePlugin({ namespace: ssrEntryNamespace, loadContent: () => { - const mainServerEntryPoint = path - .relative(workspaceRoot, ssrEntryPoint) - .replace(/\\/g, '/'); + const serverEntryPoint = relative(workspaceRoot, ssrEntryPoint).replace(/\\/g, '/'); return { contents: [ ...polyfills, - `import './${mainServerEntryPoint}';`, - `export * from './${mainServerEntryPoint}';`, + `import './${serverEntryPoint}';`, + `export * from './${serverEntryPoint}';`, ].join('\n'), loader: 'js', resolveDir: workspaceRoot, diff --git a/packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel b/packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel new file mode 100644 index 000000000000..afabea069cd5 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel @@ -0,0 +1,27 @@ +# Copyright Google Inc. 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.io/license + +load("//tools:defaults.bzl", "ts_library") + +# NOTE This is built as ESM as this is included in the users server bundle. +licenses(["notice"]) + +package(default_visibility = ["//packages/angular_devkit/build_angular:__subpackages__"]) + +ts_library( + name = "routes-extractor", + srcs = [ + "extractor.ts", + ], + devmode_module = "es2015", + prodmode_module = "es2015", + deps = [ + "@npm//@angular/core", + "@npm//@angular/platform-server", + "@npm//@angular/router", + "@npm//@types/node", + "@npm//rxjs", + ], +) diff --git a/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts b/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts new file mode 100644 index 000000000000..c0fce54544f2 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts @@ -0,0 +1,111 @@ +/** + * @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.io/license + */ + +import { ApplicationRef, Injector, Type, createPlatformFactory, platformCore } from '@angular/core'; +import { + INITIAL_CONFIG, + ɵINTERNAL_SERVER_PLATFORM_PROVIDERS as INTERNAL_SERVER_PLATFORM_PROVIDERS, +} from '@angular/platform-server'; +import { Route, Router, ɵROUTER_PROVIDERS } from '@angular/router'; +import { first } from 'rxjs/operators'; // Import from `/operators` to support rxjs 6 which is still supported by the Framework. + +// TODO(alanagius): replace the below once `RouterConfigLoader` is privately exported from `@angular/router`. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const RouterConfigLoader = ɵROUTER_PROVIDERS[5] as any; +type RouterConfigLoader = typeof RouterConfigLoader; + +interface RouterResult { + route: string; + success: boolean; + redirect: boolean; +} + +async function* getRoutesFromRouterConfig( + routes: Route[], + routerConfigLoader: RouterConfigLoader, + injector: Injector, + parent = '', +): AsyncIterableIterator { + for (const route of routes) { + const { path, redirectTo, loadChildren, children } = route; + if (path === undefined) { + continue; + } + + const currentRoutePath = buildRoutePath(parent, path); + + if (redirectTo !== undefined) { + // TODO: handle `redirectTo`. + yield { route: currentRoutePath, success: false, redirect: true }; + continue; + } + + if (/[:*]/.test(path)) { + // TODO: handle parameterized routes population. + yield { route: currentRoutePath, success: false, redirect: false }; + continue; + } + + yield { route: currentRoutePath, success: true, redirect: false }; + + if (children?.length || loadChildren) { + yield* getRoutesFromRouterConfig( + children ?? (await routerConfigLoader.loadChildren(injector, route).toPromise()).routes, + routerConfigLoader, + injector, + currentRoutePath, + ); + } + } +} + +export async function* extractRoutes( + bootstrapAppFnOrModule: (() => Promise) | Type, + document: string, +): AsyncIterableIterator { + const platformRef = createPlatformFactory(platformCore, 'server', [ + [ + { + provide: INITIAL_CONFIG, + useValue: { document, url: '' }, + }, + ], + ...INTERNAL_SERVER_PLATFORM_PROVIDERS, + ])(); + + try { + let applicationRef: ApplicationRef; + if (isBootstrapFn(bootstrapAppFnOrModule)) { + applicationRef = await bootstrapAppFnOrModule(); + } else { + const moduleRef = await platformRef.bootstrapModule(bootstrapAppFnOrModule); + applicationRef = moduleRef.injector.get(ApplicationRef); + } + + // Wait until the application is stable. + await applicationRef.isStable.pipe(first((isStable) => isStable)).toPromise(); + + const injector = applicationRef.injector; + const router = injector.get(Router); + const routerConfigLoader = injector.get(RouterConfigLoader); + + // Extract all the routes from the config. + yield* getRoutesFromRouterConfig(router.config, routerConfigLoader, injector); + } finally { + platformRef.destroy(); + } +} + +function isBootstrapFn(value: unknown): value is () => Promise { + // We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property: + return typeof value === 'function' && !('ɵmod' in value); +} + +function buildRoutePath(...routeParts: string[]): string { + return routeParts.filter(Boolean).join('/'); +} diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts index c6f2e89db82b..710b9e72e6fe 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-file-loader.ts @@ -17,10 +17,12 @@ import { JavaScriptTransformer } from '../../tools/esbuild/javascript-transforme * @see: https://nodejs.org/api/esm.html#loaders for more information about loaders. */ -const { outputFiles, workspaceRoot } = workerData as { +export interface ESMInMemoryFileLoaderWorkerData { outputFiles: Record; workspaceRoot: string; -}; +} + +const { outputFiles, workspaceRoot } = workerData as ESMInMemoryFileLoaderWorkerData; const TRANSFORMED_FILES: Record = {}; const CHUNKS_REGEXP = /file:\/\/\/(main\.server|chunk-\w+)\.mjs/; diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts new file mode 100644 index 000000000000..866e478d8bcf --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts @@ -0,0 +1,28 @@ +/** + * @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.io/license + */ + +import type { ApplicationRef, Type } from '@angular/core'; +import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; +import type { extractRoutes } from '../routes-extractor/extractor'; + +export interface MainServerBundleExports { + /** An internal token that allows providing extra information about the server context. */ + ɵSERVER_CONTEXT: typeof ɵSERVER_CONTEXT; + + /** Render an NgModule application. */ + renderModule: typeof renderModule; + + /** Method to render a standalone application. */ + renderApplication: typeof renderApplication; + + /** Standalone application bootstrapping function. */ + default: (() => Promise) | Type; + + /** Method to extract routes from the router config. */ + extractRoutes: typeof extractRoutes; +} 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 991f9bc83e1a..09128765e799 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 @@ -12,12 +12,15 @@ import { extname, join, posix } from 'node:path'; import { pathToFileURL } from 'node:url'; import Piscina from 'piscina'; import type { RenderResult, ServerContext } from './render-page'; -import type { WorkerData } from './render-worker'; +import type { RenderWorkerData } from './render-worker'; +import type { + RoutersExtractorWorkerResult, + RoutesExtractorWorkerData, +} from './routes-extractor-worker'; interface PrerenderOptions { routesFile?: string; discoverRoutes?: boolean; - routes?: string[]; } interface AppShellOptions { @@ -26,13 +29,13 @@ interface AppShellOptions { export async function prerenderPages( workspaceRoot: string, - tsConfigPath: string, appShellOptions: AppShellOptions = {}, prerenderOptions: PrerenderOptions = {}, outputFiles: Readonly, document: string, inlineCriticalCss?: boolean, maxThreads = 1, + verbose = false, ): Promise<{ output: Record; warnings: string[]; @@ -41,16 +44,6 @@ export async function prerenderPages( const output: Record = {}; const warnings: string[] = []; const errors: string[] = []; - const allRoutes = await getAllRoutes(tsConfigPath, appShellOptions, prerenderOptions); - - if (allRoutes.size < 1) { - return { - errors, - warnings, - output, - }; - } - const outputFilesForWorker: Record = {}; for (const { text, path } of outputFiles) { @@ -62,6 +55,27 @@ export async function prerenderPages( } } + const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes( + workspaceRoot, + outputFilesForWorker, + document, + appShellOptions, + prerenderOptions, + verbose, + ); + + if (routesWarnings?.length) { + warnings.push(...routesWarnings); + } + + if (allRoutes.size < 1) { + return { + errors, + warnings, + output, + }; + } + const renderWorker = new Piscina({ filename: require.resolve('./render-worker'), maxThreads: Math.min(allRoutes.size, maxThreads), @@ -70,7 +84,7 @@ export async function prerenderPages( outputFiles: outputFilesForWorker, inlineCriticalCss, document, - } as WorkerData, + } as RenderWorkerData, execArgv: [ '--no-warnings', // Suppress `ExperimentalWarning: Custom ESM Loaders is an experimental feature...`. '--loader', @@ -80,20 +94,16 @@ export async function prerenderPages( try { const renderingPromises: Promise[] = []; + const appShellRoute = appShellOptions.route && removeLeadingSlash(appShellOptions.route); for (const route of allRoutes) { - const isAppShellRoute = appShellOptions.route === route; + const isAppShellRoute = appShellRoute === route; const serverContext: ServerContext = isAppShellRoute ? 'app-shell' : 'ssg'; 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.startsWith('/') ? route.slice(1) /* Remove leading slash */ : route, - 'index.html', - ); + const outPath = isAppShellRoute ? 'index.html' : posix.join(route, 'index.html'); output[outPath] = content; } @@ -121,13 +131,22 @@ export async function prerenderPages( }; } +class RoutesSet extends Set { + override add(value: string): this { + return super.add(removeLeadingSlash(value)); + } +} + async function getAllRoutes( - tsConfigPath: string, + workspaceRoot: string, + outputFilesForWorker: Record, + document: string, appShellOptions: AppShellOptions, prerenderOptions: PrerenderOptions, -): Promise> { - const { routesFile, discoverRoutes, routes: existingRoutes } = prerenderOptions; - const routes = new Set(existingRoutes); + verbose: boolean, +): Promise<{ routes: Set; warnings?: string[] }> { + const { routesFile, discoverRoutes } = prerenderOptions; + const routes = new RoutesSet(); const { route: appShellRoute } = appShellOptions; if (appShellRoute !== undefined) { @@ -136,23 +155,42 @@ async function getAllRoutes( if (routesFile) { const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); - for (let route of routesFromFile) { - route = route.trim(); - if (route) { - routes.add(route); - } + for (const route of routesFromFile) { + routes.add(route.trim()); } } - if (discoverRoutes) { - const { parseAngularRoutes } = await import('guess-parser'); - for (const { path } of parseAngularRoutes(tsConfigPath)) { - // Exclude dynamic routes as these cannot be pre-rendered. - if (!/[*:]/.test(path)) { - routes.add(path); - } - } + if (!discoverRoutes) { + return { routes }; } - return routes; + const renderWorker = new Piscina({ + filename: require.resolve('./routes-extractor-worker'), + maxThreads: 1, + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + document, + verbose, + } as RoutesExtractorWorkerData, + execArgv: [ + '--no-warnings', // Suppress `ExperimentalWarning: Custom ESM Loaders is an experimental feature...`. + '--loader', + pathToFileURL(join(__dirname, 'esm-in-memory-file-loader.js')).href, // Loader cannot be an absolute path on Windows. + ], + }); + + const { routes: extractedRoutes, warnings }: RoutersExtractorWorkerResult = await renderWorker + .run({}) + .finally(() => void renderWorker.destroy()); + + for (const route of extractedRoutes) { + routes.add(route); + } + + return { routes, warnings }; +} + +function removeLeadingSlash(value: string): string { + return value.charAt(0) === '/' ? value.slice(1) : value; } diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts index 37bb8cf3f42d..9f0085582ab7 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts @@ -6,11 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import type { ApplicationRef, StaticProvider, Type } from '@angular/core'; -import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; +import type { ApplicationRef, StaticProvider } from '@angular/core'; import { basename } from 'node:path'; import { InlineCriticalCssProcessor } from '../index-file/inline-critical-css'; import { loadEsmModule } from '../load-esm'; +import { MainServerBundleExports } from './main-bundle-exports'; export interface RenderOptions { route: string; @@ -29,20 +29,6 @@ export interface RenderResult { export type ServerContext = 'app-shell' | 'ssg' | 'ssr'; -interface MainServerBundleExports { - /** An internal token that allows providing extra information about the server context. */ - ɵSERVER_CONTEXT: typeof ɵSERVER_CONTEXT; - - /** Render an NgModule application. */ - renderModule: typeof renderModule; - - /** Method to render a standalone application. */ - renderApplication: typeof renderApplication; - - /** Standalone application bootstrapping function. */ - default: (() => Promise) | Type; -} - /** * Renders each route in routes and writes them to //index.html. */ @@ -107,6 +93,6 @@ export async function renderPage({ } function isBootstrapFn(value: unknown): value is () => Promise { - // We can differentiate between a module and a bootstrap function by reading `cmp`: + // We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property: return typeof value === 'function' && !('ɵmod' in value); } diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts index 095a3a617050..e75e3c6fa968 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts @@ -7,10 +7,10 @@ */ import { workerData } from 'node:worker_threads'; +import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-file-loader'; import { RenderResult, ServerContext, renderPage } from './render-page'; -export interface WorkerData { - outputFiles: Record; +export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { document: string; inlineCriticalCss?: boolean; } @@ -23,7 +23,7 @@ export interface RenderOptions { /** * This is passed as workerData when setting up the worker via the `piscina` package. */ -const { outputFiles, document, inlineCriticalCss } = workerData as WorkerData; +const { outputFiles, document, inlineCriticalCss } = workerData as RenderWorkerData; export default function (options: RenderOptions): Promise { return renderPage({ ...options, outputFiles, document, inlineCriticalCss }); diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts new file mode 100644 index 000000000000..f995413e74c9 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts @@ -0,0 +1,73 @@ +/** + * @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.io/license + */ + +import { workerData } from 'node:worker_threads'; +import { loadEsmModule } from '../load-esm'; +import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-file-loader'; +import { MainServerBundleExports } from './main-bundle-exports'; + +export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { + document: string; + verbose: boolean; +} + +export interface RoutersExtractorWorkerResult { + routes: string[]; + warnings?: string[]; +} + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { document, verbose } = workerData as RoutesExtractorWorkerData; + +export default async function (): Promise { + const { default: bootstrapAppFnOrModule, extractRoutes } = + await loadEsmModule('./main.server.mjs'); + + const skippedRedirects: string[] = []; + const skippedOthers: string[] = []; + const routes: string[] = []; + + for await (const { route, success, redirect } of extractRoutes( + bootstrapAppFnOrModule, + document, + )) { + if (success) { + routes.push(route); + continue; + } + + if (redirect) { + skippedRedirects.push(route); + } else { + skippedOthers.push(route); + } + } + + if (!verbose) { + return { routes }; + } + + let warnings: string[] | undefined; + if (skippedOthers.length) { + (warnings ??= []).push( + 'The following routes were skipped from prerendering because they contain routes with dynamic parameters:\n' + + skippedOthers.join('\n'), + ); + } + + if (skippedRedirects.length) { + (warnings ??= []).push( + 'The following routes were skipped from prerendering because they contain redirects:\n', + skippedRedirects.join('\n'), + ); + } + + return { routes, warnings }; +} diff --git a/tests/legacy-cli/e2e.bzl b/tests/legacy-cli/e2e.bzl index 3859faa45236..83e1556e715e 100644 --- a/tests/legacy-cli/e2e.bzl +++ b/tests/legacy-cli/e2e.bzl @@ -37,6 +37,7 @@ ESBUILD_TESTS = [ "tests/build/prod-build.js", "tests/build/relative-sourcemap.js", "tests/build/styles/**", + "tests/build/prerender/**", "tests/commands/add/**", "tests/i18n/extract-ivy*", ] diff --git a/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts new file mode 100644 index 000000000000..6b99415289bf --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts @@ -0,0 +1,114 @@ +import { join } from 'path'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch, rimraf, writeFile } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useSha } from '../../../utils/project'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // Forcibly remove in case another test doesn't clean itself up. + await rimraf('node_modules/@angular/ssr'); + + // Angular SSR is not needed to do prerendering but it is the easiest way to enable when usign webpack based builders. + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + } else { + await ng('generate', 'server', '--skip-install'); + } + + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app-routing.module.ts', + ` + import { NgModule } from '@angular/core'; + import { RouterModule, Routes } from '@angular/router'; + import { OneComponent } from './one/one.component'; + import { TwoChildOneComponent } from './two-child-one/two-child-one.component'; + import { TwoChildTwoComponent } from './two-child-two/two-child-two.component'; + + const routes: Routes = [ + { + path: '', + component: OneComponent, + }, + { + path: 'two', + children: [ + { + path: 'two-child-one', + component: TwoChildOneComponent, + }, + { + path: 'two-child-two', + component: TwoChildTwoComponent, + }, + ], + }, + ]; + + @NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule], + }) + export class AppRoutingModule {} + `, + ); + + // Generate components for the above routes + await ng('generate', 'component', 'one'); + await ng('generate', 'component', 'two-child-one'); + await ng('generate', 'component', 'two-child-two'); + + // Generate lazy routes + await ng('generate', 'module', 'lazy-one', '--route', 'lazy-one', '--module', 'app.module'); + await ng( + 'generate', + 'module', + 'lazy-one-child', + '--route', + 'lazy-one-child', + '--module', + 'lazy-one/lazy-one.module', + ); + await ng('generate', 'module', 'lazy-two', '--route', 'lazy-two', '--module', 'app.module'); + + // Prerender pages + if (useWebpackBuilder) { + await ng('run', 'test-project:prerender:production'); + await runExpects(); + + return; + } + + await ng('build', '--configuration=production', '--prerender'); + await runExpects(); + + // Test also JIT mode. + await ng('build', '--configuration=development', '--prerender', '--no-aot'); + await runExpects(); + + async function runExpects(): Promise { + const expects: Record = { + 'index.html': 'one works!', + 'two/index.html': 'router-outlet', + 'two/two-child-one/index.html': 'two-child-one works!', + 'two/two-child-two/index.html': 'two-child-two works!', + 'lazy-one/index.html': 'lazy-one works!', + 'lazy-one/lazy-one-child/index.html': 'lazy-one-child works!', + 'lazy-two/index.html': 'lazy-two works!', + }; + + let distPath = 'dist/test-project'; + if (useWebpackBuilder) { + distPath += '/browser'; + } + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join(distPath, filePath), fileMatch); + } + } +} 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 new file mode 100644 index 000000000000..cf7c99180af0 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/prerender/discover-routes-standalone.ts @@ -0,0 +1,148 @@ +import { join } from 'path'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch, rimraf, writeFile } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile, useSha } from '../../../utils/project'; + +export default async function () { + const projectName = 'test-project-two'; + await ng('generate', 'application', projectName, '--standalone', '--skip-install'); + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // Forcibly remove in case another test doesn't clean itself up. + await rimraf('node_modules/@angular/ssr'); + + // Setup webpack builder if esbuild is not requested on the commandline + await updateJsonFile('angular.json', (json) => { + const build = json['projects'][projectName]['architect']['build']; + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + buildOptimizer: false, + }; + }); + + // Angular SSR is not needed to do prerendering but it is the easiest way to enable when usign webpack based builders. + await ng( + 'add', + '@angular/ssr', + '--project', + projectName, + '--skip-confirmation', + '--skip-install', + ); + } else { + await ng('generate', 'server', '--project', projectName, '--skip-install'); + } + + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + `projects/${projectName}/src/app/app.routes.ts`, + ` + import { Routes } from '@angular/router'; + import { OneComponent } from './one/one.component'; + import { TwoChildOneComponent } from './two-child-one/two-child-one.component'; + import { TwoChildTwoComponent } from './two-child-two/two-child-two.component'; + + export const routes: Routes = [ + { + path: '', + component: OneComponent, + }, + { + path: 'two', + children: [ + { + path: 'two-child-one', + component: TwoChildOneComponent, + }, + { + path: 'two-child-two', + component: TwoChildTwoComponent, + }, + ], + }, + { + path: 'lazy-one', + children: [ + { + path: '', + loadComponent: () => import('./lazy-one/lazy-one.component').then(c => c.LazyOneComponent), + }, + { + path: 'lazy-one-child', + loadComponent: () => import('./lazy-one-child/lazy-one-child.component').then(c => c.LazyOneChildComponent), + }, + ], + }, + { + path: 'lazy-two', + loadComponent: () => import('./lazy-two/lazy-two.component').then(c => c.LazyTwoComponent), + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = [ + 'one', + 'two-child-one', + 'two-child-two', + 'lazy-one', + 'lazy-one-child', + 'lazy-two', + ]; + + for (const componentName of componentNames) { + await ng('generate', 'component', componentName, '--project', projectName); + } + + // Prerender pages + if (useWebpackBuilder) { + await ng('run', projectName + ':prerender:production'); + await runExpects(); + + return; + } + + await ng('build', projectName, '--configuration=production', '--prerender'); + await runExpects(); + + // Test also JIT mode. + await ng('build', projectName, '--configuration=development', '--prerender', '--no-aot'); + await runExpects(); + + async function runExpects(): Promise { + const expects: Record = { + 'index.html': 'one works!', + 'two/index.html': 'router-outlet', + 'two/two-child-one/index.html': 'two-child-one works!', + 'two/two-child-two/index.html': 'two-child-two works!', + 'lazy-one/index.html': 'lazy-one works!', + 'lazy-one/lazy-one-child/index.html': 'lazy-one-child works!', + 'lazy-two/index.html': 'lazy-two works!', + }; + + let distPath = 'dist/' + projectName; + if (useWebpackBuilder) { + distPath += '/browser'; + } + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join(distPath, filePath), fileMatch); + } + } +}