diff --git a/CHANGELOG.md b/CHANGELOG.md index 713512b7565..2e2776390cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ ## Unreleased +* Add support for `imports` in `package.json` ([#1691](https://github.com/evanw/esbuild/issues/1691)) + + This release adds basic support for the `imports` field in `package.json`. It behaves similarly to the `exports` field but only applies to import paths that start with `#`. The `imports` field provides a way for a package to remap its own internal imports for itself, while the `exports` field provides a way for a package to remap its external exports for other packages. This is useful because the `imports` field respects the currently-configured conditions which means that the import mapping can change at run-time. For example: + + ``` + $ cat entry.mjs + import '#example' + + $ cat package.json + { + "imports": { + "#example": { + "foo": "./example.foo.mjs", + "default": "./example.mjs" + } + } + } + + $ cat example.foo.mjs + console.log('foo is enabled') + + $ cat example.mjs + console.log('foo is disabled') + + $ node entry.mjs + foo is disabled + + $ node --conditions=foo entry.mjs + foo is enabled + ``` + + Now that esbuild supports this feature too, import paths starting with `#` and any provided conditions will be respected when bundling: + + ``` + $ esbuild --bundle entry.mjs | node + foo is disabled + + $ esbuild --conditions=foo --bundle entry.mjs | node + foo is enabled + ``` + * Fix using `npm rebuild` with the `esbuild` package ([#1703](https://github.com/evanw/esbuild/issues/1703)) Version 0.13.4 accidentally introduced a regression in the install script where running `npm rebuild` multiple times could fail after the second time. The install script creates a copy of the binary executable using [`link`](https://man7.org/linux/man-pages/man2/link.2.html) followed by [`rename`](https://www.man7.org/linux/man-pages/man2/rename.2.html). Using `link` creates a hard link which saves space on the file system, and `rename` is used for safety since it atomically replaces the destination. diff --git a/internal/bundler/bundler_packagejson_test.go b/internal/bundler/bundler_packagejson_test.go index 110c8559a15..c00446f06da 100644 --- a/internal/bundler/bundler_packagejson_test.go +++ b/internal/bundler/bundler_packagejson_test.go @@ -1929,3 +1929,162 @@ Users/user/project/src/entry.js: note: Import from "pkg/extra/other/file.js" to `, }) } + +func TestPackageJsonImports(t *testing.T) { + packagejson_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import '#top-level' + import '#nested/path.js' + import '#star/c.js' + import '#slash/d.js' + `, + "/Users/user/project/src/package.json": ` + { + "imports": { + "#top-level": "./a.js", + "#nested/path.js": "./b.js", + "#star/*": "./some-star/*", + "#slash/": "./some-slash/" + } + } + `, + "/Users/user/project/src/a.js": `console.log('a.js')`, + "/Users/user/project/src/b.js": `console.log('b.js')`, + "/Users/user/project/src/some-star/c.js": `console.log('c.js')`, + "/Users/user/project/src/some-slash/d.js": `console.log('d.js')`, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + }) +} + +func TestPackageJsonImportsRemapToOtherPackage(t *testing.T) { + packagejson_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import '#top-level' + import '#nested/path.js' + import '#star/c.js' + import '#slash/d.js' + `, + "/Users/user/project/src/package.json": ` + { + "imports": { + "#top-level": "pkg/a.js", + "#nested/path.js": "pkg/b.js", + "#star/*": "pkg/some-star/*", + "#slash/": "pkg/some-slash/" + } + } + `, + "/Users/user/project/src/node_modules/pkg/a.js": `console.log('a.js')`, + "/Users/user/project/src/node_modules/pkg/b.js": `console.log('b.js')`, + "/Users/user/project/src/node_modules/pkg/some-star/c.js": `console.log('c.js')`, + "/Users/user/project/src/node_modules/pkg/some-slash/d.js": `console.log('d.js')`, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + }) +} + +func TestPackageJsonImportsErrorMissingRemappedPackage(t *testing.T) { + packagejson_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import '#foo' + `, + "/Users/user/project/src/package.json": ` + { + "imports": { + "#foo": "bar" + } + } + `, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + expectedScanLog: `Users/user/project/src/entry.js: error: Could not resolve "#foo" (mark it as external to exclude it from the bundle) +Users/user/project/src/package.json: note: The remapped path "bar" could not be resolved +`, + }) +} + +func TestPackageJsonImportsInvalidPackageConfiguration(t *testing.T) { + packagejson_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import '#foo' + `, + "/Users/user/project/src/package.json": ` + { + "imports": "#foo" + } + `, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + expectedScanLog: `Users/user/project/src/entry.js: error: Could not resolve "#foo" (mark it as external to exclude it from the bundle) +Users/user/project/src/package.json: note: The package configuration has an invalid value here +Users/user/project/src/package.json: warning: The value for "imports" must be an object +`, + }) +} + +func TestPackageJsonImportsErrorEqualsHash(t *testing.T) { + packagejson_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import '#' + `, + "/Users/user/project/src/package.json": ` + { + "imports": {} + } + `, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + expectedScanLog: `Users/user/project/src/entry.js: error: Could not resolve "#" (mark it as external to exclude it from the bundle) +Users/user/project/src/package.json: note: This "imports" map was ignored because the module specifier "#" is invalid +`, + }) +} + +func TestPackageJsonImportsErrorStartsWithHashSlash(t *testing.T) { + packagejson_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/Users/user/project/src/entry.js": ` + import '#/foo' + `, + "/Users/user/project/src/package.json": ` + { + "imports": {} + } + `, + }, + entryPaths: []string{"/Users/user/project/src/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/Users/user/project/out.js", + }, + expectedScanLog: `Users/user/project/src/entry.js: error: Could not resolve "#/foo" (mark it as external to exclude it from the bundle) +Users/user/project/src/package.json: note: This "imports" map was ignored because the module specifier "#/foo" is invalid +`, + }) +} diff --git a/internal/bundler/snapshots/snapshots_packagejson.txt b/internal/bundler/snapshots/snapshots_packagejson.txt index 97a9c190b8c..d95fc90f9fb 100644 --- a/internal/bundler/snapshots/snapshots_packagejson.txt +++ b/internal/bundler/snapshots/snapshots_packagejson.txt @@ -544,6 +544,36 @@ var require_require = __commonJS({ // Users/user/project/src/entry.js require_require(); +================================================================================ +TestPackageJsonImports +---------- /Users/user/project/out.js ---------- +// Users/user/project/src/a.js +console.log("a.js"); + +// Users/user/project/src/b.js +console.log("b.js"); + +// Users/user/project/src/some-star/c.js +console.log("c.js"); + +// Users/user/project/src/some-slash/d.js +console.log("d.js"); + +================================================================================ +TestPackageJsonImportsRemapToOtherPackage +---------- /Users/user/project/out.js ---------- +// Users/user/project/src/node_modules/pkg/a.js +console.log("a.js"); + +// Users/user/project/src/node_modules/pkg/b.js +console.log("b.js"); + +// Users/user/project/src/node_modules/pkg/some-star/c.js +console.log("c.js"); + +// Users/user/project/src/node_modules/pkg/some-slash/d.js +console.log("d.js"); + ================================================================================ TestPackageJsonMain ---------- /Users/user/project/out.js ---------- diff --git a/internal/resolver/package_json.go b/internal/resolver/package_json.go index c77f602dee7..c7d9725eb93 100644 --- a/internal/resolver/package_json.go +++ b/internal/resolver/package_json.go @@ -61,6 +61,9 @@ type packageJSON struct { sideEffectsRegexps []*regexp.Regexp sideEffectsData *SideEffectsData + // This represents the "imports" field in this package.json file. + importsMap *pjMap + // This represents the "exports" field in this package.json file. exportsMap *pjMap } @@ -366,9 +369,20 @@ func (r resolverQuery) parsePackageJSON(inputPath string) *packageJSON { } } + // Read the "imports" map + if importsJSON, _, ok := getProperty(json, "imports"); ok { + if importsMap := parseImportsExportsMap(jsonSource, r.log, importsJSON); importsMap != nil { + if importsMap.root.kind != pjObject { + r.log.AddRangeWarning(&tracker, importsMap.root.firstToken, + "The value for \"imports\" must be an object") + } + packageJSON.importsMap = importsMap + } + } + // Read the "exports" map if exportsJSON, _, ok := getProperty(json, "exports"); ok { - if exportsMap := parseExportsMap(jsonSource, r.log, exportsJSON); exportsMap != nil { + if exportsMap := parseImportsExportsMap(jsonSource, r.log, exportsJSON); exportsMap != nil { packageJSON.exportsMap = exportsMap } } @@ -485,7 +499,7 @@ func (entry pjEntry) valueForKey(key string) (pjEntry, bool) { return pjEntry{}, false } -func parseExportsMap(source logger.Source, log logger.Log, json js_ast.Expr) *pjMap { +func parseImportsExportsMap(source logger.Source, log logger.Log, json js_ast.Expr) *pjMap { var visit func(expr js_ast.Expr) pjEntry tracker := logger.MakeLineColumnTracker(&source) @@ -606,7 +620,8 @@ const ( pjStatusUndefinedNoConditionsMatch // A more friendly error message for when no conditions are matched pjStatusNull pjStatusExact - pjStatusInexact // This means we may need to try CommonJS-style extension suffixes + pjStatusInexact // This means we may need to try CommonJS-style extension suffixes + pjStatusPackageResolve // Need to re-run package resolution on the result // Module specifier is an invalid URL, package name or package subpath specifier. pjStatusInvalidModuleSpecifier @@ -620,6 +635,9 @@ const ( // Package exports do not define or permit a target subpath in the package for the given module. pjStatusPackagePathNotExported + // Package imports do not define the specifiespecifier + pjStatusPackageImportNotDefined + // The package or module requested does not exist. pjStatusModuleNotFound @@ -640,13 +658,11 @@ type pjDebug struct { unmatchedConditions []string } -func (r resolverQuery) esmPackageExportsResolveWithPostConditions( - packageURL string, - subpath string, - exports pjEntry, - conditions map[string]bool, +func (r resolverQuery) esmHandlePostConditions( + resolved string, + status pjStatus, + debug pjDebug, ) (string, pjStatus, pjDebug) { - resolved, status, debug := r.esmPackageExportsResolve(packageURL, subpath, exports, conditions) if status != pjStatusExact && status != pjStatusInexact { return resolved, status, debug } @@ -690,6 +706,27 @@ func (r resolverQuery) esmPackageExportsResolveWithPostConditions( return resolvedPath, status, debug } +func (r resolverQuery) esmPackageImportsResolve( + specifier string, + imports pjEntry, + conditions map[string]bool, +) (string, pjStatus, pjDebug) { + // ALGORITHM DEVIATION: Provide a friendly error message if "imports" is not an object + if imports.kind != pjObject { + return "", pjStatusInvalidPackageConfiguration, pjDebug{token: imports.firstToken} + } + + resolved, status, debug := r.esmPackageImportsExportsResolve(specifier, imports, "/", true, conditions) + if status != pjStatusNull && status != pjStatusUndefined { + return resolved, status, debug + } + + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("The package import %q is not defined", specifier)) + } + return specifier, pjStatusPackageImportNotDefined, pjDebug{token: imports.firstToken} +} + func (r resolverQuery) esmPackageExportsResolve( packageURL string, subpath string, @@ -702,6 +739,7 @@ func (r resolverQuery) esmPackageExportsResolve( } return "", pjStatusInvalidPackageConfiguration, pjDebug{token: exports.firstToken} } + if subpath == "." { mainExport := pjEntry{kind: pjNull} if exports.kind == pjString || exports.kind == pjArray || (exports.kind == pjObject && !exports.keysStartWithDot()) { @@ -715,19 +753,20 @@ func (r resolverQuery) esmPackageExportsResolve( } } if mainExport.kind != pjNull { - resolved, status, debug := r.esmPackageTargetResolve(packageURL, mainExport, "", false, conditions) + resolved, status, debug := r.esmPackageTargetResolve(packageURL, mainExport, "", false, false, conditions) if status != pjStatusNull && status != pjStatusUndefined { return resolved, status, debug } } } else if exports.kind == pjObject && exports.keysStartWithDot() { - resolved, status, debug := r.esmPackageImportsExportsResolve(subpath, exports, packageURL, conditions) + resolved, status, debug := r.esmPackageImportsExportsResolve(subpath, exports, packageURL, false, conditions) if status != pjStatusNull && status != pjStatusUndefined { return resolved, status, debug } } + if r.debugLogs != nil { - r.debugLogs.addNote(fmt.Sprintf("The path %q not exported", subpath)) + r.debugLogs.addNote(fmt.Sprintf("The path %q is not exported", subpath)) } return "", pjStatusPackagePathNotExported, pjDebug{token: exports.firstToken} } @@ -736,6 +775,7 @@ func (r resolverQuery) esmPackageImportsExportsResolve( matchKey string, matchObj pjEntry, packageURL string, + isImports bool, conditions map[string]bool, ) (string, pjStatus, pjDebug) { if r.debugLogs != nil { @@ -747,7 +787,7 @@ func (r resolverQuery) esmPackageImportsExportsResolve( if r.debugLogs != nil { r.debugLogs.addNote(fmt.Sprintf("Found exact match for %q", matchKey)) } - return r.esmPackageTargetResolve(packageURL, target, "", false, conditions) + return r.esmPackageTargetResolve(packageURL, target, "", false, isImports, conditions) } } @@ -761,7 +801,7 @@ func (r resolverQuery) esmPackageImportsExportsResolve( if r.debugLogs != nil { r.debugLogs.addNote(fmt.Sprintf("The key %q matched with %q left over", expansion.key, subpath)) } - return r.esmPackageTargetResolve(packageURL, target, subpath, true, conditions) + return r.esmPackageTargetResolve(packageURL, target, subpath, true, isImports, conditions) } } @@ -771,7 +811,7 @@ func (r resolverQuery) esmPackageImportsExportsResolve( if r.debugLogs != nil { r.debugLogs.addNote(fmt.Sprintf("The key %q matched with %q left over", expansion.key, subpath)) } - result, status, debug := r.esmPackageTargetResolve(packageURL, target, subpath, false, conditions) + result, status, debug := r.esmPackageTargetResolve(packageURL, target, subpath, false, isImports, conditions) if status == pjStatusExact { // Return the object { resolved, exact: false }. status = pjStatusInexact @@ -819,6 +859,7 @@ func (r resolverQuery) esmPackageTargetResolve( target pjEntry, subpath string, pattern bool, + internal bool, conditions map[string]bool, ) (string, pjStatus, pjDebug) { switch target.kind { @@ -838,7 +879,22 @@ func (r resolverQuery) esmPackageTargetResolve( return target.strData, pjStatusInvalidModuleSpecifier, pjDebug{token: target.firstToken} } + // If target does not start with "./", then... if !strings.HasPrefix(target.strData, "./") { + if internal && !strings.HasPrefix(target.strData, "../") && !strings.HasPrefix(target.strData, "/") { + if pattern { + result := strings.ReplaceAll(target.strData, "*", subpath) + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("Substituted %q for \"*\" in %q to get %q", subpath, target.strData, result)) + } + return result, pjStatusPackageResolve, pjDebug{token: target.firstToken} + } + result := target.strData + subpath + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("Joined %q to %q to get %q", target.strData, subpath, result)) + } + return result, pjStatusPackageResolve, pjDebug{token: target.firstToken} + } if r.debugLogs != nil { r.debugLogs.addNote(fmt.Sprintf("The target %q is invalid because it doesn't start with \"./\"", target.strData)) } @@ -902,7 +958,7 @@ func (r resolverQuery) esmPackageTargetResolve( if r.debugLogs != nil { r.debugLogs.addNote(fmt.Sprintf("The key %q applies", p.key)) } - resolved, status, debug := r.esmPackageTargetResolve(packageURL, p.value, subpath, pattern, conditions) + resolved, status, debug := r.esmPackageTargetResolve(packageURL, p.value, subpath, pattern, internal, conditions) if status.isUndefined() { didFindMapEntry = true lastMapEntry = p @@ -978,7 +1034,7 @@ func (r resolverQuery) esmPackageTargetResolve( lastDebug := pjDebug{token: target.firstToken} for _, targetValue := range target.arrData { // Let resolved be the result, continuing the loop on any Invalid Package Target error. - resolved, status, debug := r.esmPackageTargetResolve(packageURL, targetValue, subpath, pattern, conditions) + resolved, status, debug := r.esmPackageTargetResolve(packageURL, targetValue, subpath, pattern, internal, conditions) if status == pjStatusInvalidPackageTarget || status == pjStatusNull { lastException = status lastDebug = debug diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 3b40452e26e..bf20138bbea 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -683,7 +683,7 @@ func (r resolverQuery) resolveWithoutSymlinks(sourceDir string, importPath strin if remapped, ok := r.checkBrowserMap(sourceDirInfo, importPath, packagePathKind); ok { if remapped == nil { // "browser": {"module": false} - if absolute, ok, diffCase, _ := r.loadNodeModules(importPath, sourceDirInfo); ok { + if absolute, ok, diffCase, _ := r.loadNodeModules(importPath, sourceDirInfo, false /* forbidImports */); ok { absolute.Primary = logger.Path{Text: absolute.Primary.Text, Namespace: "file", Flags: logger.PathDisabled} if absolute.HasSecondary() { absolute.Secondary = logger.Path{Text: absolute.Secondary.Text, Namespace: "file", Flags: logger.PathDisabled} @@ -713,7 +713,7 @@ func (r resolverQuery) resolveWithoutSymlinks(sourceDir string, importPath strin func (r resolverQuery) resolveWithoutRemapping(sourceDirInfo *dirInfo, importPath string) (PathPair, bool, *fs.DifferentCase, DebugMeta) { if IsPackagePath(importPath) { - return r.loadNodeModules(importPath, sourceDirInfo) + return r.loadNodeModules(importPath, sourceDirInfo, false /* forbidImports */) } else { pair, ok, diffCase := r.loadAsFileOrDirectory(r.fs.Join(sourceDirInfo.absPath, importPath)) return pair, ok, diffCase, DebugMeta{} @@ -1466,7 +1466,7 @@ func (r resolverQuery) matchTSConfigPaths(tsConfigJSON *TSConfigJSON, path strin return PathPair{}, false, nil } -func (r resolverQuery) loadNodeModules(importPath string, dirInfo *dirInfo) (PathPair, bool, *fs.DifferentCase, DebugMeta) { +func (r resolverQuery) loadNodeModules(importPath string, dirInfo *dirInfo, forbidImports bool) (PathPair, bool, *fs.DifferentCase, DebugMeta) { if r.debugLogs != nil { r.debugLogs.addNote(fmt.Sprintf("Searching for %q in \"node_modules\" directories starting from %q", importPath, dirInfo.absPath)) r.debugLogs.increaseIndent() @@ -1491,6 +1491,61 @@ func (r resolverQuery) loadNodeModules(importPath string, dirInfo *dirInfo) (Pat } } + // Then check for the package in any enclosing "node_modules" directories + if packageJSON := dirInfo.enclosingPackageJSON; strings.HasPrefix(importPath, "#") && !forbidImports && packageJSON != nil && packageJSON.importsMap != nil { + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("Looking for %q in \"imports\" map in %q", importPath, packageJSON.source.KeyPath.Text)) + r.debugLogs.increaseIndent() + defer r.debugLogs.decreaseIndent() + } + + // Filter out invalid module specifiers now where we have more information for + // a better error message instead of later when we're inside the algorithm + if importPath == "#" || strings.HasPrefix(importPath, "#/") { + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("The path %q must not equal \"#\" and must not start with \"#/\"", importPath)) + } + tracker := logger.MakeLineColumnTracker(&packageJSON.source) + return PathPair{}, false, nil, DebugMeta{notes: []logger.MsgData{ + logger.RangeData(&tracker, packageJSON.importsMap.root.firstToken, + fmt.Sprintf("This \"imports\" map was ignored because the module specifier %q is invalid", importPath)), + }} + } + + // The condition set is determined by the kind of import + conditions := r.esmConditionsDefault + switch r.kind { + case ast.ImportStmt, ast.ImportDynamic: + conditions = r.esmConditionsImport + case ast.ImportRequire, ast.ImportRequireResolve: + conditions = r.esmConditionsRequire + } + + resolvedPath, status, debug := r.esmPackageImportsResolve(importPath, packageJSON.importsMap.root, conditions) + resolvedPath, status, debug = r.esmHandlePostConditions(resolvedPath, status, debug) + + if status == pjStatusPackageResolve { + // The import path was remapped via "imports" to another import path + // that now needs to be resolved too. Set "forbidImports" to true + // so we don't try to resolve "imports" again and end up in a loop. + absolute, ok, diffCase, debugMeta := r.loadNodeModules(resolvedPath, dirInfo, true /* forbidImports */) + if !ok { + tracker := logger.MakeLineColumnTracker(&packageJSON.source) + debugMeta.notes = append( + []logger.MsgData{logger.RangeData(&tracker, debug.token, + fmt.Sprintf("The remapped path %q could not be resolved", resolvedPath))}, + debugMeta.notes...) + } + return absolute, ok, diffCase, debugMeta + } + + return r.finalizeImportsExportsResult( + dirInfo.absPath, conditions, *packageJSON.importsMap, packageJSON, + resolvedPath, status, debug, + "", "", "", + ) + } + esmPackageName, esmPackageSubpath, esmOK := esmParsePackageName(importPath) if r.debugLogs != nil && esmOK { r.debugLogs.addNote(fmt.Sprintf("Parsed package name %q and package subpath %q", esmPackageName, esmPackageSubpath)) @@ -1533,139 +1588,14 @@ func (r resolverQuery) loadNodeModules(importPath string, dirInfo *dirInfo) (Pat // want problems due to Windows paths, which are very unlike URL // paths. We also want to avoid any "%" characters in the absolute // directory path accidentally being interpreted as URL escapes. - resolvedPath, status, debug := r.esmPackageExportsResolveWithPostConditions("/", esmPackageSubpath, packageJSON.exportsMap.root, conditions) - if (status == pjStatusExact || status == pjStatusInexact) && strings.HasPrefix(resolvedPath, "/") { - absResolvedPath := r.fs.Join(absPkgPath, resolvedPath[1:]) - - switch status { - case pjStatusExact: - if r.debugLogs != nil { - r.debugLogs.addNote(fmt.Sprintf("The resolved path %q is exact", absResolvedPath)) - } - resolvedDirInfo := r.dirInfoCached(r.fs.Dir(absResolvedPath)) - if resolvedDirInfo == nil { - status = pjStatusModuleNotFound - } else if entry, diffCase := resolvedDirInfo.entries.Get(r.fs.Base(absResolvedPath)); entry == nil { - status = pjStatusModuleNotFound - } else if kind := entry.Kind(r.fs); kind == fs.DirEntry { - if r.debugLogs != nil { - r.debugLogs.addNote(fmt.Sprintf("The path %q is a directory, which is not allowed", absResolvedPath)) - } - status = pjStatusUnsupportedDirectoryImport - } else if kind != fs.FileEntry { - status = pjStatusModuleNotFound - } else { - if r.debugLogs != nil { - r.debugLogs.addNote(fmt.Sprintf("Resolved to %q", absResolvedPath)) - } - return PathPair{Primary: logger.Path{Text: absResolvedPath, Namespace: "file"}}, true, diffCase, DebugMeta{} - } - - case pjStatusInexact: - // If this was resolved against an expansion key ending in a "/" - // instead of a "*", we need to try CommonJS-style implicit - // extension and/or directory detection. - if r.debugLogs != nil { - r.debugLogs.addNote(fmt.Sprintf("The resolved path %q is inexact", absResolvedPath)) - } - if absolute, ok, diffCase := r.loadAsFileOrDirectory(absResolvedPath); ok { - return absolute, true, diffCase, DebugMeta{} - } - status = pjStatusModuleNotFound - } - } - - var debugMeta DebugMeta - if strings.HasPrefix(resolvedPath, "/") { - resolvedPath = "." + resolvedPath - } - - // Provide additional details about the failure to help with debugging - tracker := logger.MakeLineColumnTracker(&packageJSON.source) - switch status { - case pjStatusInvalidModuleSpecifier: - debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, - fmt.Sprintf("The module specifier %q is invalid", resolvedPath))} - - case pjStatusInvalidPackageConfiguration: - debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, - "The package configuration has an invalid value here")} - - case pjStatusInvalidPackageTarget: - why := fmt.Sprintf("The package target %q is invalid", resolvedPath) - if resolvedPath == "" { - // "PACKAGE_TARGET_RESOLVE" is specified to throw an "Invalid - // Package Target" error for what is actually an invalid package - // configuration error - why = "The package configuration has an invalid value here" - } - debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, why)} - - case pjStatusPackagePathNotExported: - debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, - fmt.Sprintf("The path %q is not exported by package %q", esmPackageSubpath, esmPackageName))} - - // If this fails, try to resolve it using the old algorithm - if absolute, ok, _ := r.loadAsFileOrDirectory(absPath); ok && absolute.Primary.Namespace == "file" { - if relPath, ok := r.fs.Rel(absPkgPath, absolute.Primary.Text); ok { - query := "." + path.Join("/", strings.ReplaceAll(relPath, "\\", "/")) - - // If that succeeds, try to do a reverse lookup using the - // "exports" map for the currently-active set of conditions - if ok, subpath, token := r.esmPackageExportsReverseResolve( - query, pkgDirInfo.packageJSON.exportsMap.root, conditions); ok { - debugMeta.notes = append(debugMeta.notes, logger.RangeData(&tracker, token, - fmt.Sprintf("The file %q is exported at path %q", query, subpath))) - - // Provide an inline suggestion message with the correct import path - actualImportPath := path.Join(esmPackageName, subpath) - debugMeta.suggestionText = string(js_printer.QuoteForJSON(actualImportPath, false)) - debugMeta.suggestionMessage = fmt.Sprintf("Import from %q to get the file %q", - actualImportPath, r.PrettyPath(absolute.Primary)) - } - } - } - - case pjStatusModuleNotFound: - debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, - fmt.Sprintf("The module %q was not found on the file system", resolvedPath))} - - case pjStatusUnsupportedDirectoryImport: - debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, - fmt.Sprintf("Importing the directory %q is not supported", resolvedPath))} - - case pjStatusUndefinedNoConditionsMatch: - prettyPrintConditions := func(conditions []string) string { - quoted := make([]string, len(conditions)) - for i, condition := range conditions { - quoted[i] = fmt.Sprintf("%q", condition) - } - return strings.Join(quoted, ", ") - } - keys := make([]string, 0, len(conditions)) - for key := range conditions { - keys = append(keys, key) - } - sort.Strings(keys) - debugMeta.notes = []logger.MsgData{ - logger.RangeData(&tracker, packageJSON.exportsMap.root.firstToken, - fmt.Sprintf("The path %q is not currently exported by package %q", - esmPackageSubpath, esmPackageName)), - logger.RangeData(&tracker, debug.token, - fmt.Sprintf("None of the conditions provided (%s) match any of the currently active conditions (%s)", - prettyPrintConditions(debug.unmatchedConditions), - prettyPrintConditions(keys), - ))} - for _, key := range debug.unmatchedConditions { - if key == "import" && (r.kind == ast.ImportRequire || r.kind == ast.ImportRequireResolve) { - debugMeta.suggestionMessage = "Consider using an \"import\" statement to import this file" - } else if key == "require" && (r.kind == ast.ImportStmt || r.kind == ast.ImportDynamic) { - debugMeta.suggestionMessage = "Consider using a \"require()\" call to import this file" - } - } - } - - return PathPair{}, false, nil, debugMeta + resolvedPath, status, debug := r.esmPackageExportsResolve("/", esmPackageSubpath, packageJSON.exportsMap.root, conditions) + resolvedPath, status, debug = r.esmHandlePostConditions(resolvedPath, status, debug) + + return r.finalizeImportsExportsResult( + absPkgPath, conditions, *packageJSON.exportsMap, packageJSON, + resolvedPath, status, debug, + esmPackageName, esmPackageSubpath, absPath, + ) } // Check the "browser" map @@ -1709,6 +1639,160 @@ func (r resolverQuery) loadNodeModules(importPath string, dirInfo *dirInfo) (Pat return PathPair{}, false, nil, DebugMeta{} } +func (r resolverQuery) finalizeImportsExportsResult( + absDirPath string, + conditions map[string]bool, + importExportMap pjMap, + packageJSON *packageJSON, + + // Resolution results + resolvedPath string, + status pjStatus, + debug pjDebug, + + // Only for exports + esmPackageName string, + esmPackageSubpath string, + absImportPath string, +) (PathPair, bool, *fs.DifferentCase, DebugMeta) { + if (status == pjStatusExact || status == pjStatusInexact) && strings.HasPrefix(resolvedPath, "/") { + absResolvedPath := r.fs.Join(absDirPath, resolvedPath[1:]) + + switch status { + case pjStatusExact: + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("The resolved path %q is exact", absResolvedPath)) + } + resolvedDirInfo := r.dirInfoCached(r.fs.Dir(absResolvedPath)) + if resolvedDirInfo == nil { + status = pjStatusModuleNotFound + } else if entry, diffCase := resolvedDirInfo.entries.Get(r.fs.Base(absResolvedPath)); entry == nil { + status = pjStatusModuleNotFound + } else if kind := entry.Kind(r.fs); kind == fs.DirEntry { + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("The path %q is a directory, which is not allowed", absResolvedPath)) + } + status = pjStatusUnsupportedDirectoryImport + } else if kind != fs.FileEntry { + status = pjStatusModuleNotFound + } else { + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("Resolved to %q", absResolvedPath)) + } + return PathPair{Primary: logger.Path{Text: absResolvedPath, Namespace: "file"}}, true, diffCase, DebugMeta{} + } + + case pjStatusInexact: + // If this was resolved against an expansion key ending in a "/" + // instead of a "*", we need to try CommonJS-style implicit + // extension and/or directory detection. + if r.debugLogs != nil { + r.debugLogs.addNote(fmt.Sprintf("The resolved path %q is inexact", absResolvedPath)) + } + if absolute, ok, diffCase := r.loadAsFileOrDirectory(absResolvedPath); ok { + return absolute, true, diffCase, DebugMeta{} + } + status = pjStatusModuleNotFound + } + } + + var debugMeta DebugMeta + if strings.HasPrefix(resolvedPath, "/") { + resolvedPath = "." + resolvedPath + } + + // Provide additional details about the failure to help with debugging + tracker := logger.MakeLineColumnTracker(&packageJSON.source) + switch status { + case pjStatusInvalidModuleSpecifier: + debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, + fmt.Sprintf("The module specifier %q is invalid", resolvedPath))} + + case pjStatusInvalidPackageConfiguration: + debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, + "The package configuration has an invalid value here")} + + case pjStatusInvalidPackageTarget: + why := fmt.Sprintf("The package target %q is invalid", resolvedPath) + if resolvedPath == "" { + // "PACKAGE_TARGET_RESOLVE" is specified to throw an "Invalid + // Package Target" error for what is actually an invalid package + // configuration error + why = "The package configuration has an invalid value here" + } + debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, why)} + + case pjStatusPackagePathNotExported: + debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, + fmt.Sprintf("The path %q is not exported by package %q", esmPackageSubpath, esmPackageName))} + + // If this fails, try to resolve it using the old algorithm + if absolute, ok, _ := r.loadAsFileOrDirectory(absImportPath); ok && absolute.Primary.Namespace == "file" { + if relPath, ok := r.fs.Rel(absDirPath, absolute.Primary.Text); ok { + query := "." + path.Join("/", strings.ReplaceAll(relPath, "\\", "/")) + + // If that succeeds, try to do a reverse lookup using the + // "exports" map for the currently-active set of conditions + if ok, subpath, token := r.esmPackageExportsReverseResolve( + query, importExportMap.root, conditions); ok { + debugMeta.notes = append(debugMeta.notes, logger.RangeData(&tracker, token, + fmt.Sprintf("The file %q is exported at path %q", query, subpath))) + + // Provide an inline suggestion message with the correct import path + actualImportPath := path.Join(esmPackageName, subpath) + debugMeta.suggestionText = string(js_printer.QuoteForJSON(actualImportPath, false)) + debugMeta.suggestionMessage = fmt.Sprintf("Import from %q to get the file %q", + actualImportPath, r.PrettyPath(absolute.Primary)) + } + } + } + + case pjStatusPackageImportNotDefined: + debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, + fmt.Sprintf("The package import %q is not defined in this \"imports\" map", resolvedPath))} + + case pjStatusModuleNotFound: + debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, + fmt.Sprintf("The module %q was not found on the file system", resolvedPath))} + + case pjStatusUnsupportedDirectoryImport: + debugMeta.notes = []logger.MsgData{logger.RangeData(&tracker, debug.token, + fmt.Sprintf("Importing the directory %q is not supported", resolvedPath))} + + case pjStatusUndefinedNoConditionsMatch: + prettyPrintConditions := func(conditions []string) string { + quoted := make([]string, len(conditions)) + for i, condition := range conditions { + quoted[i] = fmt.Sprintf("%q", condition) + } + return strings.Join(quoted, ", ") + } + keys := make([]string, 0, len(conditions)) + for key := range conditions { + keys = append(keys, key) + } + sort.Strings(keys) + debugMeta.notes = []logger.MsgData{ + logger.RangeData(&tracker, importExportMap.root.firstToken, + fmt.Sprintf("The path %q is not currently exported by package %q", + esmPackageSubpath, esmPackageName)), + logger.RangeData(&tracker, debug.token, + fmt.Sprintf("None of the conditions provided (%s) match any of the currently active conditions (%s)", + prettyPrintConditions(debug.unmatchedConditions), + prettyPrintConditions(keys), + ))} + for _, key := range debug.unmatchedConditions { + if key == "import" && (r.kind == ast.ImportRequire || r.kind == ast.ImportRequireResolve) { + debugMeta.suggestionMessage = "Consider using an \"import\" statement to import this file" + } else if key == "require" && (r.kind == ast.ImportStmt || r.kind == ast.ImportDynamic) { + debugMeta.suggestionMessage = "Consider using a \"require()\" call to import this file" + } + } + } + + return PathPair{}, false, nil, debugMeta +} + // Package paths are loaded from a "node_modules" directory. Non-package paths // are relative or absolute paths. func IsPackagePath(path string) bool { diff --git a/scripts/end-to-end-tests.js b/scripts/end-to-end-tests.js index c471a96a460..e6374558581 100644 --- a/scripts/end-to-end-tests.js +++ b/scripts/end-to-end-tests.js @@ -4638,9 +4638,68 @@ }), ) - // Test "exports" in package.json + // Test "imports" and "exports" in package.json for (const flags of [[], ['--bundle']]) { tests.push( + // "imports" + test(['in.js', '--outfile=node.js', '--format=esm'].concat(flags), { + 'in.js': `import abc from '#pkg'; if (abc !== 123) throw 'fail'`, + 'package.json': `{ + "type": "module", + "imports": { + "#pkg": "./foo.js" + } + }`, + 'foo.js': `export default 123`, + }), + test(['in.js', '--outfile=node.js', '--format=esm'].concat(flags), { + 'in.js': `import abc from '#pkg/bar.js'; if (abc !== 123) throw 'fail'`, + 'package.json': `{ + "type": "module", + "imports": { + "#pkg/": "./foo/" + } + }`, + 'foo/bar.js': `export default 123`, + }), + test(['in.js', '--outfile=node.js', '--format=esm'].concat(flags), { + 'in.js': `import abc from '#pkg/bar.js'; if (abc !== 123) throw 'fail'`, + 'package.json': `{ + "type": "module", + "imports": { + "#pkg/*": "./foo/*" + } + }`, + 'foo/bar.js': `export default 123`, + }), + test(['in.js', '--outfile=node.js', '--format=esm'].concat(flags), { + 'in.js': `import abc from '#pkg'; if (abc !== 123) throw 'fail'`, + 'package.json': `{ + "type": "module", + "imports": { + "#pkg": { + "import": "./yes.js", + "default": "./no.js" + } + } + }`, + 'yes.js': `export default 123`, + }), + test(['in.js', '--outfile=node.js', '--format=cjs'].concat(flags), { + 'in.js': `const abc = require('#pkg'); if (abc !== 123) throw 'fail'`, + 'package.json': `{ + "type": "commonjs", + "imports": { + "#pkg": { + "require": "./yes.js", + "default": "./no.js" + } + } + }`, + 'yes.js': `module.exports = 123`, + }), + + // "exports" test(['in.js', '--outfile=node.js', '--format=esm'].concat(flags), { 'in.js': `import abc from 'pkg'; if (abc !== 123) throw 'fail'`, 'package.json': `{ "type": "module" }`, @@ -4823,7 +4882,7 @@ }), test(['in.js', '--outfile=node.js', '--format=cjs'].concat(flags), { 'in.js': `const abc = require('pkg/dir/test'); if (abc !== 123) throw 'fail'`, - 'package.json': `{ "type": "module" }`, + 'package.json': `{ "type": "commonjs" }`, 'node_modules/pkg/sub/test.js': `module.exports = 123`, 'node_modules/pkg/package.json': `{ "exports": { @@ -4833,7 +4892,7 @@ }), test(['in.js', '--outfile=node.js', '--format=cjs'].concat(flags), { 'in.js': `const abc = require('pkg/dir/test'); if (abc !== 123) throw 'fail'`, - 'package.json': `{ "type": "module" }`, + 'package.json': `{ "type": "commonjs" }`, 'node_modules/pkg/sub/test/index.js': `module.exports = 123`, 'node_modules/pkg/package.json': `{ "exports": { @@ -5087,16 +5146,18 @@ // only supports absolute paths on Unix-style systems, not on Windows. // See https://github.com/nodejs/node/issues/31710 for more info. const nodePath = path.join(thisTestDir, 'node') + const pjPath = path.join(thisTestDir, 'package.json') + const pjExists = await fs.stat(pjPath).then(() => true, () => false) let testExports switch (format) { case 'cjs': case 'iife': - await fs.writeFile(path.join(thisTestDir, 'package.json'), '{"type": "commonjs"}') + if (!pjExists) await fs.writeFile(pjPath, '{"type": "commonjs"}') testExports = (await import(url.pathToFileURL(`${nodePath}.js`))).default break case 'esm': - await fs.writeFile(path.join(thisTestDir, 'package.json'), '{"type": "module"}') + if (!pjExists) await fs.writeFile(pjPath, '{"type": "module"}') testExports = await import(url.pathToFileURL(`${nodePath}.js`)) break }