From 698814583f5f8702f857aa3e12ba4fd4ffb47285 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Fri, 16 Dec 2022 17:11:15 -0500 Subject: [PATCH] fix #2754: plugins can now resolve injected files --- CHANGELOG.md | 4 + internal/bundler/bundler.go | 429 ++++++++++-------- .../bundler_tests/bundler_default_test.go | 37 +- internal/bundler_tests/bundler_test.go | 4 +- .../snapshots/snapshots_default.txt | 6 + internal/config/config.go | 2 +- pkg/api/api_impl.go | 5 +- scripts/plugin-tests.js | 22 + 8 files changed, 296 insertions(+), 213 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 380437bff83..a7d38dd72cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +* Allow plugins to resolve injected files ([#2754](https://github.com/evanw/esbuild/issues/2754)) + + Previously paths passed to the `inject` feature were always interpreted as file system paths. This meant that `onResolve` plugins would not be run for them and esbuild's default path resolver would always be used. This meant that the `inject` feature couldn't be used in the browser since the browser doesn't have access to a file system. This release runs paths passed to `inject` through esbuild's full path resolution pipeline so plugins now have a chance to handle them using `onResolve` callbacks. This makes it possible to write a plugin that makes esbuild's `inject` work in the browser. + * Add the `empty` loader ([#1541](https://github.com/evanw/esbuild/issues/1541), [#2753](https://github.com/evanw/esbuild/issues/2753)) The new `empty` loader tells esbuild to pretend that a file is empty. So for example `--loader:.css=empty` effectively skips all imports of `.css` files in JavaScript so that they aren't included in the bundle, since `import "./some-empty-file"` in JavaScript doesn't bundle anything. You can also use the `empty` loader to remove asset references in CSS files. For example `--loader:.png=empty` causes esbuild to replace asset references such as `url(image.png)` with `url()` so that they are no longer included in the resulting style sheet. diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 84942afb914..3b7321ee31e 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -345,185 +345,189 @@ func parseFile(args parseArgs) { args.log.AddError(&tracker, args.importPathRange, message) } - // This must come before we send on the "results" channel to avoid deadlock - if args.inject != nil { - var exports []config.InjectableExport - if repr, ok := result.file.inputFile.Repr.(*graph.JSRepr); ok { - aliases := make([]string, 0, len(repr.AST.NamedExports)) - for alias := range repr.AST.NamedExports { - aliases = append(aliases, alias) - } - sort.Strings(aliases) // Sort for determinism - exports = make([]config.InjectableExport, len(aliases)) - for i, alias := range aliases { - exports[i] = config.InjectableExport{ - Alias: alias, - Loc: repr.AST.NamedExports[alias].AliasLoc, - } - } - } - args.inject <- config.InjectedFile{ - Source: source, - Exports: exports, - } - } - - // Stop now if parsing failed - if !result.ok { - args.results <- result - return - } - - // Run the resolver on the parse thread so it's not run on the main thread. - // That way the main thread isn't blocked if the resolver takes a while. - if recordsPtr := result.file.inputFile.Repr.ImportRecords(); args.options.Mode == config.ModeBundle && !args.skipResolve && recordsPtr != nil { - // Clone the import records because they will be mutated later - records := append([]ast.ImportRecord{}, *recordsPtr...) - *recordsPtr = records - result.resolveResults = make([]*resolver.ResolveResult, len(records)) - - if len(records) > 0 { - resolverCache := make(map[ast.ImportKind]map[string]*resolver.ResolveResult) - tracker := logger.MakeLineColumnTracker(&source) - - for importRecordIndex := range records { - // Don't try to resolve imports that are already resolved - record := &records[importRecordIndex] - if record.SourceIndex.IsValid() { - continue - } - - // Ignore records that the parser has discarded. This is used to remove - // type-only imports in TypeScript files. - if record.Flags.Has(ast.IsUnused) { - continue - } - - // Cache the path in case it's imported multiple times in this file - cache, ok := resolverCache[record.Kind] - if !ok { - cache = make(map[string]*resolver.ResolveResult) - resolverCache[record.Kind] = cache - } - if resolveResult, ok := cache[record.Path.Text]; ok { - result.resolveResults[importRecordIndex] = resolveResult - continue - } + // Only continue now if parsing was successful + if result.ok { + // Run the resolver on the parse thread so it's not run on the main thread. + // That way the main thread isn't blocked if the resolver takes a while. + if recordsPtr := result.file.inputFile.Repr.ImportRecords(); args.options.Mode == config.ModeBundle && !args.skipResolve && recordsPtr != nil { + // Clone the import records because they will be mutated later + records := append([]ast.ImportRecord{}, *recordsPtr...) + *recordsPtr = records + result.resolveResults = make([]*resolver.ResolveResult, len(records)) + + if len(records) > 0 { + resolverCache := make(map[ast.ImportKind]map[string]*resolver.ResolveResult) + tracker := logger.MakeLineColumnTracker(&source) + + for importRecordIndex := range records { + // Don't try to resolve imports that are already resolved + record := &records[importRecordIndex] + if record.SourceIndex.IsValid() { + continue + } - // Run the resolver and log an error if the path couldn't be resolved - resolveResult, didLogError, debug := RunOnResolvePlugins( - args.options.Plugins, - args.res, - args.log, - args.fs, - &args.caches.FSCache, - &source, - record.Range, - source.KeyPath, - record.Path.Text, - record.Kind, - absResolveDir, - pluginData, - ) - cache[record.Path.Text] = resolveResult + // Ignore records that the parser has discarded. This is used to remove + // type-only imports in TypeScript files. + if record.Flags.Has(ast.IsUnused) { + continue + } - // All "require.resolve()" imports should be external because we don't - // want to waste effort traversing into them - if record.Kind == ast.ImportRequireResolve { - if resolveResult != nil && resolveResult.IsExternal { - // Allow path substitution as long as the result is external + // Cache the path in case it's imported multiple times in this file + cache, ok := resolverCache[record.Kind] + if !ok { + cache = make(map[string]*resolver.ResolveResult) + resolverCache[record.Kind] = cache + } + if resolveResult, ok := cache[record.Path.Text]; ok { result.resolveResults[importRecordIndex] = resolveResult - } else if !record.Flags.Has(ast.HandlesImportErrors) { - args.log.AddID(logger.MsgID_Bundler_RequireResolveNotExternal, logger.Warning, &tracker, record.Range, - fmt.Sprintf("%q should be marked as external for use with \"require.resolve\"", record.Path.Text)) + continue } - continue - } - if resolveResult == nil { - // Failed imports inside a try/catch are silently turned into - // external imports instead of causing errors. This matches a common - // code pattern for conditionally importing a module with a graceful - // fallback. - if !didLogError && !record.Flags.Has(ast.HandlesImportErrors) { - text, suggestion, notes := ResolveFailureErrorTextSuggestionNotes(args.res, record.Path.Text, record.Kind, - pluginName, args.fs, absResolveDir, args.options.Platform, source.PrettyPath, debug.ModifiedImportPath) - debug.LogErrorMsg(args.log, &source, record.Range, text, suggestion, notes) - } else if !didLogError && record.Flags.Has(ast.HandlesImportErrors) { - args.log.AddIDWithNotes(logger.MsgID_Bundler_IgnoredDynamicImport, logger.Debug, &tracker, record.Range, - fmt.Sprintf("Importing %q was allowed even though it could not be resolved because dynamic import failures appear to be handled here:", - record.Path.Text), []logger.MsgData{tracker.MsgData(js_lexer.RangeOfIdentifier(source, record.ErrorHandlerLoc), - "The handler for dynamic import failures is here:")}) + // Run the resolver and log an error if the path couldn't be resolved + resolveResult, didLogError, debug := RunOnResolvePlugins( + args.options.Plugins, + args.res, + args.log, + args.fs, + &args.caches.FSCache, + &source, + record.Range, + source.KeyPath, + record.Path.Text, + record.Kind, + absResolveDir, + pluginData, + ) + cache[record.Path.Text] = resolveResult + + // All "require.resolve()" imports should be external because we don't + // want to waste effort traversing into them + if record.Kind == ast.ImportRequireResolve { + if resolveResult != nil && resolveResult.IsExternal { + // Allow path substitution as long as the result is external + result.resolveResults[importRecordIndex] = resolveResult + } else if !record.Flags.Has(ast.HandlesImportErrors) { + args.log.AddID(logger.MsgID_Bundler_RequireResolveNotExternal, logger.Warning, &tracker, record.Range, + fmt.Sprintf("%q should be marked as external for use with \"require.resolve\"", record.Path.Text)) + } + continue } - continue - } - result.resolveResults[importRecordIndex] = resolveResult - } - } - } + if resolveResult == nil { + // Failed imports inside a try/catch are silently turned into + // external imports instead of causing errors. This matches a common + // code pattern for conditionally importing a module with a graceful + // fallback. + if !didLogError && !record.Flags.Has(ast.HandlesImportErrors) { + text, suggestion, notes := ResolveFailureErrorTextSuggestionNotes(args.res, record.Path.Text, record.Kind, + pluginName, args.fs, absResolveDir, args.options.Platform, source.PrettyPath, debug.ModifiedImportPath) + debug.LogErrorMsg(args.log, &source, record.Range, text, suggestion, notes) + } else if !didLogError && record.Flags.Has(ast.HandlesImportErrors) { + args.log.AddIDWithNotes(logger.MsgID_Bundler_IgnoredDynamicImport, logger.Debug, &tracker, record.Range, + fmt.Sprintf("Importing %q was allowed even though it could not be resolved because dynamic import failures appear to be handled here:", + record.Path.Text), []logger.MsgData{tracker.MsgData(js_lexer.RangeOfIdentifier(source, record.ErrorHandlerLoc), + "The handler for dynamic import failures is here:")}) + } + continue + } - // Attempt to parse the source map if present - if loader.CanHaveSourceMap() && args.options.SourceMap != config.SourceMapNone { - var sourceMapComment logger.Span - switch repr := result.file.inputFile.Repr.(type) { - case *graph.JSRepr: - sourceMapComment = repr.AST.SourceMapComment - case *graph.CSSRepr: - sourceMapComment = repr.AST.SourceMapComment + result.resolveResults[importRecordIndex] = resolveResult + } + } } - if sourceMapComment.Text != "" { - tracker := logger.MakeLineColumnTracker(&source) + // Attempt to parse the source map if present + if loader.CanHaveSourceMap() && args.options.SourceMap != config.SourceMapNone { + var sourceMapComment logger.Span + switch repr := result.file.inputFile.Repr.(type) { + case *graph.JSRepr: + sourceMapComment = repr.AST.SourceMapComment + case *graph.CSSRepr: + sourceMapComment = repr.AST.SourceMapComment + } - if path, contents := extractSourceMapFromComment(args.log, args.fs, &args.caches.FSCache, - args.res, &source, &tracker, sourceMapComment, absResolveDir); contents != nil { - prettyPath := args.res.PrettyPath(path) - log := logger.NewDeferLog(logger.DeferLogNoVerboseOrDebug, args.log.Overrides) + if sourceMapComment.Text != "" { + tracker := logger.MakeLineColumnTracker(&source) - sourceMap := js_parser.ParseSourceMap(log, logger.Source{ - KeyPath: path, - PrettyPath: prettyPath, - Contents: *contents, - }) + if path, contents := extractSourceMapFromComment(args.log, args.fs, &args.caches.FSCache, + args.res, &source, &tracker, sourceMapComment, absResolveDir); contents != nil { + prettyPath := args.res.PrettyPath(path) + log := logger.NewDeferLog(logger.DeferLogNoVerboseOrDebug, args.log.Overrides) - if msgs := log.Done(); len(msgs) > 0 { - var text string - if path.Namespace == "file" { - text = fmt.Sprintf("The source map %q was referenced by the file %q here:", prettyPath, args.prettyPath) - } else { - text = fmt.Sprintf("This source map came from the file %q here:", args.prettyPath) - } - note := tracker.MsgData(sourceMapComment.Range, text) - for _, msg := range msgs { - msg.Notes = append(msg.Notes, note) - args.log.AddMsg(msg) - } - } + sourceMap := js_parser.ParseSourceMap(log, logger.Source{ + KeyPath: path, + PrettyPath: prettyPath, + Contents: *contents, + }) - // If "sourcesContent" isn't present, try filling it in using the file system - if sourceMap != nil && sourceMap.SourcesContent == nil && !args.options.ExcludeSourcesContent { - for _, source := range sourceMap.Sources { - var absPath string - if args.fs.IsAbs(source) { - absPath = source - } else if path.Namespace == "file" { - absPath = args.fs.Join(args.fs.Dir(path.Text), source) + if msgs := log.Done(); len(msgs) > 0 { + var text string + if path.Namespace == "file" { + text = fmt.Sprintf("The source map %q was referenced by the file %q here:", prettyPath, args.prettyPath) } else { - sourceMap.SourcesContent = append(sourceMap.SourcesContent, sourcemap.SourceContent{}) - continue + text = fmt.Sprintf("This source map came from the file %q here:", args.prettyPath) } - var sourceContent sourcemap.SourceContent - if contents, err, _ := args.caches.FSCache.ReadFile(args.fs, absPath); err == nil { - sourceContent.Value = helpers.StringToUTF16(contents) + note := tracker.MsgData(sourceMapComment.Range, text) + for _, msg := range msgs { + msg.Notes = append(msg.Notes, note) + args.log.AddMsg(msg) } - sourceMap.SourcesContent = append(sourceMap.SourcesContent, sourceContent) } + + // If "sourcesContent" isn't present, try filling it in using the file system + if sourceMap != nil && sourceMap.SourcesContent == nil && !args.options.ExcludeSourcesContent { + for _, source := range sourceMap.Sources { + var absPath string + if args.fs.IsAbs(source) { + absPath = source + } else if path.Namespace == "file" { + absPath = args.fs.Join(args.fs.Dir(path.Text), source) + } else { + sourceMap.SourcesContent = append(sourceMap.SourcesContent, sourcemap.SourceContent{}) + continue + } + var sourceContent sourcemap.SourceContent + if contents, err, _ := args.caches.FSCache.ReadFile(args.fs, absPath); err == nil { + sourceContent.Value = helpers.StringToUTF16(contents) + } + sourceMap.SourcesContent = append(sourceMap.SourcesContent, sourceContent) + } + } + + result.file.inputFile.InputSourceMap = sourceMap } + } + } + } - result.file.inputFile.InputSourceMap = sourceMap + // Note: We must always send on the "inject" channel before we send on the + // "results" channel to avoid deadlock + if args.inject != nil { + var exports []config.InjectableExport + + if repr, ok := result.file.inputFile.Repr.(*graph.JSRepr); ok { + aliases := make([]string, 0, len(repr.AST.NamedExports)) + for alias := range repr.AST.NamedExports { + aliases = append(aliases, alias) + } + sort.Strings(aliases) // Sort for determinism + exports = make([]config.InjectableExport, len(aliases)) + for i, alias := range aliases { + exports[i] = config.InjectableExport{ + Alias: alias, + Loc: repr.AST.NamedExports[alias].AliasLoc, + } } } + + // Once we send on the "inject" channel, the main thread may mutate the + // "options" object to populate the "InjectedFiles" field. So we must + // only send on the "inject" channel after we're done using the "options" + // object so we don't introduce a data race. + args.inject <- config.InjectedFile{ + Source: source, + Exports: exports, + } } args.results <- result @@ -1216,6 +1220,9 @@ func (s *scanner) maybeParseFile( // Only parse a given file path once visited, ok := s.visited[visitedKey] if ok { + if inject != nil { + inject <- config.InjectedFile{} + } return visited.sourceIndex } @@ -1346,9 +1353,7 @@ func (s *scanner) preprocessInjectedFiles() { s.timer.Begin("Preprocess injected files") defer s.timer.End("Preprocess injected files") - injectedFiles := make([]config.InjectedFile, 0, len(s.options.InjectedDefines)+len(s.options.InjectAbsPaths)) - duplicateInjectedFiles := make(map[string]bool) - injectWaitGroup := sync.WaitGroup{} + injectedFiles := make([]config.InjectedFile, 0, len(s.options.InjectedDefines)+len(s.options.InjectPaths)) // These are virtual paths that are generated for compound "--define" values. // They are special-cased and are not available for plugins to intercept. @@ -1394,41 +1399,91 @@ func (s *scanner) preprocessInjectedFiles() { go func() { s.resultChannel <- result }() } - results := make([]config.InjectedFile, len(s.options.InjectAbsPaths)) - j := 0 - for _, absPath := range s.options.InjectAbsPaths { - prettyPath := s.res.PrettyPath(logger.Path{Text: absPath, Namespace: "file"}) - absPathKey := canonicalFileSystemPathForWindows(absPath) - - if duplicateInjectedFiles[absPathKey] { - s.log.AddError(nil, logger.Range{}, fmt.Sprintf("Duplicate injected file %q", prettyPath)) - continue - } + // Add user-specified injected files. Run resolver plugins on these files + // so plugins can alter where they resolve to. These are run in parallel in + // case any of these plugins block. + injectResolveResults := make([]*resolver.ResolveResult, len(s.options.InjectPaths)) + injectAbsResolveDir := s.fs.Cwd() + injectResolveWaitGroup := sync.WaitGroup{} + injectResolveWaitGroup.Add(len(s.options.InjectPaths)) + for i, importPath := range s.options.InjectPaths { + go func(i int, importPath string) { + var importer logger.Path - duplicateInjectedFiles[absPathKey] = true - resolveResult := s.res.ResolveAbs(absPath) + // Add a leading "./" if it's missing, similar to entry points + absPath := importPath + if !s.fs.IsAbs(absPath) { + absPath = s.fs.Join(injectAbsResolveDir, absPath) + } + dir := s.fs.Dir(absPath) + base := s.fs.Base(absPath) + if entries, err, originalError := s.fs.ReadDirectory(dir); err == nil { + if entry, _ := entries.Get(base); entry != nil && entry.Kind(s.fs) == fs.FileEntry { + importer.Namespace = "file" + if !s.fs.IsAbs(importPath) && resolver.IsPackagePath(importPath) { + importPath = "./" + importPath + } + } + } else if s.log.Level <= logger.LevelDebug && originalError != nil { + s.log.AddID(logger.MsgID_None, logger.Debug, nil, logger.Range{}, fmt.Sprintf("Failed to read directory %q: %s", absPath, originalError.Error())) + } - if resolveResult == nil { - s.log.AddError(nil, logger.Range{}, fmt.Sprintf("Could not resolve %q", prettyPath)) - continue - } + // Run the resolver and log an error if the path couldn't be resolved + resolveResult, didLogError, debug := RunOnResolvePlugins( + s.options.Plugins, + s.res, + s.log, + s.fs, + &s.caches.FSCache, + nil, + logger.Range{}, + importer, + importPath, + ast.ImportEntryPoint, + injectAbsResolveDir, + nil, + ) + if resolveResult != nil { + if resolveResult.IsExternal { + s.log.AddError(nil, logger.Range{}, fmt.Sprintf("The injected path %q cannot be marked as external", importPath)) + } else { + injectResolveResults[i] = resolveResult + } + } else if !didLogError { + debug.LogErrorMsg(s.log, nil, logger.Range{}, fmt.Sprintf("Could not resolve %q", importPath), "", nil) + } + injectResolveWaitGroup.Done() + }(i, importPath) + } + injectResolveWaitGroup.Wait() - channel := make(chan config.InjectedFile) - s.maybeParseFile(*resolveResult, prettyPath, nil, logger.Range{}, nil, inputKindNormal, channel) + // Parse all entry points that were resolved successfully + results := make([]config.InjectedFile, len(s.options.InjectPaths)) + j := 0 + var injectWaitGroup sync.WaitGroup + for _, resolveResult := range injectResolveResults { + if resolveResult != nil { + channel := make(chan config.InjectedFile, 1) + s.maybeParseFile(*resolveResult, s.res.PrettyPath(resolveResult.PathPair.Primary), nil, logger.Range{}, nil, inputKindNormal, channel) + injectWaitGroup.Add(1) - // Wait for the results in parallel. The results slice is large enough so - // it is not reallocated during the computations. - injectWaitGroup.Add(1) - go func(i int) { - results[i] = <-channel - injectWaitGroup.Done() - }(j) - j++ + // Wait for the results in parallel. The results slice is large enough so + // it is not reallocated during the computations. + go func(i int) { + results[i] = <-channel + injectWaitGroup.Done() + }(j) + j++ + } } - injectWaitGroup.Wait() injectedFiles = append(injectedFiles, results[:j]...) + // It's safe to mutate the options object to add the injected files here + // because there aren't any concurrent "parseFile" goroutines at this point. + // The only ones that were created by this point are the ones we created + // above, and we've already waited for all of them to finish using the + // "options" object. s.options.InjectedFiles = injectedFiles } diff --git a/internal/bundler_tests/bundler_default_test.go b/internal/bundler_tests/bundler_default_test.go index 6a83cbe22ce..5d5327f2422 100644 --- a/internal/bundler_tests/bundler_default_test.go +++ b/internal/bundler_tests/bundler_default_test.go @@ -2919,11 +2919,11 @@ func TestUseStrictDirectiveBundleIssue1837(t *testing.T) { }, entryPaths: []string{"/entry.js"}, options: config.Options{ - Mode: config.ModeBundle, - AbsOutputFile: "/out.js", - InjectAbsPaths: []string{"/shims.js"}, - Platform: config.PlatformNode, - OutputFormat: config.FormatIIFE, + Mode: config.ModeBundle, + AbsOutputFile: "/out.js", + InjectPaths: []string{"/shims.js"}, + Platform: config.PlatformNode, + OutputFormat: config.FormatIIFE, }, }) } @@ -4127,11 +4127,11 @@ func TestInjectMissing(t *testing.T) { options: config.Options{ Mode: config.ModeBundle, AbsOutputFile: "/out.js", - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject.js", }, }, - expectedScanLog: "ERROR: Could not read from file: /inject.js\n", + expectedScanLog: "ERROR: Could not resolve \"/inject.js\"\n", }) default_suite.expectBundledWindows(t, bundled{ @@ -4142,31 +4142,30 @@ func TestInjectMissing(t *testing.T) { options: config.Options{ Mode: config.ModeBundle, AbsOutputFile: "/out.js", - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject.js", }, }, - expectedScanLog: "ERROR: Could not read from file: C:\\inject.js\n", + expectedScanLog: "ERROR: Could not resolve \"C:\\\\inject.js\"\n", }) } +// Duplicates are allowed, and should only be injected once func TestInjectDuplicate(t *testing.T) { default_suite.expectBundled(t, bundled{ files: map[string]string{ "/entry.js": ``, - "/inject.js": ``, + "/inject.js": `console.log('injected')`, }, entryPaths: []string{"/entry.js"}, options: config.Options{ Mode: config.ModeBundle, AbsOutputFile: "/out.js", - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject.js", "/inject.js", }, }, - expectedScanLog: `ERROR: Duplicate injected file "inject.js" -`, }) } @@ -4233,7 +4232,7 @@ func TestInject(t *testing.T) { AbsOutputFile: "/out.js", Defines: &defines, OutputFormat: config.FormatCommonJS, - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject.js", "/node_modules/unused/index.js", "/node_modules/sideEffects-false/index.js", @@ -4313,7 +4312,7 @@ func TestInjectNoBundle(t *testing.T) { TreeShaking: true, AbsOutputFile: "/out.js", Defines: &defines, - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject.js", "/node_modules/unused/index.js", "/node_modules/sideEffects-false/index.js", @@ -4353,7 +4352,7 @@ func TestInjectJSX(t *testing.T) { Mode: config.ModeBundle, AbsOutputFile: "/out.js", Defines: &defines, - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject.js", }, }, @@ -4380,7 +4379,7 @@ func TestInjectImportTS(t *testing.T) { Mode: config.ModeConvertFormat, OutputFormat: config.FormatESModule, AbsOutputFile: "/out.js", - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject.js", }, }, @@ -4407,7 +4406,7 @@ func TestInjectImportOrder(t *testing.T) { options: config.Options{ Mode: config.ModeBundle, AbsOutputFile: "/out.js", - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject-1.js", "/inject-2.js", }, @@ -4436,7 +4435,7 @@ func TestInjectAssign(t *testing.T) { options: config.Options{ Mode: config.ModeBundle, AbsOutputFile: "/out.js", - InjectAbsPaths: []string{ + InjectPaths: []string{ "/inject.js", }, }, diff --git a/internal/bundler_tests/bundler_test.go b/internal/bundler_tests/bundler_test.go index 7bbda3d124b..3b6e6d1b98e 100644 --- a/internal/bundler_tests/bundler_test.go +++ b/internal/bundler_tests/bundler_test.go @@ -130,8 +130,8 @@ func (s *suite) __expectBundledImpl(t *testing.T, args bundled, fsKind fs.MockKi entryPoints[i] = entry } - for i, absPath := range args.options.InjectAbsPaths { - args.options.InjectAbsPaths[i] = unix2win(absPath) + for i, absPath := range args.options.InjectPaths { + args.options.InjectPaths[i] = unix2win(absPath) } for key, value := range args.options.PackageAliases { diff --git a/internal/bundler_tests/snapshots/snapshots_default.txt b/internal/bundler_tests/snapshots/snapshots_default.txt index 329663ce83a..de0a021849f 100644 --- a/internal/bundler_tests/snapshots/snapshots_default.txt +++ b/internal/bundler_tests/snapshots/snapshots_default.txt @@ -1477,6 +1477,12 @@ console.log(replace.test); console.log(collide); console.log(import_external_pkg.re_export); +================================================================================ +TestInjectDuplicate +---------- /out.js ---------- +// inject.js +console.log("injected"); + ================================================================================ TestInjectImportOrder ---------- /out.js ---------- diff --git a/internal/config/config.go b/internal/config/config.go index 3339375a7f5..db152a70c33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -286,7 +286,7 @@ type Options struct { ExtensionToLoader map[string]Loader PublicPath string - InjectAbsPaths []string + InjectPaths []string InjectedDefines []InjectedDefine InjectedFiles []InjectedFile diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 49e054bd391..365fd835c02 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -1011,7 +1011,7 @@ func rebuildImpl( MainFields: buildOpts.MainFields, PublicPath: buildOpts.PublicPath, KeepNames: buildOpts.KeepNames, - InjectAbsPaths: make([]string, len(buildOpts.Inject)), + InjectPaths: append([]string{}, buildOpts.Inject...), AbsNodePaths: make([]string, len(buildOpts.NodePaths)), JSBanner: bannerJS, JSFooter: footerJS, @@ -1027,9 +1027,6 @@ func rebuildImpl( if options.MainFields != nil { options.MainFields = append([]string{}, options.MainFields...) } - for i, path := range buildOpts.Inject { - options.InjectAbsPaths[i] = validatePath(log, realFS, path, "inject path") - } for i, path := range buildOpts.NodePaths { options.AbsNodePaths[i] = validatePath(log, realFS, path, "node path") } diff --git a/scripts/plugin-tests.js b/scripts/plugin-tests.js index 2aaffa9e47f..42883c8a31c 100644 --- a/scripts/plugin-tests.js +++ b/scripts/plugin-tests.js @@ -2367,6 +2367,28 @@ error: Invalid path suffix "%what" returned from plugin (must start with "?" or }) assert.strictEqual(result.outputFiles[0].text, `console.log(123);\n`) }, + + async injectWithVirtualFile({ esbuild, testDir }) { + const input = path.join(testDir, 'input.js') + await writeFileAsync(input, `console.log(test)`) + const result = await esbuild.build({ + entryPoints: [input], + write: false, + inject: ['plugin-file'], + plugins: [{ + name: 'plugin', + setup(build) { + build.onResolve({ filter: /^plugin-file$/ }, () => { + return { namespace: 'plugin', path: 'path' } + }) + build.onLoad({ filter: /^path$/, namespace: 'plugin' }, () => { + return { contents: `export let test = 'injected'` } + }) + }, + }], + }) + assert.strictEqual(result.outputFiles[0].text, `var test = "injected";\nconsole.log(test);\n`) + }, } // These tests have to run synchronously