@@ -19,6 +19,7 @@ import type {
1919} from 'esbuild' ;
2020import assert from 'node:assert' ;
2121import { createHash } from 'node:crypto' ;
22+ import { readFileSync } from 'node:fs' ;
2223import { readFile } from 'node:fs/promises' ;
2324import * as path from 'node:path' ;
2425import { 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 (?: i m p o r t | e x p o r t ) \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+
760850function 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 ;
0 commit comments