Skip to content

Commit

Permalink
fix #3058: support extends that uses exports
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Apr 16, 2023
1 parent 23cee51 commit 8eb364d
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 0 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
}
```

* Support `exports` in `package.json` for `extends` in `tsconfig.json` ([#3058](https://github.com/evanw/esbuild/issues/3058))

TypeScript 5.0 added the ability to use `extends` in `tsconfig.json` to reference a path in a package whose `package.json` file contains an `exports` map that points to the correct location. This doesn't automatically work in esbuild because `tsconfig.json` affects esbuild's path resolution, so esbuild's normal path resolution logic doesn't apply.

This release adds support for doing this by adding some additional code that attempts to resolve the `extends` path using the `exports` field. The behavior should be similar enough to esbuild's main path resolution logic to work as expected.

Note that esbuild always treats this `extends` import as a `require()` import since that's what TypeScript appears to do. Specifically the `require` condition will be active and the `import` condition will be inactive.

* Fix watch mode with `NODE_PATH` ([#3062](https://github.com/evanw/esbuild/issues/3062))

Node has a rarely-used feature where you can extend the set of directories that node searches for packages using the `NODE_PATH` environment variable. While esbuild supports this too, previously a bug prevented esbuild's watch mode from picking up changes to imported files that were contained directly in a `NODE_PATH` directory. You're supposed to use `NODE_PATH` for packages, but some people abuse this feature by putting files in that directory instead (e.g. `node_modules/some-file.js` instead of `node_modules/some-pkg/some-file.js`). The watch mode bug happens when you do this because esbuild first tries to read `some-file.js` as a directory and then as a file. Watch mode was incorrectly waiting for `some-file.js` to become a valid directory. This release fixes this edge case bug by changing watch mode to watch `some-file.js` as a file when this happens.
Expand Down
134 changes: 134 additions & 0 deletions internal/bundler_tests/bundler_tsconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2145,3 +2145,137 @@ func TestTsConfigExtendsDotDotWithSlash(t *testing.T) {
`,
})
}

