Skip to content

Commit 6c170b2

Browse files
committed
perf(@angular/build): batch prerender routes and share in-memory server bundles
Back in-memory server bundles with SharedArrayBuffer to eliminate V8 structuredClone memory duplication across worker threads and loader hooks. Batch routes dynamically during prerendering to amortize IPC messaging and event loop scheduling overhead.
1 parent 204eafc commit 6c170b2

6 files changed

Lines changed: 588 additions & 68 deletions

File tree

packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
1616
* @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks.
1717
*/
1818
const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=true;');
19+
const UTF8_DECODER = new TextDecoder();
1920

2021
/**
2122
* 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
2526
const MEMORY_URL_SCHEME = 'memory://';
2627

2728
export interface ESMInMemoryFileLoaderWorkerData {
28-
outputFiles: Record<string, string>;
29+
outputFiles: Record<string, string | Uint8Array>;
2930
workspaceRoot: string;
3031
}
3132

3233
let memoryVirtualRootUrl: string;
33-
let outputFiles: Record<string, string>;
34+
let outputFiles: Record<string, string | Uint8Array>;
3435

3536
export function initialize(data: ESMInMemoryFileLoaderWorkerData) {
3637
// This path does not actually exist but is used to overlay the in memory files with the
@@ -84,7 +85,7 @@ export function resolve(
8485
} catch {}
8586

8687
if (
87-
specifierUrl?.pathname &&
88+
specifierUrl?.href.startsWith(memoryVirtualRootUrl) &&
8889
Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length))
8990
) {
9091
return {
@@ -114,12 +115,14 @@ export async function load(url: string, context: { format?: string | null }, nex
114115

115116
// Load the file from memory if the URL is based in the virtual root
116117
if (url.startsWith(memoryVirtualRootUrl)) {
117-
const source = outputFiles[url.slice(memoryVirtualRootUrl.length)];
118-
assert(source !== undefined, 'Resolved in-memory ESM file should always exist: ' + url);
118+
const rawSource = outputFiles[url.slice(memoryVirtualRootUrl.length)];
119+
assert(rawSource !== undefined, 'Resolved in-memory ESM file should always exist: ' + url);
120+
121+
const source = typeof rawSource === 'string' ? rawSource : UTF8_DECODER.decode(rawSource);
119122

120123
// In-memory files have already been transformer during bundling and can be returned directly
121124
return {
122-
format,
125+
format: format ?? 'module',
123126
shortCircuit: true,
124127
source,
125128
};
@@ -128,14 +131,15 @@ export async function load(url: string, context: { format?: string | null }, nex
128131
// Only module files potentially require transformation. Angular libraries that would
129132
// need linking are ESM only.
130133
if (format === 'module' && isFileProtocol(url)) {
131-
const filePath = fileURLToPath(url);
132-
let source = await readFile(filePath);
133-
134-
if (filePath.includes('@angular/')) {
135-
// Prepend 'var ngServerMode=true;' to the source.
136-
source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, source]);
134+
// Check url instead of filePath so the check is robust across Windows and POSIX path separators.
135+
if (!url.includes('/@angular/')) {
136+
return nextLoad(url, context);
137137
}
138138

139+
const filePath = fileURLToPath(url);
140+
const fileBytes = await readFile(filePath);
141+
const source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, fileBytes]);
142+
139143
return {
140144
format,
141145
shortCircuit: true,
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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.dev/license
7+
*/
8+
9+
import { initialize, load, resolve } from './loader-hooks';
10+
import { createSharedServerFiles } from './utils';
11+
12+
describe('esm-in-memory-loader loader-hooks', () => {
13+
const workspaceRoot = '/mock/workspace/root';
14+
const sharedFiles = createSharedServerFiles({
15+
'main.server.mjs': 'export const main = true;',
16+
'chunk-abc.mjs': 'export const chunk = "abc";',
17+
'nested/chunk-sub.mjs': 'export const sub = "sub";',
18+
'utf8.mjs': 'export const text = "🔥 UTF-8 🚀";',
19+
'empty.mjs': '',
20+
});
21+
22+
beforeEach(() => {
23+
initialize({
24+
workspaceRoot,
25+
outputFiles: sharedFiles,
26+
});
27+
});
28+
29+
describe('resolve', () => {
30+
it('should resolve memory:// URLs into virtual filesystem URLs', () => {
31+
const nextResolve = jasmine.createSpy('nextResolve');
32+
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
33+
const result = resolve(memoryUrl, { parentURL: undefined }, nextResolve);
34+
35+
expect(nextResolve).not.toHaveBeenCalled();
36+
expect(result.format).toBe('module');
37+
expect(result.shortCircuit).toBeTrue();
38+
expect(result.url).toContain('/.angular/prerender-root/');
39+
expect(result.url).toContain('/main.server.mjs');
40+
});
41+
42+
it('should fail when memory:// URL is malformed', () => {
43+
const nextResolve = jasmine.createSpy('nextResolve');
44+
expect(() => {
45+
resolve('memory://::invalid', { parentURL: undefined }, nextResolve);
46+
}).toThrowMatching((err: Error) =>
47+
err.message.includes('External code attempted to use malformed memory scheme'),
48+
);
49+
expect(nextResolve).not.toHaveBeenCalled();
50+
});
51+
52+
it('should resolve relative specifiers within in-memory files', () => {
53+
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
54+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
55+
const parentURL = rootResolve.url;
56+
57+
const nextResolve = jasmine.createSpy('nextResolve');
58+
const result = resolve('./chunk-abc.mjs', { parentURL }, nextResolve);
59+
60+
expect(nextResolve).not.toHaveBeenCalled();
61+
expect(result.format).toBe('module');
62+
expect(result.shortCircuit).toBeTrue();
63+
expect(result.url).toContain('/chunk-abc.mjs');
64+
});
65+
66+
it('should resolve relative specifiers navigating parent directories within in-memory files', () => {
67+
const memoryUrl = new URL('./nested/chunk-sub.mjs', 'memory://').href;
68+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
69+
const parentURL = rootResolve.url;
70+
71+
const nextResolve = jasmine.createSpy('nextResolve');
72+
const result = resolve('../chunk-abc.mjs', { parentURL }, nextResolve);
73+
74+
expect(nextResolve).not.toHaveBeenCalled();
75+
expect(result.format).toBe('module');
76+
expect(result.shortCircuit).toBeTrue();
77+
expect(result.url).toContain('/chunk-abc.mjs');
78+
});
79+
80+
it('should fail when relative specifier from in-memory file does not exist', () => {
81+
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
82+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
83+
const parentURL = rootResolve.url;
84+
85+
const nextResolve = jasmine.createSpy('nextResolve');
86+
expect(() => {
87+
resolve('./non-existent.mjs', { parentURL }, nextResolve);
88+
}).toThrowMatching((err: Error) =>
89+
err.message.includes('In-memory ESM relative file should always exist'),
90+
);
91+
expect(nextResolve).not.toHaveBeenCalled();
92+
});
93+
94+
it('should rewrite parentURL to index.js in virtual root for bare package specifiers', () => {
95+
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
96+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
97+
const parentURL = rootResolve.url;
98+
99+
const nextResolve = jasmine
100+
.createSpy('nextResolve')
101+
.and.returnValue({ url: 'file:///some/node_modules/@angular/core/index.js' });
102+
const result = resolve('@angular/core', { parentURL }, nextResolve);
103+
104+
expect(nextResolve).toHaveBeenCalledWith(
105+
'@angular/core',
106+
jasmine.objectContaining({
107+
parentURL: jasmine.stringMatching(/\/\.angular\/prerender-root\/[^/]+\/index\.js$/),
108+
}),
109+
);
110+
expect(result.url).toBe('file:///some/node_modules/@angular/core/index.js');
111+
});
112+
113+
it('should delegate to nextResolve for external non-memory URLs', () => {
114+
const nextResolve = jasmine
115+
.createSpy('nextResolve')
116+
.and.returnValue({ url: 'file:///some/ext/pkg' });
117+
const result = resolve('some-pkg', { parentURL: 'file:///some/ext/file.js' }, nextResolve);
118+
119+
expect(nextResolve).toHaveBeenCalledWith('some-pkg', {
120+
parentURL: 'file:///some/ext/file.js',
121+
});
122+
expect(result.url).toBe('file:///some/ext/pkg');
123+
});
124+
});
125+
126+
describe('load', () => {
127+
it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', async () => {
128+
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
129+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
130+
const nextLoad = jasmine.createSpy('nextLoad');
131+
132+
const result = await load(rootResolve.url, { format: 'module' }, nextLoad);
133+
134+
expect(nextLoad).not.toHaveBeenCalled();
135+
expect(result.format).toBe('module');
136+
expect(result.shortCircuit).toBeTrue();
137+
expect(result.source).toBe('export const main = true;');
138+
});
139+
140+
it('should load in-memory file with multi-byte UTF-8 characters', async () => {
141+
const memoryUrl = new URL('./utf8.mjs', 'memory://').href;
142+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
143+
const nextLoad = jasmine.createSpy('nextLoad');
144+
145+
const result = await load(rootResolve.url, { format: 'module' }, nextLoad);
146+
147+
expect(nextLoad).not.toHaveBeenCalled();
148+
expect(result.format).toBe('module');
149+
expect(result.shortCircuit).toBeTrue();
150+
expect(result.source).toBe('export const text = "🔥 UTF-8 🚀";');
151+
});
152+
153+
it('should load in-memory file with empty content', async () => {
154+
const memoryUrl = new URL('./empty.mjs', 'memory://').href;
155+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
156+
const nextLoad = jasmine.createSpy('nextLoad');
157+
158+
const result = await load(rootResolve.url, { format: 'module' }, nextLoad);
159+
160+
expect(nextLoad).not.toHaveBeenCalled();
161+
expect(result.format).toBe('module');
162+
expect(result.shortCircuit).toBeTrue();
163+
expect(result.source).toBe('');
164+
});
165+
166+
it('should load in-memory file source with non-zero byteOffset in Uint8Array', async () => {
167+
const target = 'export const sliced = 42;';
168+
const fullBuffer = Buffer.from(`__PADDING__${target}__MORE__`);
169+
const offset = Buffer.byteLength('__PADDING__', 'utf-8');
170+
const length = Buffer.byteLength(target, 'utf-8');
171+
const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length);
172+
173+
initialize({
174+
workspaceRoot,
175+
outputFiles: {
176+
'sliced.mjs': subView,
177+
},
178+
});
179+
180+
const memoryUrl = new URL('./sliced.mjs', 'memory://').href;
181+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
182+
const nextLoad = jasmine.createSpy('nextLoad');
183+
184+
const result = await load(rootResolve.url, { format: 'module' }, nextLoad);
185+
186+
expect(nextLoad).not.toHaveBeenCalled();
187+
expect(result.format).toBe('module');
188+
expect(result.shortCircuit).toBeTrue();
189+
expect(result.source).toBe(target);
190+
});
191+
192+
it('should load in-memory file source when outputFiles contain string values', async () => {
193+
initialize({
194+
workspaceRoot,
195+
outputFiles: {
196+
'string-file.mjs': 'export const fromString = 1;',
197+
},
198+
});
199+
200+
const memoryUrl = new URL('./string-file.mjs', 'memory://').href;
201+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
202+
const nextLoad = jasmine.createSpy('nextLoad');
203+
204+
const result = await load(rootResolve.url, { format: 'module' }, nextLoad);
205+
206+
expect(nextLoad).not.toHaveBeenCalled();
207+
expect(result.format).toBe('module');
208+
expect(result.shortCircuit).toBeTrue();
209+
expect(result.source).toBe('export const fromString = 1;');
210+
});
211+
212+
it('should reject when in-memory file does not exist in outputFiles', async () => {
213+
const memoryUrl = new URL('./main.server.mjs', 'memory://').href;
214+
const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {});
215+
const nonExistentVirtualUrl = rootResolve.url.replace('main.server.mjs', 'missing.mjs');
216+
const nextLoad = jasmine.createSpy('nextLoad');
217+
218+
await expectAsync(
219+
load(nonExistentVirtualUrl, { format: 'module' }, nextLoad),
220+
).toBeRejectedWithError(/Resolved in-memory ESM file should always exist/);
221+
expect(nextLoad).not.toHaveBeenCalled();
222+
});
223+
224+
it('should delegate to nextLoad for non-angular file URLs', async () => {
225+
const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'module' });
226+
const result = await load(
227+
'file:///workspace/node_modules/rxjs/index.js',
228+
{ format: 'module' },
229+
nextLoad,
230+
);
231+
232+
expect(nextLoad).toHaveBeenCalledWith('file:///workspace/node_modules/rxjs/index.js', {
233+
format: 'module',
234+
});
235+
expect(result.format).toBe('module');
236+
});
237+
238+
it('should delegate to nextLoad for non-memory non-file URLs', async () => {
239+
const nextLoad = jasmine.createSpy('nextLoad').and.resolveTo({ format: 'builtin' });
240+
const result = await load('node:fs', { format: 'builtin' }, nextLoad);
241+
242+
expect(nextLoad).toHaveBeenCalledWith('node:fs');
243+
expect(result.format).toBe('builtin');
244+
});
245+
});
246+
});

packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,41 @@ import { pathToFileURL } from 'node:url';
1111

1212
export const IMPORT_EXEC_ARGV =
1313
'--import=' + pathToFileURL(join(__dirname, 'register-hooks.js')).href;
14+
15+
/**
16+
* Creates a shared zero-copy `Uint8Array` backed by a `SharedArrayBuffer` for the given file content.
17+
*/
18+
export function createSharedFile(content: string | Uint8Array): Uint8Array {
19+
if (typeof content === 'string') {
20+
const byteLength = Buffer.byteLength(content, 'utf-8');
21+
const sab = new SharedArrayBuffer(byteLength);
22+
Buffer.from(sab).write(content, 'utf-8');
23+
24+
return new Uint8Array(sab);
25+
}
26+
27+
if (content.buffer instanceof SharedArrayBuffer) {
28+
return content;
29+
}
30+
31+
const sab = new SharedArrayBuffer(content.byteLength);
32+
const view = new Uint8Array(sab);
33+
view.set(content);
34+
35+
return view;
36+
}
37+
38+
/**
39+
* Creates shared zero-copy `Uint8Array` views backed by `SharedArrayBuffer` for all output files.
40+
*/
41+
export function createSharedServerFiles(
42+
outputFiles: Record<string, string | Uint8Array>,
43+
): Record<string, Uint8Array> {
44+
const sharedFiles: Record<string, Uint8Array> = {};
45+
46+
for (const [key, value] of Object.entries(outputFiles)) {
47+
sharedFiles[key] = createSharedFile(value);
48+
}
49+
50+
return sharedFiles;
51+
}

0 commit comments

Comments
 (0)