diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index d3f3ca567a0f..58f2df2a05c8 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -17,7 +17,9 @@ import { context, } from 'esbuild'; import assert from 'node:assert'; -import { basename, extname, join, relative } from 'node:path'; +import { realpathSync } from 'node:fs'; +import { basename, extname, join, relative, resolve } from 'node:path'; +import { toPosixPath } from '../../utils/path'; import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest'; import { type BuildOutputFile, @@ -64,6 +66,7 @@ export class BundlerContext { #optionsFactory: BundlerOptionsFactory; #shouldCacheResult: boolean; #loadCache?: MemoryLoadResultCache; + #realWorkspaceRoot?: string; readonly watchFiles = new Set(); constructor( @@ -261,6 +264,17 @@ export class BundlerContext { } } + // esbuild always resolves its working directory through symbolic links (including + // Windows directory junctions) and generates metafile paths relative to the resolved + // path. When `preserveSymlinks` is enabled, the workspace root is intentionally not + // resolved, and the metafile paths are then relative to a different base directory. + // The paths are remapped so that all downstream consumers can rely on the documented + // invariant that metafile paths are relative to the workspace root. + this.#realWorkspaceRoot ??= realpathSync(this.workspaceRoot); + if (this.#realWorkspaceRoot !== this.workspaceRoot) { + remapMetafileBasePath(result.metafile, this.#realWorkspaceRoot, this.workspaceRoot); + } + // Update files that should be watched. // While this should technically not be linked to incremental mode, incremental is only // currently enabled with watch mode where watch files are needed. @@ -487,6 +501,71 @@ export class BundlerContext { } } +/** + * Remaps all relative paths within an esbuild metafile from one base directory to another. + * Virtual files (e.g., `angular:` namespaced or bundler generated), external imports, and + * non-relative paths are left unmodified. + * + * @param metafile The metafile to update in place. + * @param fromBase The absolute base directory the metafile paths are currently relative to. + * @param toBase The absolute base directory the metafile paths should be made relative to. + */ +export function remapMetafileBasePath(metafile: Metafile, fromBase: string, toBase: string): void { + const remapped = new Map(); + const remap = (value: string): string => { + // Skip virtual files and paths with a scheme-like or namespace prefix (e.g., `angular:`) + if ( + isInternalAngularFile(value) || + isInternalBundlerFile(value) || + /^[^\\/.]{2,}:/.test(value) + ) { + return value; + } + + let result = remapped.get(value); + if (result === undefined) { + // esbuild metafile paths always use POSIX path separators + result = toPosixPath(relative(toBase, resolve(fromBase, value))); + remapped.set(value, result); + } + + return result; + }; + + const inputs: Metafile['inputs'] = {}; + for (const [key, value] of Object.entries(metafile.inputs)) { + for (const importRecord of value.imports) { + if (!importRecord.external) { + importRecord.path = remap(importRecord.path); + } + } + inputs[remap(key)] = value; + } + metafile.inputs = inputs; + + const outputs: Metafile['outputs'] = {}; + for (const [key, value] of Object.entries(metafile.outputs)) { + if (value.entryPoint !== undefined) { + value.entryPoint = remap(value.entryPoint); + } + if (value.cssBundle !== undefined) { + value.cssBundle = remap(value.cssBundle); + } + for (const importRecord of value.imports) { + if (!importRecord.external) { + importRecord.path = remap(importRecord.path); + } + } + const outputInputs: (typeof value)['inputs'] = {}; + for (const [inputKey, inputValue] of Object.entries(value.inputs)) { + outputInputs[remap(inputKey)] = inputValue; + } + value.inputs = outputInputs; + outputs[remap(key)] = value; + } + metafile.outputs = outputs; +} + function isInternalAngularFile(file: string) { return file.startsWith('angular:'); } diff --git a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts new file mode 100644 index 000000000000..8806f1d90406 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts @@ -0,0 +1,99 @@ +/** + * @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 type { Metafile } from 'esbuild'; +import { join, relative } from 'node:path'; +import { remapMetafileBasePath } from './bundler-context'; + +describe('remapMetafileBasePath', () => { + // Simulates a workspace root accessed through a symbolic link or Windows + // directory junction (`toBase`) that resolves to a different real path + // (`fromBase`), as esbuild resolves its working directory through links. + const fromBase = join('/real', 'projects', 'demo'); + const toBase = join('/linked', 'demo'); + + /** Creates a metafile path as esbuild would: relative to the resolved (real) base. */ + const fromBaseRelative = (filePath: string): string => relative(fromBase, join(toBase, filePath)); + + it('remaps input and output paths onto the target base directory', () => { + const metafile: Metafile = { + inputs: { + [fromBaseRelative('src/main.ts')]: { bytes: 10, imports: [] }, + }, + outputs: { + [fromBaseRelative('main.js')]: { + bytes: 100, + inputs: { [fromBaseRelative('src/main.ts')]: { bytesInOutput: 10 } }, + imports: [{ path: fromBaseRelative('chunk-ABC.js'), kind: 'import-statement' }], + exports: [], + entryPoint: fromBaseRelative('src/main.ts'), + cssBundle: fromBaseRelative('main.css'), + }, + }, + }; + + remapMetafileBasePath(metafile, fromBase, toBase); + + expect(Object.keys(metafile.inputs)).toEqual(['src/main.ts']); + expect(Object.keys(metafile.outputs)).toEqual(['main.js']); + + const output = metafile.outputs['main.js']; + expect(output.entryPoint).toBe('src/main.ts'); + expect(output.cssBundle).toBe('main.css'); + expect(Object.keys(output.inputs)).toEqual(['src/main.ts']); + expect(output.imports[0].path).toBe('chunk-ABC.js'); + }); + + it('does not modify virtual and namespaced files', () => { + const metafile: Metafile = { + inputs: { + 'angular:polyfills': { + bytes: 10, + imports: [{ path: '', kind: 'import-statement' }], + }, + }, + outputs: { + [fromBaseRelative('polyfills.js')]: { + bytes: 100, + inputs: { 'angular:polyfills': { bytesInOutput: 10 } }, + imports: [], + exports: [], + entryPoint: 'angular:polyfills', + }, + }, + }; + + remapMetafileBasePath(metafile, fromBase, toBase); + + expect(Object.keys(metafile.inputs)).toEqual(['angular:polyfills']); + expect(metafile.inputs['angular:polyfills'].imports[0].path).toBe(''); + + const output = metafile.outputs['polyfills.js']; + expect(output.entryPoint).toBe('angular:polyfills'); + expect(Object.keys(output.inputs)).toEqual(['angular:polyfills']); + }); + + it('does not modify external imports', () => { + const externalPath = 'https://example.com/module.js'; + const metafile: Metafile = { + inputs: {}, + outputs: { + [fromBaseRelative('main.js')]: { + bytes: 100, + inputs: {}, + imports: [{ path: externalPath, kind: 'import-statement', external: true }], + exports: [], + }, + }, + }; + + remapMetafileBasePath(metafile, fromBase, toBase); + + expect(metafile.outputs['main.js'].imports[0].path).toBe(externalPath); + }); +});