diff --git a/WORKSPACE b/WORKSPACE index ad34e27f8980..b44201619612 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -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 diff --git a/package.json b/package.json index 3294808d2e7c..89bd40a54222 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/angular_devkit/build_angular/BUILD.bazel b/packages/angular_devkit/build_angular/BUILD.bazel index b7134b720ab8..ce0962b81858 100644 --- a/packages/angular_devkit/build_angular/BUILD.bazel +++ b/packages/angular_devkit/build_angular/BUILD.bazel @@ -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", @@ -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", ], diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index 75a484c4ae14..8d7d73bfb230 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -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", 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 ab328f053a8f..1b1cf3277401 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 @@ -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, { @@ -101,6 +101,11 @@ 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`: + 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. 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 50337f0c5650..3f2429baa7fc 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 @@ -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, @@ -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, @@ -46,6 +50,8 @@ export async function executeBuild( assets, indexHtmlOptions, cacheOptions, + prerenderOptions, + appShellOptions, } = options; const browsers = getSupportedBrowsers(projectRoot, context.logger); @@ -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 @@ -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); + } +} 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 2a4a02fbbde6..703f3bdeeada 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/application/options.ts @@ -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, @@ -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 @@ -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, @@ -215,6 +239,8 @@ export async function normalizeOptions( stylePreprocessorOptions, subresourceIntegrity, serverEntryPoint, + prerenderOptions, + appShellOptions, verbose, watch, workspaceRoot, @@ -230,7 +256,8 @@ export async function normalizeOptions( fileReplacements, globalStyles, globalScripts, - serviceWorker, + serviceWorker: + typeof serviceWorker === 'string' ? path.join(workspaceRoot, serviceWorker) : undefined, indexHtmlOptions, tailwindConfiguration, }; 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 d0320c4fc67a..f982a9278454 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/application/schema.json @@ -349,6 +349,7 @@ }, { "const": false, + "type": "boolean", "description": "Does not generate a service worker configuration." } ] @@ -380,6 +381,7 @@ }, { "const": false, + "type": "boolean", "description": "Does not generate an `index.html` file." } ] @@ -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, diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts b/packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts new file mode 100644 index 000000000000..d63d12483731 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts @@ -0,0 +1,177 @@ +/** + * @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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const appShellRouteFiles: Record = { + 'src/styles.css': `p { color: #000 }`, + 'src/app/app-shell/app-shell.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-app-shell', + styles: ['div { color: #fff; }'], + template: '

app-shell works!

', + }) + export class AppShellComponent {}`, + 'src/main.server.ts': ` + import { AppServerModule } from './app/app.module.server'; + export default AppServerModule; + `, + 'src/app/app.module.ts': ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppRoutingModule } from './app-routing.module'; + import { AppComponent } from './app.component'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + AppRoutingModule, + RouterModule + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.module.server.ts': ` + import { NgModule } from '@angular/core'; + import { ServerModule } from '@angular/platform-server'; + + import { AppModule } from './app.module'; + import { AppComponent } from './app.component'; + import { Routes, RouterModule } from '@angular/router'; + import { AppShellComponent } from './app-shell/app-shell.component'; + + const routes: Routes = [ { path: 'shell', component: AppShellComponent }]; + + @NgModule({ + imports: [ + AppModule, + ServerModule, + RouterModule.forRoot(routes), + ], + bootstrap: [AppComponent], + declarations: [AppShellComponent], + }) + export class AppServerModule {} + `, + 'src/main.ts': ` + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app/app.module'; + + platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.log(err)); + `, + 'src/app/app-routing.module.ts': ` + import { NgModule } from '@angular/core'; + import { Routes, RouterModule } from '@angular/router'; + + const routes: Routes = []; + + @NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] + }) + export class AppRoutingModule { } + `, + 'src/app/app.component.html': ``, +}; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFiles(appShellRouteFiles); + }); + + describe('Option: "appShell"', () => { + it('renders the application shell', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + appShell: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/main.js').toExist(); + const indexFileContent = harness.expectFile('dist/index.html').content; + indexFileContent.toContain('app-shell works!'); + indexFileContent.toContain('ng-server-context="app-shell"'); + }); + + it('critical CSS is inlined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + appShell: true, + styles: ['src/styles.css'], + optimization: { + styles: { + minify: true, + inlineCritical: true, + }, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const indexFileContent = harness.expectFile('dist/index.html').content; + indexFileContent.toContain('app-shell works!'); + indexFileContent.toContain('p{color:#000}'); + indexFileContent.toContain( + ``, + ); + }); + + it('applies CSP nonce to critical CSS', async () => { + await harness.modifyFile('src/index.html', (content) => + content.replace(/`, + ); + indexFileContent.toContain('