From 4e68c27cdc19f50031af36913b89cc4ea825bea1 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Thu, 11 Aug 2022 21:49:29 -0400 Subject: [PATCH] fix #2456: `extends` in `tsconfig.json` with pnp --- CHANGELOG.md | 4 +++ internal/resolver/resolver.go | 61 ++++++++++++++++++++++++++++++----- internal/resolver/yarnpnp.go | 33 ++++++++++++------- scripts/js-api-tests.js | 58 +++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 745c982c4a9..1ce6ec3020c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +* Fix Yarn PnP support for `extends` in `tsconfig.json` ([#2456](https://github.com/evanw/esbuild/issues/2456)) + + Previously using `extends` in `tsconfig.json` with a path in a Yarn PnP package didn't work. This is because the process of setting up package path resolution rules requires parsing `tsconfig.json` files (due to the `baseUrl` and `paths` features) and resolving `extends` to a package path requires package path resolution rules to already be set up, which is a circular dependency. This cycle is broken by using special rules for `extends` in `tsconfig.json` that bypasses esbuild's normal package path resolution process. This is why using `extends` with a Yarn PnP package didn't automatically work. With this release, these special rules have been modified to check for a Yarn PnP manifest so this case should work now. + * Fix Yarn PnP support in `esbuild-wasm` ([#2458](https://github.com/evanw/esbuild/issues/2458)) When running esbuild via WebAssembly, Yarn PnP support previously failed because Go's file system internals return `EINVAL` when trying to read a `.zip` file as a directory when run with WebAssembly. This was unexpected because Go's file system internals return `ENOTDIR` for this case on native. This release updates esbuild to treat `EINVAL` like `ENOTDIR` in this case, which fixes using `esbuild-wasm` to bundle a Yarn PnP project. diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index b527e78a682..fed76ba9d9d 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -936,12 +936,57 @@ func (r resolverQuery) parseTSConfig(file string, visited map[string]bool) (*TSC fileDir := r.fs.Dir(file) result := ParseTSConfigJSON(r.log, source, &r.caches.JSONCache, func(extends string, extendsRange logger.Range) *TSConfigJSON { + // Note: This doesn't use the normal node module resolution algorithm + // both because it's different (e.g. we don't want to match a directory) + // and because it would deadlock since we're currently in the middle of + // populating the directory info cache. + + // Check for a Yarn PnP manifest and use that to rewrite the path + isAbsolutePathFromYarnPnP := false if IsPackagePath(extends) { - // If this is a package path, try to resolve it to a "node_modules" - // folder. This doesn't use the normal node module resolution algorithm - // both because it's different (e.g. we don't want to match a directory) - // and because it would deadlock since we're currently in the middle of - // populating the directory info cache. + current := fileDir + for { + if _, _, ok := fs.ParseYarnPnPVirtualPath(current); !ok { + var pnpData *pnpData + absPath := r.fs.Join(current, ".pnp.data.json") + if json := r.extractYarnPnPDataFromJSON(absPath, pnpIgnoreErrorsAboutMissingFiles); json.Data != nil { + pnpData = compileYarnPnPData(absPath, current, json) + } else { + absPath := r.fs.Join(current, ".pnp.cjs") + if json := r.extractYarnPnPDataFromJSON(absPath, pnpIgnoreErrorsAboutMissingFiles); json.Data != nil { + pnpData = compileYarnPnPData(absPath, current, json) + } else { + absPath := r.fs.Join(current, ".pnp.js") + if json := r.extractYarnPnPDataFromJSON(absPath, pnpIgnoreErrorsAboutMissingFiles); json.Data != nil { + pnpData = compileYarnPnPData(absPath, current, json) + } + } + } + if pnpData != nil { + if result, ok := r.pnpResolve(extends, current, pnpData); ok { + extends = result // Continue with the module resolution algorithm from node.js + if r.fs.IsAbs(result) { + // Windows-style absolute paths are considered package paths + // because they do not start with a "/", but they should + // not go through package path resolution + isAbsolutePathFromYarnPnP = true + } + } + break + } + } + + // Go to the parent directory, stopping at the file system root + next := r.fs.Dir(current) + if current == next { + break + } + current = next + } + } + + if IsPackagePath(extends) && !isAbsolutePathFromYarnPnP { + // If this is still a package path, try to resolve it to a "node_modules" directory current := fileDir for { // Skip "node_modules" folders @@ -1215,17 +1260,17 @@ func (r resolverQuery) dirInfoUncached(path string) *dirInfo { if _, _, ok := fs.ParseYarnPnPVirtualPath(path); !ok { if pnp, _ := entries.Get(".pnp.data.json"); pnp != nil && pnp.Kind(r.fs) == fs.FileEntry { absPath := r.fs.Join(path, ".pnp.data.json") - if json := r.extractYarnPnPDataFromJSON(absPath, &r.caches.JSONCache); json.Data != nil { + if json := r.extractYarnPnPDataFromJSON(absPath, pnpReportErrorsAboutMissingFiles); json.Data != nil { info.pnpData = compileYarnPnPData(absPath, path, json) } } else if pnp, _ := entries.Get(".pnp.cjs"); pnp != nil && pnp.Kind(r.fs) == fs.FileEntry { absPath := r.fs.Join(path, ".pnp.cjs") - if json := r.tryToExtractYarnPnPDataFromJS(absPath, &r.caches.JSONCache); json.Data != nil { + if json := r.tryToExtractYarnPnPDataFromJS(absPath, pnpReportErrorsAboutMissingFiles); json.Data != nil { info.pnpData = compileYarnPnPData(absPath, path, json) } } else if pnp, _ := entries.Get(".pnp.js"); pnp != nil && pnp.Kind(r.fs) == fs.FileEntry { absPath := r.fs.Join(path, ".pnp.js") - if json := r.tryToExtractYarnPnPDataFromJS(absPath, &r.caches.JSONCache); json.Data != nil { + if json := r.tryToExtractYarnPnPDataFromJS(absPath, pnpReportErrorsAboutMissingFiles); json.Data != nil { info.pnpData = compileYarnPnPData(absPath, path, json) } } diff --git a/internal/resolver/yarnpnp.go b/internal/resolver/yarnpnp.go index 3bb7984e631..8695dc39b4f 100644 --- a/internal/resolver/yarnpnp.go +++ b/internal/resolver/yarnpnp.go @@ -4,8 +4,8 @@ import ( "fmt" "regexp" "strings" + "syscall" - "github.com/evanw/esbuild/internal/cache" "github.com/evanw/esbuild/internal/helpers" "github.com/evanw/esbuild/internal/js_ast" "github.com/evanw/esbuild/internal/js_parser" @@ -577,15 +577,24 @@ func getDependencyTarget(json js_ast.Expr) (pnpIdentAndReference, bool) { return pnpIdentAndReference{}, false } -func (r resolverQuery) extractYarnPnPDataFromJSON(pnpDataPath string, jsonCache *cache.JSONCache) (result js_ast.Expr) { +type pnpDataMode uint8 + +const ( + pnpIgnoreErrorsAboutMissingFiles pnpDataMode = iota + pnpReportErrorsAboutMissingFiles +) + +func (r resolverQuery) extractYarnPnPDataFromJSON(pnpDataPath string, mode pnpDataMode) (result js_ast.Expr) { contents, err, originalError := r.caches.FSCache.ReadFile(r.fs, pnpDataPath) if r.debugLogs != nil && originalError != nil { r.debugLogs.addNote(fmt.Sprintf("Failed to read file %q: %s", pnpDataPath, originalError.Error())) } if err != nil { - r.log.AddError(nil, logger.Range{}, - fmt.Sprintf("Cannot read file %q: %s", - r.PrettyPath(logger.Path{Text: pnpDataPath, Namespace: "file"}), err.Error())) + if mode == pnpReportErrorsAboutMissingFiles || err != syscall.ENOENT { + r.log.AddError(nil, logger.Range{}, + fmt.Sprintf("Cannot read file %q: %s", + r.PrettyPath(logger.Path{Text: pnpDataPath, Namespace: "file"}), err.Error())) + } return } if r.debugLogs != nil { @@ -597,19 +606,21 @@ func (r resolverQuery) extractYarnPnPDataFromJSON(pnpDataPath string, jsonCache PrettyPath: r.PrettyPath(keyPath), Contents: contents, } - result, _ = jsonCache.Parse(r.log, source, js_parser.JSONOptions{}) + result, _ = r.caches.JSONCache.Parse(r.log, source, js_parser.JSONOptions{}) return } -func (r resolverQuery) tryToExtractYarnPnPDataFromJS(pnpDataPath string, jsonCache *cache.JSONCache) (result js_ast.Expr) { +func (r resolverQuery) tryToExtractYarnPnPDataFromJS(pnpDataPath string, mode pnpDataMode) (result js_ast.Expr) { contents, err, originalError := r.caches.FSCache.ReadFile(r.fs, pnpDataPath) if r.debugLogs != nil && originalError != nil { r.debugLogs.addNote(fmt.Sprintf("Failed to read file %q: %s", pnpDataPath, originalError.Error())) } if err != nil { - r.log.AddError(nil, logger.Range{}, - fmt.Sprintf("Cannot read file %q: %s", - r.PrettyPath(logger.Path{Text: pnpDataPath, Namespace: "file"}), err.Error())) + if mode == pnpReportErrorsAboutMissingFiles || err != syscall.ENOENT { + r.log.AddError(nil, logger.Range{}, + fmt.Sprintf("Cannot read file %q: %s", + r.PrettyPath(logger.Path{Text: pnpDataPath, Namespace: "file"}), err.Error())) + } return } if r.debugLogs != nil { @@ -622,7 +633,7 @@ func (r resolverQuery) tryToExtractYarnPnPDataFromJS(pnpDataPath string, jsonCac PrettyPath: r.PrettyPath(keyPath), Contents: contents, } - ast, _ := js_parser.Parse(r.log, source, js_parser.OptionsForYarnPnP()) + ast, _ := r.caches.JSCache.Parse(r.log, source, js_parser.OptionsForYarnPnP()) if r.debugLogs != nil && ast.ManifestForYarnPnP.Data != nil { r.debugLogs.addNote(fmt.Sprintf(" Extracted JSON data from %q", pnpDataPath)) diff --git a/scripts/js-api-tests.js b/scripts/js-api-tests.js index 6938a064209..51d3dbb65a9 100644 --- a/scripts/js-api-tests.js +++ b/scripts/js-api-tests.js @@ -3058,6 +3058,64 @@ require("/assets/file.png"); // scripts/.js-api-tests/yarnPnP_ignoreNestedManifests/entry.js console.log(foo_default); })(); +`) + }, + + async yarnPnP_tsconfig({ esbuild, testDir }) { + const entry = path.join(testDir, 'entry.js') + const tsconfigExtends = path.join(testDir, 'tsconfig.json') + const tsconfigBase = path.join(testDir, 'foo', 'tsconfig.json') + const manifest = path.join(testDir, '.pnp.data.json') + + await writeFileAsync(entry, ` + x **= 2 + `) + + await writeFileAsync(tsconfigExtends, `{ + "extends": "@scope/base/tsconfig.json", + }`) + + await mkdirAsync(path.dirname(tsconfigBase), { recursive: true }) + await writeFileAsync(tsconfigBase, `{ + "compilerOptions": { + "target": "ES5" + } + }`) + + await writeFileAsync(manifest, `{ + "packageRegistryData": [ + [null, [ + [null, { + "packageLocation": "./", + "packageDependencies": [ + ["@scope/base", "npm:1.0.0"] + ], + "linkType": "SOFT" + }] + ]], + ["@scope/base", [ + ["npm:1.0.0", { + "packageLocation": "./foo/", + "packageDependencies": [], + "linkType": "HARD" + }] + ]] + ] + }`) + + const value = await esbuild.build({ + entryPoints: [entry], + bundle: true, + write: false, + }) + + assert.strictEqual(value.outputFiles.length, 1) + assert.strictEqual(value.outputFiles[0].text, `(() => { + var __pow = Math.pow; + + // scripts/.js-api-tests/yarnPnP_tsconfig/entry.js + x = __pow(x, 2); +})(); `) }, }