func TestTsConfigExtendsWithExports(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/src/main.ts": `
console.log(123n)
`,
"/Users/user/project/tsconfig.json": `{
"extends": "@whatever/tsconfig/a/b/c"
}`,
"/Users/user/project/node_modules/@whatever/tsconfig/package.json": `{
"exports": {
"./a/b/c": "./foo.json"
}
}`,
"/Users/user/project/node_modules/@whatever/tsconfig/foo.json": `{
"compilerOptions": {
"target": "ES6"
}
}`,
},
entryPaths: []string{"/Users/user/project/src/main.ts"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/Users/user/project/out",
OutputFormat: config.FormatESModule,
},
expectedScanLog: `Users/user/project/src/main.ts: ERROR: Big integer literals are not available in the configured target environment ("ES6")
Users/user/project/node_modules/@whatever/tsconfig/foo.json: NOTE: The target environment was set to "ES6" here:
`,
})
}

func TestTsConfigExtendsWithExportsStar(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/src/main.ts": `
console.log(123n)
`,
"/Users/user/project/tsconfig.json": `{
"extends": "@whatever/tsconfig/a/b/c"
}`,
"/Users/user/project/node_modules/@whatever/tsconfig/package.json": `{
"exports": {
"./*": "./tsconfig.*.json"
}
}`,
"/Users/user/project/node_modules/@whatever/tsconfig/tsconfig.a/b/c.json": `{
"compilerOptions": {
"target": "ES6"
}
}`,
},
entryPaths: []string{"/Users/user/project/src/main.ts"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/Users/user/project/out",
OutputFormat: config.FormatESModule,
},
expectedScanLog: `Users/user/project/src/main.ts: ERROR: Big integer literals are not available in the configured target environment ("ES6")
Users/user/project/node_modules/@whatever/tsconfig/tsconfig.a/b/c.json: NOTE: The target environment was set to "ES6" here:
`,
})
}

func TestTsConfigExtendsWithExportsStarTrailing(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/src/main.ts": `
console.log(123n)
`,
"/Users/user/project/tsconfig.json": `{
"extends": "@whatever/tsconfig/a/b/c.json"
}`,
"/Users/user/project/node_modules/@whatever/tsconfig/package.json": `{
"exports": {
"./*": "./tsconfig.*"
}
}`,
"/Users/user/project/node_modules/@whatever/tsconfig/tsconfig.a/b/c.json": `{
"compilerOptions": {
"target": "ES6"
}
}`,
},
entryPaths: []string{"/Users/user/project/src/main.ts"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/Users/user/project/out",
OutputFormat: config.FormatESModule,
},
expectedScanLog: `Users/user/project/src/main.ts: ERROR: Big integer literals are not available in the configured target environment ("ES6")
Users/user/project/node_modules/@whatever/tsconfig/tsconfig.a/b/c.json: NOTE: The target environment was set to "ES6" here:
`,
})
}

func TestTsConfigExtendsWithExportsRequire(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/src/main.ts": `
console.log(123n)
`,
"/Users/user/project/tsconfig.json": `{
"extends": "@whatever/tsconfig/a/b/c.json"
}`,
"/Users/user/project/node_modules/@whatever/tsconfig/package.json": `{
"exports": {
"./*": {
"import": "./import.json",
"require": "./require.json",
"default": "./default.json"
}
}
}`,
"/Users/user/project/node_modules/@whatever/tsconfig/import.json": `FAILURE`,
"/Users/user/project/node_modules/@whatever/tsconfig/default.json": `FAILURE`,
"/Users/user/project/node_modules/@whatever/tsconfig/require.json": `{
"compilerOptions": {
"target": "ES6"
}
}`,
},
entryPaths: []string{"/Users/user/project/src/main.ts"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/Users/user/project/out",
OutputFormat: config.FormatESModule,
},
expectedScanLog: `Users/user/project/src/main.ts: ERROR: Big integer literals are not available in the configured target environment ("ES6")
Users/user/project/node_modules/@whatever/tsconfig/require.json: NOTE: The target environment was set to "ES6" here:
`,
})
}
36 changes: 36 additions & 0 deletions internal/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1089,11 +1089,47 @@ func (r resolverQuery) parseTSConfig(file string, visited map[string]bool) (*TSC
}

if IsPackagePath(extends) && !r.fs.IsAbs(extends) {
esmPackageName, esmPackageSubpath, esmOK := esmParsePackageName(extends)
if r.debugLogs != nil && esmOK {
r.debugLogs.addNote(fmt.Sprintf("Parsed tsconfig package name %q and package subpath %q", esmPackageName, esmPackageSubpath))
}

// If this is still a package path, try to resolve it to a "node_modules" directory
current := fileDir
for {
// Skip "node_modules" folders
if r.fs.Base(current) != "node_modules" {
// if "package.json" exists, try checking the "exports" map. The
// ability to use "extends" like this was added in TypeScript 5.0.
pkgDir := r.fs.Join(current, "node_modules", esmPackageName)
pjFile := r.fs.Join(pkgDir, "package.json")
if _, err, originalError := r.fs.ReadFile(pjFile); err == nil {
if packageJSON := r.parsePackageJSON(pkgDir); packageJSON != nil && packageJSON.exportsMap != nil {
if r.debugLogs != nil {
r.debugLogs.addNote(fmt.Sprintf("Looking for %q in \"exports\" map in %q", esmPackageSubpath, packageJSON.source.KeyPath.Text))
r.debugLogs.increaseIndent()
defer r.debugLogs.decreaseIndent()
}

// Note: TypeScript appears to always treat this as a "require" import
conditions := r.esmConditionsRequire
resolvedPath, status, debug := r.esmPackageExportsResolve("/", esmPackageSubpath, packageJSON.exportsMap.root, conditions)
resolvedPath, status, debug = r.esmHandlePostConditions(resolvedPath, status, debug)

// This is a very abbreviated version of our ESM resolution
if status == pjStatusExact || status == pjStatusExactEndsWithStar {
fileToCheck := r.fs.Join(pkgDir, resolvedPath)
base, err := r.parseTSConfig(fileToCheck, visited)

if result, shouldReturn := maybeFinishOurSearch(base, err, fileToCheck); shouldReturn {
return result
}
}
}
} else if r.debugLogs != nil && originalError != nil {
r.debugLogs.addNote(fmt.Sprintf("Failed to read file %q: %s", pjFile, originalError.Error()))
}

join := r.fs.Join(current, "node_modules", extends)
filesToCheck := []string{r.fs.Join(join, "tsconfig.json"), join, join + ".json"}
for _, fileToCheck := range filesToCheck {
Expand Down

0 comments on commit 8eb364d

Please sign in to comment.