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
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,109 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
harness.expectFile('dist/browser/main.js').content.not.toContain('12345');
harness.expectFile('dist/browser/main.js').content.toContain('67890');
});

it('should apply file replacements inside web workers', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
fileReplacements: [{ replace: './src/app/env.ts', with: './src/app/env.prod.ts' }],
});

await harness.writeFile('src/app/env.ts', `export const value = 'development';`);
await harness.writeFile('src/app/env.prod.ts', `export const value = 'production';`);

await harness.writeFile(
'src/app/worker.ts',
`import { value } from './env';\nself.postMessage(value);`,
);

await harness.writeFile(
'src/app/app.component.ts',
`
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
standalone: false,
template: '<h1>Worker Test</h1>',
})
export class AppComponent {
worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
}
`,
);

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();

// Verify the worker output file exists
expect(harness.hasFileMatch('dist/browser', /^worker-[A-Z0-9]{8}\.js$/)).toBeTrue();

// Find the worker filename from the main bundle and read its content
const mainContent = harness.readFile('dist/browser/main.js');
const workerMatch = mainContent.match(/worker-([A-Z0-9]{8})\.js/);
expect(workerMatch).not.toBeNull();

if (workerMatch) {
const workerFilename = `dist/browser/${workerMatch[0]}`;
// The worker bundle should contain the replaced (production) value
harness.expectFile(workerFilename).content.toContain('production');
// The worker bundle should NOT contain the original (development) value
harness.expectFile(workerFilename).content.not.toContain('development');
}
});

it('should apply file replacements to transitive imports inside web workers', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
fileReplacements: [{ replace: './src/app/env.ts', with: './src/app/env.prod.ts' }],
});

await harness.writeFile('src/app/env.ts', `export const value = 'development';`);
await harness.writeFile('src/app/env.prod.ts', `export const value = 'production';`);

// The worker imports a helper that in turn imports the replaceable env file.
await harness.writeFile(
'src/app/worker-helper.ts',
`export { value } from './env';`,
);

await harness.writeFile(
'src/app/worker.ts',
`import { value } from './worker-helper';\nself.postMessage(value);`,
);

await harness.writeFile(
'src/app/app.component.ts',
`
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
standalone: false,
template: '<h1>Worker Test</h1>',
})
export class AppComponent {
worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
}
`,
);

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();

// Verify the worker output file exists
expect(harness.hasFileMatch('dist/browser', /^worker-[A-Z0-9]{8}\.js$/)).toBeTrue();

// Find the worker filename from the main bundle and read its content
const mainContent = harness.readFile('dist/browser/main.js');
const workerMatch = mainContent.match(/worker-([A-Z0-9]{8})\.js/);
expect(workerMatch).not.toBeNull();

if (workerMatch) {
const workerFilename = `dist/browser/${workerMatch[0]}`;
// The worker bundle should contain the replaced (production) value
harness.expectFile(workerFilename).content.toContain('production');
// The worker bundle should NOT contain the original (development) value
harness.expectFile(workerFilename).content.not.toContain('development');
}
});
});
});
149 changes: 141 additions & 8 deletions packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from 'esbuild';
import assert from 'node:assert';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as path from 'node:path';
import { maxWorkers, useTypeChecking } from '../../../utils/environment-options';
Expand Down Expand Up @@ -278,12 +279,21 @@ export function createCompilerPlugin(
metafile: workerResult.metafile,
});

referencedFileTracker.add(
containingFile,
Object.keys(workerResult.metafile.inputs).map((input) =>
path.join(build.initialOptions.absWorkingDir ?? '', input),
),
);
const metafileInputPaths = Object.keys(workerResult.metafile.inputs)
// When file replacements are used, the worker entry is passed via stdin and
// esbuild reports it as "<stdin>" in the metafile. Exclude this virtual entry
// since it does not correspond to a real file path.
.filter((input) => input !== '<stdin>')
.map((input) => path.join(build.initialOptions.absWorkingDir ?? '', input));

// Always ensure the actual worker entry file is tracked as a dependency even when
// the build used stdin (e.g. due to file replacements). This guarantees rebuilds
// are triggered when the source worker file changes.
if (!metafileInputPaths.includes(fullWorkerPath)) {
metafileInputPaths.push(fullWorkerPath);
}

