Skip to content

Commit 4aa1c10

Browse files
committed
fix(@angular/build): apply file replacements to full worker bundle graph via onResolve
1 parent a2b3d52 commit 4aa1c10

File tree

2 files changed

+118
-59
lines changed

2 files changed

+118
-59
lines changed

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,60 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
8282
harness.expectFile(workerFilename).content.not.toContain('development');
8383
}
8484
});
85+
86+
it('should apply file replacements to transitive imports inside web workers', async () => {
87+
harness.useTarget('build', {
88+
...BASE_OPTIONS,
89+
fileReplacements: [{ replace: './src/app/env.ts', with: './src/app/env.prod.ts' }],
90+
});
91+
92+
await harness.writeFile('src/app/env.ts', `export const value = 'development';`);
93+
await harness.writeFile('src/app/env.prod.ts', `export const value = 'production';`);
94+
95+
// The worker imports a helper that in turn imports the replaceable env file.
96+
await harness.writeFile(
97+
'src/app/worker-helper.ts',
98+
`export { value } from './env';`,
99+
);
100+
101+
await harness.writeFile(
102+
'src/app/worker.ts',
103+
`import { value } from './worker-helper';\nself.postMessage(value);`,
104+
);
105+
106+
await harness.writeFile(
107+
'src/app/app.component.ts',
108+
`
109+
import { Component } from '@angular/core';
110+
@Component({
111+
selector: 'app-root',
112+
standalone: false,
113+
template: '<h1>Worker Test</h1>',
114+
})
115+
export class AppComponent {
116+
worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
117+
}
118+
`,
119+
);
120+
121+
const { result } = await harness.executeOnce();
122+
expect(result?.success).toBeTrue();
123+
124+
// Verify the worker output file exists
125+
expect(harness.hasFileMatch('dist/browser', /^worker-[A-Z0-9]{8}\.js$/)).toBeTrue();
126+
127+
// Find the worker filename from the main bundle and read its content
128+
const mainContent = harness.readFile('dist/browser/main.js');
129+
const workerMatch = mainContent.match(/worker-([A-Z0-9]{8})\.js/);
130+
expect(workerMatch).not.toBeNull();
131+
132+
if (workerMatch) {
133+
const workerFilename = `dist/browser/${workerMatch[0]}`;
134+
// The worker bundle should contain the replaced (production) value
135+
harness.expectFile(workerFilename).content.toContain('production');
136+
// The worker bundle should NOT contain the original (development) value
137+
harness.expectFile(workerFilename).content.not.toContain('development');
138+
}
139+
});
85140
});
86141
});

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

Lines changed: 63 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -771,15 +771,11 @@ function createCompilerOptionsTransformer(
771771
* Rewrites static import/export specifiers in a TypeScript/JavaScript source file to apply
772772
* file replacements. For each relative or absolute specifier that resolves to a path present
773773
* 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
774+
* path. This allows file replacements to be honoured inside web worker entry files, where the
775775
* esbuild synchronous API does not support plugins.
776776
*
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).
777+
* @param contents Raw source text of the source file.
778+
* @param workerDir Absolute directory of the source file (used to resolve relative specifiers).
783779
* @param fileReplacements Map from original absolute path to replacement absolute path.
784780
* @returns The rewritten source text, or the original text if no replacements are needed.
785781
*/
@@ -788,63 +784,47 @@ function applyFileReplacementsToContent(
788784
workerDir: string,
789785
fileReplacements: Record<string, string>,
790786
): 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-
798787
// Extensions to try when resolving a specifier without an explicit extension.
799788
const candidateExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs'];
800789

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

816-
let replacementPath: string | undefined;
802+
const resolvedBase = path.isAbsolute(specifier)
803+
? specifier
804+
: path.join(workerDir, specifier);
817805

