Skip to content

Commit

Permalink
fix #2191: add a path alias feature
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Nov 27, 2022
1 parent 4e9f9c1 commit a7eb789
Show file tree
Hide file tree
Showing 14 changed files with 290 additions and 7 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Expand Up @@ -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 `<x y />`, `y` is a string) or an object spread (e.g. in `<x {...y} />`, `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 `<x {...{ [y]: z }} />`. When minification is enabled, esbuild's internal representation of this is something like `<x [y]={z} />` 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 `<x {...{[y]:z}}/>` in this scenario instead of crashing.
Expand Down
21 changes: 19 additions & 2 deletions internal/bundler/bundler.go
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions internal/bundler/bundler_default_test.go
Expand Up @@ -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",
},
},
})
}
6 changes: 6 additions & 0 deletions internal/bundler/bundler_test.go
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions internal/bundler/snapshots/snapshots_loader.txt
Expand Up @@ -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 ----------
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/fs/fs_mock.go
Expand Up @@ -65,20 +65,29 @@ 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
}
return DirEntries{}, syscall.ENOENT, syscall.ENOENT
}

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
}
return "", syscall.ENOENT, syscall.ENOENT
}

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
}
Expand Down
40 changes: 36 additions & 4 deletions internal/resolver/resolver.go
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ||

Expand Down
7 changes: 7 additions & 0 deletions lib/shared/common.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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}`);
Expand Down
2 changes: 2 additions & 0 deletions lib/shared/types.ts
Expand Up @@ -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<string, string>;
/** Documentation: https://esbuild.github.io/api/#loader */
loader?: { [ext: string]: Loader };
/** Documentation: https://esbuild.github.io/api/#resolve-extensions */
Expand Down
1 change: 1 addition & 0 deletions pkg/api/api.go
Expand Up @@ -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
Expand Down
80 changes: 79 additions & 1 deletion pkg/api/api_impl.go
Expand Up @@ -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] != '.'
}
Expand Down Expand Up @@ -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...),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit a7eb789

Please sign in to comment.