Skip to content

Commit a2b3d52

Browse files
committed
fix(@angular/build): apply file replacements to web worker entry bundles
The esbuild synchronous API used for worker sub-builds does not support plugins, which are the mechanism through which file replacements are applied in the main application bundle. As a result, fileReplacements entries were silently ignored when bundling web workers. Fix this by rewriting the worker entry file's import specifiers before passing the content to buildSync. Each relative specifier is resolved to an absolute path and compared against the fileReplacements map; matching specifiers are replaced with the absolute path of the replacement file. The worker is then bundled via stdin so that esbuild resolves and bundles the replacement files normally. Also handle the case where the worker entry file itself is replaced, and ensure that rebuild file-tracking correctly excludes the synthetic stdin entry added by esbuild to the metafile while still tracking the original worker source file. Closes #29546
1 parent 703b03f commit a2b3d52

File tree

3 files changed

+246
-8
lines changed

3 files changed

+246
-8
lines changed

packages/angular/build/src/builders/application/tests/options/file-replacements_spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,54 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
3333
harness.expectFile('dist/browser/main.js').content.not.toContain('12345');
3434
harness.expectFile('dist/browser/main.js').content.toContain('67890');
3535
});
36+
37+
it('should apply file replacements inside web workers', async () => {
38+
harness.useTarget('build', {
39+
...BASE_OPTIONS,
40+
fileReplacements: [{ replace: './src/app/env.ts', with: './src/app/env.prod.ts' }],
41+
});
42+
43+
await harness.writeFile('src/app/env.ts', `export const value = 'development';`);
44+
await harness.writeFile('src/app/env.prod.ts', `export const value = 'production';`);
45+
46+
await harness.writeFile(
47+
'src/app/worker.ts',
48+
`import { value } from './env';\nself.postMessage(value);`,
49+
);
50+
51+
await harness.writeFile(
52+
'src/app/app.component.ts',
53+
`
54+
import { Component } from '@angular/core';
55+
@Component({
56+
selector: 'app-root',
57+
standalone: false,
58+
template: '<h1>Worker Test</h1>',
59+
})
60+
export class AppComponent {
61+
worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
62+
}
63+
`,
64+
);
65+
66+
const { result } = await harness.executeOnce();
67+
expect(result?.success).toBeTrue();
68+
69+
// Verify the worker output file exists
70+
expect(harness.hasFileMatch('dist/browser', /^worker-[A-Z0-9]{8}\.js$/)).toBeTrue();
71+
72+
// Find the worker filename from the main bundle and read its content
73+
const mainContent = harness.readFile('dist/browser/main.js');
74+
const workerMatch = mainContent.match(/worker-([A-Z0-9]{8})\.js/);
75+
expect(workerMatch).not.toBeNull();
76+
77+
if (workerMatch) {
78+
const workerFilename = `dist/browser/${workerMatch[0]}`;
79+
// The worker bundle should contain the replaced (production) value
80+
harness.expectFile(workerFilename).content.toContain('production');
81+
// The worker bundle should NOT contain the original (development) value
82+
harness.expectFile(workerFilename).content.not.toContain('development');
83+
}
84+
});
3685
});
3786
});

packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts

Lines changed: 137 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
} from 'esbuild';
2020
import assert from 'node:assert';
2121
import { createHash } from 'node:crypto';
22+
import { readFileSync } from 'node:fs';
2223
import { readFile } from 'node:fs/promises';
2324
import * as path from 'node:path';
2425
import { maxWorkers, useTypeChecking } from '../../../utils/environment-options';
@@ -278,12 +279,21 @@ export function createCompilerPlugin(
278279
metafile: workerResult.metafile,
279280
});
280281

281-
referencedFileTracker.add(
282-
containingFile,
283-
Object.keys(workerResult.metafile.inputs).map((input) =>
284-
path.join(build.initialOptions.absWorkingDir ?? '', input),
285-
),
286-
);
282+
const metafileInputPaths = Object.keys(workerResult.metafile.inputs)
283+
// When file replacements are used, the worker entry is passed via stdin and
284+
// esbuild reports it as "<stdin>" in the metafile. Exclude this virtual entry
285+
// since it does not correspond to a real file path.
286+
.filter((input) => input !== '<stdin>')
287+
.map((input) => path.join(build.initialOptions.absWorkingDir ?? '', input));
288+
289+
// Always ensure the actual worker entry file is tracked as a dependency even when
290+
// the build used stdin (e.g. due to file replacements). This guarantees rebuilds
291+
// are triggered when the source worker file changes.
292+
if (!metafileInputPaths.includes(fullWorkerPath)) {
293+
metafileInputPaths.push(fullWorkerPath);
294+
}
295+
296+
referencedFileTracker.add(containingFile, metafileInputPaths);
287297

