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 1d0d9df32d30..6f21bfbc739b 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 @@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; * @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks. */ const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=true;'); +const UTF8_DECODER = new TextDecoder(); /** * Node.js ESM loader to redirect imports to in memory files. @@ -25,12 +26,12 @@ const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=tru const MEMORY_URL_SCHEME = 'memory://'; export interface ESMInMemoryFileLoaderWorkerData { - outputFiles: Record; + outputFiles: Record; workspaceRoot: string; } let memoryVirtualRootUrl: string; -let outputFiles: Record; +let outputFiles: Record; export function initialize(data: ESMInMemoryFileLoaderWorkerData) { // This path does not actually exist but is used to overlay the in memory files with the @@ -84,7 +85,7 @@ export function resolve( } catch {} if ( - specifierUrl?.pathname && + specifierUrl?.href.startsWith(memoryVirtualRootUrl) && Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length)) ) { return { @@ -114,12 +115,14 @@ export async function load(url: string, context: { format?: string | null }, nex // Load the file from memory if the URL is based in the virtual root if (url.startsWith(memoryVirtualRootUrl)) { - const source = outputFiles[url.slice(memoryVirtualRootUrl.length)]; - assert(source !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + const rawSource = outputFiles[url.slice(memoryVirtualRootUrl.length)]; + assert(rawSource !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + + const source = typeof rawSource === 'string' ? rawSource : UTF8_DECODER.decode(rawSource); // In-memory files have already been transformer during bundling and can be returned directly return { - format, + format: format ?? 'module', shortCircuit: true, source, }; @@ -128,14 +131,15 @@ export async function load(url: string, context: { format?: string | null }, nex // Only module files potentially require transformation. Angular libraries that would // need linking are ESM only. if (format === 'module' && isFileProtocol(url)) { - const filePath = fileURLToPath(url); - let source = await readFile(filePath); - - if (filePath.includes('@angular/')) { - // Prepend 'var ngServerMode=true;' to the source. - source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, source]); + // Check url instead of filePath so the check is robust across Windows and POSIX path separators. + if (!url.includes('/@angular/')) { + return nextLoad(url, context); } + const filePath = fileURLToPath(url); + const fileBytes = await readFile(filePath); + const source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, fileBytes]); + return { format, shortCircuit: true, diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts new file mode 100644 index 000000000000..2b7622145378 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts @@ -0,0 +1,246 @@ +/** + * @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.dev/license + */ + +import { initialize, load, resolve } from './loader-hooks'; +import { createSharedServerFiles } from './utils'; + +describe('esm-in-memory-loader loader-hooks', () => { + const workspaceRoot = '/mock/workspace/root'; + const sharedFiles = createSharedServerFiles({ + 'main.server.mjs': 'export const main = true;', + 'chunk-abc.mjs': 'export const chunk = "abc";', + 'nested/chunk-sub.mjs': 'export const sub = "sub";', + 'utf8.mjs': 'export const text = "🔥 UTF-8 🚀";', + 'empty.mjs': '', + }); + + beforeEach(() => { + initialize({ + workspaceRoot, + outputFiles: sharedFiles, + }); + }); + + describe('resolve', () => { + it('should resolve memory:// URLs into virtual filesystem URLs', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const result = resolve(memoryUrl, { parentURL: undefined }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/.angular/prerender-root/'); + expect(result.url).toContain('/main.server.mjs'); + }); + + it('should fail when memory:// URL is malformed', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('memory://::invalid', { parentURL: undefined }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('External code attempted to use malformed memory scheme'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should resolve relative specifiers within in-memory files', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('./chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should resolve relative specifiers navigating parent directories within in-memory files', () => { + const memoryUrl = new URL('./nested/chunk-sub.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('../chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should fail when relative specifier from in-memory file does not exist', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('./non-existent.mjs', { parentURL }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('In-memory ESM relative file should always exist'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should rewrite parentURL to index.js in virtual root for bare package specifiers', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/node_modules/@angular/core/index.js' }); + const result = resolve('@angular/core', { parentURL }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith( + '@angular/core', + jasmine.objectContaining({ + parentURL: jasmine.stringMatching(/\/\.angular\/prerender-root\/[^/]+\/index\.js$/), + }), + ); + expect(result.url).toBe('file:///some/node_modules/@angular/core/index.js'); + }); + + it('should delegate to nextResolve for external non-memory URLs', () => { + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/ext/pkg' }); + const result = resolve('some-pkg', { parentURL: 'file:///some/ext/file.js' }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith('some-pkg', { + parentURL: 'file:///some/ext/file.js', + }); + expect(result.url).toBe('file:///some/ext/pkg'); + }); + }); + + describe('load', () => { + it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', async () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const main = true;'); + }); + + it('should load in-memory file with multi-byte UTF-8 characters', async () => { + const memoryUrl = new URL('./utf8.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const text = "🔥 UTF-8 🚀";'); + }); + + it('should load in-memory file with empty content', async () => { + const memoryUrl = new URL('./empty.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(''); + }); + + it('should load in-memory file source with non-zero byteOffset in Uint8Array', async () => { + const target = 'export const sliced = 42;'; + const fullBuffer = Buffer.from(`__PADDING__${target}__MORE__`); + const offset = Buffer.byteLength('__PADDING__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + initialize({ + workspaceRoot, + outputFiles: { + 'sliced.mjs': subView, + }, + }); + + const memoryUrl = new URL('./sliced.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(target); + }); + + it('should load in-memory file source when outputFiles contain string values', async () => { + initialize({ + workspaceRoot, + outputFiles: { + 'string-file.mjs': 'export const fromString = 1;', + }, + }); + + const memoryUrl = new URL('./string-file.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = await load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const fromString = 1;'); + }); + + it('should reject when in-memory file does not exist in outputFiles', async () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nonExistentVirtualUrl = rootResolve.url.replace('main.server.mjs', 'missing.mjs'); + const nextLoad = jasmine.createSpy('nextLoad'); + + await expectAsync( + load(nonExistentVirtualUrl, { format: 'module' }, nextLoad), + ).toBeRejectedWithError(/Resolved in-memory ESM file should always exist/); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + it('should delegate to nextLoad for non-angular file URLs', async () => { + const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'module' }); + const result = await load( + 'file:///workspace/node_modules/rxjs/index.js', + { format: 'module' }, + nextLoad, + ); + + expect(nextLoad).toHaveBeenCalledWith('file:///workspace/node_modules/rxjs/index.js', { + format: 'module', + }); + expect(result.format).toBe('module'); + }); + + it('should delegate to nextLoad for non-memory non-file URLs', async () => { + const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'builtin' }); + const result = await load('node:fs', { format: 'builtin' }, nextLoad); + + expect(nextLoad).toHaveBeenCalledWith('node:fs'); + expect(result.format).toBe('builtin'); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts index 3af354f6ba0f..7eb2bd1f5c49 100644 --- a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts @@ -11,3 +11,41 @@ import { pathToFileURL } from 'node:url'; export const IMPORT_EXEC_ARGV = '--import=' + pathToFileURL(join(__dirname, 'register-hooks.js')).href; + +/** + * Creates a shared zero-copy `Uint8Array` backed by a `SharedArrayBuffer` for the given file content. + */ +export function createSharedFile(content: string | Uint8Array): Uint8Array { + if (typeof content === 'string') { + const byteLength = Buffer.byteLength(content, 'utf-8'); + const sab = new SharedArrayBuffer(byteLength); + Buffer.from(sab).write(content, 'utf-8'); + + return new Uint8Array(sab); + } + + if (content.buffer instanceof SharedArrayBuffer) { + return content; + } + + const sab = new SharedArrayBuffer(content.byteLength); + const view = new Uint8Array(sab); + view.set(content); + + return view; +} + +/** + * Creates shared zero-copy `Uint8Array` views backed by `SharedArrayBuffer` for all output files. + */ +export function createSharedServerFiles( + outputFiles: Record, +): Record { + const sharedFiles: Record = {}; + + for (const [key, value] of Object.entries(outputFiles)) { + sharedFiles[key] = createSharedFile(value); + } + + return sharedFiles; +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts new file mode 100644 index 000000000000..b7bb2844b66e --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts @@ -0,0 +1,147 @@ +/** + * @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.dev/license + */ + +import { createSharedFile, createSharedServerFiles } from './utils'; + +describe('server-rendering shared file utilities', () => { + describe('createSharedFile', () => { + it('should create a Uint8Array backed by SharedArrayBuffer from string', () => { + const text = 'console.log("hello world");'; + const shared = createSharedFile(text); + + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(Buffer.byteLength(text, 'utf-8')); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should correctly handle multi-byte UTF-8 characters', () => { + const text = 'export const greeting = "🚀 Привет, мир! 🌍";'; + const shared = createSharedFile(text); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should handle empty string', () => { + const shared = createSharedFile(''); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(''); + }); + + it('should handle empty Uint8Array', () => { + const shared = createSharedFile(new Uint8Array(0)); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + }); + + it('should return the same Uint8Array if already backed by SharedArrayBuffer', () => { + const text = 'export default 42;'; + const shared1 = createSharedFile(text); + const shared2 = createSharedFile(shared1); + + expect(shared2).toBe(shared1); + expect(shared2.buffer).toBe(shared1.buffer); + }); + + it('should return existing Uint8Array view if already backed by SharedArrayBuffer with non-zero byteOffset', () => { + const text = '0123456789abcdef'; + const shared1 = createSharedFile(text); + const subView = new Uint8Array(shared1.buffer, 4, 8); + const shared2 = createSharedFile(subView); + + expect(shared2).toBe(subView); + expect(shared2.byteOffset).toBe(4); + expect(shared2.byteLength).toBe(8); + + const decoded = Buffer.from(shared2.buffer, shared2.byteOffset, shared2.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('456789ab'); + }); + + it('should convert standard Uint8Array to SharedArrayBuffer backed Uint8Array', () => { + const regularBuffer = Buffer.from('export const test = true;'); + expect(regularBuffer.buffer instanceof SharedArrayBuffer).toBeFalse(); + + const shared = createSharedFile(regularBuffer); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('export const test = true;'); + }); + + it('should handle Uint8Array slice with non-zero byteOffset from standard ArrayBuffer', () => { + const target = 'export const data = 123;'; + const fullBuffer = Buffer.from(`__PREFIX__${target}__SUFFIX__`); + const offset = Buffer.byteLength('__PREFIX__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + const shared = createSharedFile(subView); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(length); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(target); + }); + }); + + describe('createSharedServerFiles', () => { + it('should convert all entries in a record to SharedArrayBuffer backed Uint8Arrays', () => { + const files: Record = { + 'main.server.mjs': 'export default function() {}', + 'chunk-1.mjs': 'export const x = 1;', + 'render-utils.mjs': Buffer.from('export const helper = () => true;'), + }; + + const sharedFiles = createSharedServerFiles(files); + + expect(Object.keys(sharedFiles)).toEqual([ + 'main.server.mjs', + 'chunk-1.mjs', + 'render-utils.mjs', + ]); + + for (const [key, shared] of Object.entries(sharedFiles)) { + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const expectedText = + typeof files[key] === 'string' ? files[key] : (files[key] as Buffer).toString('utf-8'); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(expectedText); + } + }); + + it('should handle empty file map', () => { + const sharedFiles = createSharedServerFiles({}); + expect(Object.keys(sharedFiles).length).toBe(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts index b4f81f03a800..37e29f02385b 100644 --- a/packages/angular/build/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -16,7 +16,11 @@ import { assertIsError } from '../error'; import { toPosixPath } from '../path'; import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../url'; import { WorkerPool } from '../worker-pool'; -import { IMPORT_EXEC_ARGV } from './esm-in-memory-loader/utils'; +import { + IMPORT_EXEC_ARGV, + createSharedFile, + createSharedServerFiles, +} from './esm-in-memory-loader/utils'; import { SERVER_APP_MANIFEST_FILENAME } from './manifest'; import { RouteRenderMode, @@ -25,7 +29,7 @@ import { SerializableRouteTreeNode, WritableSerializableRouteTreeNode, } from './models'; -import type { RenderWorkerData } from './render-worker'; +import type { RenderResult, RenderWorkerData } from './render-worker'; import { generateRedirectStaticPage } from './utils'; type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions']; @@ -63,7 +67,7 @@ export async function prerenderPages( errors: string[]; serializableRouteTreeNode: SerializableRouteTreeNode; }> { - const outputFilesForWorker: Record = {}; + const rawOutputFiles: Record = {}; const serverBundlesSourceMaps = new Map(); const warnings: string[] = []; const errors: string[] = []; @@ -77,23 +81,25 @@ export async function prerenderPages( if (extname(path) === '.map') { serverBundlesSourceMaps.set(path.slice(0, -4), text); } else { - outputFilesForWorker[path] = text; + rawOutputFiles[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 = rawOutputFiles[filePath]; if (jsContent) { - outputFilesForWorker[filePath] = + rawOutputFiles[filePath] = jsContent + - `\n//# sourceMappingURL=` + + '\n//# sourceMappingURL=' + `data:application/json;base64,${Buffer.from(map).toString('base64')}`; } } serverBundlesSourceMaps.clear(); + const outputFilesForWorker = createSharedServerFiles(rawOutputFiles); + const assetsReversed: Record = {}; for (const { source, destination } of assets) { // Assets are not stored with baseHref when using i18n, @@ -169,9 +175,13 @@ export async function prerenderPages( // We could re-generate it from the start, but that would require a number of options to be passed down. const manifest = outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME]; if (manifest) { - outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME] = manifest.replace( - 'routes: undefined,', - `routes: ${JSON.stringify(serializableRouteTreeNodeForPrerender, undefined, 2)},`, + const manifestText = new TextDecoder().decode(manifest); + + outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME] = createSharedFile( + manifestText.replace( + 'routes: undefined,', + `routes: ${JSON.stringify(serializableRouteTreeNodeForPrerender, undefined, 2)},`, + ), ); } @@ -204,7 +214,7 @@ async function renderPages( serializableRouteTreeNode: SerializableRouteTreeNode, maxThreads: number, workspaceRoot: string, - outputFilesForWorker: Record, + outputFilesForWorker: Record, assetFilesForWorker: Record, outputMode: OutputMode | undefined, appShellRoute: string | undefined, @@ -214,15 +224,59 @@ async function renderPages( }> { const output: PrerenderOutput = {}; const errors: string[] = []; - const workerExecArgv = [IMPORT_EXEC_ARGV]; + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + const appShellRouteWithoutBaseHref = appShellRoute + ? addTrailingSlash(appShellRoute).startsWith(baseHrefPathnameWithLeadingSlash) + ? addLeadingSlash(appShellRoute.slice(baseHrefPathnameWithLeadingSlash.length)) + : addLeadingSlash(appShellRoute) + : undefined; + + const routesToRender: { route: string; outPath: string; isAppShell: boolean }[] = []; + + for (const { route, redirectTo } of serializableRouteTreeNode) { + // Remove the base href from the file output path. + const routeWithoutBaseHref = addTrailingSlash(route).startsWith( + baseHrefPathnameWithLeadingSlash, + ) + ? addLeadingSlash(route.slice(baseHrefPathnameWithLeadingSlash.length)) + : route; + + const outPath = stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); + + if (typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + + continue; + } + + routesToRender.push({ + route, + outPath, + isAppShell: appShellRouteWithoutBaseHref === routeWithoutBaseHref, + }); + } + + if (routesToRender.length === 0) { + return { + errors, + output, + }; + } + + // Batch routes to reduce IPC overhead while ensuring enough batches exist for load balancing across worker threads. + const batchSize = Math.max(1, Math.min(50, Math.ceil(routesToRender.length / (maxThreads * 4)))); + const numBatches = Math.ceil(routesToRender.length / batchSize); + const effectiveMaxThreads = Math.min(numBatches, maxThreads); + + const workerExecArgv = [IMPORT_EXEC_ARGV]; if (sourcemap) { workerExecArgv.push('--enable-source-maps'); } const renderWorker = new WorkerPool({ filename: require.resolve('./render-worker'), - maxThreads: Math.min(serializableRouteTreeNode.length, maxThreads), + maxThreads: effectiveMaxThreads, workerData: { workspaceRoot, outputFiles: outputFilesForWorker, @@ -238,45 +292,46 @@ async function renderPages( }); try { - const renderingPromises: Promise[] = []; - const appShellRouteWithLeadingSlash = appShellRoute && addLeadingSlash(appShellRoute); - const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; - - for (const { route, redirectTo } of serializableRouteTreeNode) { - // Remove the base href from the file output path. - const routeWithoutBaseHref = addTrailingSlash(route).startsWith( - baseHrefPathnameWithLeadingSlash, - ) - ? addLeadingSlash(route.slice(baseHrefPathnameWithLeadingSlash.length)) - : route; - - const outPath = stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); - - if (typeof redirectTo === 'string') { - output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + const routeOutPathMap = new Map(); + for (const item of routesToRender) { + routeOutPathMap.set(item.route, item); + } - continue; - } + const renderingPromises: Promise[] = []; - const render: Promise = renderWorker.run({ url: route }); - const renderResult: Promise = render - .then((content) => { - if (content !== null) { - output[outPath] = { - content, - appShellRoute: appShellRouteWithLeadingSlash === routeWithoutBaseHref, - }; + for (let i = 0; i < routesToRender.length; i += batchSize) { + const batch = routesToRender.slice(i, i + batchSize); + const urls = batch.map((item) => item.route); + const renderBatchPromise: Promise = renderWorker.run(urls); + const batchResultPromise = renderBatchPromise + .then((results) => { + for (const { url, content, error } of results) { + if (error) { + errors.push(`An error occurred while prerendering route '${url}'.\n\n${error}`); + continue; + } + + if (content !== null) { + const routeInfo = routeOutPathMap.get(url); + if (routeInfo) { + output[routeInfo.outPath] = { + content, + appShellRoute: routeInfo.isAppShell, + }; + } + } } }) .catch((err) => { assertIsError(err); - errors.push( - `An error occurred while prerendering route '${route}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`, - ); - void renderWorker.destroy(); + for (const url of urls) { + errors.push( + `An error occurred while prerendering route '${url}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ); + } }); - renderingPromises.push(renderResult); + renderingPromises.push(batchResultPromise); } await Promise.all(renderingPromises); @@ -293,7 +348,7 @@ async function renderPages( async function getAllRoutes( workspaceRoot: string, baseHref: string, - outputFilesForWorker: Record, + outputFilesForWorker: Record, assetFilesForWorker: Record, appShellOptions: AppShellOptions | undefined, prerenderOptions: PrerenderOptions | undefined, 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 7ded0550b826..b8f01c440c4b 100644 --- a/packages/angular/build/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -8,6 +8,7 @@ import { workerData } from 'node:worker_threads'; import type { OutputMode } from '../../builders/application/schema'; +import { assertIsError } from '../error'; import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; import { DEFAULT_URL, launchServer } from './launch-server'; @@ -20,10 +21,14 @@ export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { hasSsrEntry: boolean; } -export interface RenderOptions { +export interface RenderResultItem { url: string; + content: string | null; + error?: string; } +export type RenderResult = RenderResultItem[]; + /** * This is passed as workerData when setting up the worker via the `piscina` package. */ @@ -35,16 +40,12 @@ const { outputMode, hasSsrEntry } = workerData as { let serverURL = DEFAULT_URL; /** - * Renders each route in routes and writes them to //index.html. + * Renders a single route URL. */ -async function renderPage({ url }: RenderOptions): Promise { - const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = - await loadEsmModuleFromMemory('./main.server.mjs'); - - const angularServerApp = getOrCreateAngularServerApp({ - allowStaticRouteRender: true, - }); - +async function renderPage( + url: string, + angularServerApp: { handle: (request: Request) => Promise }, +): Promise { const response = await angularServerApp.handle( new Request(new URL(url, serverURL), { signal: AbortSignal.timeout(30_000) }), ); @@ -58,6 +59,35 @@ async function renderPage({ url }: RenderOptions): Promise { return location ? generateRedirectStaticPage(location) : response.text(); } +/** + * Renders routes in batch or individual URL. + */ +async function renderPages(urls: string[]): Promise { + const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const angularServerApp = getOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + const results: RenderResult = []; + for (const currentUrl of urls) { + try { + const content = await renderPage(currentUrl, angularServerApp); + results.push({ url: currentUrl, content }); + } catch (err) { + assertIsError(err); + results.push({ + url: currentUrl, + content: null, + error: err.stack ?? err.message ?? err.code ?? `${err}`, + }); + } + } + + return results; +} + async function initialize() { // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, // which must be processed by the runtime linker, even if they are not used. @@ -69,7 +99,7 @@ async function initialize() { patchFetchToLoadInMemoryAssets(serverURL); - return renderPage; + return renderPages; } export default initialize();