diff --git a/CHANGELOG.md b/CHANGELOG.md index bdda24a3c61..301a2a6ad15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/internal/bundler_tests/bundler_tsconfig_test.go b/internal/bundler_tests/bundler_tsconfig_test.go index ed3b11994a0..742056db28a 100644 --- a/internal/bundler_tests/bundler_tsconfig_test.go +++ b/internal/bundler_tests/bundler_tsconfig_test.go @@ -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: +`, + }) +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 561a57e9ab4..42bf534f102 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -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 {