Skip to content

Commit

Permalink
fix(@angular-devkit/build-angular): isolate zone.js usage when render…
Browse files Browse the repository at this point in the history
…ing 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.

(cherry picked from commit 484cda5)
  • Loading branch information
alan-agius4 committed Nov 7, 2022
1 parent 4f730aa commit 12b2dc5
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 53 deletions.
1 change: 1 addition & 0 deletions packages/angular_devkit/build_angular/BUILD.bazel
Expand Up @@ -106,6 +106,7 @@ ts_library(
"@npm//@angular/compiler-cli",
"@npm//@angular/core",
"@npm//@angular/localize",
"@npm//@angular/platform-server",
"@npm//@angular/service-worker",
"@npm//@babel/core",
"@npm//@babel/generator",
Expand Down
104 changes: 51 additions & 53 deletions packages/angular_devkit/build_angular/src/builders/app-shell/index.ts
Expand Up @@ -15,6 +15,7 @@ import {
import { JsonObject } from '@angular-devkit/core';
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';
Expand Down Expand Up @@ -42,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) {
Expand All @@ -63,65 +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 } = await import(serverBundlePath);

const renderModuleFn: ((module: unknown, options: {}) => Promise<string>) | undefined =
renderModule;

if (!(renderModuleFn && AppServerModule)) {
throw new Error(
`renderModule method and/or AppServerModule were not exported from: ${serverBundlePath}.`,
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,
);
}

// Load platform server module renderer
const renderOpts = {
document: indexHtml,
url: options.route,
};

let html = await renderModuleFn(AppServerModule, renderOpts);
// 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,
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;
Expand Down
@@ -0,0 +1,81 @@
/**
* @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 'assert';
import { workerData } from '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<string> {
const { AppServerModule, renderModule } = (await import(serverBundlePath)) as {
renderModule: typeof platformServer.renderModule | undefined;
AppServerModule: Type<unknown> | undefined;
};

assert(renderModule, `renderModule was not exported from: ${serverBundlePath}.`);
assert(AppServerModule, `AppServerModule was not exported from: ${serverBundlePath}.`);

// Render platform server module
const html = await renderModule(AppServerModule, {
document,
url,
});

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();

0 comments on commit 12b2dc5

Please sign in to comment.