288298
// Return bundled worker file entry name to be used in the built output
289299
const workerCodeFile = workerResult.outputFiles.find((file) =>
@@ -757,27 +767,146 @@ function createCompilerOptionsTransformer(
757767
};
758768
}
759769

770+
/**
771+
* Rewrites static import/export specifiers in a TypeScript/JavaScript source file to apply
772+
* file replacements. For each relative or absolute specifier that resolves to a path present
773+
* in the `fileReplacements` map, the specifier is replaced with the corresponding replacement
774+
* path. This allows file replacements to be honoured inside web worker bundles, where the
775+
* esbuild synchronous API does not support plugins.
776+
*
777+
* Only the entry-file level is rewritten; transitive imports are handled because the rewritten
778+
* specifiers point directly to the replacement files on disk, so esbuild will bundle them
779+
* normally.
780+
*
781+
* @param contents Raw source text of the worker entry file.
782+
* @param workerDir Absolute directory of the worker entry file (used to resolve relative specifiers).
783+
* @param fileReplacements Map from original absolute path to replacement absolute path.
784+
* @returns The rewritten source text, or the original text if no replacements are needed.
785+
*/
786+
function applyFileReplacementsToContent(
787+
contents: string,
788+
workerDir: string,
789+
fileReplacements: Record<string, string>,
790+
): string {
791+
// Matches static import/export specifiers:
792+
// import ... from 'specifier'
793+
// export ... from 'specifier'
794+
// import 'specifier'
795+
// Captures the quote character (group 1) and the specifier (group 2).
796+
const importExportRe = /\b(?:import|export)\b[^;'"]*?(['"])([^'"]+)\1/g;
797+
798+
// Extensions to try when resolving a specifier without an explicit extension.
799+
const candidateExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'];
800+
801+
let result = contents;
802+
let match: RegExpExecArray | null;
803+
804+
while ((match = importExportRe.exec(contents)) !== null) {
805+
const specifier = match[2];
806+
807+
// Only process relative specifiers; bare package-name imports are not file-path replacements.
808+
if (!specifier.startsWith('.') && !path.isAbsolute(specifier)) {
809+
continue;
810+
}
811+
812+
const resolvedBase = path.isAbsolute(specifier)
813+
? specifier
814+
: path.join(workerDir, specifier);
815+
816+
let replacementPath: string | undefined;
817+
818+
// First check if the specifier already includes an extension and resolves directly.
819+
const directCandidate = path.normalize(resolvedBase);
820+
replacementPath = fileReplacements[directCandidate];
821+
822+
if (!replacementPath) {
823+
// Try appending each supported extension to resolve extensionless specifiers.
824+
for (const ext of candidateExtensions) {
825+
const candidate = path.normalize(resolvedBase + ext);
826+
replacementPath = fileReplacements[candidate];
827+
if (replacementPath) {
828+
break;
829+
}
830+
}
831+
}
832+
833+
if (replacementPath) {
834+
// Replace only the specifier part within the matched import/export statement.
835+
const fullMatch = match[0];
836+
const quote = match[1];
837+
const newSpecifier = replacementPath.replaceAll('\\', '/');
838+
const escapedSpecifier = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
839+
const newMatch = fullMatch.replace(
840+
new RegExp(`${quote}${escapedSpecifier}${quote}`),
841+
`${quote}${newSpecifier}${quote}`,
842+
);
843+
result = result.replace(fullMatch, newMatch);
844+
}
845+
}
846+
847+
return result;
848+
}
849+
760850
function bundleWebWorker(
761851
build: PluginBuild,
762852
pluginOptions: CompilerPluginOptions,
763853
workerFile: string,
764854
) {
765855
try {
766-
return build.esbuild.buildSync({
856+
// If file replacements are configured, apply them to the worker entry file so that the
857+
// synchronous esbuild build honours the same substitutions as the main application build.
858+
// The esbuild synchronous API does not support plugins (which normally handle file
859+
// replacements for the main build), so we rewrite the entry file's import specifiers
860+
// before bundling. Imports in the rewritten file point directly to the replacement paths,
861+
// which esbuild then resolves and bundles normally.
862+
let entryPoints: string[] | undefined;
863+
let stdin: { contents: string; resolveDir: string; loader: Loader } | undefined;
864+
865+
if (pluginOptions.fileReplacements) {
866+
// Check whether the worker entry file itself is being replaced.
867+
const entryReplacement = pluginOptions.fileReplacements[path.normalize(workerFile)];
868+
const effectiveWorkerFile = entryReplacement ?? workerFile;
869+
870+
// Rewrite any direct imports that are covered by file replacements.
871+
const workerDir = path.dirname(effectiveWorkerFile);
872+
const originalContents = readFileSync(effectiveWorkerFile, 'utf-8');
873+
const rewrittenContents = applyFileReplacementsToContent(
874+
originalContents,
875+
workerDir,
876+
pluginOptions.fileReplacements,
877+
);
878+
879+
if (rewrittenContents !== originalContents || entryReplacement) {
880+
// Use stdin to pass the rewritten content so that the correct bundle is produced.
881+
// Infer the esbuild loader from the effective worker file extension.
882+
const stdinLoader: Loader =
883+
path.extname(effectiveWorkerFile).toLowerCase() === '.tsx' ? 'tsx' : 'ts';
884+
stdin = { contents: rewrittenContents, resolveDir: workerDir, loader: stdinLoader };
885+
} else {
886+
entryPoints = [workerFile];
887+
}
888+
} else {
889+
entryPoints = [workerFile];
890+
}
891+
892+
const result = build.esbuild.buildSync({
767893
...build.initialOptions,
768894
platform: 'browser',
769895
write: false,
770896
bundle: true,
771897
metafile: true,
772898
format: 'esm',
773899
entryNames: 'worker-[hash]',
774-
entryPoints: [workerFile],
900+
entryPoints,
901+
stdin,
775902
sourcemap: pluginOptions.sourcemap,
776903
// Zone.js is not used in Web workers so no need to disable
777904
supported: undefined,
778905
// Plugins are not supported in sync esbuild calls
779906
plugins: undefined,
780907
});
908+
909+
return result;
781910
} catch (error) {
782911
if (error && typeof error === 'object' && 'errors' in error && 'warnings' in error) {
783912
return error as BuildFailure;

packages/angular/cli/src/commands/update/cli.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,17 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
231231
logger.info('Collecting installed dependencies...');
232232

233233
const rootDependencies = await packageManager.getProjectDependencies();
234+
235+
// In npm/pnpm/yarn workspace setups the package manager's `list` command is
236+
// executed against the workspace root, so it may only surface the root
237+
// workspace's direct dependencies. When the Angular project lives inside a
238+
// workspace member its own `package.json` entries (e.g. `@angular/core`) will
239+
// be absent from that list. To preserve the pre-v21 behaviour we supplement
240+
// the map with any packages declared in the Angular project root's
241+
// `package.json` that are resolvable from `node_modules` but were not already
242+
// returned by the package manager.
243+
await supplementWithLocalDependencies(rootDependencies, this.context.root);
244+
234245
logger.info(`Found ${rootDependencies.size} dependencies.`);
235246

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

689+
/**
690+
* Supplements the given dependency map with packages that are declared in the
691+
* Angular project root's `package.json` but were not returned by the package
692+
* manager's `list` command.
693+
*
694+
* In npm/pnpm/yarn workspace setups the package manager runs against the
695+
* workspace root, which may not include dependencies that only appear in a
696+
* workspace member's `package.json`. Reading the member's `package.json`
697+
* directly and resolving the installed version from `node_modules` restores
698+
* the behaviour that was present before the package-manager abstraction was
699+
* introduced in v21.
700+
*
701+
* @param dependencies The map to supplement in place.
702+
* @param projectRoot The root directory of the Angular project (workspace member).
703+
*/
704+
export async function supplementWithLocalDependencies(
705+
dependencies: Map<string, InstalledPackage>,
706+
projectRoot: string,
707+
): Promise<void> {
708+
const localManifest = await readPackageManifest(path.join(projectRoot, 'package.json'));
709+
if (!localManifest) {
710+
return;
711+
}
712+
713+
const localDeps: Record<string, string> = {
714+
...localManifest.dependencies,
715+
...localManifest.devDependencies,
716+
...localManifest.peerDependencies,
717+
};
718+
719+
for (const depName of Object.keys(localDeps)) {
720+
if (dependencies.has(depName)) {
721+
continue;
722+
}
723+
const pkgJsonPath = findPackageJson(projectRoot, depName);
724+
if (!pkgJsonPath) {
725+
continue;
726+
}
727+
const installed = await readPackageManifest(pkgJsonPath);
728+
if (installed?.version) {
729+
dependencies.set(depName, {
730+
name: depName,
731+
version: installed.version,
732+
path: path.dirname(pkgJsonPath),
733+
});
734+
}
735+
}
736+
}
737+
678738
async function readPackageManifest(manifestPath: string): Promise<PackageManifest | undefined> {
679739
try {
680740
const content = await fs.readFile(manifestPath, 'utf8');

0 commit comments

Comments
 (0)