Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add pre-rendering (SSG) and App-…
Browse files Browse the repository at this point in the history
…shell support generation to application builder

This commit introduces experimental support to pre-render (SSG) and app-shell generation to the new application builder.

- `appShell`: option which can have a value of `true` or `false` has been added to support generating an app-shell.
- `prerender`: option which can have a value of `true`, `false` or an object with the below listed properties can be used to static render pages;
  - `routes`: Array of routes to render.
  - `discoverRoutes`: Whether the builder should statically discover routes.
  - `routesFile`: The path to a file containing routes separated by newlines.
  • Loading branch information
alan-agius4 authored and dgp1130 committed Jul 7, 2023
1 parent 9afe193 commit cb165a7
Show file tree
Hide file tree
Showing 20 changed files with 1,017 additions and 46 deletions.
2 changes: 1 addition & 1 deletion WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ nodejs_register_toolchains(

nodejs_register_toolchains(
name = "node18",
node_version = "18.10.0",
node_version = "18.13.0",
)

# Set the default nodejs toolchain to the latest supported major version
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"eslint-plugin-import": "2.27.5",
"express": "4.18.2",
"fast-glob": "3.2.12",
"guess-parser": "0.4.22",
"http-proxy": "^1.18.1",
"https-proxy-agent": "5.0.1",
"husky": "8.0.3",
Expand Down
5 changes: 5 additions & 0 deletions packages/angular_devkit/build_angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ ts_library(
"@npm//esbuild",
"@npm//esbuild-wasm",
"@npm//fast-glob",
"@npm//guess-parser",
"@npm//https-proxy-agent",
"@npm//inquirer",
"@npm//jsonc-parser",
Expand Down Expand Up @@ -298,6 +299,10 @@ ts_library(
LARGE_SPECS = {
"application": {
"shards": 10,
"tags": [
# TODO: This is broken as app-shell tests do not work in Node versions prior to 18.13 due to Zone.js and SafePromise.
"node16-broken",
],
"extra_deps": [
"@npm//buffer",
],
Expand Down
1 change: 1 addition & 0 deletions packages/angular_devkit/build_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"css-loader": "6.8.1",
"esbuild-wasm": "0.18.10",
"fast-glob": "3.2.12",
"guess-parser": "0.4.22",
"https-proxy-agent": "5.0.1",
"inquirer": "8.2.4",
"jsonc-parser": "3.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async function render({ serverBundlePath, document, url }: RenderRequest): Promi
];

// Render platform server module
if (bootstrapAppFn) {
if (isBootstrapFn(bootstrapAppFn)) {
assert(renderApplication, `renderApplication was not exported from: ${serverBundlePath}.`);

return renderApplication(bootstrapAppFn, {
Expand All @@ -101,6 +101,11 @@ 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`:
return typeof value === 'function' && !('ɵmod' in value);
}

/**
* Initializes the worker when it is first created by loading the Zone.js package
* into the worker instance.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { BuilderContext } from '@angular-devkit/architect';
import assert from 'node:assert';
import { SourceFileCache } from '../../tools/esbuild/angular/compiler-plugin';
import {
createBrowserCodeBundleOptions,
Expand All @@ -26,10 +27,13 @@ import {
transformSupportedBrowsersToTargets,
} from '../../tools/esbuild/utils';
import { copyAssets } from '../../utils/copy-assets';
import { maxWorkers } from '../../utils/environment-options';
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
import { prerenderPages } from '../../utils/ssg/render';
import { getSupportedBrowsers } from '../../utils/supported-browsers';
import { NormalizedApplicationBuildOptions } from './options';

// eslint-disable-next-line max-lines-per-function
export async function executeBuild(
options: NormalizedApplicationBuildOptions,
context: BuilderContext,
Expand All @@ -46,6 +50,8 @@ export async function executeBuild(
assets,
indexHtmlOptions,
cacheOptions,
prerenderOptions,
appShellOptions,
} = options;

const browsers = getSupportedBrowsers(projectRoot, context.logger);
Expand Down Expand Up @@ -138,21 +144,58 @@ export async function executeBuild(
await logMessages(context, { warnings: messages });
}

/**
* Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR).
*
* NOTE: we don't perform critical CSS inlining as this will be done during server rendering.
*/
let indexContentOutputNoCssInlining: string | undefined;

// Generate index HTML file
if (indexHtmlOptions) {
const { errors, warnings, content } = await generateIndexHtml(
const { content, contentWithoutCriticalCssInlined, errors, warnings } = await generateIndexHtml(
initialFiles,
executionResult,
options,
{
...options,
optimizationOptions,
},
);
for (const error of errors) {
context.logger.error(error);
}
for (const warning of warnings) {
context.logger.warn(warning);
}

indexContentOutputNoCssInlining = contentWithoutCriticalCssInlined;
printWarningsAndErrorsToConsole(context, warnings, errors);

executionResult.addOutputFile(indexHtmlOptions.output, content);

if (serverEntryPoint) {
// TODO only add the below file when SSR is enabled.
executionResult.addOutputFile('index.server.html', contentWithoutCriticalCssInlined);
}
}

// Pre-render (SSG) and App-shell
if (prerenderOptions || appShellOptions) {
assert(
indexContentOutputNoCssInlining,
'The "index" option is required when using the "ssg" or "appShell" options.',
);

const { output, warnings, errors } = await prerenderPages(
workspaceRoot,
options.tsconfig,
appShellOptions,
prerenderOptions,
executionResult.outputFiles,
indexContentOutputNoCssInlining,
optimizationOptions.styles.inlineCritical,
maxWorkers,
);

printWarningsAndErrorsToConsole(context, warnings, errors);

for (const [path, content] of Object.entries(output)) {
executionResult.addOutputFile(path, content);
}
}

// Copy assets
Expand Down Expand Up @@ -206,3 +249,16 @@ export async function executeBuild(

return executionResult;
}

function printWarningsAndErrorsToConsole(
context: BuilderContext,
warnings: string[],
errors: string[],
): void {
for (const error of errors) {
context.logger.error(error);
}
for (const warning of warnings) {
context.logger.warn(warning);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type ApplicationBuilderInternalOptions = Omit<
* @param options An object containing the options to use for the build.
* @returns An object containing normalized options required to perform the build.
*/
// eslint-disable-next-line max-lines-per-function
export async function normalizeOptions(
context: BuilderContext,
projectName: string,
Expand Down Expand Up @@ -149,7 +150,8 @@ export async function normalizeOptions(
}

let indexHtmlOptions;
if (options.index) {
// index can never have a value of `true` but in the schema it's of type `boolean`.
if (typeof options.index !== 'boolean') {
indexHtmlOptions = {
input: path.join(workspaceRoot, getIndexInputFile(options.index)),
// The output file will be created within the configured output path
Expand All @@ -169,6 +171,28 @@ export async function normalizeOptions(
throw new Error('`server` option cannot be an empty string.');
}

let prerenderOptions;
if (options.prerender) {
const {
discoverRoutes = true,
routes = [],
routesFile = undefined,
} = options.prerender === true ? {} : options.prerender;

prerenderOptions = {
discoverRoutes,
routes,
routesFile: routesFile && path.join(workspaceRoot, routesFile),
};
}

let appShellOptions;
if (options.appShell) {
appShellOptions = {
route: 'shell',
};
}

// Initial options to keep
const {
allowedCommonJsDependencies,
Expand Down Expand Up @@ -215,6 +239,8 @@ export async function normalizeOptions(
stylePreprocessorOptions,
subresourceIntegrity,
serverEntryPoint,
prerenderOptions,
appShellOptions,
verbose,
watch,
workspaceRoot,
Expand All @@ -230,7 +256,8 @@ export async function normalizeOptions(
fileReplacements,
globalStyles,
globalScripts,
serviceWorker,
serviceWorker:
typeof serviceWorker === 'string' ? path.join(workspaceRoot, serviceWorker) : undefined,
indexHtmlOptions,
tailwindConfiguration,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@
},
{
"const": false,
"type": "boolean",
"description": "Does not generate a service worker configuration."
}
]
Expand Down Expand Up @@ -380,6 +381,7 @@
},
{
"const": false,
"type": "boolean",
"description": "Does not generate an `index.html` file."
}
]
Expand Down Expand Up @@ -414,6 +416,46 @@
"type": "string"
},
"default": []
},
"prerender": {
"description": "Prerender (SSG) pages of your application during build time.",
"default": false,
"oneOf": [
{
"type": "boolean",
"description": "Enable prerending of pages of your application during build time."
},
{
"type": "object",
"properties": {
"routesFile": {
"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.",
"default": true
}
},
"additionalProperties": false
}
]
},
"appShell": {
"type": "boolean",
"description": "Generates an application shell during build time.",
"default": false
}
},
"additionalProperties": false,
Expand Down
Loading

0 comments on commit cb165a7

Please sign in to comment.