Skip to content

Commit 484cda5

Browse files
clydinalan-agius4
authored andcommitted
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.
1 parent 2230374 commit 484cda5

File tree

2 files changed

+142
-62
lines changed

2 files changed

+142
-62
lines changed

packages/angular_devkit/build_angular/src/builders/app-shell/index.ts

Lines changed: 53 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@ import {
1313
targetFromTargetString,
1414
} from '@angular-devkit/architect';
1515
import { JsonObject } from '@angular-devkit/core';
16-
import type { Type } from '@angular/core';
17-
import type * as platformServer from '@angular/platform-server';
18-
import assert from 'assert';
1916
import * as fs from 'fs';
2017
import * as path from 'path';
18+
import Piscina from 'piscina';
2119
import { normalizeOptimization } from '../../utils';
2220
import { assertIsError } from '../../utils/error';
2321
import { InlineCriticalCssProcessor } from '../../utils/index-file/inline-critical-css';
@@ -45,10 +43,9 @@ async function _renderUniversal(
4543
browserBuilderName,
4644
);
4745

48-
// Initialize zone.js
46+
// Locate zone.js to load in the render worker
4947
const root = context.workspaceRoot;
5048
const zonePackage = require.resolve('zone.js', { paths: [root] });
51-
await import(zonePackage);
5249

5350
const projectName = context.target && context.target.project;
5451
if (!projectName) {
@@ -66,69 +63,63 @@ async function _renderUniversal(
6663
})
6764
: undefined;
6865

