Skip to content

Commit 8f9a0d7

Browse files
committed
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.
1 parent 5a204b8 commit 8f9a0d7

29 files changed

+802
-194
lines changed

package.json

-1
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@
145145
"eslint-plugin-import": "2.28.1",
146146
"express": "4.18.2",
147147
"fast-glob": "3.3.1",
148-
"guess-parser": "0.4.22",
149148
"http-proxy": "^1.18.1",
150149
"http-proxy-middleware": "2.0.6",
151150
"https-proxy-agent": "7.0.2",

packages/angular/ssr/schematics/ng-add/files/server-builder/server.ts.template

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,4 @@ if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
6464
run();
6565
}
6666

67-
<% if (isStandalone) { %>export default bootstrap;<% } else { %>export * from './src/main.server';<% } %>
67+
export default <% if (isStandalone) { %>bootstrap<% } else { %>AppServerModule<% } %>;

packages/angular/ssr/schematics/ng-add/index_spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ describe('SSR Schematic', () => {
194194
const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree);
195195

196196
const content = tree.readContent('/projects/test-app/server.ts');
197-
expect(content).toContain(`export * from './src/main.server'`);
197+
expect(content).toContain(`export default AppServerModule`);
198198
});
199199

200200
it(`should add correct value to 'distFolder'`, async () => {

packages/angular/ssr/src/common-engine.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ async function exists(path: fs.PathLike): Promise<boolean> {
162162
}
163163

164164
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
165-
// We can differentiate between a module and a bootstrap function by reading `cmp`:
165+
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
166166
return typeof value === 'function' && !('ɵmod' in value);
167167
}
168168

packages/angular_devkit/build_angular/BUILD.bazel

+1-1
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ ts_library(
122122
module_root = "src/index.d.ts",
123123
deps = [
124124
"//packages/angular_devkit/architect",
125+
"//packages/angular_devkit/build_angular/src/utils/routes-extractor",
125126
"//packages/angular_devkit/build_webpack",
126127
"//packages/angular_devkit/core",
127128
"//packages/angular_devkit/core/node",
@@ -168,7 +169,6 @@ ts_library(
168169
"@npm//esbuild",
169170
"@npm//esbuild-wasm",
170171
"@npm//fast-glob",
171-
"@npm//guess-parser",
172172
"@npm//http-proxy-middleware",
173173
"@npm//https-proxy-agent",
174174
"@npm//inquirer",

packages/angular_devkit/build_angular/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
"css-loader": "6.8.1",
3535
"esbuild-wasm": "0.19.3",
3636
"fast-glob": "3.3.1",
37-
"guess-parser": "0.4.22",
3837
"https-proxy-agent": "7.0.2",
3938
"http-proxy-middleware": "2.0.6",
4039
"inquirer": "8.2.6",

packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ async function render({ serverBundlePath, document, url }: RenderRequest): Promi
102102
}
103103

104104
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
105-
// We can differentiate between a module and a bootstrap function by reading `cmp`:
105+
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
106106
return typeof value === 'function' && !('ɵmod' in value);
107107
}
108108

packages/angular_devkit/build_angular/src/builders/application/execute-build.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export async function executeBuild(
5353
prerenderOptions,
5454
appShellOptions,
5555
ssrOptions,
56+
verbose,
5657
} = options;
5758

5859
const browsers = getSupportedBrowsers(projectRoot, context.logger);
@@ -182,13 +183,13 @@ export async function executeBuild(
182183

183184
const { output, warnings, errors } = await prerenderPages(
184185
workspaceRoot,
185-
options.tsconfig,
186186
appShellOptions,
187187
prerenderOptions,
188188
executionResult.outputFiles,
189189
indexContentOutputNoCssInlining,
190190
optimizationOptions.styles.inlineCritical,
191191
maxWorkers,
192+
verbose,
192193
);
193194

194195
printWarningsAndErrorsToConsole(context, warnings, errors);
@@ -242,6 +243,7 @@ export async function executeBuild(
242243
if (optimizationOptions.scripts || optimizationOptions.styles.minify) {
243244
estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles);
244245
}
246+
245247
logBuildStats(context, metafile, initialFiles, estimatedTransferSizes);
246248

247249
const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9;

packages/angular_devkit/build_angular/src/builders/application/options.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,11 @@ export async function normalizeOptions(
181181

182182
let prerenderOptions;
183183
if (options.prerender) {
184-
const {
185-
discoverRoutes = true,
186-
routes = [],
187-
routesFile = undefined,
188-
} = options.prerender === true ? {} : options.prerender;
184+
const { discoverRoutes = true, routesFile = undefined } =
185+
options.prerender === true ? {} : options.prerender;
189186

190187
prerenderOptions = {
191188
discoverRoutes,
192-
routes,
193189
routesFile: routesFile && path.join(workspaceRoot, routesFile),
194190
};
195191
}

packages/angular_devkit/build_angular/src/builders/application/schema.json

+1-11
Original file line numberDiff line numberDiff line change
@@ -423,19 +423,9 @@
423423
"type": "string",
424424
"description": "The path to a file containing routes separated by newlines."
425425
},
426-
"routes": {
427-
"type": "array",
428-
"description": "The routes to render.",
429-
"items": {
430-
"minItems": 1,
431-
"type": "string",
432-
"uniqueItems": true
433-
},
434-
"default": []
435-
},
436426
"discoverRoutes": {
437427
"type": "boolean",
438-
"description": "Whether the builder should statically discover routes.",
428+
"description": "Whether the builder should discover routers using the Angular Router.",
439429
"default": true
440430
}
441431
},

packages/angular_devkit/build_angular/src/builders/prerender/index.ts

+71-12
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,78 @@ import {
1414
} from '@angular-devkit/architect';
1515
import { json } from '@angular-devkit/core';
1616
import * as fs from 'fs';
17+
import { readFile } from 'node:fs/promises';
1718
import ora from 'ora';
1819
import * as path from 'path';
1920
import Piscina from 'piscina';
2021
import { normalizeOptimization } from '../../utils';
2122
import { maxWorkers } from '../../utils/environment-options';
2223
import { assertIsError } from '../../utils/error';
2324
import { augmentAppWithServiceWorker } from '../../utils/service-worker';
25+
import { getIndexOutputFile } from '../../utils/webpack-browser-config';
2426
import { BrowserBuilderOutput } from '../browser';
2527
import { Schema as BrowserBuilderOptions } from '../browser/schema';
2628
import { ServerBuilderOutput } from '../server';
2729
import type { RenderOptions, RenderResult } from './render-worker';
30+
import { RoutesExtractorWorkerData } from './routes-extractor-worker';
2831
import { Schema } from './schema';
29-
import { getIndexOutputFile, getRoutes } from './utils';
3032

3133
type PrerenderBuilderOptions = Schema & json.JsonObject;
3234
type PrerenderBuilderOutput = BuilderOutput;
3335

36+
class RoutesSet extends Set<string> {
37+
override add(value: string): this {
38+
return super.add(value.charAt(0) === '/' ? value.slice(1) : value);
39+
}
40+
}
41+
42+
async function getRoutes(
43+
indexFile: string,
44+
outputPath: string,
45+
serverBundlePath: string,
46+
options: PrerenderBuilderOptions,
47+
workspaceRoot: string,
48+
): Promise<string[]> {
49+
const { routes: extraRoutes = [], routesFile, discoverRoutes } = options;
50+
const routes = new RoutesSet(extraRoutes);
51+
52+
if (routesFile) {
53+
const routesFromFile = (await readFile(path.join(workspaceRoot, routesFile), 'utf8')).split(
54+
/\r?\n/,
55+
);
56+
for (const route of routesFromFile) {
57+
routes.add(route);
58+
}
59+
}
60+
61+
if (discoverRoutes) {
62+
const renderWorker = new Piscina({
63+
filename: require.resolve('./routes-extractor-worker'),
64+
maxThreads: 1,
65+
workerData: {
66+
indexFile,
67+
outputPath,
68+
serverBundlePath,
69+
zonePackage: require.resolve('zone.js', { paths: [workspaceRoot] }),
70+
} as RoutesExtractorWorkerData,
71+
});
72+
73+
const extractedRoutes: string[] = await renderWorker
74+
.run({})
75+
.finally(() => void renderWorker.destroy());
76+
77+
for (const route of extractedRoutes) {
78+
routes.add(route);
79+
}
80+
}
81+
82+
if (routes.size === 0) {
83+
throw new Error('Could not find any routes to prerender.');
84+
}
85+
86+
return [...routes];
87+
}
88+
3489
/**
3590
* Schedules the server and browser builds and returns their results if both builds are successful.
3691
*/
@@ -80,7 +135,7 @@ async function _scheduleBuilds(
80135
* <route>/index.html for each output path in the browser result.
81136
*/
82137
async function _renderUniversal(
83-
routes: string[],
138+
options: PrerenderBuilderOptions,
84139
context: BuilderContext,
85140
browserResult: BrowserBuilderOutput,
86141
serverResult: ServerBuilderOutput,
@@ -98,7 +153,7 @@ async function _renderUniversal(
98153
);
99154

100155
// Users can specify a different base html file e.g. "src/home.html"
101-
const indexFile = getIndexOutputFile(browserOptions);
156+
const indexFile = getIndexOutputFile(browserOptions.index);
102157
const { styles: normalizedStylesOptimization } = normalizeOptimization(
103158
browserOptions.optimization,
104159
);
@@ -112,15 +167,26 @@ async function _renderUniversal(
112167
workerData: { zonePackage },
113168
});
114169

170+
let routes: string[] | undefined;
171+
115172
try {
116173
// We need to render the routes for each locale from the browser output.
117174
for (const { path: outputPath } of browserResult.outputs) {
118175
const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath);
119176
const serverBundlePath = path.join(baseOutputPath, localeDirectory, 'main.js');
177+
120178
if (!fs.existsSync(serverBundlePath)) {
121179
throw new Error(`Could not find the main bundle: ${serverBundlePath}`);
122180
}
123181

182+
routes ??= await getRoutes(
183+
indexFile,
184+
outputPath,
185+
serverBundlePath,
186+
options,
187+
context.workspaceRoot,
188+
);
189+
124190
const spinner = ora(`Prerendering ${routes.length} route(s) to ${outputPath}...`).start();
125191

126192
try {
@@ -197,21 +263,14 @@ export async function execute(
197263
const browserOptions = (await context.getTargetOptions(
198264
browserTarget,
199265
)) as unknown as BrowserBuilderOptions;
200-
const tsConfigPath =
201-
typeof browserOptions.tsConfig === 'string' ? browserOptions.tsConfig : undefined;
202-
203-
const routes = await getRoutes(options, tsConfigPath, context);
204-
if (!routes.length) {
205-
throw new Error(`Could not find any routes to prerender.`);
206-
}
207-
208266
const result = await _scheduleBuilds(options, context);
209267
const { success, error, browserResult, serverResult } = result;
268+
210269
if (!success || !browserResult || !serverResult) {
211270
return { success, error } as BuilderOutput;
212271
}
213272

214-
return _renderUniversal(routes, context, browserResult, serverResult, browserOptions);
273+
return _renderUniversal(options, context, browserResult, serverResult, browserOptions);
215274
}
216275

217276
export default createBuilder(execute);

packages/angular_devkit/build_angular/src/builders/prerender/render-worker.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ async function render({
148148
}
149149

150150
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
151-
// We can differentiate between a module and a bootstrap function by reading `cmp`:
151+
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
152152
return typeof value === 'function' && !('ɵmod' in value);
153153
}
154154

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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.io/license
7+
*/
8+
9+
import type { ApplicationRef, Type } from '@angular/core';
10+
import assert from 'node:assert';
11+
import * as fs from 'node:fs';
12+
import * as path from 'node:path';
13+
import { workerData } from 'node:worker_threads';
14+
import type { extractRoutes } from '../../utils/routes-extractor/extractor';
15+
16+
export interface RoutesExtractorWorkerData {
17+
zonePackage: string;
18+
indexFile: string;
19+
outputPath: string;
20+
serverBundlePath: string;
21+
}
22+
23+
interface ServerBundleExports {
24+
/** NgModule to render. */
25+
AppServerModule?: Type<unknown>;
26+
27+
/** Standalone application bootstrapping function. */
28+
default?: (() => Promise<ApplicationRef>) | Type<unknown>;
29+
30+
/** Method to extract routes from the router config. */
31+
extractRoutes: typeof extractRoutes;
32+
}
33+
34+
const { zonePackage, serverBundlePath, outputPath, indexFile } =
35+
workerData as RoutesExtractorWorkerData;
36+
37+
async function extract(): Promise<string[]> {
38+
const {
39+
AppServerModule,
40+
extractRoutes,
41+
default: bootstrapAppFn,
42+
} = (await import(serverBundlePath)) as ServerBundleExports;
43+
44+
const browserIndexInputPath = path.join(outputPath, indexFile);
45+
const document = await fs.promises.readFile(browserIndexInputPath, 'utf8');
46+
47+
const bootstrapAppFnOrModule = bootstrapAppFn || AppServerModule;
48+
assert(
49+
bootstrapAppFnOrModule,
50+
`Neither an AppServerModule nor a bootstrapping function was exported from: ${serverBundlePath}.`,
51+
);
52+
53+
const routes: string[] = [];
54+
for await (const { route, success } of extractRoutes(bootstrapAppFnOrModule, document)) {
55+
if (success) {
56+
routes.push(route);
57+
}
58+
}
59+
60+
return routes;
61+
}
62+
63+
/**
64+
* Initializes the worker when it is first created by loading the Zone.js package
65+
* into the worker instance.
66+
*
67+
* @returns A promise resolving to the extract function of the worker.
68+
*/
69+
async function initialize() {
70+
// Setup Zone.js
71+
await import(zonePackage);
72+
73+
return extract;
74+
}
75+
76+
/**
77+
* The default export will be the promise returned by the initialize function.
78+
* This is awaited by piscina prior to using the Worker.
79+
*/
80+
export default initialize();

packages/angular_devkit/build_angular/src/builders/prerender/schema.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
},
2828
"default": []
2929
},
30-
"guessRoutes": {
30+
"discoverRoutes": {
3131
"type": "boolean",
32-
"description": "Whether or not the builder should extract routes and guess which paths to render.",
32+
"description": "Whether the builder should discover routers using the Angular Router.",
3333
"default": true
3434
}
3535
},

0 commit comments

Comments
 (0)