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

fix: CommonJs bare specifier resolution #96

Merged
merged 3 commits into from
Jun 14, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/get-esm-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function warn (txt) {
* @param {string} params.moduleSource The source code of the module to parse
* and interpret.
*
* @returns {string[]} The identifiers exported by the module along with any
* @returns {Set<string>} The identifiers exported by the module along with any
* custom directives.
*/
function getEsmExports (moduleSource) {
Expand Down Expand Up @@ -62,7 +62,7 @@ function getEsmExports (moduleSource) {
warn('unrecognized export type: ' + node.type)
}
}
return Array.from(exportedNames)
return exportedNames
}

function parseDeclaration (node, exportedNames) {
Expand Down
84 changes: 54 additions & 30 deletions lib/get-exports.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,60 @@
'use strict'

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

function addDefault (arr) {
return Array.from(new Set(['default', ...arr]))
return new Set(['default', ...arr])
}

// Cached exports for Node built-in modules
const BUILT_INS = new Map()

function getExportsForNodeBuiltIn (name) {
let exports = BUILT_INS.get()

if (!exports) {
exports = new Set(addDefault(Object.keys(require(name))))
BUILT_INS.set(name, exports)
}

return exports
}

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

async function getFullCjsExports (url, context, parentLoad, source) {
async function getCjsExports (url, context, parentLoad, source) {
if (urlsBeingProcessed.has(url)) {
return []
}
urlsBeingProcessed.add(url)

const ex = getCjsExports(source)
const full = Array.from(new Set([
...addDefault(ex.exports),
...(await Promise.all(ex.reexports.map(re => getExports(
(/^(..?($|\/|\\))/).test(re)
? pathToFileURL(require.resolve(fileURLToPath(new URL(re, url)))).toString()
: pathToFileURL(require.resolve(re)).toString(),
context,
parentLoad
)))).flat()
]))

urlsBeingProcessed.delete(url)
return full
try {
const result = parseCjs(source)
const full = addDefault(result.exports)

await Promise.all(result.reexports.map(async re => {
if (re.startsWith('node:') || builtinModules.includes(re)) {
for (const each of getExportsForNodeBuiltIn(re)) {
full.add(each)
}
} else {
// Resolve the re-exported module relative to the current module.
const newUrl = pathToFileURL(require.resolve(re, { paths: [dirname(fileURLToPath(url))] })).href
for (const each of await getExports(newUrl, context, parentLoad)) {
full.add(each)
}
}
}))

return full
} finally {
urlsBeingProcessed.delete(url)
}
}

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

// TODO support non-node/file urls somehow?
if (format === 'builtin') {
// Builtins don't give us the source property, so we're stuck
// just requiring it to get the exports.
return addDefault(Object.keys(require(url)))
}

if (!source) {
// Sometimes source is retrieved by parentLoad, sometimes it isn't.
source = fs.readFileSync(fileURLToPath(url), 'utf8')
if (format === 'builtin') {
// Builtins don't give us the source property, so we're stuck
// just requiring it to get the exports.
return getExportsForNodeBuiltIn(url)
}

// Sometimes source is retrieved by parentLoad, CommonJs isn't.
source = readFileSync(fileURLToPath(url), 'utf8')
}

if (format === 'module') {
return getEsmExports(source)
}

if (format === 'commonjs') {
return getFullCjsExports(url, context, parentLoad, source)
return getCjsExports(url, context, parentLoad, source)
}

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

Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/node_modules/some-external-cjs-module/index.js

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

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

1 change: 1 addition & 0 deletions test/fixtures/re-export-cjs-built-in.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('util').deprecate
1 change: 1 addition & 0 deletions test/fixtures/re-export-cjs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('some-external-cjs-module').foo
2 changes: 1 addition & 1 deletion test/get-esm-exports/v20-get-esm-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ fixture.split('\n').forEach(line => {
if (expectedNames[0] === '') {
expectedNames.length = 0
}
const names = getEsmExports(mod)
const names = Array.from(getEsmExports(mod))
assert.deepEqual(expectedNames, names)
console.log(`${mod}\n ✅ contains exports: ${testStr}`)
})
Expand Down
19 changes: 19 additions & 0 deletions test/hook/re-export-cjs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Hook from '../../index.js'
import foo from '../fixtures/re-export-cjs-built-in.js'
import foo2 from '../fixtures/re-export-cjs.js'
import { strictEqual } from 'assert'

Hook((exports, name) => {
if (name.endsWith('fixtures/re-export-cjs-built-in.js')) {
strictEqual(typeof exports.default, 'function')
exports.default = '1'
}

if (name.endsWith('fixtures/re-export-cjs.js')) {
strictEqual(exports.default, 'bar')
exports.default = '2'
}
})

strictEqual(foo, '1')
strictEqual(foo2, '2')
Loading