818-
// First check if the specifier already includes an extension and resolves directly.
819-
const directCandidate = path.normalize(resolvedBase);
820-
replacementPath = fileReplacements[directCandidate];
806+
// First check if the specifier already includes an extension and resolves directly.
807+
let replacementPath: string | undefined = fileReplacements[path.normalize(resolvedBase)];
821808

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;
809+
if (!replacementPath) {
810+
// Try appending each supported extension to resolve extensionless specifiers.
811+
for (const ext of candidateExtensions) {
812+
replacementPath = fileReplacements[path.normalize(resolvedBase + ext)];
813+
if (replacementPath) {
814+
break;
815+
}
829816
}
830817
}
831-
}
832818

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];
819+
if (!replacementPath) {
820+
return match;
821+
}
822+
837823
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-
}
846824

847-
return result;
825+
return match.replace(`${quote}${specifier}${quote}`, `${quote}${newSpecifier}${quote}`);
826+
},
827+
);
848828
}
849829

850830
function bundleWebWorker(
@@ -853,21 +833,44 @@ function bundleWebWorker(
853833
workerFile: string,
854834
) {
855835
try {
856-
// If file replacements are configured, apply them to the worker entry file so that the
836+
// If file replacements are configured, apply them to the worker bundle so that the
857837
// 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.
838+
//
839+
// Because the esbuild synchronous API does not support plugins, file replacements are
840+
// applied via two complementary mechanisms:
841+
//
842+
// 1. `alias`: esbuild's built-in alias option intercepts every resolve call across the
843+
// entire bundle graph — entry file and all transitive imports — and redirects any
844+
// import whose specifier exactly matches an original path to the replacement path.
845+
// This covers imports that use a path form identical to the fileReplacements key
846+
// (e.g. TypeScript path-mapped or absolute imports).
847+
//
848+
// 2. stdin rewriting: for relative specifiers in the worker entry file (the most common
849+
// case), `applyFileReplacementsToContent` resolves each specifier to an absolute path,
850+
// looks it up in the fileReplacements map, and rewrites the source text before passing
851+
// it to esbuild via stdin. The rewritten specifiers now point directly to the
852+
// replacement files, so esbuild bundles them without needing further intervention.
862853
let entryPoints: string[] | undefined;
863854
let stdin: { contents: string; resolveDir: string; loader: Loader } | undefined;
855+
let alias: Record<string, string> | undefined;
864856

865857
if (pluginOptions.fileReplacements) {
858+
// Pass all file replacements as esbuild aliases so that every import in the worker
859+
// bundle graph — not just the entry — is subject to replacement at resolve time.
860+
alias = Object.fromEntries(
861+
Object.entries(pluginOptions.fileReplacements).map(([original, replacement]) => [
862+
original.replaceAll('\\', '/'),
863+
replacement.replaceAll('\\', '/'),
864+
]),
865+
);
866+
866867
// Check whether the worker entry file itself is being replaced.
867868
const entryReplacement = pluginOptions.fileReplacements[path.normalize(workerFile)];
868869
const effectiveWorkerFile = entryReplacement ?? workerFile;
869870

870-
// Rewrite any direct imports that are covered by file replacements.
871+
// Rewrite relative import specifiers in the entry file that resolve to a replaced path.
872+
// This handles the common case where transitive-dependency imports inside the entry use
873+
// relative paths that esbuild alias (which matches raw specifier text) would not catch.
871874
const workerDir = path.dirname(effectiveWorkerFile);
872875
const originalContents = readFileSync(effectiveWorkerFile, 'utf-8');
873876
const rewrittenContents = applyFileReplacementsToContent(
@@ -899,6 +902,7 @@ function bundleWebWorker(
899902
entryNames: 'worker-[hash]',
900903
entryPoints,
901904
stdin,
905+
alias,
902906
sourcemap: pluginOptions.sourcemap,
903907
// Zone.js is not used in Web workers so no need to disable
904908
supported: undefined,

0 commit comments

Comments
 (0)