69-
for (const { path: outputPath, baseHref } of browserResult.outputs) {
70-
const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath);
71-
const browserIndexOutputPath = path.join(outputPath, 'index.html');
72-
const indexHtml = await fs.promises.readFile(browserIndexOutputPath, 'utf8');
73-
const serverBundlePath = await _getServerModuleBundlePath(
74-
options,
75-
context,
76-
serverResult,
77-
localeDirectory,
78-
);
79-
80-
const { AppServerModule, renderModule, ɵSERVER_CONTEXT } = (await import(serverBundlePath)) as {
81-
renderModule: typeof platformServer.renderModule | undefined;
82-
ɵSERVER_CONTEXT: typeof platformServer.ɵSERVER_CONTEXT | undefined;
83-
AppServerModule: Type<unknown> | undefined;
84-
};
85-
86-
assert(renderModule, `renderModule was not exported from: ${serverBundlePath}.`);
87-
assert(AppServerModule, `AppServerModule was not exported from: ${serverBundlePath}.`);
88-
assert(ɵSERVER_CONTEXT, `ɵSERVER_CONTEXT was not exported from: ${serverBundlePath}.`);
89-
90-
// Load platform server module renderer
91-
let html = await renderModule(AppServerModule, {
92-
document: indexHtml,
93-
url: options.route,
94-
extraProviders: [
95-
{
96-
provide: ɵSERVER_CONTEXT,
97-
useValue: 'app-shell',
98-
},
99-
],
100-
});
101-
102-
// Overwrite the client index file.
103-
const outputIndexPath = options.outputIndexPath
104-
? path.join(root, options.outputIndexPath)
105-
: browserIndexOutputPath;
106-
107-
if (inlineCriticalCssProcessor) {
108-
const { content, warnings, errors } = await inlineCriticalCssProcessor.process(html, {
109-
outputPath,
66+
const renderWorker = new Piscina({
67+
filename: require.resolve('./render-worker'),
68+
maxThreads: 1,
69+
workerData: { zonePackage },
70+
});
71+
72+
try {
73+
for (const { path: outputPath, baseHref } of browserResult.outputs) {
74+
const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath);
75+
const browserIndexOutputPath = path.join(outputPath, 'index.html');
76+
const indexHtml = await fs.promises.readFile(browserIndexOutputPath, 'utf8');
77+
const serverBundlePath = await _getServerModuleBundlePath(
78+
options,
79+
context,
80+
serverResult,
81+
localeDirectory,
82+
);
83+
84+
let html: string = await renderWorker.run({
85+
serverBundlePath,
86+
document: indexHtml,
87+
url: options.route,
11088
});
111-
html = content;
11289

113-
if (warnings.length || errors.length) {
114-
spinner.stop();
115-
warnings.forEach((m) => context.logger.warn(m));
116-
errors.forEach((m) => context.logger.error(m));
117-
spinner.start();
90+
// Overwrite the client index file.
91+
const outputIndexPath = options.outputIndexPath
92+
? path.join(root, options.outputIndexPath)
93+
: browserIndexOutputPath;
94+
95+
if (inlineCriticalCssProcessor) {
96+
const { content, warnings, errors } = await inlineCriticalCssProcessor.process(html, {
97+
outputPath,
98+
});
99+
html = content;
100+
101+
if (warnings.length || errors.length) {
102+
spinner.stop();
103+
warnings.forEach((m) => context.logger.warn(m));
104+
errors.forEach((m) => context.logger.error(m));
105+
spinner.start();
106+
}
118107
}
119-
}
120108

121-
await fs.promises.writeFile(outputIndexPath, html);
109+
await fs.promises.writeFile(outputIndexPath, html);
122110

123-
if (browserOptions.serviceWorker) {
124-
await augmentAppWithServiceWorker(
125-
projectRoot,
126-
root,
127-
outputPath,
128-
baseHref ?? '/',
129-
browserOptions.ngswConfigPath,
130-
);
111+
if (browserOptions.serviceWorker) {
112+
await augmentAppWithServiceWorker(
113+
projectRoot,
114+
root,
115+
outputPath,
116+
baseHref ?? '/',
117+
browserOptions.ngswConfigPath,
118+
);
119+
}
131120
}
121+
} finally {
122+
await renderWorker.destroy();
132123
}
133124

134125
return browserResult;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import type { Type } from '@angular/core';
10+
import type * as platformServer from '@angular/platform-server';
11+
import assert from 'node:assert';
12+
import { workerData } from 'node:worker_threads';
13+
14+
/**
15+
* The fully resolved path to the zone.js package that will be loaded during worker initialization.
16+
* This is passed as workerData when setting up the worker via the `piscina` package.
17+
*/
18+
const { zonePackage } = workerData as {
19+
zonePackage: string;
20+
};
21+
22+
/**
23+
* A request to render a Server bundle generate by the universal server builder.
24+
*/
25+
interface RenderRequest {
26+
/**
27+
* The path to the server bundle that should be loaded and rendered.
28+
*/
29+
serverBundlePath: string;
30+
/**
31+
* The existing HTML document as a string that will be augmented with the rendered application.
32+
*/
33+
document: string;
34+
/**
35+
* An optional URL path that represents the Angular route that should be rendered.
36+
*/
37+
url: string | undefined;
38+
}
39+
40+
/**
41+
* Renders an application based on a provided server bundle path, initial document, and optional URL route.
42+
* @param param0 A request to render a server bundle.
43+
* @returns A promise that resolves to the render HTML document for the application.
44+
*/
45+
async function render({ serverBundlePath, document, url }: RenderRequest): Promise<string> {
46+
const { AppServerModule, renderModule, ɵSERVER_CONTEXT } = (await import(serverBundlePath)) as {
47+
renderModule: typeof platformServer.renderModule | undefined;
48+
ɵSERVER_CONTEXT: typeof platformServer.ɵSERVER_CONTEXT | undefined;
49+
AppServerModule: Type<unknown> | undefined;
50+
};
51+
52+
assert(renderModule, `renderModule was not exported from: ${serverBundlePath}.`);
53+
assert(AppServerModule, `AppServerModule was not exported from: ${serverBundlePath}.`);
54+
assert(ɵSERVER_CONTEXT, `ɵSERVER_CONTEXT was not exported from: ${serverBundlePath}.`);
55+
56+
// Render platform server module
57+
const html = await renderModule(AppServerModule, {
58+
document,
59+
url,
60+
extraProviders: [
61+
{
62+
provide: ɵSERVER_CONTEXT,
63+
useValue: 'app-shell',
64+
},
65+
],
66+
});
67+
68+
return html;
69+
}
70+
71+
/**
72+
* Initializes the worker when it is first created by loading the Zone.js package
73+
* into the worker instance.
74+
*
75+
* @returns A promise resolving to the render function of the worker.
76+
*/
77+
async function initialize() {
78+
// Setup Zone.js
79+
await import(zonePackage);
80+
81+
// Return the render function for use
82+
return render;
83+
}
84+
85+
/**
86+
* The default export will be the promise returned by the initialize function.
87+
* This is awaited by piscina prior to using the Worker.
88+
*/
89+
export default initialize();

0 commit comments

Comments
 (0)