diff --git a/CHANGELOG.md b/CHANGELOG.md index f22b2887a6c..42da8683a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +* Add a package alias feature ([#2191](https://github.com/evanw/esbuild/issues/2191)) + + With this release, you can now easily substitute one package for another at build time with the new `alias` feature. For example, `--alias:oldpkg=newpkg` replaces all imports of `oldpkg` with `newpkg`. One use case for this is easily replacing a node-only package with a browser-friendly package in 3rd-party code that you don't control. These new substitutions happen first before all of esbuild's existing path resolution logic. + + Note that when an import path is substituted using an alias, the resulting import path is resolved in the working directory instead of in the directory containing the source file with the import path. The working directory can be set with the `cd` command when using the CLI or with the `absWorkingDir` setting when using the JS or Go APIs. + * Fix crash when pretty-printing minified JSX with object spread of object literal with computed property ([#2697](https://github.com/evanw/esbuild/issues/2697)) JSX elements are translated to JavaScript function calls and JSX element attributes are translated to properties on a JavaScript object literal. These properties are always either strings (e.g. in ``, `y` is a string) or an object spread (e.g. in ``, `y` is an object spread) because JSX doesn't provide syntax for directly passing a computed property as a JSX attribute. However, esbuild's minifier has a rule that tries to inline object spread with an inline object literal in JavaScript. For example, `x = { ...{ y } }` is minified to `x={y}` when minification is enabled. This means that there is a way to generate a non-string non-spread JSX attribute in esbuild's internal representation. One example is with ``. When minification is enabled, esbuild's internal representation of this is something like `` due to object spread inlining, which is not valid JSX syntax. If this internal representation is then pretty-printed as JSX using `--minify --jsx=preserve`, esbuild previously crashed when trying to print this invalid syntax. With this release, esbuild will now print `` in this scenario instead of crashing. diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index c5659f20fc2..396bea66b47 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -440,7 +440,7 @@ func parseFile(args parseArgs) { // 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) + 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, @@ -506,8 +506,18 @@ func ResolveFailureErrorTextSuggestionNotes( absResolveDir string, platform config.Platform, originatingFilePath string, + modifiedImportPath string, ) (text string, suggestion string, notes []logger.MsgData) { - text = fmt.Sprintf("Could not resolve %q", path) + if modifiedImportPath != "" { + text = fmt.Sprintf("Could not resolve %q (originally %q)", modifiedImportPath, path) + notes = append(notes, logger.MsgData{Text: fmt.Sprintf( + "The path %q was remapped to %q using the alias feature, which then couldn't be resolved. "+ + "Keep in mind that import path aliases are resolved in the current working directory.", + path, modifiedImportPath)}) + path = modifiedImportPath + } else { + text = fmt.Sprintf("Could not resolve %q", path) + } hint := "" if resolver.IsPackagePath(path) { @@ -557,6 +567,13 @@ func ResolveFailureErrorTextSuggestionNotes( } if hint != "" { + if modifiedImportPath != "" { + // Add a newline if there's already a paragraph of text + notes = append(notes, logger.MsgData{}) + + // Don't add a suggestion if the path was rewritten using an alias + suggestion = "" + } notes = append(notes, logger.MsgData{Text: hint}) } return diff --git a/internal/bundler/bundler_default_test.go b/internal/bundler/bundler_default_test.go index 6ccf661287f..bd5b19b4f6b 100644 --- a/internal/bundler/bundler_default_test.go +++ b/internal/bundler/bundler_default_test.go @@ -6816,3 +6816,45 @@ func TestMinifiedJSXPreserveWithObjectSpread(t *testing.T) { }, }) } + +func TestPackageAlias(t *testing.T) { + loader_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import "pkg1" + import "pkg2/foo" + import "./nested3" + import "@scope/pkg4" + import "@scope/pkg5/foo" + import "@abs-path/pkg6" + import "@abs-path/pkg7/foo" + import "@scope-only/pkg8" + `, + "/nested3/index.js": `import "pkg3"`, + "/nested3/node_modules/alias3/index.js": `test failure`, + "/node_modules/alias1/index.js": `console.log(1)`, + "/node_modules/alias2/foo.js": `console.log(2)`, + "/node_modules/alias3/index.js": `console.log(3)`, + "/node_modules/alias4/index.js": `console.log(4)`, + "/node_modules/alias5/foo.js": `console.log(5)`, + "/alias6/dir/index.js": `console.log(6)`, + "/alias7/dir/foo/index.js": `console.log(7)`, + "/alias8/dir/pkg8/index.js": `console.log(8)`, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.js", + PackageAliases: map[string]string{ + "pkg1": "alias1", + "pkg2": "alias2", + "pkg3": "alias3", + "@scope/pkg4": "alias4", + "@scope/pkg5": "alias5", + "@abs-path/pkg6": `/alias6/dir`, + "@abs-path/pkg7": `/alias7/dir`, + "@scope-only": "/alias8/dir", + }, + }, + }) +} diff --git a/internal/bundler/bundler_test.go b/internal/bundler/bundler_test.go index 56be90d2e37..f2ea9112ccf 100644 --- a/internal/bundler/bundler_test.go +++ b/internal/bundler/bundler_test.go @@ -132,6 +132,12 @@ func (s *suite) __expectBundledImpl(t *testing.T, args bundled, fsKind fs.MockKi args.options.InjectAbsPaths[i] = unix2win(absPath) } + for key, value := range args.options.PackageAliases { + if strings.HasPrefix(value, "/") { + args.options.PackageAliases[key] = unix2win(value) + } + } + replace := make(map[string]bool) for k, v := range args.options.ExternalSettings.PostResolve.Exact { replace[unix2win(k)] = v diff --git a/internal/bundler/snapshots/snapshots_loader.txt b/internal/bundler/snapshots/snapshots_loader.txt index 4d4e3466fd2..c6183bd5c5c 100644 --- a/internal/bundler/snapshots/snapshots_loader.txt +++ b/internal/bundler/snapshots/snapshots_loader.txt @@ -997,6 +997,33 @@ export { b as aap }; +================================================================================ +TestPackageAlias +---------- /out.js ---------- +// node_modules/alias1/index.js +console.log(1); + +// node_modules/alias2/foo.js +console.log(2); + +// node_modules/alias3/index.js +console.log(3); + +// node_modules/alias4/index.js +console.log(4); + +// node_modules/alias5/foo.js +console.log(5); + +// alias6/dir/index.js +console.log(6); + +// alias7/dir/foo/index.js +console.log(7); + +// alias8/dir/pkg8/index.js +console.log(8); + ================================================================================ TestRequireCustomExtensionBase64 ---------- /out.js ---------- diff --git a/internal/config/config.go b/internal/config/config.go index 1a996b13eff..f83bb60e19f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -253,6 +253,7 @@ type Options struct { Conditions []string AbsNodePaths []string // The "NODE_PATH" variable from Node.js ExternalSettings ExternalSettings + PackageAliases map[string]string AbsOutputFile string AbsOutputDir string diff --git a/internal/fs/fs_mock.go b/internal/fs/fs_mock.go index 688fe69876f..c87488f6cd4 100644 --- a/internal/fs/fs_mock.go +++ b/internal/fs/fs_mock.go @@ -65,6 +65,9 @@ func MockFS(input map[string]string, kind MockKind) FS { } func (fs *mockFS) ReadDirectory(path string) (DirEntries, error, error) { + if fs.Kind == MockWindows { + path = strings.ReplaceAll(path, "/", "\\") + } if dir, ok := fs.dirs[path]; ok { return dir, nil, nil } @@ -72,6 +75,9 @@ func (fs *mockFS) ReadDirectory(path string) (DirEntries, error, error) { } func (fs *mockFS) ReadFile(path string) (string, error, error) { + if fs.Kind == MockWindows { + path = strings.ReplaceAll(path, "/", "\\") + } if contents, ok := fs.files[path]; ok { return contents, nil, nil } @@ -79,6 +85,9 @@ func (fs *mockFS) ReadFile(path string) (string, error, error) { } func (fs *mockFS) OpenFile(path string) (OpenedFile, error, error) { + if fs.Kind == MockWindows { + path = strings.ReplaceAll(path, "/", "\\") + } if contents, ok := fs.files[path]; ok { return &InMemoryOpenedFile{Contents: []byte(contents)}, nil, nil } diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index eb05f7d0a6f..226a0c468ef 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -136,10 +136,11 @@ const ( ) type DebugMeta struct { - notes []logger.MsgData - suggestionText string - suggestionMessage string - suggestionRange suggestionRange + notes []logger.MsgData + suggestionText string + suggestionMessage string + suggestionRange suggestionRange + ModifiedImportPath string } func (dm DebugMeta) LogErrorMsg(log logger.Log, source *logger.Source, r logger.Range, text string, suggestion string, notes []logger.MsgData) { @@ -297,6 +298,37 @@ func (rr *resolver) Resolve(sourceDir string, importPath string, kind ast.Import importPath, sourceDir, kind.StringForMetafile())} } + // Apply package alias substitutions first + if r.options.PackageAliases != nil && IsPackagePath(importPath) { + if r.debugLogs != nil { + r.debugLogs.addNote("Checking for package alias matches") + } + foundMatch := false + for key, value := range r.options.PackageAliases { + if strings.HasPrefix(importPath, key) && (len(importPath) == len(key) || importPath[len(key)] == '/') { + // Resolve the package using the current path instead of the original + // path. This is trying to resolve the substitute in the top-level + // package instead of the nested package, which lets the top-level + // package control the version of the substitution. It's also critical + // when using Yarn PnP because Yarn PnP doesn't allow nested packages + // to "reach outside" of their normal dependency lists. + sourceDir = r.fs.Cwd() + debugMeta.ModifiedImportPath = value + importPath[len(key):] + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf(" Matched with alias from %q to %q", key, value)) + r.debugLogs.addNote(fmt.Sprintf(" Modified import path from %q to %q", importPath, debugMeta.ModifiedImportPath)) + r.debugLogs.addNote(fmt.Sprintf(" Changed resolve directory to %q", sourceDir)) + } + importPath = debugMeta.ModifiedImportPath + foundMatch = true + break + } + } + if r.debugLogs != nil && !foundMatch { + r.debugLogs.addNote(" Failed to find any package alias matches") + } + } + // Certain types of URLs default to being external for convenience if isExplicitlyExternal := r.isExternal(r.options.ExternalSettings.PreResolve, importPath); isExplicitlyExternal || diff --git a/lib/shared/common.ts b/lib/shared/common.ts index a5302f400b2..c0d1d4efb57 100644 --- a/lib/shared/common.ts +++ b/lib/shared/common.ts @@ -247,6 +247,7 @@ function flagsForBuildOptions( let mainFields = getFlag(options, keys, 'mainFields', mustBeArray); let conditions = getFlag(options, keys, 'conditions', mustBeArray); let external = getFlag(options, keys, 'external', mustBeArray); + let alias = getFlag(options, keys, 'alias', mustBeObject); let loader = getFlag(options, keys, 'loader', mustBeObject); let outExtension = getFlag(options, keys, 'outExtension', mustBeObject); let publicPath = getFlag(options, keys, 'publicPath', mustBeString); @@ -319,6 +320,12 @@ function flagsForBuildOptions( flags.push(`--conditions=${values.join(',')}`); } if (external) for (let name of external) flags.push(`--external:${name}`); + if (alias) { + for (let old in alias) { + if (old.indexOf('=') >= 0) throw new Error(`Invalid package name in alias: ${old}`); + flags.push(`--alias:${old}=${alias[old]}`); + } + } if (banner) { for (let type in banner) { if (type.indexOf('=') >= 0) throw new Error(`Invalid banner file type: ${type}`); diff --git a/lib/shared/types.ts b/lib/shared/types.ts index 3177b37cf43..0304463b9be 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -98,6 +98,8 @@ export interface BuildOptions extends CommonOptions { outbase?: string; /** Documentation: https://esbuild.github.io/api/#external */ external?: string[]; + /** Documentation: https://esbuild.github.io/api/#alias */ + alias?: Record; /** Documentation: https://esbuild.github.io/api/#loader */ loader?: { [ext: string]: Loader }; /** Documentation: https://esbuild.github.io/api/#resolve-extensions */ diff --git a/pkg/api/api.go b/pkg/api/api.go index 3aefb20e85a..2304399c09c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -299,6 +299,7 @@ type BuildOptions struct { Platform Platform // Documentation: https://esbuild.github.io/api/#platform Format Format // Documentation: https://esbuild.github.io/api/#format External []string // Documentation: https://esbuild.github.io/api/#external + Alias map[string]string // Documentation: https://esbuild.github.io/api/#alias MainFields []string // Documentation: https://esbuild.github.io/api/#main-fields Conditions []string // Documentation: https://esbuild.github.io/api/#conditions Loader map[string]Loader // Documentation: https://esbuild.github.io/api/#loader diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 451ec4fad2f..8c2c48a212d 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -446,6 +446,80 @@ func validateExternals(log logger.Log, fs fs.FS, paths []string) config.External return result } +func esmParsePackageName(packageSpecifier string) (packageName string, packageSubpath string, ok bool) { + if packageSpecifier == "" { + return + } + + slash := strings.IndexByte(packageSpecifier, '/') + if !strings.HasPrefix(packageSpecifier, "@") { + if slash == -1 { + slash = len(packageSpecifier) + } + packageName = packageSpecifier[:slash] + } else { + if slash == -1 { + return + } + slash2 := strings.IndexByte(packageSpecifier[slash+1:], '/') + if slash2 == -1 { + slash2 = len(packageSpecifier[slash+1:]) + } + packageName = packageSpecifier[:slash+1+slash2] + } + + if strings.HasPrefix(packageName, ".") || strings.ContainsAny(packageName, "\\%") { + return + } + + packageSubpath = "." + packageSpecifier[len(packageName):] + ok = true + return +} + +func validateAlias(log logger.Log, fs fs.FS, alias map[string]string) map[string]string { + valid := make(map[string]string, len(alias)) + + for old, new := range alias { + if new == "" || new == "." || new == ".." || + strings.HasPrefix(new, "./") || strings.HasPrefix(new, "../") || + strings.HasPrefix(new, ".\\") || strings.HasPrefix(new, "..\\") { + log.AddError(nil, logger.Range{}, fmt.Sprintf("Invalid alias substitution: %q", new)) + continue + } + + // Valid alias names: + // "foo" + // "@foo" + // "@foo/bar" + // + // Invalid alias names: + // "./foo" + // "foo/bar" + // "@foo/" + // "@foo/bar/baz" + // + if !strings.HasPrefix(old, ".") && !strings.HasPrefix(old, "/") && !fs.IsAbs(old) { + slash := strings.IndexByte(old, '/') + isScope := strings.HasPrefix(old, "@") + if slash != -1 && isScope { + pkgAfterScope := old[slash+1:] + if slash2 := strings.IndexByte(pkgAfterScope, '/'); slash2 == -1 && pkgAfterScope != "" { + valid[old] = new + continue + } + } else if slash == -1 { + valid[old] = new + continue + } + } + + log.AddError(nil, logger.Range{}, fmt.Sprintf("Invalid alias name: %q", old)) + } + + return valid +} + func isValidExtension(ext string) bool { return len(ext) >= 2 && ext[0] == '.' && ext[len(ext)-1] != '.' } @@ -938,6 +1012,7 @@ func rebuildImpl( ExtensionToLoader: validateLoaders(log, buildOpts.Loader), ExtensionOrder: validateResolveExtensions(log, buildOpts.ResolveExtensions), ExternalSettings: validateExternals(log, realFS, buildOpts.External), + PackageAliases: validateAlias(log, realFS, buildOpts.Alias), TsConfigOverride: validatePath(log, realFS, buildOpts.Tsconfig, "tsconfig path"), MainFields: buildOpts.MainFields, Conditions: append([]string{}, buildOpts.Conditions...), @@ -1022,6 +1097,9 @@ func rebuildImpl( if options.ExternalSettings.PreResolve.HasMatchers() || options.ExternalSettings.PostResolve.HasMatchers() { log.AddError(nil, logger.Range{}, "Cannot use \"external\" without \"bundle\"") } + if len(options.PackageAliases) > 0 { + log.AddError(nil, logger.Range{}, "Cannot use \"alias\" without \"bundle\"") + } } else if options.OutputFormat == config.FormatPreserve { // If the format isn't specified, set the default format using the platform switch options.Platform { @@ -1823,7 +1901,7 @@ func loadPlugins(initialOptions *BuildOptions, fs fs.FS, log logger.Log, caches if options.PluginName != "" { pluginName = options.PluginName } - text, _, notes := bundler.ResolveFailureErrorTextSuggestionNotes(resolver, path, kind, pluginName, fs, absResolveDir, buildOptions.Platform, "") + text, _, notes := bundler.ResolveFailureErrorTextSuggestionNotes(resolver, path, kind, pluginName, fs, absResolveDir, buildOptions.Platform, "", "") result.Errors = append(result.Errors, convertMessagesToPublic(logger.Error, []logger.Msg{{ Data: logger.MsgData{Text: text}, Notes: notes, diff --git a/pkg/cli/cli_impl.go b/pkg/cli/cli_impl.go index 09da40503af..add61da1271 100644 --- a/pkg/cli/cli_impl.go +++ b/pkg/cli/cli_impl.go @@ -602,6 +602,21 @@ func parseOptionsImpl( case strings.HasPrefix(arg, "--inject:") && buildOpts != nil: buildOpts.Inject = append(buildOpts.Inject, arg[len("--inject:"):]) + case strings.HasPrefix(arg, "--alias:") && buildOpts != nil: + value := arg[len("--alias:"):] + equals := strings.IndexByte(value, '=') + if equals == -1 { + return parseOptionsExtras{}, cli_helpers.MakeErrorWithNote( + fmt.Sprintf("Missing \"=\" in %q", arg), + "You need to use \"=\" to specify both the original package name and the replacement package name. "+ + "For example, \"--alias:old=new\" replaces package \"old\" with package \"new\".", + ) + } + if buildOpts.Alias == nil { + buildOpts.Alias = make(map[string]string) + } + buildOpts.Alias[value[:equals]] = value[equals+1:] + case strings.HasPrefix(arg, "--jsx="): value := arg[len("--jsx="):] var mode api.JSXMode @@ -828,6 +843,7 @@ func parseOptionsImpl( } colon := map[string]bool{ + "alias": true, "banner": true, "define": true, "drop": true, diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index 317f7bc949d..b0cfc13151e 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -128,6 +128,45 @@ let buildTests = { assert.strictEqual(require(bOut).y, true) }, + async alias({ esbuild }) { + const valid = async alias => { + const result = await esbuild.build({ + stdin: { contents: 'import ' + JSON.stringify(alias) }, + bundle: true, + alias: { [alias]: 'foo' }, + external: ['foo'], + format: 'esm', + write: false, + }) + assert.strictEqual(result.outputFiles[0].text, '// \nimport "foo";\n') + } + + const invalid = async alias => { + try { + await esbuild.build({ + bundle: true, + alias: { [alias]: 'foo' }, + logLevel: 'silent', + }) + } catch { + return + } + throw new Error('Expected an error for alias: ' + alias) + } + + await Promise.all([ + valid('foo'), + valid('@scope'), + valid('@scope/foo'), + + invalid('foo/bar'), + invalid('/foo'), + invalid('./foo'), + invalid('@scope/'), + invalid('@scope/foo/bar'), + ]) + }, + async pathResolverEACCS({ esbuild, testDir }) { let outerDir = path.join(testDir, 'outer'); let innerDir = path.join(outerDir, 'inner');