Skip to content

Commit 903062a

Browse files
committed
fix(@angular/build): remap metafile paths when workspace root is a symlink or junction
esbuild always resolves its working directory through symbolic links and Windows directory junctions, so when preserveSymlinks is enabled the metafile paths are relative to a different base than the workspace root. This caused initial file detection to silently fail and index.html to be generated without script tags. The metafile paths are now remapped to be relative to the workspace root. Fixes #32306
1 parent 6b990bf commit 903062a

2 files changed

Lines changed: 174 additions & 1 deletion

File tree

packages/angular/build/src/tools/esbuild/bundler-context.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import {
1717
context,
1818
} from 'esbuild';
1919
import assert from 'node:assert';
20-
import { basename, extname, join, relative } from 'node:path';
20+
import { realpathSync } from 'node:fs';
21+
import { basename, extname, join, relative, resolve } from 'node:path';
22+
import { toPosixPath } from '../../utils/path';
2123
import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest';
2224
import {
2325
type BuildOutputFile,
@@ -64,6 +66,7 @@ export class BundlerContext {
6466
#optionsFactory: BundlerOptionsFactory<BuildOptions & { metafile: true; write: false }>;
6567
#shouldCacheResult: boolean;
6668
#loadCache?: MemoryLoadResultCache;
69+
#realWorkspaceRoot?: string;
6770
readonly watchFiles = new Set<string>();
6871

6972
constructor(
@@ -261,6 +264,17 @@ export class BundlerContext {
261264
}
262265
}
263266

267+
// esbuild always resolves its working directory through symbolic links (including
268+
// Windows directory junctions) and generates metafile paths relative to the resolved
269+
// path. When `preserveSymlinks` is enabled, the workspace root is intentionally not
270+
// resolved, and the metafile paths are then relative to a different base directory.
271+
// The paths are remapped so that all downstream consumers can rely on the documented
272+
// invariant that metafile paths are relative to the workspace root.
273+
this.#realWorkspaceRoot ??= realpathSync(this.workspaceRoot);
274+
if (this.#realWorkspaceRoot !== this.workspaceRoot) {
275+
remapMetafileBasePath(result.metafile, this.#realWorkspaceRoot, this.workspaceRoot);
276+
}
277+
264278
// Update files that should be watched.
265279
// While this should technically not be linked to incremental mode, incremental is only
266280
// currently enabled with watch mode where watch files are needed.
@@ -487,6 +501,68 @@ export class BundlerContext {
487501
}
488502
}
489503

504+
/**
505+
* Remaps all relative paths within an esbuild metafile from one base directory to another.
506+
* Virtual files (e.g., `angular:` namespaced or bundler generated), external imports, and
507+
* non-relative paths are left unmodified.
508+
*
509+
* @param metafile The metafile to update in place.
510+
* @param fromBase The absolute base directory the metafile paths are currently relative to.
511+
* @param toBase The absolute base directory the metafile paths should be made relative to.
512+
*/
513+
export function remapMetafileBasePath(metafile: Metafile, fromBase: string, toBase: string): void {
514+
const remapped = new Map<string, string>();
515+
const remap = (value: string): string => {
516+
// Skip virtual files and paths with a scheme-like or namespace prefix (e.g., `angular:`)
517+
if (
518+
isInternalAngularFile(value) ||
519+
isInternalBundlerFile(value) ||
520+
/^[^\\/.]{2,}:/.test(value)
521+
) {
522+
return value;
523+
}
524+
525+
let result = remapped.get(value);
526+
if (result === undefined) {
527+
// esbuild metafile paths always use POSIX path separators
528+
result = toPosixPath(relative(toBase, resolve(fromBase, value)));
529+
remapped.set(value, result);
530+
}
531+
532+
return result;
533+
};
534+
535+
const inputs: Metafile['inputs'] = {};
536+
for (const [key, value] of Object.entries(metafile.inputs)) {
537+
for (const importRecord of value.imports) {
538+
if (!importRecord.external) {
539+
importRecord.path = remap(importRecord.path);
540+
}
541+
}
542+
inputs[remap(key)] = value;
543+
}
544+
metafile.inputs = inputs;
545+
546+
const outputs: Metafile['outputs'] = {};
547+
for (const [key, value] of Object.entries(metafile.outputs)) {
548+
if (value.entryPoint !== undefined) {
549+
value.entryPoint = remap(value.entryPoint);
550+
}
551+
for (const importRecord of value.imports) {
552+
if (!importRecord.external) {
553+
importRecord.path = remap(importRecord.path);
554+
}
555+
}
556+
const outputInputs: (typeof value)['inputs'] = {};
557+
for (const [inputKey, inputValue] of Object.entries(value.inputs)) {
558+
outputInputs[remap(inputKey)] = inputValue;
559+
}
560+
value.inputs = outputInputs;
561+
outputs[remap(key)] = value;
562+
}
563+
metafile.outputs = outputs;
564+
}
565+
490566
function isInternalAngularFile(file: string) {
491567
return file.startsWith('angular:');
492568
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 type { Metafile } from 'esbuild';
10+
import { join, relative } from 'node:path';
11+
import { remapMetafileBasePath } from './bundler-context';
12+
13+
describe('remapMetafileBasePath', () => {
14+
// Simulates a workspace root accessed through a symbolic link or Windows
15+
// directory junction (`toBase`) that resolves to a different real path
16+
// (`fromBase`), as esbuild resolves its working directory through links.
17+
const fromBase = join('/real', 'projects', 'demo');
18+
const toBase = join('/linked', 'demo');
19+
20+
/** Creates a metafile path as esbuild would: relative to the resolved (real) base. */
21+
const fromBaseRelative = (filePath: string): string => relative(fromBase, join(toBase, filePath));
22+
23+
it('remaps input and output paths onto the target base directory', () => {
24+
const metafile: Metafile = {
25+
inputs: {
26+
[fromBaseRelative('src/main.ts')]: { bytes: 10, imports: [] },
27+
},
28+
outputs: {
29+
[fromBaseRelative('main.js')]: {
30+
bytes: 100,
31+
inputs: { [fromBaseRelative('src/main.ts')]: { bytesInOutput: 10 } },
32+
imports: [{ path: fromBaseRelative('chunk-ABC.js'), kind: 'import-statement' }],
33+
exports: [],
34+
entryPoint: fromBaseRelative('src/main.ts'),
35+
},
36+
},
37+
};
38+
39+
remapMetafileBasePath(metafile, fromBase, toBase);
40+
41+
expect(Object.keys(metafile.inputs)).toEqual(['src/main.ts']);
42+
expect(Object.keys(metafile.outputs)).toEqual(['main.js']);
43+
44+
const output = metafile.outputs['main.js'];
45+
expect(output.entryPoint).toBe('src/main.ts');
46+
expect(Object.keys(output.inputs)).toEqual(['src/main.ts']);
47+
expect(output.imports[0].path).toBe('chunk-ABC.js');
48+
});
49+
50+
it('does not modify virtual and namespaced files', () => {
51+
const metafile: Metafile = {
52+
inputs: {
53+
'angular:polyfills': {
54+
bytes: 10,
55+
imports: [{ path: '<runtime>', kind: 'import-statement' }],
56+
},
57+
},
58+
outputs: {
59+
[fromBaseRelative('polyfills.js')]: {
60+
bytes: 100,
61+
inputs: { 'angular:polyfills': { bytesInOutput: 10 } },
62+
imports: [],
63+
exports: [],
64+
entryPoint: 'angular:polyfills',
65+
},
66+
},
67+
};
68+
69+
remapMetafileBasePath(metafile, fromBase, toBase);
70+
71+
expect(Object.keys(metafile.inputs)).toEqual(['angular:polyfills']);
72+
expect(metafile.inputs['angular:polyfills'].imports[0].path).toBe('<runtime>');
73+
74+
const output = metafile.outputs['polyfills.js'];
75+
expect(output.entryPoint).toBe('angular:polyfills');
76+
expect(Object.keys(output.inputs)).toEqual(['angular:polyfills']);
77+
});
78+
79+
it('does not modify external imports', () => {
80+
const externalPath = 'https://example.com/module.js';
81+
const metafile: Metafile = {
82+
inputs: {},
83+
outputs: {
84+
[fromBaseRelative('main.js')]: {
85+
bytes: 100,
86+
inputs: {},
87+
imports: [{ path: externalPath, kind: 'import-statement', external: true }],
88+
exports: [],
89+
},
90+
},
91+
};
92+
93+
remapMetafileBasePath(metafile, fromBase, toBase);
94+
95+
expect(metafile.outputs['main.js'].imports[0].path).toBe(externalPath);
96+
});
97+
});

0 commit comments

Comments
 (0)