Skip to content
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

[WIP] Draft PR for adding imports to package.json #995

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
269 changes: 269 additions & 0 deletions packages/metro-resolver/src/PackageImportsResolve.js
@@ -0,0 +1,269 @@
import path from 'path';
import fs from 'fs';
import * as url from 'url';
export function resolvePackageTargetsFromImports(specifier: string, parentURL, conditions){
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update this function to use the existing ResolutionContext object, which provides APIs such as context.getPackageForModule() and context.doesFileExist().

  • This should enable your implementation to work without importing and using fs.
  • Removes the need for separate lookupParentScope and readPackageJSON utils.
  • Enables this function to read config options such as resolver.unstable_conditionNames.

The function should also be named to reflect returning a singular Target (typo). Together, the signature might look closer to:

export function resolvePackageTargetFromImports(
  context: ResolutionContext,
  importSpecifier: string,
  importsField: ExportMap,
  platform: string | null,
): FileResolution {

This also means we should move the specifier.startsWith('#') check externally, to around resolve.js#L52 and call this function with the parsed imports field if it exists.


if (!specifier.startsWith('#')) {
throw new Error("Specifier must start with '#'.");
}

if(specifier.startsWith('#/') || specifier === '#') {
throw Error('Invalid Module Specifier.');
Copy link
Member

@huntie huntie Jun 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be replaced with a dedicated error type — this existed previously and can be copied over: https://github.com/facebook/metro/blob/47856447a885d695f781694e5200ca2c3adecc87/packages/metro-resolver/src/errors/InvalidModuleSpecifierError.js

Similarly for any other errors we're introducing from the Node.js spec.

}
const packageURL = lookupParentScope(parentURL);

if (packageURL) {
const pjson = readPackageJSON(packageURL);

// If pjson.imports is a non-null Object, then
if (pjson.imports && typeof pjson.imports === 'object') {
const resolved = packageImportsExportsResolver(specifier, pjson.imports, packageURL, true, conditions);

// If resolved is not null or undefined, return resolved.
if (resolved != null && typeof resolved !== 'undefined') {
return resolved;
}
}

throw new Error('Package Import Not Defined.');
}
}

function lookupParentScope(url: string): string {
let scopeURL = url;

while (scopeURL !== path.parse(scopeURL).root) {
scopeURL = path.dirname(scopeURL);

// If scopeURL ends in a "node_modules" path segment, return null.
if (scopeURL.endsWith('node_modules')) {
return null;
}

const pjsonURL = path.resolve(scopeURL, 'package.json');

// If the file at pjsonURL exists, then
if (fs.existsSync(pjsonURL)) {
return scopeURL;
}
}
return null;
}

function readPackageJSON(url: string): string {
const pjsonURL = path.resolve(packageURL, 'package.json');

// If the file at pjsonURL does not exist, then
if (!fs.existsSync(pjsonURL)) {
return null;
}

try {
// Parse JSON from file
const packageJson = fs.readFileSync(pjsonURL, 'utf-8');
const parsedJson = JSON.parse(packageJson);

// If the file at pjsonURL does not parse as valid JSON, then
// Throw an Invalid Package Configuration error.
return parsedJson;
} catch (e) {
throw new Error('Invalid Package Configuration');
}
}

function packageImportsExportsResolver(matchKey, matchObj, packageURL, isImports, conditions) {
if (matchObj.hasOwnProperty(matchKey) && !matchKey.includes('*')) {
const target = matchObj[matchKey];
return packageTargetResolve(packageURL, target, null, isImports, conditions);
}

const expansionKeys = Object.keys(matchObj).filter(key => key.split('*').length - 1 === 1).sort(PATTERN_KEY_COMPARE);

for (const expansionKey of expansionKeys) {
const patternBase = expansionKey.split('*')[0];
if (matchKey.startsWith(patternBase) && matchKey !== patternBase) {
const patternTrailer = expansionKey.split('*')[1];
if (patternTrailer.length === 0 || (matchKey.endsWith(patternTrailer) && matchKey.length >= expansionKey.length)) {
const target = matchObj[expansionKey];
const patternMatch = matchKey.substring(patternBase.length, matchKey.length - patternTrailer.length);
return packageTargetResolve(packageURL, target, patternMatch, isImports, conditions);
}
}
}

return null;
}

function packageTargetResolve(packageURL, target, patternMatch, isImports, conditions) {
Copy link
Member

@huntie huntie Jun 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to Implement the methods PACKAGE_IMPORTS_RESOLVE depends on[?]

We don't! 😄 packageTargetResolve and PackageExportsResolve are already implemented elsewhere in metro-resolver — please remove.

While this lines up 1:1 with the Node.js resolution algorithm, let's reuse what's available in the rest of the resolver code rather than reimplementing here — which (while not an exact match with Node's strategy) will respect other Metro resolution behaviours such as resolver.extraNodeModules.

Please look to locate the function call for resolvePackageTargetsFromImports() within the main resolve() function and use resolve() to evaluate the package target read from "imports" (updating context.originModulePath when calling this).

The control flow would look roughly like:

  • Within resolve()
    • if the import specifier (moduleName) begins with # AND if imports is in context.getPackageForModule(context.originModulePath)
      • get the result of resolvePackageTargetFromImports(context, moduleName, importsField, ...)
      • if a target was returned (containing the matched import specifier value from "imports"), call and return resolve(target)
      • else, fall through (will error after all other resolution steps)

if (typeof target === 'string') {
if (!target.startsWith('./')) {
if (!isImports || target.startsWith('../') || target.startsWith('/') || isValidURL(target)) {
throw new Error('Invalid Package Target');
}
}

if (patternMatch) {
return packageResolve(target.replace(/\*/g, patternMatch), `${packageURL}/`);
}

return packageResolve(target, `${packageURL}/`);
} else if (target && typeof target === 'object' && !Array.isArray(target)) {
if (Object.keys(target).some(key => /^\d+$/.test(key))) {
throw new Error('Invalid Package Configuration');
}

for (const p in target) {
if (p === 'default' || conditions.includes(p)) {
const targetValue = target[p];
const resolved = packageTargetResolve(packageURL, targetValue, patternMatch, isImports, conditions);
if (resolved != null) {
return resolved;
}
}
}

return undefined;
} else if (Array.isArray(target)) {
if (target.length === 0) {
return null;
}

for (const targetValue of target) {
try {
const resolved = packageTargetResolve(packageURL, targetValue, patternMatch, isImports, conditions);
if (resolved != null) {
return resolved;
}
} catch (e) {
if (e.message !== 'Invalid Package Target') {
throw e;
}
}
}

throw new Error('Invalid Package Target');
} else if (target == null) {
return null;
}

throw new Error('Invalid Package Target');
}

function isValidURL(str) {
try {
new URL(str);
return true;
} catch (e) {
return false;
}
}

function packageResolve(packageSpecifier, parentURL) {
if (packageSpecifier === '') {
throw new Error('Invalid Module Specifier');
}

if (isBuiltinModule(packageSpecifier)) {
return `node:${packageSpecifier}`;
}

let packageName;
if (!packageSpecifier.startsWith('@')) {
packageName = packageSpecifier.split('/')[0];
} else {
if (!packageSpecifier.includes('/')) {
throw new Error('Invalid Module Specifier');
}
packageName = packageSpecifier.split('/').slice(0, 2).join('/');
}

if (packageName.startsWith('.') || packageName.includes('\\') || packageName.includes('%')) {
throw new Error('Invalid Module Specifier');
}

const packageSubpath = '.' + packageSpecifier.slice(packageName.length);

if (packageSubpath.endsWith('/')) {
throw new Error('Invalid Module Specifier');
}

const selfUrl = PackageSelfResolve(packageName, packageSubpath, parentURL);

if (selfUrl != null) {
return selfUrl;
}

while (parentURL !== path.parse(parentURL).root) {
const packageURL = url.resolve(parentURL, `node_modules/${packageSpecifier}`);
parentURL = path.dirname(parentURL);

if (!fs.existsSync(packageURL)) {
continue;
}

const pjson = readPackageJSON(packageURL);

if (pjson != null && pjson.exports != null) {
return PackageExportsResolve(packageURL, packageSubpath, pjson.exports, ['default']);
} else if (packageSubpath === '.') {
if (typeof pjson.main === 'string') {
return url.resolve(packageURL, pjson.main);
} else {
return url.resolve(packageURL, packageSubpath);
}
}
}

throw new Error('Module Not Found');
}

function isBuiltinModule(moduleName) {
// Check if moduleName is a Node.js builtin module
return require('module').builtinModules.includes(moduleName);
}

function PackageSelfResolve(packageName, packageSubpath, parentURL) {
const packageURL = lookupParentScope(parentURL);
if (packageURL == null) {
return undefined;
}
const pjson = readPackageJSON(packageURL);
if (pjson == null || pjson.exports == null || pjson.exports == null) {
return undefined;
}
if (pjson.name === packageName) {
return PackageExportsResolve(packageURL, packageSubpath, pjson.exports, defaultConditions);
}
return undefined;
}

function PackageExportsResolve(packageURL, subpath, exports, conditions) {
if (typeof exports === 'object' && Object.keys(exports).some(key => key.startsWith('.')) && Object.keys(exports).some(key => !key.startsWith('.'))) {
throw new Error('Invalid Package Configuration');
}

if (subpath === '.') {
let mainExport = undefined;

if (typeof exports === 'string' || Array.isArray(exports) || (typeof exports === 'object' && !Object.keys(exports).some(key => key.startsWith('.')))) {
mainExport = exports;
} else if (typeof exports === 'object' && exports.hasOwnProperty('.')) {
mainExport = exports['.'];
}

if (mainExport != null) {
const resolved = packageTargetResolve(packageURL, mainExport, null, false, conditions);
if (resolved != null && resolved != null) {
return resolved;
}
}
} else if (typeof exports === 'object' && Object.keys(exports).every(key => key.startsWith('.'))) {
const matchKey = './' + subpath;
const resolved = packageImportsExportsResolver(matchKey, exports, packageURL, false, conditions);
if (resolved != null && resolved != null) {
return resolved;
}
}

throw new Error('Package Path Not Exported');
}
1 change: 1 addition & 0 deletions packages/metro-resolver/src/types.js
Expand Up @@ -76,6 +76,7 @@ export type PackageJson = $ReadOnly<{
name?: string,
main?: string,
exports?: ExportsField,
imports?: ExportMap,
...
}>;

Expand Down