Skip to content

Commit 0ba7584

Browse files
authored
Allow resolving subfolders with package.json files, and improve CJS interop (#143)
1 parent a9ef60c commit 0ba7584

File tree

7 files changed

+200
-94
lines changed

7 files changed

+200
-94
lines changed

.changeset/pink-dogs-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pleasantest': patch
3+
---
4+
5+
Improve CJS interop with packages that can't be statically analyzed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pleasantest': patch
3+
---
4+
5+
Allow resolving subfolders with package.json files

jest.config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
module.exports = {
22
testEnvironment: 'node',
33
moduleNameMapper: {
4-
pleasantest: '<rootDir>/dist/cjs/index.cjs',
4+
'^pleasantest$': '<rootDir>/dist/cjs/index.cjs',
55
},
66
testRunner: 'jest-circus/runner',
7-
watchPathIgnorePatterns: ['<rootDir>/src/'],
7+
watchPathIgnorePatterns: ['<rootDir>/src/', '<rootDir>/.cache'],
88
transform: {
99
'^.+\\.[jt]sx?$': ['esbuild-jest', { sourcemap: true }],
1010
},

package-lock.json

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

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "pleasantest",
33
"version": "0.6.1",
44
"engines": {
5-
"node": "12 || 14 || 16"
5+
"node": "^12.2 || 14 || 16"
66
},
77
"files": [
88
"dist"
@@ -38,6 +38,7 @@
3838
"polka": "0.5.2",
3939
"preact": "10.5.14",
4040
"prettier": "2.3.2",
41+
"prop-types": "^15.7.2",
4142
"react": "17.0.2",
4243
"react-dom": "17.0.2",
4344
"remark-cli": "9.0.0",

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

Lines changed: 108 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { dirname, join, normalize, posix } from 'path';
22
import type { Plugin, RollupCache } from 'rollup';
33
import { rollup } from 'rollup';
4-
import { existsSync, promises as fs } from 'fs';
4+
import { promises as fs } from 'fs';
55
import { resolve, legacy as resolveLegacy } from 'resolve.exports';
66
import commonjs from '@rollup/plugin-commonjs';
77
import { processGlobalPlugin } from './process-global-plugin';
88
import * as esbuild from 'esbuild';
99
import { parse } from 'cjs-module-lexer';
10-
import MagicString from 'magic-string';
1110
import { fileURLToPath } from 'url';
11+
import { createRequire } from 'module';
1212
import { jsExts } from '../middleware/js';
1313
import { changeErrorMessage } from '../../utils';
1414

@@ -78,9 +78,9 @@ export const npmPlugin = ({ root }: { root: string }): Plugin => {
7878
const cachePath = join(cacheDir, '@npm', `${resolved.idWithVersion}.js`);
7979
const cached = await getFromCache(cachePath);
8080
if (cached) return cached;
81-
const result = await bundleNpmModule(resolved.path, false);
81+
const result = await bundleNpmModule(resolved.path, id, false);
8282
// Queue up a second-pass optimized/minified build
83-
bundleNpmModule(resolved.path, true).then((optimizedResult) => {
83+
bundleNpmModule(resolved.path, id, true).then((optimizedResult) => {
8484
setInCache(cachePath, optimizedResult);
8585
});
8686
setInCache(cachePath, result);
@@ -89,17 +89,16 @@ export const npmPlugin = ({ root }: { root: string }): Plugin => {
8989
};
9090
};
9191

92-
const nodeResolve = async (id: string, root: string) => {
93-
const pathChunks = id.split(posix.sep);
94-
const isNpmNamespace = id[0] === '@';
95-
const packageName = pathChunks.slice(0, isNpmNamespace ? 2 : 1);
96-
// If it is an npm namespace, then get the first two folders, otherwise just one
97-
const pkgDir = join(root, 'node_modules', ...packageName);
98-
await fs.stat(pkgDir).catch(() => {
99-
throw new Error(`Could not resolve ${id} from ${root}`);
100-
});
101-
// Path within imported module
102-
const subPath = join(...pathChunks.slice(isNpmNamespace ? 2 : 1));
92+
interface ResolveResult {
93+
path: string;
94+
idWithVersion: string;
95+
}
96+
97+
const resolveFromFolder = async (
98+
pkgDir: string,
99+
subPath: string,
100+
packageName: string[],
101+
): Promise<false | ResolveResult> => {
103102
const pkgJsonPath = join(pkgDir, 'package.json');
104103
let pkgJson;
105104
try {
@@ -133,31 +132,63 @@ const nodeResolve = async (id: string, root: string) => {
133132
if (!result && subPath === '.')
134133
result = resolveLegacy(pkgJson, { browser: false, fields: ['main'] });
135134

136-
if (!result) {
135+
if (!result && !('exports' in pkgJson)) {
137136
const extensions = ['.js', '/index.js', '.cjs', '/index.cjs'];
137+
// If this was not conditionally included, this would have infinite recursion
138+
if (subPath !== '.') extensions.unshift('');
138139
for (const extension of extensions) {
139140
const path = normalize(join(pkgDir, subPath) + extension);
140-
if (existsSync(path)) return { path, idWithVersion };
141+
const stats = await fs.stat(path).catch(() => null);
142+
if (stats) {
143+
if (stats.isFile()) return { path, idWithVersion };
144+
if (stats.isDirectory()) {
145+
// If you import some-package/foo and foo is a folder with a package.json in it,
146+
// resolve main fields from the package.json
147+
const result = await resolveFromFolder(path, '.', packageName);
148+
if (result) return { path: result.path, idWithVersion };
149+
}
150+
}
141151
}
142-
143-
throw new Error(`Could not resolve ${id}`);
144152
}
145153

154+
if (!result) return false;
146155
return { path: join(pkgDir, result), idWithVersion };
147156
};
148157

158+
const resolveCache = new Map<string, ResolveResult>();
159+
160+
const resolveCacheKey = (id: string, root: string) => `${id}\0\0${root}`;
161+
162+
const nodeResolve = async (id: string, root: string) => {
163+
const cacheKey = resolveCacheKey(id, root);
164+
const cached = resolveCache.get(cacheKey);
165+
if (cached) return cached;
166+
const pathChunks = id.split(posix.sep);
167+
const isNpmNamespace = id[0] === '@';
168+
const packageName = pathChunks.slice(0, isNpmNamespace ? 2 : 1);
169+
// If it is an npm namespace, then get the first two folders, otherwise just one
170+
const pkgDir = join(root, 'node_modules', ...packageName);
171+
await fs.stat(pkgDir).catch(() => {
172+
throw new Error(`Could not resolve ${id} from ${root}`);
173+
});
174+
// Path within imported module
175+
const subPath = join(...pathChunks.slice(isNpmNamespace ? 2 : 1));
176+
const result = await resolveFromFolder(pkgDir, subPath, packageName);
177+
if (result) {
178+
resolveCache.set(cacheKey, result);
179+
return result;
180+
}
181+
182+
throw new Error(`Could not resolve ${id}`);
183+
};
184+
149185
const pluginNodeResolve = (): Plugin => {
150186
return {
151187
name: 'node-resolve',
152188
resolveId(id) {
153189
if (isBareImport(id)) return { id: prefix + id, external: true };
154-
if (id.startsWith(prefix)) {
155-
return {
156-
// Remove the leading slash, otherwise rollup turns it into a relative path up to disk root
157-
id,
158-
external: true,
159-
};
160-
}
190+
// If requests already have the npm prefix, mark them as external
191+
if (id.startsWith(prefix)) return { id, external: true };
161192
},
162193
};
163194
};
@@ -166,58 +197,60 @@ let npmCache: RollupCache | undefined;
166197

167198
/**
168199
* Bundle am npm module entry path into a single file
169-
* @param mod The module to bundle, including subpackage/path
200+
* @param mod The full path of the module to bundle, including subpackage/path
201+
* @param id The imported identifier
170202
* @param optimize Whether the bundle should be a minified/optimized bundle, or the default quick non-optimized bundle
171203
*/
172-
const bundleNpmModule = async (mod: string, optimize: boolean) => {
204+
const bundleNpmModule = async (mod: string, id: string, optimize: boolean) => {
205+
let namedExports: string[] = [];
206+
if (dynamicCJSModules.has(id)) {
207+
let isValidCJS = true;
208+
try {
209+
const text = await fs.readFile(mod, 'utf8');
210+
// Goal: Determine if it is ESM or CJS.
211+
// Try to parse it with cjs-module-lexer, if it fails, assume it is ESM
212+
// eslint-disable-next-line @cloudfour/typescript-eslint/await-thenable
213+
await parse(text);
214+
} catch {
215+
isValidCJS = false;
216+
}
217+
218+
if (isValidCJS) {
219+
const require = createRequire(import.meta.url);
220+
// eslint-disable-next-line @cloudfour/typescript-eslint/no-var-requires
221+
const imported = require(mod);
222+
if (typeof imported === 'object' && !imported.__esModule)
223+
namedExports = Object.keys(imported);
224+
}
225+
}
226+
227+
const virtualEntry = '\0virtualEntry';
228+
const hasSyntheticNamedExports = namedExports.length > 0;
173229
const bundle = await rollup({
174-
input: mod,
230+
input: hasSyntheticNamedExports ? virtualEntry : mod,
175231
cache: npmCache,
176232
shimMissingExports: true,
177233
treeshake: true,
178234
preserveEntrySignatures: 'allow-extension',
179235
plugins: [
180-
{
181-
// This plugin fixes cases of module.exports = require('...')
182-
// By default, the named exports from the required module are not generated
183-
// This plugin detects those exports,
184-
// and makes it so that @rollup/plugin-commonjs can see them and turn them into ES exports (via syntheticNamedExports)
185-
// This edge case happens in React, so it was necessary to fix it.
186-
name: 'cjs-module-lexer',
187-
async transform(code, id) {
188-
if (id.startsWith('\0')) return;
189-
const out = new MagicString(code);
190-
const re =
191-
/(^|[\s;])module\.exports\s*=\s*require\(["']([^"']*)["']\)($|[\s;])/g;
192-
let match;
193-
while ((match = re.exec(code))) {
194-
const [, leadingWhitespace, moduleName, trailingWhitespace] = match;
195-
196-
const resolved = await this.resolve(moduleName, id);
197-
if (!resolved || resolved.external) return;
198-
199-
try {
200-
const text = await fs.readFile(resolved.id, 'utf8');
201-
// eslint-disable-next-line @cloudfour/typescript-eslint/await-thenable
202-
const parsed = await parse(text);
203-
let replacement = '';
204-
for (const exportName of parsed.exports) {
205-
replacement += `\nmodule.exports.${exportName} = require("${moduleName}").${exportName}`;
206-
}
207-
208-
out.overwrite(
209-
match.index,
210-
re.lastIndex,
211-
leadingWhitespace + replacement + trailingWhitespace,
212-
);
213-
} catch {
214-
return;
236+
hasSyntheticNamedExports &&
237+
({
238+
// This plugin handles special-case packages whose named exports cannot be found via static analysis
239+
// For these packages, the package is require()'d, and the named exports are determined that way.
240+
// A virtual entry exports the named exports from the real entry package
241+
name: 'cjs-named-exports',
242+
resolveId(id) {
243+
if (id === virtualEntry) return virtualEntry;
244+
},
245+
load(id) {
246+
if (id === virtualEntry) {
247+
const code = `export * from '${mod}'
248+
export {${namedExports.join(', ')}} from '${mod}'
249+
export { default } from '${mod}'`;
250+
return code;
215251
}
216-
}
217-
218-
return out.toString();
219-
},
220-
} as Plugin,
252+
},
253+
} as Plugin),
221254
pluginNodeResolve(),
222255
processGlobalPlugin({ NODE_ENV: 'development' }),
223256
commonjs({
@@ -247,3 +280,9 @@ const bundleNpmModule = async (mod: string, optimize: boolean) => {
247280

248281
return output[0].code;
249282
};
283+
284+
/**
285+
* Any package names in this set will need to have their named exports detected manually via require()
286+
* because the export names cannot be statically analyzed
287+
*/
288+
const dynamicCJSModules = new Set(['prop-types', 'react-dom', 'react']);

0 commit comments

Comments
 (0)