Skip to content

fix: handle missing pnpapi #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 127 additions & 8 deletions src/compiler/moduleNameResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,22 +281,22 @@ namespace ts {

/**
* Returns the path to every node_modules/@types directory from some ancestor directory.
* Returns undefined if there are none.
*/
function getDefaultTypeRoots(currentDirectory: string, host: { directoryExists?: (directoryName: string) => boolean }): string[] | undefined {
function getNodeModulesTypeRoots(currentDirectory: string, host: { directoryExists?: (directoryName: string) => boolean }) {
if (!host.directoryExists) {
return [combinePaths(currentDirectory, nodeModulesAtTypes)];
// And if it doesn't exist, tough.
}

let typeRoots: string[] | undefined;
const typeRoots: string[] = [];
forEachAncestorDirectory(normalizePath(currentDirectory), directory => {
const atTypes = combinePaths(directory, nodeModulesAtTypes);
if (host.directoryExists!(atTypes)) {
(typeRoots || (typeRoots = [])).push(atTypes);
typeRoots.push(atTypes);
}
return undefined;
});

return typeRoots;
}
const nodeModulesAtTypes = combinePaths("node_modules", "@types");
Expand All @@ -306,6 +306,50 @@ namespace ts {
return comparePaths(path1, path2, !useCaseSensitiveFileNames) === Comparison.EqualTo;
}

/**
* @internal
*/
export function getPnpTypeRoots(currentDirectory: string) {
const pnpapi = getPnpApi(currentDirectory);
if (!pnpapi) {
return [];
}

// Some TS consumers pass relative paths that aren't normalized
currentDirectory = sys.resolvePath(currentDirectory);

const currentPackage = pnpapi.findPackageLocator(`${currentDirectory}/`);
if (!currentPackage) {
return [];
}

const {packageDependencies} = pnpapi.getPackageInformation(currentPackage);

const typeRoots: string[] = [];
for (const [name, referencish] of Array.from<any>(packageDependencies.entries())) {
// eslint-disable-next-line no-null/no-null
if (name.startsWith(typesPackagePrefix) && referencish !== null) {
const dependencyLocator = pnpapi.getLocator(name, referencish);
const {packageLocation} = pnpapi.getPackageInformation(dependencyLocator);

typeRoots.push(getDirectoryPath(packageLocation));
}
}

return typeRoots;
}

const typesPackagePrefix = "@types/";

function getDefaultTypeRoots(currentDirectory: string, host: { directoryExists?: (directoryName: string) => boolean }): string[] | undefined {
const nmTypes = getNodeModulesTypeRoots(currentDirectory, host);
const pnpTypes = getPnpTypeRoots(currentDirectory);

if (nmTypes.length > 0 || pnpTypes.length > 0) {
return [...nmTypes, ...pnpTypes];
}
}

/**
* @param {string | undefined} containingFile - file that contains type reference directive, can be undefined if containing file is unknown.
* This is possible in case if resolution is performed for directives specified via 'types' parameter. In this case initial path for secondary lookups
Expand Down Expand Up @@ -454,7 +498,10 @@ namespace ts {
}
let result: Resolved | undefined;
if (!isExternalModuleNameRelative(typeReferenceDirectiveName)) {
const searchResult = loadModuleFromNearestNodeModulesDirectory(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState, /*cache*/ undefined, /*redirectedReference*/ undefined);
const searchResult = getPnpApi(initialLocationForSecondaryLookup)
? tryLoadModuleUsingPnpResolution(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState, /*cache*/ undefined, /*redirectedReference*/ undefined)
: loadModuleFromNearestNodeModulesDirectory(Extensions.DtsOnly, typeReferenceDirectiveName, initialLocationForSecondaryLookup, moduleResolutionState, /*cache*/ undefined, /*redirectedReference*/ undefined);