referencedFileTracker.add(containingFile, metafileInputPaths);

// Return bundled worker file entry name to be used in the built output
const workerCodeFile = workerResult.outputFiles.find((file) =>
Expand Down Expand Up @@ -757,27 +767,150 @@ function createCompilerOptionsTransformer(
};
}

/**
* Rewrites static import/export specifiers in a TypeScript/JavaScript source file to apply
* file replacements. For each relative or absolute specifier that resolves to a path present
* in the `fileReplacements` map, the specifier is replaced with the corresponding replacement
* path. This allows file replacements to be honoured inside web worker entry files, where the
* esbuild synchronous API does not support plugins.
*
* @param contents Raw source text of the source file.
* @param workerDir Absolute directory of the source file (used to resolve relative specifiers).
* @param fileReplacements Map from original absolute path to replacement absolute path.
* @returns The rewritten source text, or the original text if no replacements are needed.
*/
function applyFileReplacementsToContent(
contents: string,
workerDir: string,
fileReplacements: Record<string, string>,
): string {
// Extensions to try when resolving a specifier without an explicit extension.
const candidateExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'];

// Use a line-anchored regex with a callback to replace import/export specifiers in-place.
// The pattern matches static import and export-from statements (including multiline forms)
// and captures the specifier. `[\s\S]*?` is used instead of `.*?` so that multiline import
// lists (common in TypeScript) are matched correctly.
return contents.replace(
/^(import|export)([\s\S]*?\s+from\s+|\s+)(['"])([^'"]+)\3/gm,
(match, _keyword, _middle, quote, specifier) => {
// Only process relative specifiers; bare package-name imports are not file-path replacements.
if (!specifier.startsWith('.') && !path.isAbsolute(specifier)) {
return match;
}

const resolvedBase = path.isAbsolute(specifier)
? specifier
: path.join(workerDir, specifier);

// First check if the specifier already includes an extension and resolves directly.
let replacementPath: string | undefined = fileReplacements[path.normalize(resolvedBase)];

if (!replacementPath) {
// Try appending each supported extension to resolve extensionless specifiers.
for (const ext of candidateExtensions) {
replacementPath = fileReplacements[path.normalize(resolvedBase + ext)];
if (replacementPath) {
break;
}
}
}

if (!replacementPath) {
return match;
}

const newSpecifier = replacementPath.replaceAll('\\', '/');

return match.replace(`${quote}${specifier}${quote}`, `${quote}${newSpecifier}${quote}`);
},
);
}

function bundleWebWorker(
build: PluginBuild,
pluginOptions: CompilerPluginOptions,
workerFile: string,
) {
try {
return build.esbuild.buildSync({
// If file replacements are configured, apply them to the worker bundle so that the
// synchronous esbuild build honours the same substitutions as the main application build.
//
// Because the esbuild synchronous API does not support plugins, file replacements are
// applied via two complementary mechanisms:
//
// 1. `alias`: esbuild's built-in alias option intercepts every resolve call across the
// entire bundle graph — entry file and all transitive imports — and redirects any
// import whose specifier exactly matches an original path to the replacement path.
// This covers imports that use a path form identical to the fileReplacements key
// (e.g. TypeScript path-mapped or absolute imports).
//
// 2. stdin rewriting: for relative specifiers in the worker entry file (the most common
// case), `applyFileReplacementsToContent` resolves each specifier to an absolute path,
// looks it up in the fileReplacements map, and rewrites the source text before passing
// it to esbuild via stdin. The rewritten specifiers now point directly to the
// replacement files, so esbuild bundles them without needing further intervention.
let entryPoints: string[] | undefined;
let stdin: { contents: string; resolveDir: string; loader: Loader } | undefined;
let alias: Record<string, string> | undefined;

if (pluginOptions.fileReplacements) {
// Pass all file replacements as esbuild aliases so that every import in the worker
// bundle graph — not just the entry — is subject to replacement at resolve time.
alias = Object.fromEntries(
Object.entries(pluginOptions.fileReplacements).map(([original, replacement]) => [
original.replaceAll('\\', '/'),
replacement.replaceAll('\\', '/'),
]),
);

// Check whether the worker entry file itself is being replaced.
const entryReplacement = pluginOptions.fileReplacements[path.normalize(workerFile)];
const effectiveWorkerFile = entryReplacement ?? workerFile;

// Rewrite relative import specifiers in the entry file that resolve to a replaced path.
// This handles the common case where transitive-dependency imports inside the entry use
// relative paths that esbuild alias (which matches raw specifier text) would not catch.
const workerDir = path.dirname(effectiveWorkerFile);
const originalContents = readFileSync(effectiveWorkerFile, 'utf-8');
const rewrittenContents = applyFileReplacementsToContent(
originalContents,
workerDir,
pluginOptions.fileReplacements,
);

if (rewrittenContents !== originalContents || entryReplacement) {
// Use stdin to pass the rewritten content so that the correct bundle is produced.
// Infer the esbuild loader from the effective worker file extension.
const stdinLoader: Loader =
path.extname(effectiveWorkerFile).toLowerCase() === '.tsx' ? 'tsx' : 'ts';
stdin = { contents: rewrittenContents, resolveDir: workerDir, loader: stdinLoader };
} else {
entryPoints = [workerFile];
}
} else {
entryPoints = [workerFile];
}

const result = build.esbuild.buildSync({
...build.initialOptions,
platform: 'browser',
write: false,
bundle: true,
metafile: true,
format: 'esm',
entryNames: 'worker-[hash]',
entryPoints: [workerFile],
entryPoints,
stdin,
alias,
sourcemap: pluginOptions.sourcemap,
// Zone.js is not used in Web workers so no need to disable
supported: undefined,
// Plugins are not supported in sync esbuild calls
plugins: undefined,
});

return result;
} catch (error) {
if (error && typeof error === 'object' && 'errors' in error && 'warnings' in error) {
return error as BuildFailure;
Expand Down
60 changes: 60 additions & 0 deletions packages/angular/cli/src/commands/update/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,17 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
logger.info('Collecting installed dependencies...');

const rootDependencies = await packageManager.getProjectDependencies();

// In npm/pnpm/yarn workspace setups the package manager's `list` command is
// executed against the workspace root, so it may only surface the root
// workspace's direct dependencies. When the Angular project lives inside a
// workspace member its own `package.json` entries (e.g. `@angular/core`) will
// be absent from that list. To preserve the pre-v21 behaviour we supplement
// the map with any packages declared in the Angular project root's
// `package.json` that are resolvable from `node_modules` but were not already
// returned by the package manager.
await supplementWithLocalDependencies(rootDependencies, this.context.root);

logger.info(`Found ${rootDependencies.size} dependencies.`);

const workflow = new NodeWorkflow(this.context.root, {
Expand Down Expand Up @@ -675,6 +686,55 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
}
}

/**
* Supplements the given dependency map with packages that are declared in the
* Angular project root's `package.json` but were not returned by the package
* manager's `list` command.
*
* In npm/pnpm/yarn workspace setups the package manager runs against the
* workspace root, which may not include dependencies that only appear in a
* workspace member's `package.json`. Reading the member's `package.json`
* directly and resolving the installed version from `node_modules` restores
* the behaviour that was present before the package-manager abstraction was
* introduced in v21.
*
* @param dependencies The map to supplement in place.
* @param projectRoot The root directory of the Angular project (workspace member).
*/
export async function supplementWithLocalDependencies(
dependencies: Map<string, InstalledPackage>,
projectRoot: string,
): Promise<void> {
const localManifest = await readPackageManifest(path.join(projectRoot, 'package.json'));
if (!localManifest) {
return;
}

const localDeps: Record<string, string> = {
...localManifest.dependencies,
...localManifest.devDependencies,
...localManifest.peerDependencies,
};

for (const depName of Object.keys(localDeps)) {
if (dependencies.has(depName)) {
continue;
}
const pkgJsonPath = findPackageJson(projectRoot, depName);
if (!pkgJsonPath) {
continue;
}
const installed = await readPackageManifest(pkgJsonPath);
if (installed?.version) {
dependencies.set(depName, {
name: depName,
version: installed.version,
path: path.dirname(pkgJsonPath),
});
}
}
}

async function readPackageManifest(manifestPath: string): Promise<PackageManifest | undefined> {
try {
const content = await fs.readFile(manifestPath, 'utf8');
Expand Down
Loading
Loading