Skip to content

Commit b182be8

Browse files
clydinalan-agius4
authored andcommitted
fix(@angular-devkit/build-angular): avoid in-memory prerendering ESM loader errors
The in-memory ESM loader hooks have been adjusted to avoid potential errors when resolving and loading the bundled server code during prerendering. These errors could result in hard to diagnose build failures. E2E testing via the deprecated protractor builder, would silently exit, for instance. To ensure on disk files including node modules are resolved from the workspace root, a virtual file root is used for all in memory files. 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` from the Node.js `module` builtin is one example of this usage. (cherry picked from commit f06a760)
1 parent 92fc730 commit b182be8

File tree

3 files changed

+91
-39
lines changed

3 files changed

+91
-39
lines changed

packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { join, relative } from 'node:path';
9+
import assert from 'node:assert';
10+
import { randomUUID } from 'node:crypto';
11+
import { join } from 'node:path';
1012
import { pathToFileURL } from 'node:url';
1113
import { fileURLToPath } from 'url';
1214
import { JavaScriptTransformer } from '../../../tools/esbuild/javascript-transformer';
@@ -17,14 +19,14 @@ import { callInitializeIfNeeded } from './node-18-utils';
1719
* @see: https://nodejs.org/api/esm.html#loaders for more information about loaders.
1820
*/
1921

22+
const MEMORY_URL_SCHEME = 'memory://';
23+
2024
export interface ESMInMemoryFileLoaderWorkerData {
2125
outputFiles: Record<string, string>;
2226
workspaceRoot: string;
2327
}
2428

25-
const TRANSFORMED_FILES: Record<string, string> = {};
26-
const CHUNKS_REGEXP = /file:\/\/\/((?:main|render-utils)\.server|chunk-\w+)\.mjs/;
27-
let workspaceRootFile: string;
29+
let memoryVirtualRootUrl: string;
2830
let outputFiles: Record<string, string>;
2931

3032
const javascriptTransformer = new JavaScriptTransformer(
@@ -38,7 +40,14 @@ const javascriptTransformer = new JavaScriptTransformer(
3840
callInitializeIfNeeded(initialize);
3941

4042
export function initialize(data: ESMInMemoryFileLoaderWorkerData) {
41-
workspaceRootFile = pathToFileURL(join(data.workspaceRoot, 'index.mjs')).href;
43+
// This path does not actually exist but is used to overlay the in memory files with the
44+
// actual filesystem for resolution purposes.
45+
// A custom URL schema (such as `memory://`) cannot be used for the resolve output because
46+
// the in-memory files may use `import.meta.url` in ways that assume a file URL.
47+
// `createRequire` is one example of this usage.
48+
memoryVirtualRootUrl = pathToFileURL(
49+
join(data.workspaceRoot, `.angular/prerender-root/${randomUUID()}/`),
50+
).href;
4251
outputFiles = data.outputFiles;
4352
}
4453

