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

feat(pnp): support consolidated ESM loader hooks #3603

Merged
merged 3 commits into from
Oct 21, 2021
Merged
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
28 changes: 28 additions & 0 deletions .yarn/versions/cf74cfe1.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
releases:
"@yarnpkg/cli": minor
"@yarnpkg/plugin-pnp": minor
"@yarnpkg/pnp": minor

declined:
- "@yarnpkg/esbuild-plugin-pnp"
- "@yarnpkg/plugin-compat"
- "@yarnpkg/plugin-constraints"
- "@yarnpkg/plugin-dlx"
- "@yarnpkg/plugin-essentials"
- "@yarnpkg/plugin-init"
- "@yarnpkg/plugin-interactive-tools"
- "@yarnpkg/plugin-nm"
- "@yarnpkg/plugin-npm-cli"
- "@yarnpkg/plugin-pack"
- "@yarnpkg/plugin-patch"
- "@yarnpkg/plugin-pnpm"
- "@yarnpkg/plugin-stage"
- "@yarnpkg/plugin-typescript"
- "@yarnpkg/plugin-version"
- "@yarnpkg/plugin-workspace-tools"
- "@yarnpkg/builder"
- "@yarnpkg/core"
- "@yarnpkg/doctor"
- "@yarnpkg/nm"
- "@yarnpkg/pnpify"
- "@yarnpkg/sdks"
2 changes: 1 addition & 1 deletion packages/plugin-pnp/sources/PnpLinker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export class PnpInstaller implements Installer {
}

