Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion packages/angular/build/src/tools/esbuild/bundler-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +66,7 @@ export class BundlerContext {
#optionsFactory: BundlerOptionsFactory<BuildOptions & { metafile: true; write: false }>;
#shouldCacheResult: boolean;
#loadCache?: MemoryLoadResultCache;
#realWorkspaceRoot?: string;
readonly watchFiles = new Set<string>();

constructor(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, string>();
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) {
Comment on lines +547 to +554

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The cssBundle property in esbuild's metafile outputs contains a relative path to the generated CSS bundle from the working directory. When preserveSymlinks is enabled, this path will also be relative to the resolved base directory (fromBase) rather than the workspace root (toBase). We should remap cssBundle as well to ensure all relative paths in the metafile are correctly remapped.

Suggested change
for (const [key, value] of Object.entries(metafile.outputs)) {
if (value.entryPoint !== undefined) {
value.entryPoint = remap(value.entryPoint);
}
for (const importRecord of value.imports) {
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:');
}
Expand Down
99 changes: 99 additions & 0 deletions packages/angular/build/src/tools/esbuild/bundler-context_spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Comment on lines +23 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test assertion to verify that the cssBundle property is also correctly remapped by remapMetafileBasePath.

  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: '<runtime>', 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('<runtime>');

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);
});
});
Loading