@@ -47,49 +56,93 @@ export function resolve(
4756
context: { parentURL: undefined | string },
4857
nextResolve: Function,
4958
) {
50-
if (!isFileProtocol(specifier)) {
51-
const normalizedSpecifier = specifier.replace(/^\.\//, '');
52-
if (normalizedSpecifier in outputFiles) {
59+
// In-memory files loaded from external code will contain a memory scheme
60+
if (specifier.startsWith(MEMORY_URL_SCHEME)) {
61+
let memoryUrl;
62+
try {
63+
memoryUrl = new URL(specifier);
64+
} catch {
65+
assert.fail('External code attempted to use malformed memory scheme: ' + specifier);
66+
}
67+
68+
// Resolve with a URL based from the virtual filesystem root
69+
return {
70+
format: 'module',
71+
shortCircuit: true,
72+
url: new URL(memoryUrl.pathname.slice(1), memoryVirtualRootUrl).href,
73+
};
74+
}
75+
76+
// Use next/default resolve if the parent is not from the virtual root
77+
if (!context.parentURL?.startsWith(memoryVirtualRootUrl)) {
78+
return nextResolve(specifier, context);
79+
}
80+
81+
// Check for `./` and `../` relative specifiers
82+
const isRelative =
83+
specifier[0] === '.' &&
84+
(specifier[1] === '/' || (specifier[1] === '.' && specifier[2] === '/'));
85+
86+
// Relative specifiers from memory file should be based from the parent memory location
87+
if (isRelative) {
88+
let specifierUrl;
89+
try {
90+
specifierUrl = new URL(specifier, context.parentURL);
91+
} catch {}
92+
93+
if (
94+
specifierUrl?.pathname &&
95+
Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length))
96+
) {
5397
return {
5498
format: 'module',
5599
shortCircuit: true,
56-
// File URLs need to absolute. In Windows these also need to include the drive.
57-
// The `/` will be resolved to the drive letter.
58-
url: pathToFileURL('/' + normalizedSpecifier).href,
100+
url: specifierUrl.href,
59101
};
60102
}
103+
104+
assert.fail(
105+
`In-memory ESM relative file should always exist: '${context.parentURL}' --> '${specifier}'`,
106+
);
61107
}
62108

109+
// Update the parent URL to allow for module resolution for the workspace.
110+
// This handles bare specifiers (npm packages) and absolute paths.
63111
// Defer to the next hook in the chain, which would be the
64112
// Node.js default resolve if this is the last user-specified loader.
65-
return nextResolve(
66-
specifier,
67-
isBundleEntryPointOrChunk(context) ? { ...context, parentURL: workspaceRootFile } : context,
68-
);
113+
return nextResolve(specifier, {
114+
...context,
115+
parentURL: new URL('index.js', memoryVirtualRootUrl).href,
116+
});
69117
}
70118

71119
export async function load(url: string, context: { format?: string | null }, nextLoad: Function) {
72120
const { format } = context;
73121

74-
// CommonJs modules require no transformations and are not in memory.
75-
if (format !== 'commonjs' && isFileProtocol(url)) {
76-
const filePath = fileURLToPath(url);
77-
// Remove '/' or drive letter for Windows that was added in the above 'resolve'.
78-
let source = outputFiles[relative('/', filePath)] ?? TRANSFORMED_FILES[filePath];
122+
// Load the file from memory if the URL is based in the virtual root
123+
if (url.startsWith(memoryVirtualRootUrl)) {
124+
const source = outputFiles[url.slice(memoryVirtualRootUrl.length)];
125+
assert(source !== undefined, 'Resolved in-memory ESM file should always exist: ' + url);
126+
127+
// In-memory files have already been transformer during bundling and can be returned directly
128+
return {
129+
format,
130+
shortCircuit: true,
131+
source,
132+
};
133+
}
79134

80-
if (source === undefined) {
81-
source = TRANSFORMED_FILES[filePath] = Buffer.from(
82-
await javascriptTransformer.transformFile(filePath),
83-
).toString('utf-8');
84-
}
135+
// Only module files potentially require transformation. Angular libraries that would
136+
// need linking are ESM only.
137+
if (format === 'module' && isFileProtocol(url)) {
138+
const filePath = fileURLToPath(url);
139+
const source = await javascriptTransformer.transformFile(filePath);
85140

86-
if (source !== undefined) {
87-
return {
88-
format,
89-
shortCircuit: true,
90-
source,
91-
};
92-
}
141+
return {
142+
format,
143+
shortCircuit: true,
144+
source,
145+
};
93146
}
94147

95148
// Let Node.js handle all other URLs.
@@ -104,10 +157,6 @@ function handleProcessExit(): void {
104157
void javascriptTransformer.close();
105158
}
106159

107-
function isBundleEntryPointOrChunk(context: { parentURL: undefined | string }): boolean {
108-
return !!context.parentURL && CHUNKS_REGEXP.test(context.parentURL);
109-
}
110-
111160
process.once('exit', handleProcessExit);
112161
process.once('SIGINT', handleProcessExit);
113162
process.once('uncaughtException', handleProcessExit);

packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { workerData } from 'node:worker_threads';
10+
import { loadEsmModule } from '../load-esm';
1011
import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks';
1112
import { patchFetchToLoadInMemoryAssets } from './fetch-patch';
1213
import { RenderResult, ServerContext, renderPage } from './render-page';
@@ -34,6 +35,7 @@ function render(options: RenderOptions): Promise<RenderResult> {
3435
outputFiles,
3536
document,
3637
inlineCriticalCss,
38+
loadBundle: async (path) => await loadEsmModule(new URL(path, 'memory://')),
3739
});
3840
}
3941

packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ const { document, verbose } = workerData as RoutesExtractorWorkerData;
3131
/** Renders an application based on a provided options. */
3232
async function extractRoutes(): Promise<RoutersExtractorWorkerResult> {
3333
const { extractRoutes } = await loadEsmModule<RenderUtilsServerBundleExports>(
34-
'./render-utils.server.mjs',
34+
new URL('./render-utils.server.mjs', 'memory://'),
35+
);
36+
const { default: bootstrapAppFnOrModule } = await loadEsmModule<MainServerBundleExports>(
37+
new URL('./main.server.mjs', 'memory://'),
3538
);
36-
const { default: bootstrapAppFnOrModule } =
37-
await loadEsmModule<MainServerBundleExports>('./main.server.mjs');
3839

3940
const skippedRedirects: string[] = [];
4041
const skippedOthers: string[] = [];

0 commit comments

Comments
 (0)