Skip to content

Commit f0ee064

Browse files
authored
Improve node_modules resolver (#219)
1 parent 8fa59f3 commit f0ee064

7 files changed

Lines changed: 112 additions & 43 deletions

File tree

.changeset/red-moles-leave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pleasantest': minor
3+
---
4+
5+
Improve node_modules resolver (now it is able to resolve multiple package versions and it supports pnpm)

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/module-server/bundle-npm-module.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as esbuild from 'esbuild';
77
import { parse } from 'cjs-module-lexer';
88
// @ts-expect-error @types/node@12 doesn't like this import
99
import { createRequire } from 'module';
10-
import { isBareImport, npmPrefix } from './extensions-and-detection';
10+
import { isBareImport } from './extensions-and-detection';
1111
let npmCache: RollupCache | undefined;
1212

1313
/**
@@ -110,8 +110,6 @@ export { default } from '${mod}'`;
110110
const pluginNodeResolve = (): Plugin => ({
111111
name: 'node-resolve',
112112
resolveId(id) {
113-
if (isBareImport(id)) return { id: npmPrefix + id, external: true };
114-
// If requests already have the npm prefix, mark them as external
115-
if (id.startsWith(npmPrefix)) return { id, external: true };
113+
if (isBareImport(id)) return { id, external: true };
116114
},
117115
});

src/module-server/middleware/js.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,30 +67,30 @@ export const jsMiddleware = async ({
6767
try {
6868
// Normalized path starting with slash
6969
const path = posix.normalize(req.path);
70+
const params = new URLSearchParams(req.query as Record<string, string>);
7071
let id: string;
7172
let file: string;
7273
if (path.startsWith('/@npm/')) {
7374
id = path.slice(1); // Remove leading slash
74-
file = ''; // This should never be read
75+
file = params.get('resolvedPath') || '';
7576
} else {
7677
// Remove leading slash, and convert slashes to os-specific slashes
7778
const osPath = path.slice(1).split(posix.sep).join(sep);
7879
// Absolute file path
7980
file = resolve(root, osPath);
8081
// Rollup-style Unix-normalized path "id":
8182
id = file.split(sep).join(posix.sep);
83+
}
8284

83-
const params = new URLSearchParams(req.query as Record<string, string>);
84-
params.delete('import');
85-
params.delete('inline-code');
86-
params.delete('build-id');
85+
params.delete('import');
86+
params.delete('inline-code');
87+
params.delete('build-id');
8788

88-
// Remove trailing =
89-
// This is necessary for rollup-plugin-vue, which ads ?lang.ts at the end of the id,
90-
// so the file gets processed by other transformers
91-
const qs = params.toString().replace(/=$/, '');
92-
if (qs) id += `?${qs}`;
93-
}
89+
// Remove trailing =
90+
// This is necessary for rollup-plugin-vue, which ads ?lang.ts at the end of the id,
91+
// so the file gets processed by other transformers
92+
const qs = params.toString().replace(/=$/, '');
93+
if (qs) id += `?${qs}`;
9494

9595
res.setHeader('Content-Type', 'application/javascript;charset=utf-8');
9696
const resolved = await rollupPlugins.resolveId(id);

src/module-server/node-resolve.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ afterAll(async () => {
2020
});
2121

2222
const createFs = async (input: string) => {
23-
const dir = await fs.mkdtemp(join(tmpdir(), 'pleasantest-create-fs-'));
23+
const dir = await fs.realpath(
24+
await fs.mkdtemp(join(tmpdir(), 'pleasantest-create-fs-')),
25+
);
2426
createdPaths.push(dir);
2527
const paths = input
2628
.trim()
@@ -169,6 +171,30 @@ describe('resolving in node_modules', () => {
169171
'./node_modules/preact/dist/hooks/index.js',
170172
);
171173
});
174+
175+
test('resolves multiple versions of a package correctly', async () => {
176+
// A and B depend on different versions of C
177+
// So they each have a different copy of C in their node_modules
178+
const fs = await createFs(`
179+
./node_modules/a/package.json {}
180+
./node_modules/a/index.js
181+
./node_modules/a/node_modules/c/package.json {}
182+
./node_modules/a/node_modules/c/index.js
183+
./node_modules/b/package.json {}
184+
./node_modules/b/index.js
185+
./node_modules/b/node_modules/c/package.json {}
186+
./node_modules/b/node_modules/c/index.js
187+
./node_modules/c/package.json {}
188+
./node_modules/c/index.js {}
189+
`);
190+
expect(await fs.resolve('c', { from: './node_modules/b/index.js' })).toBe(
191+
'./node_modules/b/node_modules/c/index.js',
192+
);
193+
expect(await fs.resolve('c', { from: './node_modules/a/index.js' })).toBe(
194+
'./node_modules/a/node_modules/c/index.js',
195+
);
196+
expect(await fs.resolve('c')).toBe('./node_modules/c/index.js');
197+
});
172198
});
173199

174200
describe('resolving relative paths', () => {

src/module-server/node-resolve.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { dirname, join, posix, resolve as pResolve } from 'path';
1+
import { dirname, join, posix, relative, resolve as pResolve } from 'path';
22
import { promises as fs } from 'fs';
33
import { resolve, legacy as resolveLegacy } from 'resolve.exports';
44
import {
@@ -9,7 +9,11 @@ import {
99
// Only used for node_modules
1010
const resolveCache = new Map<string, ResolveResult>();
1111

12-
const resolveCacheKey = (id: string, root: string) => `${id}\0\0${root}`;
12+
const resolveCacheKey = (
13+
id: string,
14+
importer: string | undefined,
15+
root: string,
16+
) => `${id}\n${importer}\n${root}`;
1317

1418
/**
1519
* Attempts to implement a combination of:
@@ -22,7 +26,7 @@ export const nodeResolve = async (
2226
importer: string,
2327
root: string,
2428
) => {
25-
if (isBareImport(id)) return resolveFromNodeModules(id, root);
29+
if (isBareImport(id)) return resolveFromNodeModules(id, importer, root);
2630
if (isRelativeOrAbsoluteImport(id))
2731
return resolveRelativeOrAbsolute(id, importer);
2832
};
@@ -108,9 +112,10 @@ interface ResolveResult {
108112

109113
export const resolveFromNodeModules = async (
110114
id: string,
115+
importer: string | undefined,
111116
root: string,
112117
): Promise<ResolveResult> => {
113-
const cacheKey = resolveCacheKey(id, root);
118+
const cacheKey = resolveCacheKey(id, importer, root);
114119
const cached = resolveCache.get(cacheKey);
115120
if (cached) return cached;
116121
const pathChunks = id.split(posix.sep);
@@ -120,10 +125,29 @@ export const resolveFromNodeModules = async (
120125
// Path within imported module
121126
const subPath = join(...pathChunks.slice(isNpmNamespace ? 2 : 1));
122127

123-
const pkgDir = join(root, 'node_modules', ...packageName);
124-
const stats = await stat(pkgDir);
125-
if (!stats || !stats.isDirectory())
126-
throw new Error(`Could not find ${id} in node_modules`);
128+
const realRoot = await fs.realpath(root).catch(() => root);
129+
130+
// Walk up folder by folder until a folder is found with <folder>/node_modules/<pkgName>
131+
// i.e. for 'asdf' from a/b/c.js look at
132+
// a/b/node_modules/asdf,
133+
// a/node_modules/asdf,
134+
// node_modules/asdf,
135+
136+
let pkgDir: string | undefined;
137+
let scanDir = importer
138+
? await fs
139+
.realpath(importer)
140+
.then((realImporter) => relative(realRoot, realImporter))
141+
.catch(() => relative(root, importer))
142+
: '.';
143+
while (!pkgDir || !(await stat(pkgDir))?.isDirectory()) {
144+
if (scanDir === '.' || scanDir.startsWith('..')) {
145+
throw new Error(`Could not find ${id} in node_modules`);
146+
}
147+
// Not found; go up a level and try again
148+
scanDir = dirname(scanDir);
149+
pkgDir = join(root, scanDir, 'node_modules', ...packageName);
150+
}
127151

128152
const pkgJson = await readPkgJson(pkgDir);
129153
const main = readMainFields(pkgJson, subPath, true);
@@ -144,7 +168,10 @@ export const resolveFromNodeModules = async (
144168
version ? `${normalizedPkgName}@${version}` : normalizedPkgName,
145169
subPath,
146170
);
147-
const resolved: ResolveResult = { path: result, idWithVersion };
171+
const resolved: ResolveResult = {
172+
path: await fs.realpath(result),
173+
idWithVersion,
174+
};
148175
resolveCache.set(cacheKey, resolved);
149176
return resolved;
150177
}

src/module-server/plugins/npm-plugin.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,37 +42,50 @@ export const npmPlugin = ({
4242
// Rewrite bare imports to have @npm/ prefix
4343
async resolveId(id, importer) {
4444
if (!isBareImport(id)) return;
45-
const resolved = await resolveFromNodeModules(id, root).catch((error) => {
46-
throw importer
47-
? changeErrorMessage(error, (msg) => `${msg} (imported by ${importer})`)
48-
: error;
49-
});
45+
const resolved = await resolveFromNodeModules(id, importer, root).catch(
46+
(error) => {
47+
throw importer
48+
? changeErrorMessage(
49+
error,
50+
(msg) => `${msg} (imported by ${importer})`,
51+
)
52+
: error;
53+
},
54+
);
5055
if (!jsExts.test(resolved.path))
5156
// Don't pre-bundle, use the full path to the file in node_modules
5257
// (ex: CSS files in node_modules)
5358
return resolved.path;
5459

55-
return npmPrefix + id;
60+
return `${npmPrefix}${id}?resolvedPath=${encodeURIComponent(
61+
resolved.path,
62+
)}&idWithVersion=${encodeURIComponent(resolved.idWithVersion)}`;
5663
},
57-
async load(id) {
58-
if (!id.startsWith(npmPrefix)) return;
59-
id = id.slice(npmPrefix.length);
60-
const resolved = await resolveFromNodeModules(id, root);
64+
async load(fullId) {
65+
if (!fullId.startsWith(npmPrefix)) return;
66+
fullId = fullId.slice(npmPrefix.length);
67+
const params = new URLSearchParams(fullId.slice(fullId.indexOf('?')));
68+
const [id] = fullId.split('?');
69+
let resolvedPath = params.get('resolvedPath');
70+
let idWithVersion = params.get('idWithVersion');
71+
if (!resolvedPath || !idWithVersion) {
72+
const resolveResult = await resolveFromNodeModules(id, undefined, root);
73+
resolvedPath = resolveResult.path;
74+
idWithVersion = resolveResult.idWithVersion;
75+
}
6176

6277
const cachePath = join(
6378
cacheDir,
6479
'@npm',
65-
`${resolved.idWithVersion}-${hash(envVars)}.js`,
80+
`${idWithVersion}-${hash(envVars)}.js`,
6681
);
6782
const cached = await getFromCache(cachePath);
6883
if (cached) return cached;
69-
const result = await bundleNpmModule(resolved.path, id, false, envVars);
84+
const result = await bundleNpmModule(resolvedPath, id, false, envVars);
7085
// Queue up a second-pass optimized/minified build
71-
bundleNpmModule(resolved.path, id, true, envVars).then(
72-
(optimizedResult) => {
73-
setInCache(cachePath, optimizedResult);
74-
},
75-
);
86+
bundleNpmModule(resolvedPath, id, true, envVars).then((optimizedResult) => {
87+
setInCache(cachePath, optimizedResult);
88+
});
7689
setInCache(cachePath, result);
7790
return result;
7891
},

0 commit comments

Comments
 (0)