if (this.isEsmEnabled()) {
this.opts.report.reportWarning(MessageName.UNNAMED, `ESM support for PnP uses the experimental loader API and is therefor experimental`);
this.opts.report.reportWarning(MessageName.UNNAMED, `ESM support for PnP uses the experimental loader API and is therefore experimental`);
await xfs.changeFilePromise(pnpPath.esmLoader, getESMLoaderTemplate(), {
automaticNewlines: true,
mode: 0o644,
Expand Down
2 changes: 1 addition & 1 deletion packages/yarnpkg-pnp/sources/esm-loader/built-loader.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions packages/yarnpkg-pnp/sources/esm-loader/fspatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import fs from 'fs';

//#region ESM to CJS support
/*
In order to import CJS files from ESM Node does some translating
internally[1]. This translator calls an unpatched `readFileSync`[2]
which itself calls an internal `tryStatSync`[3] which calls
`binding.fstat`[4]. A PR[5] has been made to use the monkey-patchable
`fs.readFileSync` but assuming that wont be merged this region of code
patches that final `binding.fstat` call.

1: https://github.com/nodejs/node/blob/d872aaf1cf20d5b6f56a699e2e3a64300e034269/lib/internal/modules/esm/translators.js#L177-L277
2: https://github.com/nodejs/node/blob/d872aaf1cf20d5b6f56a699e2e3a64300e034269/lib/internal/modules/esm/translators.js#L240
3: https://github.com/nodejs/node/blob/1317252dfe8824fd9cfee125d2aaa94004db2f3b/lib/fs.js#L452
4: https://github.com/nodejs/node/blob/1317252dfe8824fd9cfee125d2aaa94004db2f3b/lib/fs.js#L403
5: https://github.com/nodejs/node/pull/39513
*/

const binding = (process as any).binding(`fs`) as {
fstat: (fd: number, useBigint: false, req: any, ctx: object) => Float64Array
};
const originalfstat = binding.fstat;

const ZIP_FD = 0x80000000;
binding.fstat = function(...args) {
const [fd, useBigint, req] = args;
if ((fd & ZIP_FD) !== 0 && useBigint === false && req === undefined) {
try {
const stats = fs.fstatSync(fd);
// The reverse of this internal util
// https://github.com/nodejs/node/blob/8886b63cf66c29d453fdc1ece2e489dace97ae9d/lib/internal/fs/utils.js#L542-L551
return new Float64Array([
stats.dev,
stats.mode,
stats.nlink,
stats.uid,
stats.gid,
stats.rdev,
stats.blksize,
stats.ino,
stats.size,
stats.blocks,
// atime sec
// atime ns
// mtime sec
// mtime ns
// ctime sec
// ctime ns
// birthtime sec
// birthtime ns
]);
} catch {}
}

return originalfstat.apply(this, args);
};
//#endregion
23 changes: 23 additions & 0 deletions packages/yarnpkg-pnp/sources/esm-loader/hooks/getFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {fileURLToPath} from 'url';

import * as loaderUtils from '../loaderUtils';

// The default `getFormat` doesn't support reading from zip files
export async function getFormat(
resolved: string,
context: object,
defaultGetFormat: typeof getFormat,
): Promise<{ format: string }> {
const url = loaderUtils.tryParseURL(resolved);
if (url?.protocol !== `file:`)
return defaultGetFormat(resolved, context, defaultGetFormat);

const format = loaderUtils.getFileFormat(fileURLToPath(url));
if (format) {
return {
format,
};
}

return defaultGetFormat(resolved, context, defaultGetFormat);
}
19 changes: 19 additions & 0 deletions packages/yarnpkg-pnp/sources/esm-loader/hooks/getSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import fs from 'fs';
import {fileURLToPath} from 'url';

import * as loaderUtils from '../loaderUtils';

// The default `getSource` doesn't support reading from zip files
export async function getSource(
urlString: string,
context: { format: string },
defaultGetSource: typeof getSource,
): Promise<{ source: string }> {
Comment on lines +7 to +11
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps we should contribute those types to DefinitelyTyped?

Copy link
Member Author

Choose a reason for hiding this comment

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

Some of these hooks are removed already so we would have to define some of them either way

const url = loaderUtils.tryParseURL(urlString);
if (url?.protocol !== `file:`)
return defaultGetSource(urlString, context, defaultGetSource);

return {
source: await fs.promises.readFile(fileURLToPath(url), `utf8`),
};
}
26 changes: 26 additions & 0 deletions packages/yarnpkg-pnp/sources/esm-loader/hooks/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import fs from 'fs';
import {fileURLToPath} from 'url';

import * as loaderUtils from '../loaderUtils';

// The default `load` doesn't support reading from zip files
export async function load(
urlString: string,
context: { format: string | null | undefined },
defaultLoad: typeof load,
): Promise<{ format: string; source: string }> {
const url = loaderUtils.tryParseURL(urlString);
if (url?.protocol !== `file:`)
return defaultLoad(urlString, context, defaultLoad);

const filePath = fileURLToPath(url);

const format = loaderUtils.getFileFormat(filePath);
if (!format)
return defaultLoad(urlString, context, defaultLoad);

return {
format,
source: await fs.promises.readFile(filePath, `utf8`),
};
}
74 changes: 74 additions & 0 deletions packages/yarnpkg-pnp/sources/esm-loader/hooks/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {NativePath, PortablePath} from '@yarnpkg/fslib';
import moduleExports from 'module';
import {fileURLToPath, pathToFileURL} from 'url';

import {PnpApi} from '../../types';
import * as loaderUtils from '../loaderUtils';

const builtins = new Set([...moduleExports.builtinModules]);

const pathRegExp = /^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/;

export async function resolve(
originalSpecifier: string,
context: { conditions: Array<string>; parentURL: string | undefined },
defaultResolver: typeof resolve,
): Promise<{ url: string }> {
const {findPnpApi} = (moduleExports as unknown) as { findPnpApi?: (path: NativePath) => null | PnpApi };
if (!findPnpApi || builtins.has(originalSpecifier))
paul-soporan marked this conversation as resolved.
Show resolved Hide resolved
return defaultResolver(originalSpecifier, context, defaultResolver);

let specifier = originalSpecifier;
const url = loaderUtils.tryParseURL(specifier);
if (url) {
if (url.protocol !== `file:`)
return defaultResolver(originalSpecifier, context, defaultResolver);

specifier = fileURLToPath(specifier);
}

const {parentURL, conditions = []} = context;

const issuer = parentURL ? fileURLToPath(parentURL) : process.cwd();

// Get the pnpapi of either the issuer or the specifier.
// The latter is required when the specifier is an absolute path to a
// zip file and the issuer doesn't belong to a pnpapi
const pnpapi = findPnpApi(issuer) ?? (url ? findPnpApi(specifier) : null);
if (!pnpapi)
return defaultResolver(originalSpecifier, context, defaultResolver);

const dependencyNameMatch = specifier.match(pathRegExp);

let allowLegacyResolve = false;

if (dependencyNameMatch) {
const [, dependencyName, subPath] = dependencyNameMatch as [unknown, string, PortablePath];

// If the package.json doesn't list an `exports` field, Node will tolerate omitting the extension
// https://github.com/nodejs/node/blob/0996eb71edbd47d9f9ec6153331255993fd6f0d1/lib/internal/modules/esm/resolve.js#L686-L691
if (subPath === ``) {
const resolved = pnpapi.resolveToUnqualified(`${dependencyName}/package.json`, issuer);
if (resolved) {
const content = await loaderUtils.tryReadFile(resolved);
if (content) {
const pkg = JSON.parse(content);
allowLegacyResolve = pkg.exports == null;
}
}
}
}

const result = pnpapi.resolveRequest(specifier, issuer, {
conditions: new Set(conditions),
// TODO: Handle --experimental-specifier-resolution=node
extensions: allowLegacyResolve ? undefined : [],
});

if (!result)
throw new Error(`Resolving '${specifier}' from '${issuer}' failed`);

return {
url: pathToFileURL(result).href,
};
}