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

Add support for package exports into .pnp.js #1359

Closed
wants to merge 1 commit 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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = `conditional/default`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = `conditional/import`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = `conditional/node-import`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = `conditional/node-require`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = `conditional/require`;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = `main`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@exports/okay",
"version": "1.0.0",
"exports": {
".": "./main.js",
"./conditional": {
"node": {
"require": "./conditional/node-require.js",
"import": "./conditional/node-import.js"
},
"require": "./conditional/require.js",
"import": "./conditional/import.js",
"default": "./conditional/default.js"
},
"./": "./public/"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = `exists`;
24 changes: 24 additions & 0 deletions packages/acceptance-tests/pkg-tests-specs/sources/require.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,28 @@ describe(`Require tests`, () => {
},
),
);

test(
`it should support package exports`,
makeTemporaryEnv(
{
dependencies: {[`@exports/okay`]: `1.0.0`},
},
async ({path, run, source}) => {
await run(`install`);

await expect(source(`require('@exports/okay')`)).resolves.toBe(`main`);
await expect(source(`require('@exports/okay/conditional')`)).resolves.toBe(`conditional/node-require`);
await expect(source(`require('@exports/okay/exists')`)).resolves.toBe(`exists`);

await expect(source(`require('@exports/okay/package.json')`)).rejects.toMatchObject({
externalException: {
code: `MODULE_NOT_FOUND`,
message: expect.stringMatching(`Qualified path resolution failed`),
pnpCode: `QUALIFIED_PATH_RESOLUTION_FAILED`,
},
});
},
),
);
});
2 changes: 1 addition & 1 deletion packages/yarnpkg-pnp/sources/hook.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/yarnpkg-pnp/sources/loader/internalTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export enum ErrorCode {
INTERNAL = `INTERNAL`,
UNDECLARED_DEPENDENCY = `UNDECLARED_DEPENDENCY`,
UNSUPPORTED = `UNSUPPORTED`,
ERR_INVALID_PACKAGE_CONFIG = `ERR_INVALID_PACKAGE_CONFIG`,
ERR_PACKAGE_PATH_NOT_EXPORTED = `ERR_PACKAGE_PATH_NOT_EXPORTED`,
ERR_INVALID_PACKAGE_TARGET = `ERR_INVALID_PACKAGE_TARGET`,
ERR_INVALID_MODULE_SPECIFIER = `ERR_INVALID_MODULE_SPECIFIER`,
}

// Some errors are exposed as MODULE_NOT_FOUND for compatibility with packages
Expand Down
199 changes: 194 additions & 5 deletions packages/yarnpkg-pnp/sources/loader/makeApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {FakeFS, NativePath, Path, PortablePath, VirtualFS, npath} from '@yarnpkg/fslib';
import {ppath, toFilename} from '@yarnpkg/fslib';
import {FakeFS, NativePath, Path, PortablePath, VirtualFS, npath} from '@yarnpkg/fslib';

import {Module} from 'module';

import {PackageInformation, PackageLocator, PnpApi, RuntimeState, PhysicalPackageLocator, DependencyTarget} from '../types';
Expand All @@ -17,13 +18,19 @@ export type ResolveToUnqualifiedOptions = {
considerBuiltins?: boolean,
};

export type ResolveUnqualifiedExportsOptions = {
env?: Array<string>,
allowMissingDotInExports?: boolean,
}

export type ResolveUnqualifiedOptions = {
extensions?: Array<string>,
};

export type ResolveRequestOptions =
ResolveToUnqualifiedOptions &
ResolveUnqualifiedOptions;
ResolveUnqualifiedOptions &
ResolveUnqualifiedExportsOptions;

export function makeApi(runtimeState: RuntimeState, opts: MakeApiOptions): PnpApi {
const alwaysWarnOnFallback = Number(process.env.PNP_ALWAYS_WARN_ON_FALLBACK) > 0;
Expand Down Expand Up @@ -91,7 +98,7 @@ export function makeApi(runtimeState: RuntimeState, opts: MakeApiOptions): PnpAp
packageRegistry,
packageLocatorsByLocations,
packageLocationLengths,
} = runtimeState as RuntimeState;
} = runtimeState;

