From 484cda5f9ee90ab17807eb7f5cfb4a40ea6cd264 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 10 Oct 2022 09:34:01 -0400 Subject: [PATCH] fix(@angular-devkit/build-angular): isolate zone.js usage when rendering server bundles When generating an app-shell via the app-shell builder, the server application rendering will now take place within a Node.js Worker. Since the rendering requires the presence of Zone.js, this change allows for the Zone.js patching to be isolated from the remainder of the builder and Angular CLI code. This prevents Zone.js from persisting past the needed render operation. This also allows for a workaround to a Zone.js/Node.js v18 problem where the TypeScript dynamic import workaround involving the Function constructor to ensure a native dynamic import expression will cause a failure when running on Node.js v18.10. --- .../src/builders/app-shell/index.ts | 115 ++++++++---------- .../src/builders/app-shell/render-worker.ts | 89 ++++++++++++++ 2 files changed, 142 insertions(+), 62 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts diff --git a/packages/angular_devkit/build_angular/src/builders/app-shell/index.ts b/packages/angular_devkit/build_angular/src/builders/app-shell/index.ts index 84085370c0db..208c3d5c611d 100644 --- a/packages/angular_devkit/build_angular/src/builders/app-shell/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/app-shell/index.ts @@ -13,11 +13,9 @@ import { targetFromTargetString, } from '@angular-devkit/architect'; import { JsonObject } from '@angular-devkit/core'; -import type { Type } from '@angular/core'; -import type * as platformServer from '@angular/platform-server'; -import assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; +import Piscina from 'piscina'; import { normalizeOptimization } from '../../utils'; import { assertIsError } from '../../utils/error'; import { InlineCriticalCssProcessor } from '../../utils/index-file/inline-critical-css'; @@ -45,10 +43,9 @@ async function _renderUniversal( browserBuilderName, ); - // Initialize zone.js + // Locate zone.js to load in the render worker const root = context.workspaceRoot; const zonePackage = require.resolve('zone.js', { paths: [root] }); - await import(zonePackage); const projectName = context.target && context.target.project; if (!projectName) { @@ -66,69 +63,63 @@ async function _renderUniversal( }) : undefined; - for (const { path: outputPath, baseHref } of browserResult.outputs) { - const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath); - const browserIndexOutputPath = path.join(outputPath, 'index.html'); - const indexHtml = await fs.promises.readFile(browserIndexOutputPath, 'utf8'); - const serverBundlePath = await _getServerModuleBundlePath( - options, - context, - serverResult, - localeDirectory, - ); - - const { AppServerModule, renderModule, ɵSERVER_CONTEXT } = (await import(serverBundlePath)) as { - renderModule: typeof platformServer.renderModule | undefined; - ɵSERVER_CONTEXT: typeof platformServer.ɵSERVER_CONTEXT | undefined; - AppServerModule: Type | undefined; - }; - - assert(renderModule, `renderModule was not exported from: ${serverBundlePath}.`); - assert(AppServerModule, `AppServerModule was not exported from: ${serverBundlePath}.`); - assert(ɵSERVER_CONTEXT, `ɵSERVER_CONTEXT was not exported from: ${serverBundlePath}.`); - - // Load platform server module renderer - let html = await renderModule(AppServerModule, { - document: indexHtml, - url: options.route, - extraProviders: [ - { - provide: ɵSERVER_CONTEXT, - useValue: 'app-shell', - }, - ], - }); - - // Overwrite the client index file. - const outputIndexPath = options.outputIndexPath - ? path.join(root, options.outputIndexPath) - : browserIndexOutputPath; - - if (inlineCriticalCssProcessor) { - const { content, warnings, errors } = await inlineCriticalCssProcessor.process(html, { - outputPath, + const renderWorker = new Piscina({ + filename: require.resolve('./render-worker'), + maxThreads: 1, + workerData: { zonePackage }, + }); + + try { + for (const { path: outputPath, baseHref } of browserResult.outputs) { + const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath); + const browserIndexOutputPath = path.join(outputPath, 'index.html'); + const indexHtml = await fs.promises.readFile(browserIndexOutputPath, 'utf8'); + const serverBundlePath = await _getServerModuleBundlePath( + options, + context, + serverResult, + localeDirectory, + ); + + let html: string = await renderWorker.run({ + serverBundlePath, + document: indexHtml, + url: options.route, }); - html = content; - if (warnings.length || errors.length) { - spinner.stop(); - warnings.forEach((m) => context.logger.warn(m)); - errors.forEach((m) => context.logger.error(m)); - spinner.start(); + // Overwrite the client index file. + const outputIndexPath = options.outputIndexPath + ? path.join(root, options.outputIndexPath) + : browserIndexOutputPath; + + if (inlineCriticalCssProcessor) { + const { content, warnings, errors } = await inlineCriticalCssProcessor.process(html, { + outputPath, + }); + html = content; + + if (warnings.length || errors.length) { + spinner.stop(); + warnings.forEach((m) => context.logger.warn(m)); + errors.forEach((m) => context.logger.error(m)); + spinner.start(); + } } - } - await fs.promises.writeFile(outputIndexPath, html); + await fs.promises.writeFile(outputIndexPath, html); - if (browserOptions.serviceWorker) { - await augmentAppWithServiceWorker( - projectRoot, - root, - outputPath, - baseHref ?? '/', - browserOptions.ngswConfigPath, - ); + if (browserOptions.serviceWorker) { + await augmentAppWithServiceWorker( + projectRoot, + root, + outputPath, + baseHref ?? '/', + browserOptions.ngswConfigPath, + ); + } } + } finally { + await renderWorker.destroy(); } return browserResult; 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 new file mode 100644 index 000000000000..28af48b5849c --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/app-shell/render-worker.ts @@ -0,0 +1,89 @@ +/** + * @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 { Type } from '@angular/core'; +import type * as platformServer from '@angular/platform-server'; +import assert from 'node:assert'; +import { workerData } from 'node:worker_threads'; + +/** + * The fully resolved path to the zone.js package that will be loaded during worker initialization. + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { zonePackage } = workerData as { + zonePackage: string; +}; + +/** + * A request to render a Server bundle generate by the universal server builder. + */ +interface RenderRequest { + /** + * The path to the server bundle that should be loaded and rendered. + */ + serverBundlePath: string; + /** + * The existing HTML document as a string that will be augmented with the rendered application. + */ + document: string; + /** + * An optional URL path that represents the Angular route that should be rendered. + */ + url: string | undefined; +} + +/** + * Renders an application based on a provided server bundle path, initial document, and optional URL route. + * @param param0 A request to render a server bundle. + * @returns A promise that resolves to the render HTML document for the application. + */ +async function render({ serverBundlePath, document, url }: RenderRequest): Promise { + const { AppServerModule, renderModule, ɵSERVER_CONTEXT } = (await import(serverBundlePath)) as { + renderModule: typeof platformServer.renderModule | undefined; + ɵSERVER_CONTEXT: typeof platformServer.ɵSERVER_CONTEXT | undefined; + AppServerModule: Type | undefined; + }; + + assert(renderModule, `renderModule was not exported from: ${serverBundlePath}.`); + assert(AppServerModule, `AppServerModule was not exported from: ${serverBundlePath}.`); + assert(ɵSERVER_CONTEXT, `ɵSERVER_CONTEXT was not exported from: ${serverBundlePath}.`); + + // Render platform server module + const html = await renderModule(AppServerModule, { + document, + url, + extraProviders: [ + { + provide: ɵSERVER_CONTEXT, + useValue: 'app-shell', + }, + ], + }); + + return html; +} + +/** + * Initializes the worker when it is first created by loading the Zone.js package + * into the worker instance. + * + * @returns A promise resolving to the render function of the worker. + */ +async function initialize() { + // Setup Zone.js + await import(zonePackage); + + // Return the render function for use + return render; +} + +/** + * 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();