Skip to content

Commit d66d54a

Browse files
authored
feat(pnp): support consolidated ESM loader hooks (#3603)
1 parent a286b0b commit d66d54a

File tree

10 files changed

+301
-201
lines changed

10 files changed

+301
-201
lines changed

.yarn/versions/cf74cfe1.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
releases:
2+
"@yarnpkg/cli": minor
3+
"@yarnpkg/plugin-pnp": minor
4+
"@yarnpkg/pnp": minor
5+
6+
declined:
7+
- "@yarnpkg/esbuild-plugin-pnp"
8+
- "@yarnpkg/plugin-compat"
9+
- "@yarnpkg/plugin-constraints"
10+
- "@yarnpkg/plugin-dlx"
11+
- "@yarnpkg/plugin-essentials"
12+
- "@yarnpkg/plugin-init"
13+
- "@yarnpkg/plugin-interactive-tools"
14+
- "@yarnpkg/plugin-nm"
15+
- "@yarnpkg/plugin-npm-cli"
16+
- "@yarnpkg/plugin-pack"
17+
- "@yarnpkg/plugin-patch"
18+
- "@yarnpkg/plugin-pnpm"
19+
- "@yarnpkg/plugin-stage"
20+
- "@yarnpkg/plugin-typescript"
21+
- "@yarnpkg/plugin-version"
22+
- "@yarnpkg/plugin-workspace-tools"
23+
- "@yarnpkg/builder"
24+
- "@yarnpkg/core"
25+
- "@yarnpkg/doctor"
26+
- "@yarnpkg/nm"
27+
- "@yarnpkg/pnpify"
28+
- "@yarnpkg/sdks"

packages/plugin-pnp/sources/PnpLinker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export class PnpInstaller implements Installer {
348348
}
349349

350350
if (this.isEsmEnabled()) {
351-
this.opts.report.reportWarning(MessageName.UNNAMED, `ESM support for PnP uses the experimental loader API and is therefor experimental`);
351+
this.opts.report.reportWarning(MessageName.UNNAMED, `ESM support for PnP uses the experimental loader API and is therefore experimental`);
352352
await xfs.changeFilePromise(pnpPath.esmLoader, getESMLoaderTemplate(), {
353353
automaticNewlines: true,
354354
mode: 0o644,

packages/yarnpkg-pnp/sources/esm-loader/built-loader.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import fs from 'fs';
2+
3+
//#region ESM to CJS support
4+
/*
5+
In order to import CJS files from ESM Node does some translating
6+
internally[1]. This translator calls an unpatched `readFileSync`[2]
7+
which itself calls an internal `tryStatSync`[3] which calls
8+
`binding.fstat`[4]. A PR[5] has been made to use the monkey-patchable
9+
`fs.readFileSync` but assuming that wont be merged this region of code
10+
patches that final `binding.fstat` call.
11+
12+
1: https://github.com/nodejs/node/blob/d872aaf1cf20d5b6f56a699e2e3a64300e034269/lib/internal/modules/esm/translators.js#L177-L277
13+
2: https://github.com/nodejs/node/blob/d872aaf1cf20d5b6f56a699e2e3a64300e034269/lib/internal/modules/esm/translators.js#L240
14+
3: https://github.com/nodejs/node/blob/1317252dfe8824fd9cfee125d2aaa94004db2f3b/lib/fs.js#L452
15+
4: https://github.com/nodejs/node/blob/1317252dfe8824fd9cfee125d2aaa94004db2f3b/lib/fs.js#L403
16+
5: https://github.com/nodejs/node/pull/39513
17+
*/
18+
19+
const binding = (process as any).binding(`fs`) as {
20+
fstat: (fd: number, useBigint: false, req: any, ctx: object) => Float64Array
21+
};
22+
const originalfstat = binding.fstat;
23+
24+
const ZIP_FD = 0x80000000;
25+
binding.fstat = function(...args) {
26+
const [fd, useBigint, req] = args;
27+
if ((fd & ZIP_FD) !== 0 && useBigint === false && req === undefined) {
28+
try {
29+
const stats = fs.fstatSync(fd);
30+
// The reverse of this internal util
31+
// https://github.com/nodejs/node/blob/8886b63cf66c29d453fdc1ece2e489dace97ae9d/lib/internal/fs/utils.js#L542-L551
32+
return new Float64Array([
33+
stats.dev,
34+
stats.mode,
35+
stats.nlink,
36+
stats.uid,
37+
stats.gid,
38+
stats.rdev,
39+
stats.blksize,
40+
stats.ino,
41+
stats.size,
42+
stats.blocks,
43+
// atime sec
44+
// atime ns
45+
// mtime sec
46+
// mtime ns
47+
// ctime sec
48+
// ctime ns
49+
// birthtime sec
50+
// birthtime ns
51+
]);
52+
} catch {}
53+
}
54+
55+
return originalfstat.apply(this, args);
56+
};
57+
//#endregion
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {fileURLToPath} from 'url';
2+
3+
import * as loaderUtils from '../loaderUtils';
4+
5+
// The default `getFormat` doesn't support reading from zip files
6+
export async function getFormat(
7+
resolved: string,
8+
context: object,
9+
defaultGetFormat: typeof getFormat,
10+
): Promise<{ format: string }> {
11+
const url = loaderUtils.tryParseURL(resolved);
12+
if (url?.protocol !== `file:`)
13+
return defaultGetFormat(resolved, context, defaultGetFormat);
14+
15+
const format = loaderUtils.getFileFormat(fileURLToPath(url));
16+
if (format) {
17+
return {
18+
format,
19+
};
20+
}
21+
22+
return defaultGetFormat(resolved, context, defaultGetFormat);
23+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import fs from 'fs';
2+
import {fileURLToPath} from 'url';
3+
4+
import * as loaderUtils from '../loaderUtils';
5+
6+
// The default `getSource` doesn't support reading from zip files
7+
export async function getSource(
8+
urlString: string,
9+
context: { format: string },
10+
defaultGetSource: typeof getSource,
11+
): Promise<{ source: string }> {
12+
const url = loaderUtils.tryParseURL(urlString);
13+
if (url?.protocol !== `file:`)
14+
return defaultGetSource(urlString, context, defaultGetSource);
15+
16+
return {
17+
source: await fs.promises.readFile(fileURLToPath(url), `utf8`),
18+
};
19+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import fs from 'fs';
2+
import {fileURLToPath} from 'url';
3+
4+
import * as loaderUtils from '../loaderUtils';
5+
6+
// The default `load` doesn't support reading from zip files
7+
export async function load(
8+
urlString: string,
9+
context: { format: string | null | undefined },
10+
defaultLoad: typeof load,
11+
): Promise<{ format: string; source: string }> {
12+
const url = loaderUtils.tryParseURL(urlString);
13+
if (url?.protocol !== `file:`)
14+
return defaultLoad(urlString, context, defaultLoad);
15+
16+
const filePath = fileURLToPath(url);
17+
18+
const format = loaderUtils.getFileFormat(filePath);
19+
if (!format)
20+
return defaultLoad(urlString, context, defaultLoad);
21+
22+
return {
23+
format,
24+
source: await fs.promises.readFile(filePath, `utf8`),
25+
};
26+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {NativePath, PortablePath} from '@yarnpkg/fslib';
2+
import moduleExports from 'module';
3+
import {fileURLToPath, pathToFileURL} from 'url';
4+
5+
import {PnpApi} from '../../types';
6+
import * as loaderUtils from '../loaderUtils';
7+
8+
const builtins = new Set([...moduleExports.builtinModules]);
9+
10+
const pathRegExp = /^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/;
11+
12+
export async function resolve(
13+
originalSpecifier: string,
14+
context: { conditions: Array<string>; parentURL: string | undefined },
15+
defaultResolver: typeof resolve,
16+
): Promise<{ url: string }> {
17+
const {findPnpApi} = (moduleExports as unknown) as { findPnpApi?: (path: NativePath) => null | PnpApi };
18+
if (!findPnpApi || builtins.has(originalSpecifier))
19+
return defaultResolver(originalSpecifier, context, defaultResolver);
20+
21+
let specifier = originalSpecifier;
22+
const url = loaderUtils.tryParseURL(specifier);
23+
if (url) {
24+
if (url.protocol !== `file:`)
25+
return defaultResolver(originalSpecifier, context, defaultResolver);
26+
27+
specifier = fileURLToPath(specifier);
28+
}
29+
30+
const {parentURL, conditions = []} = context;
31+
32+
const issuer = parentURL ? fileURLToPath(parentURL) : process.cwd();
33+
34+
// Get the pnpapi of either the issuer or the specifier.
35+
// The latter is required when the specifier is an absolute path to a
36+
// zip file and the issuer doesn't belong to a pnpapi
37+
const pnpapi = findPnpApi(issuer) ?? (url ? findPnpApi(specifier) : null);
38+
if (!pnpapi)
39+
return defaultResolver(originalSpecifier, context, defaultResolver);
40+
41+
const dependencyNameMatch = specifier.match(pathRegExp);
42+
43+
let allowLegacyResolve = false;
44+
45+
if (dependencyNameMatch) {
46+
const [, dependencyName, subPath] = dependencyNameMatch as [unknown, string, PortablePath];
47+
48+
// If the package.json doesn't list an `exports` field, Node will tolerate omitting the extension
49+
// https://github.com/nodejs/node/blob/0996eb71edbd47d9f9ec6153331255993fd6f0d1/lib/internal/modules/esm/resolve.js#L686-L691
50+
if (subPath === ``) {
51+
const resolved = pnpapi.resolveToUnqualified(`${dependencyName}/package.json`, issuer);
52+
if (resolved) {
53+
const content = await loaderUtils.tryReadFile(resolved);
54+
if (content) {
55+
const pkg = JSON.parse(content);
56+
allowLegacyResolve = pkg.exports == null;
57+
}
58+
}
59+
}
60+
}
61+
62+
const result = pnpapi.resolveRequest(specifier, issuer, {
63+
conditions: new Set(conditions),
64+
// TODO: Handle --experimental-specifier-resolution=node
65+
extensions: allowLegacyResolve ? undefined : [],
66+
});
67+
68+
if (!result)
69+
throw new Error(`Resolving '${specifier}' from '${issuer}' failed`);
70+
71+
return {
72+
url: pathToFileURL(result).href,
73+
};
74+
}

0 commit comments

Comments
 (0)