/**
* Allows to print useful logs just be setting a value in the environment
Expand Down Expand Up @@ -171,6 +178,174 @@ export function makeApi(runtimeState: RuntimeState, opts: MakeApiOptions): PnpAp
return false;
}

type StaticExports = string|Array<string>|null;
type ConditionalExports = {[env: string]: StaticExports|ConditionalExports};
type PathExports = {[path: string]: ConditionalExports | StaticExports};
type Exports = StaticExports | ConditionalExports | PathExports;

function isFullyQualifiedExportsObject(pkgExports: NonNullable<Exports>, locator: PackageLocator): pkgExports is PathExports {
if (typeof pkgExports === `string`)
return false;

let isFirst = true;
let isPath = true;

for (const key of Object.keys(pkgExports)) {
const isKeyPath = key.startsWith(`.`);

if (isFirst) {
isFirst = false;
isPath = isKeyPath;
} else if (isPath !== isKeyPath) {
throw makeError(
ErrorCode.ERR_INVALID_PACKAGE_CONFIG,
`"exports" cannot contain some keys starting with '.' and some not. The exports object must either be an object of package subpath keys or an object of main entry condition name keys only.`,
{locator},
);
}
}

return isPath;
}

function applyNodeModuleExports(unqualifiedPath: PortablePath, {env, allowMissingDot}: {env: Array<string>, allowMissingDot: boolean}): PortablePath {
const locator = findPackageLocator(ppath.join(unqualifiedPath, toFilename(`internal.js`)))!;
const {packageLocation} = getPackageInformation(locator)!;

const pathInModule = `.${unqualifiedPath.slice(packageLocation.length)}`;
let pkgExports: StaticExports|ConditionalExports|PathExports|undefined;

try {
({exports: pkgExports} = JSON.parse(opts.fakeFs.readFileSync(ppath.join(packageLocation, toFilename(`package.json`)), `utf8`)));
} catch (error) {}

if (pkgExports == null)
// no exports are defined
return unqualifiedPath;

if (!isFullyQualifiedExportsObject(pkgExports, locator))
pkgExports = {[`.`]: pkgExports} as PathExports;

const exportedPaths = Object.keys(pkgExports);

if (allowMissingDot && pathInModule === `.` && !exportedPaths.includes(`.`))
return unqualifiedPath;

let longestMatchingKey = ``;
for (const key of exportedPaths) {
if (key === pathInModule || key.endsWith(`/`) && pathInModule.startsWith(key) && key.length > longestMatchingKey.length) {
longestMatchingKey = key;
}
}

if (longestMatchingKey === ``)
throw makePackagePathNotExportedError(locator, npath.fromPortablePath(packageLocation), pathInModule);

return resolvePackageExportsTarget(locator, packageLocation, pkgExports[longestMatchingKey], longestMatchingKey, pathInModule.slice(longestMatchingKey.length) as PortablePath, env);
}

function resolvePackageExportsTarget(locator: PackageLocator, packageLocation: PortablePath, target: ConditionalExports|StaticExports, targetKey: string, subpath: PortablePath, env: Array<string>): PortablePath {
if (typeof target === `string`) {
if (!target.startsWith(`./`)) {
const pkgPath = npath.join(npath.fromPortablePath(packageLocation), `package.json`);
throw makeError(ErrorCode.ERR_INVALID_PACKAGE_TARGET,
subpath
? `Invalid "exports" target ${JSON.stringify(target)} defined for '${targetKey}' in the package config ${pkgPath}; targets must start with "./"`
: `Invalid "exports" main target ${JSON.stringify(target)} defined in the package config ${pkgPath}; targets must start with "./"`,
{locator},
);
}

const resolvedTargetPath = ppath.join(packageLocation, target as PortablePath);
if (!resolvedTargetPath.startsWith(`${packageLocation}/`) ||
resolvedTargetPath.indexOf(`/node_modules/`, packageLocation.length) !== -1) {
const pkgPath = npath.join(npath.fromPortablePath(packageLocation), `package.json`);
throw makeError(ErrorCode.ERR_INVALID_PACKAGE_TARGET,
subpath
? `Invalid "exports" target ${JSON.stringify(target)} defined for '${targetKey}' in the package config ${pkgPath}`
: `Invalid "exports" main target ${JSON.stringify(target)} defined in the package config ${pkgPath}`,
{locator},
);
}

if (subpath.length > 0 && !target.endsWith(`/`)) {
const pkgPath = npath.join(npath.fromPortablePath(packageLocation), `package.json`);
throw makeError(ErrorCode.ERR_INVALID_PACKAGE_TARGET,
`Invalid "exports" target ${JSON.stringify(target)} defined for '${subpath}' in the package config ${pkgPath}`,
{locator}
);
}

const resolvedPath = ppath.normalize((resolvedTargetPath + subpath) as PortablePath);
if (resolvedPath.startsWith(resolvedTargetPath) &&
resolvedPath.indexOf(`/node_modules/`, packageLocation.length - 1) === -1)
return resolvedPath;

throw makeError(ErrorCode.ERR_INVALID_MODULE_SPECIFIER, `Package subpath '${subpath}' is not a valid module request for the "exports" resolution of ${npath.fromPortablePath(packageLocation)}${npath.sep}package.json`);
} else if (Array.isArray(target)) {
if (target.length === 0)
throw makePackagePathNotExportedError(locator, npath.fromPortablePath(packageLocation), targetKey + subpath);

let lastError: any;
for (const t of target) {
try {
return resolvePackageExportsTarget(locator, packageLocation, t, targetKey, subpath, env);
} catch (e) {
if (e.code !== ErrorCode.ERR_PACKAGE_PATH_NOT_EXPORTED && e.code !== ErrorCode.ERR_INVALID_PACKAGE_TARGET)
throw e;

lastError = e;
}
}

throw lastError;
} else if (typeof target === `object` && target !== null) {
const conditions = Object.keys(target);
if (conditions.some(isArrayIndex))
throw makeError(ErrorCode.ERR_INVALID_PACKAGE_CONFIG, `"exports" cannot contain numeric property keys.`, {locator});

for (const condition of conditions) {
if (env.includes(condition)) {
try {
return resolvePackageExportsTarget(locator, packageLocation, target[condition], targetKey, subpath, env);
} catch (e) {
if (e.code !== ErrorCode.ERR_PACKAGE_PATH_NOT_EXPORTED) {
throw e;
}
}
}
}

throw makePackagePathNotExportedError(locator, npath.fromPortablePath(packageLocation), targetKey + subpath);
} else if (target === null) {
throw makePackagePathNotExportedError(locator, npath.fromPortablePath(packageLocation), targetKey + subpath);
} else {
throw makeError(
ErrorCode.ERR_INVALID_PACKAGE_TARGET,
``,
{locator},
);
}
}

// https://tc39.es/ecma262/#integer-index
function isArrayIndex(value: string) {
const number = Number(value);
if (String(number) !== value)
return false;
return Number.isInteger(number) && number >= 0 && number < (2 ** 32) - 1;
}

function makePackagePathNotExportedError(locator: PackageLocator, packageLocation: NativePath, pathInModule: string) {
let errorMessage: string;
if (pathInModule === `.`)
errorMessage = `No "exports" main resolved in ${packageLocation}${npath.sep}package.json`;
else
errorMessage = `Package subpath '${pathInModule}' is not defined by "exports" in ${packageLocation}${npath.sep}package.json`;

return makeError(ErrorCode.ERR_PACKAGE_PATH_NOT_EXPORTED, errorMessage, {locator});
}

/**
* Implements the node resolution for folder access and extension selection
*/
Expand Down Expand Up @@ -634,6 +809,14 @@ export function makeApi(runtimeState: RuntimeState, opts: MakeApiOptions): PnpAp
return ppath.normalize(unqualifiedPath);
}

