Skip to content

Commit 29f51f6

Browse files
authored
fix: CommonJs bare specifier resolution (#96)
Closes #95 I've also made the `getExports` functions return `Set<string>` instead of `string[]` as this saves a load of unnecessary allocations. I've also run this through the additional module tests in #93 and get the same results.
1 parent a8b39f7 commit 29f51f6

File tree

8 files changed

+84
-33
lines changed

8 files changed

+84
-33
lines changed

lib/get-esm-exports.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function warn (txt) {
2929
* @param {string} params.moduleSource The source code of the module to parse
3030
* and interpret.
3131
*
32-
* @returns {string[]} The identifiers exported by the module along with any
32+
* @returns {Set<string>} The identifiers exported by the module along with any
3333
* custom directives.
3434
*/
3535
function getEsmExports (moduleSource) {
@@ -62,7 +62,7 @@ function getEsmExports (moduleSource) {
6262
warn('unrecognized export type: ' + node.type)
6363
}
6464
}
65-
return Array.from(exportedNames)
65+
return exportedNames
6666
}
6767

6868
function parseDeclaration (node, exportedNames) {

lib/get-exports.js

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,60 @@
11
'use strict'
22

33
const getEsmExports = require('./get-esm-exports.js')
4-
const { parse: getCjsExports } = require('cjs-module-lexer')
5-
const fs = require('fs')
4+
const { parse: parseCjs } = require('cjs-module-lexer')
5+
const { readFileSync } = require('fs')
6+
const { builtinModules } = require('module')
67
const { fileURLToPath, pathToFileURL } = require('url')
8+
const { dirname } = require('path')
79

810
function addDefault (arr) {
9-
return Array.from(new Set(['default', ...arr]))
11+
return new Set(['default', ...arr])
12+
}
13+
14+
// Cached exports for Node built-in modules
15+
const BUILT_INS = new Map()
16+
17+
function getExportsForNodeBuiltIn (name) {
18+
let exports = BUILT_INS.get()
19+
20+
if (!exports) {
21+
exports = new Set(addDefault(Object.keys(require(name))))
22+
BUILT_INS.set(name, exports)
23+
}
24+
25+
return exports
1026
}
1127

1228
const urlsBeingProcessed = new Set() // Guard against circular imports.
1329

14-
async function getFullCjsExports (url, context, parentLoad, source) {
30+
async function getCjsExports (url, context, parentLoad, source) {
1531
if (urlsBeingProcessed.has(url)) {
1632
return []
1733
}
1834
urlsBeingProcessed.add(url)
1935

20-
const ex = getCjsExports(source)
21-
const full = Array.from(new Set([
22-
...addDefault(ex.exports),
23-
...(await Promise.all(ex.reexports.map(re => getExports(
24-
(/^(..?($|\/|\\))/).test(re)
25-
? pathToFileURL(require.resolve(fileURLToPath(new URL(re, url)))).toString()
26-
: pathToFileURL(require.resolve(re)).toString(),
27-
context,
28-
parentLoad
29-
)))).flat()
30-
]))
31-
32-
urlsBeingProcessed.delete(url)
33-
return full
36+
try {
37+
const result = parseCjs(source)
38+
const full = addDefault(result.exports)
39+
40+
await Promise.all(result.reexports.map(async re => {
41+
if (re.startsWith('node:') || builtinModules.includes(re)) {
42+
for (const each of getExportsForNodeBuiltIn(re)) {
43+
full.add(each)
44+
}
45+
} else {
46+
// Resolve the re-exported module relative to the current module.
47+
const newUrl = pathToFileURL(require.resolve(re, { paths: [dirname(fileURLToPath(url))] })).href
48+
for (const each of await getExports(newUrl, context, parentLoad)) {
49+
full.add(each)
50+
}
51+
}
52+
}))
53+
54+
return full
55+
} finally {
56+
urlsBeingProcessed.delete(url)
57+
}
3458
}
3559

3660
/**
@@ -45,7 +69,7 @@ async function getFullCjsExports (url, context, parentLoad, source) {
4569
* @param {Function} parentLoad Next hook function in the loaders API
4670
* hook chain.
4771
*
48-
* @returns {Promise<string[]>} An array of identifiers exported by the module.
72+
* @returns {Promise<Set<string>>} An array of identifiers exported by the module.
4973
* Please see {@link getEsmExports} for caveats on special identifiers that may
5074
* be included in the result set.
5175
*/
@@ -57,23 +81,23 @@ async function getExports (url, context, parentLoad) {
5781
let source = parentCtx.source
5882
const format = parentCtx.format
5983

60-
// TODO support non-node/file urls somehow?
61-
if (format === 'builtin') {
62-
// Builtins don't give us the source property, so we're stuck
63-
// just requiring it to get the exports.
64-
return addDefault(Object.keys(require(url)))
65-
}
66-
6784
if (!source) {
68-
// Sometimes source is retrieved by parentLoad, sometimes it isn't.
69-
source = fs.readFileSync(fileURLToPath(url), 'utf8')
85+
if (format === 'builtin') {
86+
// Builtins don't give us the source property, so we're stuck
87+
// just requiring it to get the exports.
88+
return getExportsForNodeBuiltIn(url)
89+
}
90+
91+
// Sometimes source is retrieved by parentLoad, CommonJs isn't.
92+
source = readFileSync(fileURLToPath(url), 'utf8')
7093
}
7194

7295
if (format === 'module') {
7396
return getEsmExports(source)
7497
}
98+
7599
if (format === 'commonjs') {
76-
return getFullCjsExports(url, context, parentLoad, source)
100+
return getCjsExports(url, context, parentLoad, source)
77101
}
78102

79103
// At this point our `format` is either undefined or not known by us. Fall
@@ -84,7 +108,7 @@ async function getExports (url, context, parentLoad) {
84108
// isn't set at first and yet we have an ESM module with no exports.
85109
// I couldn't construct an example that would do this, so maybe it's
86110
// impossible?
87-
return getFullCjsExports(url, context, parentLoad, source)
111+
return getCjsExports(url, context, parentLoad, source)
88112
}
89113
}
90114

test/fixtures/node_modules/some-external-cjs-module/index.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/node_modules/some-external-cjs-module/package.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('util').deprecate

test/fixtures/re-export-cjs.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('some-external-cjs-module').foo

test/get-esm-exports/v20-get-esm-exports.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ fixture.split('\n').forEach(line => {
1515
if (expectedNames[0] === '') {
1616
expectedNames.length = 0
1717
}
18-
const names = getEsmExports(mod)
18+
const names = Array.from(getEsmExports(mod))
1919
assert.deepEqual(expectedNames, names)
2020
console.log(`${mod}\n ✅ contains exports: ${testStr}`)
2121
})

test/hook/re-export-cjs.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Hook from '../../index.js'
2+
import foo from '../fixtures/re-export-cjs-built-in.js'
3+
import foo2 from '../fixtures/re-export-cjs.js'
4+
import { strictEqual } from 'assert'
5+
6+
Hook((exports, name) => {
7+
if (name.endsWith('fixtures/re-export-cjs-built-in.js')) {
8+
strictEqual(typeof exports.default, 'function')
9+
exports.default = '1'
10+
}
11+
12+
if (name.endsWith('fixtures/re-export-cjs.js')) {
13+
strictEqual(exports.default, 'bar')
14+
exports.default = '2'
15+
}
16+
})
17+
18+
strictEqual(foo, '1')
19+
strictEqual(foo2, '2')

0 commit comments

Comments
 (0)