From ddaebeddfaefa8060b31f796f1b41702338e0fc3 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 27 May 2024 08:59:47 +0000 Subject: [PATCH] fix(@angular/build): address prerendering in-memory ESM resolution in Node.js 22.2.0 and later Node.js 22.2.0 introduced a breaking change affecting custom ESM resolution. For more context, see: [Node.js issue #53097](https://github.com/nodejs/node/issues/53097) Closes: #53097 --- .../esm-in-memory-loader/loader-hooks.ts | 57 ++- .../esm-in-memory-loader/register-hooks.ts | 24 +- .../esm-in-memory-loader/utils-lts-node.ts | 12 + .../prerender-child-process.ts | 350 ++++++++++++++++++ .../src/utils/server-rendering/prerender.ts | 312 ++++------------ .../utils/server-rendering/render-worker.ts | 20 +- .../routes-extractor-worker.ts | 13 +- 7 files changed, 530 insertions(+), 258 deletions(-) create mode 100644 packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils-lts-node.ts create mode 100644 packages/angular/build/src/utils/server-rendering/prerender-child-process.ts diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts index 5ab105db03da..a56f0995fab8 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -10,6 +10,7 @@ import assert from 'node:assert'; import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { MessagePort } from 'node:worker_threads'; import { fileURLToPath } from 'url'; import { JavaScriptTransformer } from '../../../tools/esbuild/javascript-transformer'; @@ -21,12 +22,14 @@ import { JavaScriptTransformer } from '../../../tools/esbuild/javascript-transfo const MEMORY_URL_SCHEME = 'memory://'; export interface ESMInMemoryFileLoaderWorkerData { - outputFiles: Record; + jsOutputFilesForWorker: Record; workspaceRoot: string; } -let memoryVirtualRootUrl: string; -let outputFiles: Record; +interface ESMInMemoryFileLoaderResolutionData { + memoryVirtualRootUrl: string; + outputFiles: Record; +} const javascriptTransformer = new JavaScriptTransformer( // Always enable JIT linking to support applications built with and without AOT. @@ -36,23 +39,46 @@ const javascriptTransformer = new JavaScriptTransformer( 1, ); -export function initialize(data: ESMInMemoryFileLoaderWorkerData) { - // This path does not actually exist but is used to overlay the in memory files with the - // actual filesystem for resolution purposes. - // A custom URL schema (such as `memory://`) cannot be used for the resolve output because - // the in-memory files may use `import.meta.url` in ways that assume a file URL. - // `createRequire` is one example of this usage. - memoryVirtualRootUrl = pathToFileURL( - join(data.workspaceRoot, `.angular/prerender-root/${randomUUID()}/`), - ).href; - outputFiles = data.outputFiles; +let resolveData: Promise; + +export function initialize(data: { port: MessagePort } | ESMInMemoryFileLoaderWorkerData) { + resolveData = new Promise((resolve) => { + if (!('port' in data)) { + /** TODO: Remove when Node.js Removes < 22.2 are no longer supported. */ + resolve({ + outputFiles: data.jsOutputFilesForWorker, + memoryVirtualRootUrl: pathToFileURL( + join(data.workspaceRoot, `.angular/prerender-root/${randomUUID()}/`), + ).href, + }); + + return; + } + + const { port } = data; + port.once( + 'message', + ({ jsOutputFilesForWorker, workspaceRoot }: ESMInMemoryFileLoaderWorkerData) => { + resolve({ + outputFiles: jsOutputFilesForWorker, + memoryVirtualRootUrl: pathToFileURL( + join(workspaceRoot, `.angular/prerender-root/${randomUUID()}/`), + ).href, + }); + + port.close(); + }, + ); + }); } -export function resolve( +export async function resolve( specifier: string, context: { parentURL: undefined | string }, nextResolve: Function, ) { + const { outputFiles, memoryVirtualRootUrl } = await resolveData; + // In-memory files loaded from external code will contain a memory scheme if (specifier.startsWith(MEMORY_URL_SCHEME)) { let memoryUrl; @@ -89,7 +115,7 @@ export function resolve( if ( specifierUrl?.pathname && - Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length)) + outputFiles[specifierUrl.href.slice(memoryVirtualRootUrl.length)] !== undefined ) { return { format: 'module', @@ -114,6 +140,7 @@ export function resolve( } export async function load(url: string, context: { format?: string | null }, nextLoad: Function) { + const { outputFiles, memoryVirtualRootUrl } = await resolveData; const { format } = context; // Load the file from memory if the URL is based in the virtual root diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts index cf2bd309eaaf..32834cde78b9 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts @@ -8,6 +8,26 @@ import { register } from 'node:module'; import { pathToFileURL } from 'node:url'; -import { workerData } from 'node:worker_threads'; +import { MessageChannel, workerData } from 'node:worker_threads'; +import { isLegacyESMLoaderImplementation } from './utils-lts-node'; -register('./loader-hooks.js', { parentURL: pathToFileURL(__filename), data: workerData }); +if (isLegacyESMLoaderImplementation && workerData) { + /** TODO: Remove when Node.js Removes < 22.2 are no longer supported. */ + register('./loader-hooks.js', { + parentURL: pathToFileURL(__filename), + data: workerData, + }); +} else { + const { port1, port2 } = new MessageChannel(); + + process.once('message', (msg) => { + port1.postMessage(msg); + port1.close(); + }); + + register('./loader-hooks.js', { + parentURL: pathToFileURL(__filename), + data: { port: port2 }, + transferList: [port2], + }); +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils-lts-node.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils-lts-node.ts new file mode 100644 index 000000000000..fa084115653b --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils-lts-node.ts @@ -0,0 +1,12 @@ +/** + * @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 { lt } from 'semver'; + +/** TODO: Remove when Node.js Removes < 22.2 are no longer supported. */ +export const isLegacyESMLoaderImplementation = lt(process.version, '22.2.0'); diff --git a/packages/angular/build/src/utils/server-rendering/prerender-child-process.ts b/packages/angular/build/src/utils/server-rendering/prerender-child-process.ts new file mode 100644 index 000000000000..e10cfe7dd29c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/prerender-child-process.ts @@ -0,0 +1,350 @@ +/** + * @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 { readFile } from 'node:fs/promises'; +import { join, posix } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import Piscina from 'piscina'; +import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; +import { isLegacyESMLoaderImplementation } from './esm-in-memory-loader/utils-lts-node'; +import { AppShellOptions, PrerenderOptions } from './prerender'; +import type { RenderResult, ServerContext } from './render-page'; +import type { RenderWorkerData } from './render-worker'; +import type { + RoutersExtractorWorkerResult, + RoutesExtractorWorkerData, +} from './routes-extractor-worker'; + +export interface ReadyMessage { + type: 'ready'; + data: { + output: Record; + warnings: string[]; + errors: string[]; + prerenderedRoutes: Set; + }; +} + +export interface ErrorMessage { + type: 'error'; + data: { + name: string; + message: string; + stack: string; + }; +} + +export interface StartMessage { + type: 'start'; + data: null; +} + +export type ProcessMessages = ErrorMessage | ReadyMessage | StartMessage; + +export interface PrerenderPagesOptions { + appShellOptions: AppShellOptions; + prerenderOptions: PrerenderOptions; + assets: Readonly; + document: string; + sourcemap: boolean; + inlineCriticalCss: boolean; + maxThreads: number; + verbose: boolean; + cssOutputFilesForWorker?: Record; + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + jsOutputFilesForWorker?: Record; + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + workspaceRoot?: string; +} + +class RoutesSet extends Set { + override add(value: string): this { + return super.add(addLeadingSlash(value)); + } +} + +async function prerenderPages({ + appShellOptions, + prerenderOptions, + assets, + document, + sourcemap, + inlineCriticalCss, + maxThreads, + verbose, + workspaceRoot, + cssOutputFilesForWorker, + jsOutputFilesForWorker, +}: PrerenderPagesOptions): Promise<{ + output: Record; + warnings: string[]; + errors: string[]; + prerenderedRoutes: Set; +}> { + const warnings: string[] = []; + const errors: string[] = []; + + const assetsReversed: Record = {}; + for (const { source, destination } of assets) { + assetsReversed[addLeadingSlash(destination.replace(/\\/g, posix.sep))] = source; + } + + // Get routes to prerender + const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes( + assetsReversed, + document, + appShellOptions, + prerenderOptions, + sourcemap, + verbose, + jsOutputFilesForWorker, + workspaceRoot, + ); + + if (routesWarnings?.length) { + warnings.push(...routesWarnings); + } + + if (allRoutes.size < 1) { + return { + errors, + warnings, + output: {}, + prerenderedRoutes: allRoutes, + }; + } + + // Render routes + const { + warnings: renderingWarnings, + errors: renderingErrors, + output, + } = await renderPages( + sourcemap, + allRoutes, + maxThreads, + assetsReversed, + inlineCriticalCss, + document, + appShellOptions, + cssOutputFilesForWorker, + jsOutputFilesForWorker, + workspaceRoot, + ); + + errors.push(...renderingErrors); + warnings.push(...renderingWarnings); + + return { + errors, + warnings, + output, + prerenderedRoutes: allRoutes, + }; +} + +async function renderPages( + sourcemap: boolean, + allRoutes: Set, + maxThreads: number, + assetFilesForWorker: Record, + inlineCriticalCss: boolean, + document: string, + appShellOptions: AppShellOptions, + cssOutputFilesForWorker?: Record, + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + jsOutputFilesForWorker?: Record, + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + workspaceRoot?: string, +): Promise<{ + output: Record; + warnings: string[]; + errors: string[]; +}> { + const output: Record = {}; + const warnings: string[] = []; + const errors: string[] = []; + const execArgv: string[] = sourcemap + ? ['--enable-source-maps', ...process.execArgv] + : [...process.execArgv]; + + if (isLegacyESMLoaderImplementation) { + execArgv.push( + '--import', + // Loader cannot be an absolute path on Windows. + pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, + ); + } + + const renderWorker = new Piscina({ + filename: join(__dirname, 'render-worker.js'), + maxThreads: Math.min(allRoutes.size, maxThreads), + workerData: { + assetFiles: assetFilesForWorker, + inlineCriticalCss, + document, + jsOutputFilesForWorker, + cssOutputFilesForWorker, + workspaceRoot, + } as RenderWorkerData, + execArgv, + recordTiming: false, + }); + + try { + const renderingPromises: Promise[] = []; + const appShellRoute = appShellOptions.route && addLeadingSlash(appShellOptions.route); + + for (const route of allRoutes) { + const isAppShellRoute = appShellRoute === route; + const serverContext: ServerContext = isAppShellRoute ? 'app-shell' : 'ssg'; + const render: Promise = renderWorker.run({ route, serverContext }); + const renderResult: Promise = render.then(({ content, warnings, errors }) => { + if (content !== undefined) { + const outPath = isAppShellRoute + ? 'index.html' + : posix.join(removeLeadingSlash(route), 'index.html'); + output[outPath] = content; + } + + if (warnings) { + warnings.push(...warnings); + } + + if (errors) { + errors.push(...errors); + } + }); + + renderingPromises.push(renderResult); + } + + await Promise.all(renderingPromises); + } finally { + void renderWorker.destroy(); + } + + return { + errors, + warnings, + output, + }; +} + +async function getAllRoutes( + assetFilesForWorker: Record, + document: string, + appShellOptions: AppShellOptions, + prerenderOptions: PrerenderOptions, + sourcemap: boolean, + verbose: boolean, + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + jsOutputFilesForWorker?: Record, + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + workspaceRoot?: string, +): Promise<{ routes: Set; warnings?: string[] }> { + const { routesFile, discoverRoutes } = prerenderOptions; + const routes = new RoutesSet(); + const { route: appShellRoute } = appShellOptions; + + if (appShellRoute !== undefined) { + routes.add(appShellRoute); + } + + if (routesFile) { + const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); + for (const route of routesFromFile) { + routes.add(route.trim()); + } + } + + if (!discoverRoutes) { + return { routes }; + } + + const execArgv: string[] = sourcemap + ? ['--enable-source-maps', ...process.execArgv] + : [...process.execArgv]; + if (isLegacyESMLoaderImplementation) { + execArgv.push( + '--import', + // Loader cannot be an absolute path on Windows. + pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, + ); + } + + const renderWorker = new Piscina({ + filename: join(__dirname, 'routes-extractor-worker.js'), + maxThreads: 1, + workerData: { + assetFiles: assetFilesForWorker, + document, + verbose, + jsOutputFilesForWorker, + workspaceRoot, + } as RoutesExtractorWorkerData, + execArgv, + recordTiming: false, + }); + + const { routes: extractedRoutes, warnings }: RoutersExtractorWorkerResult = await renderWorker + .run({}) + .finally(() => { + void renderWorker.destroy(); + }); + + for (const route of extractedRoutes) { + routes.add(route); + } + + return { routes, warnings }; +} + +function removeLeadingSlash(value: string): string { + return value.charAt(0) === '/' ? value.slice(1) : value; +} + +function addLeadingSlash(value: string): string { + return value.charAt(0) === '/' ? value : '/' + value; +} + +process.once('message', (options: PrerenderPagesOptions) => { + prerenderPages(options) + .then((result) => { + process.send?.({ + type: 'ready', + data: result, + } as ReadyMessage); + }) + .catch((err) => { + process.send?.({ + type: 'error', + data: { name: err.name, message: err.message, stack: err.stack }, + } as ErrorMessage); + }); +}); + +process.send?.({ type: 'start', data: null }); diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index 6e37fe46d53f..2a049342ce7b 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -6,25 +6,25 @@ * found in the LICENSE file at https://angular.io/license */ -import { readFile } from 'node:fs/promises'; -import { extname, join, posix } from 'node:path'; +import { fork } from 'node:child_process'; +import { extname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import Piscina from 'piscina'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; -import type { RenderResult, ServerContext } from './render-page'; -import type { RenderWorkerData } from './render-worker'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { isLegacyESMLoaderImplementation } from './esm-in-memory-loader/utils-lts-node'; import type { - RoutersExtractorWorkerResult, - RoutesExtractorWorkerData, -} from './routes-extractor-worker'; + PrerenderPagesOptions, + ProcessMessages, + ReadyMessage, +} from './prerender-child-process'; -interface PrerenderOptions { +export interface PrerenderOptions { routesFile?: string; discoverRoutes?: boolean; } -interface AppShellOptions { +export interface AppShellOptions { route?: string; } @@ -45,29 +45,29 @@ export async function prerenderPages( errors: string[]; prerenderedRoutes: Set; }> { - const outputFilesForWorker: Record = {}; + const cssOutputFilesForWorker: Record = {}; + const jsOutputFilesForWorker: Record = {}; const serverBundlesSourceMaps = new Map(); - const warnings: string[] = []; - const errors: string[] = []; for (const { text, path, type } of outputFiles) { const fileExt = extname(path); - if (type === BuildOutputFileType.Server && fileExt === '.map') { + if (type === BuildOutputFileType.Browser && fileExt === '.css') { + // Global styles for critical CSS inlining. + cssOutputFilesForWorker[path] = text; + } else if (type === BuildOutputFileType.Server && fileExt === '.map') { serverBundlesSourceMaps.set(path.slice(0, -4), text); - } else if ( - type === BuildOutputFileType.Server || // Contains the server runnable application code - (type === BuildOutputFileType.Browser && fileExt === '.css') // Global styles for critical CSS inlining. - ) { - outputFilesForWorker[path] = text; + } else if (type === BuildOutputFileType.Server) { + // Contains the server runnable application code + jsOutputFilesForWorker[path] = text; } } // Inline sourcemap into JS file. This is needed to make Node.js resolve sourcemaps // when using `--enable-source-maps` when using in memory files. for (const [filePath, map] of serverBundlesSourceMaps) { - const jsContent = outputFilesForWorker[filePath]; + const jsContent = jsOutputFilesForWorker[filePath]; if (jsContent) { - outputFilesForWorker[filePath] = + jsOutputFilesForWorker[filePath] = jsContent + `\n//# sourceMappingURL=` + `data:application/json;base64,${Buffer.from(map).toString('base64')}`; @@ -75,223 +75,63 @@ export async function prerenderPages( } serverBundlesSourceMaps.clear(); - const assetsReversed: Record = {}; - for (const { source, destination } of assets) { - assetsReversed[addLeadingSlash(destination.replace(/\\/g, posix.sep))] = source; - } - - // Get routes to prerender - const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes( - workspaceRoot, - outputFilesForWorker, - assetsReversed, - document, - appShellOptions, - prerenderOptions, - sourcemap, - verbose, - ); - - if (routesWarnings?.length) { - warnings.push(...routesWarnings); - } - - if (allRoutes.size < 1) { - return { - errors, - warnings, - output: {}, - prerenderedRoutes: allRoutes, - }; - } - - // Render routes - const { - warnings: renderingWarnings, - errors: renderingErrors, - output, - } = await renderPages( - sourcemap, - allRoutes, - maxThreads, - workspaceRoot, - outputFilesForWorker, - assetsReversed, - inlineCriticalCss, - document, - appShellOptions, - ); - - errors.push(...renderingErrors); - warnings.push(...renderingWarnings); - - return { - errors, - warnings, - output, - prerenderedRoutes: allRoutes, - }; -} - -class RoutesSet extends Set { - override add(value: string): this { - return super.add(addLeadingSlash(value)); - } -} - -async function renderPages( - sourcemap: boolean, - allRoutes: Set, - maxThreads: number, - workspaceRoot: string, - outputFilesForWorker: Record, - assetFilesForWorker: Record, - inlineCriticalCss: boolean, - document: string, - appShellOptions: AppShellOptions, -): Promise<{ - output: Record; - warnings: string[]; - errors: string[]; -}> { - const output: Record = {}; - const warnings: string[] = []; - const errors: string[] = []; - - const workerExecArgv = [ - '--import', - // Loader cannot be an absolute path on Windows. - pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, - ]; - - if (sourcemap) { - workerExecArgv.push('--enable-source-maps'); - } - - const renderWorker = new Piscina({ - filename: require.resolve('./render-worker'), - maxThreads: Math.min(allRoutes.size, maxThreads), - workerData: { - workspaceRoot, - outputFiles: outputFilesForWorker, - assetFiles: assetFilesForWorker, - inlineCriticalCss, - document, - } as RenderWorkerData, - execArgv: workerExecArgv, - recordTiming: false, + const childProcess = fork(join(__dirname, 'prerender-child-process.js'), { + execArgv: [ + '--import', + // Loader cannot be an absolute path on Windows. + pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, + ], }); - try { - const renderingPromises: Promise[] = []; - const appShellRoute = appShellOptions.route && addLeadingSlash(appShellOptions.route); - - for (const route of allRoutes) { - const isAppShellRoute = appShellRoute === route; - const serverContext: ServerContext = isAppShellRoute ? 'app-shell' : 'ssg'; - const render: Promise = renderWorker.run({ route, serverContext }); - const renderResult: Promise = render.then(({ content, warnings, errors }) => { - if (content !== undefined) { - const outPath = isAppShellRoute - ? 'index.html' - : posix.join(removeLeadingSlash(route), 'index.html'); - output[outPath] = content; - } - - if (warnings) { - warnings.push(...warnings); - } - - if (errors) { - errors.push(...errors); - } - }); - - renderingPromises.push(renderResult); - } - - await Promise.all(renderingPromises); - } finally { - void renderWorker.destroy(); - } - - return { - errors, - warnings, - output, + // Send data to ESM loader worker. + const esmLoaderData: ESMInMemoryFileLoaderWorkerData = { + workspaceRoot, + jsOutputFilesForWorker, }; -} - -async function getAllRoutes( - workspaceRoot: string, - outputFilesForWorker: Record, - assetFilesForWorker: Record, - document: string, - appShellOptions: AppShellOptions, - prerenderOptions: PrerenderOptions, - sourcemap: boolean, - verbose: boolean, -): Promise<{ routes: Set; warnings?: string[] }> { - const { routesFile, discoverRoutes } = prerenderOptions; - const routes = new RoutesSet(); - const { route: appShellRoute } = appShellOptions; - - if (appShellRoute !== undefined) { - routes.add(appShellRoute); - } - - if (routesFile) { - const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); - for (const route of routesFromFile) { - routes.add(route.trim()); - } - } - - if (!discoverRoutes) { - return { routes }; - } - - const workerExecArgv = [ - '--import', - // Loader cannot be an absolute path on Windows. - pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, - ]; - - if (sourcemap) { - workerExecArgv.push('--enable-source-maps'); - } - - const renderWorker = new Piscina({ - filename: require.resolve('./routes-extractor-worker'), - maxThreads: 1, - workerData: { - workspaceRoot, - outputFiles: outputFilesForWorker, - assetFiles: assetFilesForWorker, - document, - verbose, - } as RoutesExtractorWorkerData, - execArgv: workerExecArgv, - recordTiming: false, - }); - - const { routes: extractedRoutes, warnings }: RoutersExtractorWorkerResult = await renderWorker - .run({}) - .finally(() => { - void renderWorker.destroy(); + childProcess.send(esmLoaderData); + + return new Promise((resolve, reject) => { + childProcess.once('error', reject); + childProcess.once('uncaughtException', reject); + childProcess.on('message', ({ type, data }: ProcessMessages) => { + switch (type) { + case 'start': + const prerenderPagesOptions: PrerenderPagesOptions = { + appShellOptions, + prerenderOptions, + assets, + document, + sourcemap, + inlineCriticalCss, + maxThreads, + verbose, + cssOutputFilesForWorker, + workspaceRoot, + }; + + // TODO: remove when Node.js version 22.2 is no longer supported. + if (isLegacyESMLoaderImplementation) { + prerenderPagesOptions.jsOutputFilesForWorker = jsOutputFilesForWorker; + prerenderPagesOptions.workspaceRoot = workspaceRoot; + } + childProcess.send(prerenderPagesOptions); + break; + case 'ready': + resolve(data); + break; + case 'error': + const { name, message, stack } = data; + const err = new Error(message); + err.name = name; + err.stack = stack; + + reject(err); + break; + default: + throw new Error(`Unhandled message type "${type}" from forked process.`); + } }); - - for (const route of extractedRoutes) { - routes.add(route); - } - - return { routes, warnings }; -} - -function addLeadingSlash(value: string): string { - return value.charAt(0) === '/' ? value : '/' + value; -} - -function removeLeadingSlash(value: string): string { - return value.charAt(0) === '/' ? value.slice(1) : value; + }).finally(() => { + childProcess.kill(); + }); } diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts index 8007986454f9..04b2d47cc1cc 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -7,14 +7,24 @@ */ import { workerData } from 'node:worker_threads'; -import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; import { RenderResult, ServerContext, renderPage } from './render-page'; -export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { +export interface RenderWorkerData { document: string; inlineCriticalCss?: boolean; assetFiles: Record; + cssOutputFilesForWorker: Record; + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + jsOutputFilesForWorker: Record; + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + workspaceRoot: string; } export interface RenderOptions { @@ -25,7 +35,11 @@ export interface RenderOptions { /** * This is passed as workerData when setting up the worker via the `piscina` package. */ -const { outputFiles, document, inlineCriticalCss } = workerData as RenderWorkerData; +const { + cssOutputFilesForWorker: outputFiles, + document, + inlineCriticalCss, +} = workerData as RenderWorkerData; /** Renders an application based on a provided options. */ function render(options: RenderOptions): Promise { diff --git a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts index 966032c4d96d..38e8352fd69f 100644 --- a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -7,14 +7,23 @@ */ import { workerData } from 'node:worker_threads'; -import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; import { loadEsmModuleFromMemory } from './load-esm-from-memory'; -export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { +export interface RoutesExtractorWorkerData { document: string; verbose: boolean; assetFiles: Record; + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + jsOutputFilesForWorker: Record; + /** + * Only defined when Node.js version is < 22.2. + * TODO: Remove when Node.js Removes < 22.2 are no longer supported. + */ + workspaceRoot: string; } export interface RoutersExtractorWorkerResult {