@@ -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 (?: i m p o r t | e x p o r t ) \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+ / ^ ( i m p o r t | e x p o r t ) ( [ \s \S ] * ?\s + f r o m \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
850830function 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