result = searchResult && searchResult.value;
}
else {
Expand Down Expand Up @@ -1405,7 +1452,9 @@ namespace ts {
if (traceEnabled) {
trace(host, Diagnostics.Loading_module_0_from_node_modules_folder_target_file_type_1, moduleName, Extensions[extensions]);
}
resolved = loadModuleFromNearestNodeModulesDirectory(extensions, moduleName, containingDirectory, state, cache, redirectedReference);
resolved = getPnpApi(containingDirectory)
? tryLoadModuleUsingPnpResolution(extensions, moduleName, containingDirectory, state, cache, redirectedReference)
: loadModuleFromNearestNodeModulesDirectory(extensions, moduleName, containingDirectory, state, cache, redirectedReference);
}
if (!resolved) return undefined;

Expand Down Expand Up @@ -2432,7 +2481,15 @@ namespace ts {

function loadModuleFromSpecificNodeModulesDirectory(extensions: Extensions, moduleName: string, nodeModulesDirectory: string, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): Resolved | undefined {
const candidate = normalizePath(combinePaths(nodeModulesDirectory, moduleName));
return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, moduleName, nodeModulesDirectory, nodeModulesDirectoryExists, state, cache, redirectedReference, candidate, /* rest */ undefined, /* packageDirectory */ undefined);
}

function loadModuleFromPnpResolution(extensions: Extensions, packageDirectory: string, rest: string, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): Resolved | undefined {
const candidate = normalizePath(combinePaths(packageDirectory, rest));
return loadModuleFromSpecificNodeModulesDirectoryImpl(extensions, /*moduleName*/ undefined, /*nodeModulesDirectory*/ undefined, /*nodeModulesDirectoryExists*/ true, state, cache, redirectedReference, candidate, rest, packageDirectory);
}

function loadModuleFromSpecificNodeModulesDirectoryImpl(extensions: Extensions, moduleName: string | undefined, nodeModulesDirectory: string | undefined, nodeModulesDirectoryExists: boolean, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined, candidate: string, rest: string | undefined, packageDirectory: string | undefined): Resolved | undefined {
// First look for a nested package.json, as in `node_modules/foo/bar/package.json`.
let packageInfo = getPackageJsonInfo(candidate, !nodeModulesDirectoryExists, state);
// But only if we're not respecting export maps (if we are, we might redirect around this location)
Expand Down Expand Up @@ -2479,8 +2536,10 @@ namespace ts {
return withPackageId(packageInfo, pathAndExtension);
};

const { packageName, rest } = parsePackageName(moduleName);
const packageDirectory = combinePaths(nodeModulesDirectory, packageName);
let packageName: string;
if (rest === undefined) ({ packageName, rest } = parsePackageName(moduleName!));
if (packageDirectory === undefined) packageDirectory = combinePaths(nodeModulesDirectory!, packageName!);

if (rest !== "") {
// Previous `packageInfo` may have been from a nested package.json; ensure we have the one from the package root now.
packageInfo = getPackageJsonInfo(packageDirectory, !nodeModulesDirectoryExists, state);
Expand Down Expand Up @@ -2709,4 +2768,64 @@ namespace ts {
trace(state.host, diagnostic, ...args);
}
}

/**
* We only allow PnP to be used as a resolution strategy if TypeScript
* itself is executed under a PnP runtime (and we only allow it to access
* the current PnP runtime, not any on the disk). This ensures that we
* don't execute potentially malicious code that didn't already have a
* chance to be executed (if we're running within the runtime, it means
* that the runtime has already been executed).
* @internal
*/
function getPnpApi(path: string) {
const {findPnpApi} = require("module");
if (findPnpApi === undefined) {
return undefined;
}
return findPnpApi(`${path}/`);
}

function loadPnpPackageResolution(packageName: string, containingDirectory: string) {
try {
const resolution = getPnpApi(containingDirectory).resolveToUnqualified(packageName, `${containingDirectory}/`, { considerBuiltins: false });
return normalizeSlashes(resolution).replace(/\/$/, "");
}
catch {
// Nothing to do
}
}

function loadPnpTypePackageResolution(packageName: string, containingDirectory: string) {
return loadPnpPackageResolution(getTypesPackageName(packageName), containingDirectory);
}

/* @internal */
function tryLoadModuleUsingPnpResolution(extensions: Extensions, moduleName: string, containingDirectory: string, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined) {
const {packageName, rest} = parsePackageName(moduleName);

const packageResolution = loadPnpPackageResolution(packageName, containingDirectory);
const packageFullResolution = packageResolution
? loadModuleFromPnpResolution(extensions, packageResolution, rest, state, cache, redirectedReference)
: undefined;

let resolved;
if (packageFullResolution) {
resolved = packageFullResolution;
}
else if (extensions === Extensions.TypeScript || extensions === Extensions.DtsOnly) {
const typePackageResolution = loadPnpTypePackageResolution(packageName, containingDirectory);
const typePackageFullResolution = typePackageResolution
? loadModuleFromPnpResolution(Extensions.DtsOnly, typePackageResolution, rest, state, cache, redirectedReference)
: undefined;

if (typePackageFullResolution) {
resolved = typePackageFullResolution;
}
}

if (resolved) {
return toSearchResult(resolved);
}
}
}
56 changes: 45 additions & 11 deletions src/compiler/moduleSpecifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,34 @@ namespace ts.moduleSpecifiers {
if (!host.fileExists || !host.readFile) {
return undefined;
}
const parts: NodeModulePathParts = getNodeModulePathParts(path)!;
let parts: NodeModulePathParts | PackagePathParts | undefined
= getNodeModulePathParts(path);

let packageName: string | undefined;
if (!parts && typeof process.versions.pnp !== "undefined") {
const {findPnpApi} = require("module");
const pnpApi = findPnpApi(path);
const locator = pnpApi?.findPackageLocator(path);
// eslint-disable-next-line no-null/no-null
if (locator !== null && locator !== undefined) {
const sourceLocator = pnpApi.findPackageLocator(`${sourceDirectory}/`);
// Don't use the package name when the imported file is inside
// the source directory (prefer a relative path instead)
if (locator === sourceLocator) {
return undefined;
}
const information = pnpApi.getPackageInformation(locator);
packageName = locator.name;
parts = {
topLevelNodeModulesIndex: undefined,
topLevelPackageNameIndex: undefined,
// The last character from packageLocation is the trailing "/", we want to point to it
packageRootIndex: information.packageLocation.length - 1,
fileNameIndex: path.lastIndexOf(`/`),
};
}
}

if (!parts) {
return undefined;
}
Expand Down Expand Up @@ -793,19 +820,26 @@ namespace ts.moduleSpecifiers {
return undefined;
}

const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
// Get a path that's relative to node_modules or the importing file's path
// if node_modules folder is in this folder or any of its parent folders, no need to keep it.
const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
if (!(startsWith(sourceDirectory, pathToTopLevelNodeModules) || globalTypingsCacheLocation && startsWith(getCanonicalFileName(globalTypingsCacheLocation), pathToTopLevelNodeModules))) {
return undefined;
// If PnP is enabled the node_modules entries we'll get will always be relevant even if they
// are located in a weird path apparently outside of the source directory
if (typeof process.versions.pnp === "undefined") {
const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
// Get a path that's relative to node_modules or the importing file's path
// if node_modules folder is in this folder or any of its parent folders, no need to keep it.
const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
if (!(startsWith(sourceDirectory, pathToTopLevelNodeModules) || globalTypingsCacheLocation && startsWith(getCanonicalFileName(globalTypingsCacheLocation), pathToTopLevelNodeModules))) {
return undefined;
}
}

// If the module was found in @types, get the actual Node package name
const nodeModulesDirectoryName = moduleSpecifier.substring(parts.topLevelPackageNameIndex + 1);
const packageName = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
const nodeModulesDirectoryName = typeof packageName !== "undefined"
? packageName + moduleSpecifier.substring(parts.packageRootIndex)
: moduleSpecifier.substring(parts.topLevelPackageNameIndex! + 1);

const packageNameFromPath = getPackageNameFromTypesPackageName(nodeModulesDirectoryName);
// For classic resolution, only allow importing from node_modules/@types, not other node_modules
return getEmitModuleResolutionKind(options) === ModuleResolutionKind.Classic && packageName === nodeModulesDirectoryName ? undefined : packageName;
return getEmitModuleResolutionKind(options) === ModuleResolutionKind.Classic && packageNameFromPath === nodeModulesDirectoryName ? undefined : packageNameFromPath;

function tryDirectoryWithPackageJson(packageRootIndex: number): { moduleFileToTry: string, packageRootPath?: string, blockedByExports?: true, verbatimFromExports?: true } {
const packageRootPath = path.substring(0, packageRootIndex);
Expand Down Expand Up @@ -868,7 +902,7 @@ namespace ts.moduleSpecifiers {
}
else {
// No package.json exists; an index.js will still resolve as the package name
const fileName = getCanonicalFileName(moduleFileToTry.substring(parts.packageRootIndex + 1));
const fileName = getCanonicalFileName(moduleFileToTry.substring(parts!.packageRootIndex + 1));
if (fileName === "index.d.ts" || fileName === "index.js" || fileName === "index.ts" || fileName === "index.tsx") {
return { moduleFileToTry, packageRootPath };
}
Expand Down
5 changes: 5 additions & 0 deletions src/compiler/sys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1683,6 +1683,11 @@ namespace ts {
}

function isFileSystemCaseSensitive(): boolean {
// The PnP runtime is always case-sensitive
// @ts-ignore
if (process.versions.pnp) {
return true;
}
// win32\win64 are case insensitive platforms
if (platform === "win32" || platform === "win64") {
return false;
Expand Down
8 changes: 8 additions & 0 deletions src/compiler/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7691,6 +7691,14 @@ namespace ts {
readonly packageRootIndex: number;
readonly fileNameIndex: number;
}

export interface PackagePathParts {
readonly topLevelNodeModulesIndex: undefined;
readonly topLevelPackageNameIndex: undefined;
readonly packageRootIndex: number;
readonly fileNameIndex: number;
}

export function getNodeModulePathParts(fullPath: string): NodeModulePathParts | undefined {
// If fullPath can't be valid module file within node_modules, returns undefined.
// Example of expected pattern: /base/path/node_modules/[@scope/otherpackage/@otherscope/node_modules/]package/[subdirectory/]file.js
Expand Down
Loading