function resolveUnqualifiedExport(request: PortablePath, unqualifiedPath: PortablePath, {env = [`node` ,`require`], allowMissingDotInExports: allowMissingDot = true}: ResolveUnqualifiedExportsOptions = {}): PortablePath {
// exports only apply when requiring a package, not when requiring via an absolute/relative path
if (isStrictRegExp.test(request))
return unqualifiedPath;

return applyNodeModuleExports(unqualifiedPath, {env, allowMissingDot});
}

/**
* Transforms an unqualified path into a qualified path by using the Node resolution algorithm (which automatically
* appends ".js" / ".json", and transforms directory accesses into "index.js").
Expand Down Expand Up @@ -662,12 +845,14 @@ export function makeApi(runtimeState: RuntimeState, opts: MakeApiOptions): PnpAp
* imports won't be computed correctly (they'll get resolved relative to "/tmp/" instead of "/tmp/foo/").
*/

function resolveRequest(request: PortablePath, issuer: PortablePath | null, {considerBuiltins, extensions}: ResolveRequestOptions = {}): PortablePath | null {
const unqualifiedPath = resolveToUnqualified(request, issuer, {considerBuiltins});
function resolveRequest(request: PortablePath, issuer: PortablePath | null, {considerBuiltins, extensions, allowMissingDotInExports, env}: ResolveRequestOptions = {}): PortablePath | null {
let unqualifiedPath = resolveToUnqualified(request, issuer, {considerBuiltins});

if (unqualifiedPath === null)
return null;

unqualifiedPath = resolveUnqualifiedExport(request, unqualifiedPath, {allowMissingDotInExports, env});

try {
return resolveUnqualified(unqualifiedPath, {extensions});
} catch (resolutionError) {
Expand Down Expand Up @@ -727,6 +912,10 @@ export function makeApi(runtimeState: RuntimeState, opts: MakeApiOptions): PnpAp
return npath.fromPortablePath(resolution);
}),

resolveUnqualifiedExport: maybeLog(`resolveUnqualifiedExport`, (request: NativePath, unqualifiedPath: NativePath, opts?: ResolveUnqualifiedExportsOptions) => {
return npath.fromPortablePath(resolveUnqualifiedExport(npath.toPortablePath(request), npath.toPortablePath(unqualifiedPath), opts));
}),

resolveUnqualified: maybeLog(`resolveUnqualified`, (unqualifiedPath: NativePath, opts?: ResolveUnqualifiedOptions) => {
return npath.fromPortablePath(resolveUnqualified(npath.toPortablePath(unqualifiedPath), opts));
}),
Expand Down
3 changes: 2 additions & 1 deletion packages/yarnpkg-pnp/sources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ export type PnpApi = {
findPackageLocator: (location: NativePath) => PhysicalPackageLocator | null,

resolveToUnqualified: (request: string, issuer: NativePath | null, opts?: {considerBuiltins?: boolean}) => NativePath | null,
resolveUnqualifiedExport: (request: NativePath, unqualifiedPath: NativePath, opts?: {env?: Array<string>, allowMissingDotInExports?: boolean}) => NativePath,
resolveUnqualified: (unqualified: NativePath, opts?: {extensions?: Array<string>}) => NativePath,
resolveRequest: (request: string, issuer: NativePath | null, opts?: {considerBuiltins?: boolean, extensions?: Array<string>}) => NativePath | null,
resolveRequest: (request: string, issuer: NativePath | null, opts?: {considerBuiltins?: boolean, extensions?: Array<string>, env?: Array<string>, allowMissingDotInExports?: boolean}) => NativePath | null,

// Extension method
resolveVirtual?: (p: NativePath) => NativePath | null,
Expand Down