Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): support standalone apps route di…
Browse files Browse the repository at this point in the history
…scovery 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.
  • Loading branch information
alan-agius4 committed Sep 18, 2023
1 parent 5a204b8 commit 8f9a0d7
Show file tree
Hide file tree
Showing 29 changed files with 802 additions and 194 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<% } %>;
2 changes: 1 addition & 1 deletion packages/angular/ssr/schematics/ng-add/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/ssr/src/common-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ async function exists(path: fs.PathLike): Promise<boolean> {
}

function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
// 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);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/angular_devkit/build_angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion packages/angular_devkit/build_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ async function render({ serverBundlePath, document, url }: RenderRequest): Promi
}

function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
// 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export async function executeBuild(
prerenderOptions,
appShellOptions,
ssrOptions,
verbose,
} = options;

const browsers = getSupportedBrowsers(projectRoot, context.logger);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,78 @@ 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';
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<string> {
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<string[]> {
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.
*/
Expand Down Expand Up @@ -80,7 +135,7 @@ async function _scheduleBuilds(
* <route>/index.html for each output path in the browser result.
*/
async function _renderUniversal(
routes: string[],
options: PrerenderBuilderOptions,
context: BuilderContext,
browserResult: BrowserBuilderOutput,
serverResult: ServerBuilderOutput,
Expand All @@ -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,
);
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ async function render({
}

function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
// 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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>;

/** Standalone application bootstrapping function. */
default?: (() => Promise<ApplicationRef>) | Type<unknown>;

/** Method to extract routes from the router config. */
extractRoutes: typeof extractRoutes;
}

const { zonePackage, serverBundlePath, outputPath, indexFile } =
workerData as RoutesExtractorWorkerData;

async function extract(): Promise<string[]> {
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();
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down
Loading

0 comments on commit 8f9a0d7

Please sign in to comment.