diff --git a/hook.js b/hook.js index e88131d..89a53d8 100644 --- a/hook.js +++ b/hook.js @@ -111,33 +111,75 @@ function isStarExportLine(line) { * @param {object} params * @param {string} params.srcUrl The full URL to the module to process. * @param {object} params.context Provided by the loaders API. - * @param {function} parentGetSource Provides the source code for the parent - * module. + * @param {Function} params.parentGetSource Provides the source code for the + * parent module. + * @param {string} [params.ns='namespace'] A string identifier that will be + * used as the namespace for the identifiers exported by the module. + * @param {string} [params.defaultAs='default'] The name to give the default + * identifier exported by the module (if one exists). This is really only + * useful in a recursive situation where a transitive module's default export + * needs to be renamed to the name of the module. + * * @returns {Promise} */ -async function processModule({ srcUrl, context, parentGetSource }) { - const exportNames = await getExports(srcUrl, context, parentGetSource) - const imports = [`import * as namespace from ${JSON.stringify(srcUrl)}`] - const namespaces = ['namespace'] +async function processModule({ + srcUrl, + context, + parentGetSource, + ns = 'namespace', + defaultAs = 'default' +}) { + const exportNames = await getExports({ + url: srcUrl, + context, + parentLoad: parentGetSource, + defaultAs + }) + const imports = [`import * as ${ns} from ${JSON.stringify(srcUrl)}`] + const namespaces = [ns] const setters = [] for (const n of exportNames) { if (isStarExportLine(n) === true) { const [_, modFile] = n.split('* from ') + const normalizedModName = normalizeModName(modFile) const modUrl = new URL(modFile, srcUrl).toString() const modName = Buffer.from(modFile, 'hex') + Date.now() + randomBytes(4).toString('hex') - imports.push(`import * as $${modName} from ${JSON.stringify(modUrl)}`) - namespaces.push(`$${modName}`) - - const data = await processModule({ srcUrl: modUrl, context, parentGetSource }) + const data = await processModule({ + srcUrl: modUrl, + context, + parentGetSource, + ns: `$${modName}`, + defaultAs: normalizedModName + }) + Array.prototype.push.apply(imports, data.imports) + Array.prototype.push.apply(namespaces, data.namespaces) Array.prototype.push.apply(setters, data.setters) continue } + const matches = /^rename (.+) as (.+)$/.exec(n) + if (matches !== null) { + // Transitive modules that export a default identifier need to have + // that identifier renamed to the name of module. And our shim setter + // needs to utilize that new name while being initialized from the + // corresponding origin namespace. + const renamedExport = matches[2] + setters.push(` + let $${renamedExport} = ${ns}.default + export { $${renamedExport} as ${renamedExport} } + set.${renamedExport} = (v) => { + $${renamedExport} = v + return true + } + `) + continue + } + setters.push(` - let $${n} = _.${n} + let $${n} = ${ns}.${n} export { $${n} as ${n} } set.${n} = (v) => { $${n} = v @@ -146,7 +188,25 @@ async function processModule({ srcUrl, context, parentGetSource }) { `) } - return { imports, namespaces, setters: Array.from(new Set(setters)) } + return { imports, namespaces, setters } +} + +/** + * Given a module name, e.g. 'foo-bar' or './foo-bar.js', normalize it to a + * string that is a valid JavaScript identifier, e.g. `fooBar`. Normalization + * means converting kebab-case to camelCase while removing any path tokens and + * file extensions. + * + * @param {string} name The module name to normalize. + * + * @returns {string} The normalized identifier. + */ +function normalizeModName(name) { + return name + .split('\/') + .pop() + .replace(/(.+)\.(?:js|mjs)$/, '$1') + .replaceAll(/(-.)/g, x => x[1].toUpperCase()) } function addIitm (url) { @@ -200,15 +260,53 @@ function createHook (meta) { parentGetSource }) + // When we encounter modules that re-export all identifiers from other + // modules, it is possible that the transitive modules export a default + // identifier. Due to us having to merge all transitive modules into a + // single common namespace, we need to recognize these default exports + // and remap them to a name based on the module name. This prevents us + // from overriding the top-level module's (the one actually being imported + // by some source code) default export when we merge the namespaces. + const renamedDefaults = setters + .map(s => { + const matches = /let \$(.+) = (\$.+)\.default/.exec(s) + if (matches === null) return + return `_['${matches[1]}'] = ${matches[2]}.default` + }) + .filter(s => s) + + // The for loops are how we merge namespaces into a common namespace that + // can be proxied. We can't use a simple `Object.assign` style merging + // because transitive modules can export a default identifier that would + // override the desired default identifier. So we need to do manual + // merging with some logic around default identifiers. + // + // Additionally, we need to make sure any renamed default exports in + // transitive dependencies are added to the common namespace. This is + // accomplished through the `renamedDefaults` array. return { source: ` import { register } from '${iitmURL}' ${imports.join('\n')} -const _ = Object.assign({}, ...[${namespaces.join(', ')}]) +const namespaces = [${namespaces.join(', ')}] +const _ = {} const set = {} +const primary = namespaces.shift() +for (const [k, v] of Object.entries(primary)) { + _[k] = v +} +for (const ns of namespaces) { + for (const [k, v] of Object.entries(ns)) { + if (k === 'default') continue + _[k] = v + } +} + ${setters.join('\n')} +${renamedDefaults.join('\n')} + register(${JSON.stringify(realUrl)}, _, set, ${JSON.stringify(specifiers.get(realUrl))}) ` } diff --git a/lib/get-esm-exports.js b/lib/get-esm-exports.js index c04799e..8b158a3 100644 --- a/lib/get-esm-exports.js +++ b/lib/get-esm-exports.js @@ -14,9 +14,36 @@ function warn (txt) { process.emitWarning(txt, 'get-esm-exports') } -function getEsmExports (moduleStr) { +/** + * Utilizes an AST parser to interpret ESM source code and build a list of + * exported identifiers. In the baseline case, the list of identifiers will be + * the simple identifier names as written in the source code of the module. + * However, there are some special cases: + * + * 1. When an `export * from './foo.js'` line is encountered it is rewritten + * as `* from ./foo.js`. This allows the interpreting code to recognize a + * transitive export and recursively parse the indicated module. The returned + * identifier list will have "* from ./foo.js" as an item. + * + * 2. When `defaultAs` has a value other than 'default', the export line will + * be rewritten as `rename as `. This rename string + * will be an item in the returned identifier list. + * + * @param {object} params + * @param {string} params.moduleSource The source code of the module to parse + * and interpret. + * @param {string} [defaultAs='default'] When anything other than 'default' any + * `export default` lines will be rewritten utilizing the value provided. For + * example, if a module 'foo-bar.js' has the line `export default foo` and the + * value of this parameter is 'baz', then the export will be rewritten to + * `rename foo as baz`. + * + * @returns {string[]} The identifiers exported by the module along with any + * custom directives. + */ +function getEsmExports ({ moduleSource, defaultAs = 'default' }) { const exportedNames = new Set() - const tree = parser.parse(moduleStr, acornOpts) + const tree = parser.parse(moduleSource, acornOpts) for (const node of tree.body) { if (!node.type.startsWith('Export')) continue switch (node.type) { @@ -27,9 +54,24 @@ function getEsmExports (moduleStr) { parseSpecifiers(node, exportedNames) } break - case 'ExportDefaultDeclaration': - exportedNames.add('default') + + case 'ExportDefaultDeclaration': { + if (defaultAs === 'default') { + exportedNames.add('default') + break + } + + if (node.declaration.type.toLowerCase() === 'identifier') { + // e.g. `export default foo` + exportedNames.add(`rename ${node.declaration.name} as ${defaultAs}`) + } else { + // e.g. `export function foo () {} + exportedNames.add(`rename ${node.declaration.id.name} as ${defaultAs}`) + } + break + } + case 'ExportAllDeclaration': if (node.exported) { exportedNames.add(node.exported.name) diff --git a/lib/get-exports.js b/lib/get-exports.js index cfa86d4..a50315a 100644 --- a/lib/get-exports.js +++ b/lib/get-exports.js @@ -9,7 +9,30 @@ function addDefault(arr) { return Array.from(new Set(['default', ...arr])) } -async function getExports (url, context, parentLoad) { +/** + * Inspects a module for its type (commonjs or module), attempts to get the + * source code for said module from the loader API, and parses the result + * for the entities exported from that module. + * + * @param {object} params + * @param {string} params.url A file URL string pointing to the module that + * we should get the exports of. + * @param {object} params.context Context object as provided by the `load` + * hook from the loaders API. + * @param {Function} params.parentLoad Next hook function in the loaders API + * hook chain. + * @param {string} [defaultAs='default'] When anything other than 'default', + * will trigger remapping of default exports in ESM source files to the + * provided name. For example, if a submodule has `export default foo` and + * 'myFoo' is provided for this parameter, the export line will be rewritten + * to `rename foo as myFoo`. This is key to being able to support + * `export * from 'something'` exports. + * + * @returns {Promise} 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. + */ +async function getExports ({ url, context, parentLoad, defaultAs = 'default' }) { // `parentLoad` gives us the possibility of getting the source // from an upstream loader. This doesn't always work though, // so later on we fall back to reading it from disk. @@ -30,7 +53,7 @@ async function getExports (url, context, parentLoad) { } if (format === 'module') { - return getEsmExports(source) + return getEsmExports({ moduleSource: source, defaultAs }) } if (format === 'commonjs') { return addDefault(getCjsExports(source).exports) @@ -38,7 +61,7 @@ async function getExports (url, context, parentLoad) { // At this point our `format` is either undefined or not known by us. Fall // back to parsing as ESM/CJS. - const esmExports = getEsmExports(source) + const esmExports = getEsmExports({ moduleSource: source, defaultAs }) if (!esmExports.length) { // TODO(bengl) it's might be possible to get here if somehow the format // isn't set at first and yet we have an ESM module with no exports. diff --git a/test/fixtures/default-class.mjs b/test/fixtures/default-class.mjs new file mode 100644 index 0000000..6c3d0f8 --- /dev/null +++ b/test/fixtures/default-class.mjs @@ -0,0 +1,3 @@ +export default class DefaultClass { + value = 'DefaultClass' +} diff --git a/test/fixtures/got-alike.mjs b/test/fixtures/got-alike.mjs index 3f0dc1a..68a719a 100644 --- a/test/fixtures/got-alike.mjs +++ b/test/fixtures/got-alike.mjs @@ -5,10 +5,13 @@ // This replicates the way the in-the-wild `got` module does things: // https://github.com/sindresorhus/got/blob/3822412/source/index.ts -const got = { - foo: 'foo' +class got { + foo = 'foo' } export default got export { got } export * from './something.mjs' +export * from './default-class.mjs' +export * from './snake_case.mjs' +export { default as renamedDefaultExport } from './lib/baz.mjs' diff --git a/test/fixtures/lib/baz.mjs b/test/fixtures/lib/baz.mjs index 210d922..26f5f33 100644 --- a/test/fixtures/lib/baz.mjs +++ b/test/fixtures/lib/baz.mjs @@ -1,3 +1,4 @@ export function baz() { return 'baz' } +export default baz diff --git a/test/fixtures/snake_case.mjs b/test/fixtures/snake_case.mjs new file mode 100644 index 0000000..003c28d --- /dev/null +++ b/test/fixtures/snake_case.mjs @@ -0,0 +1,2 @@ +const snakeCase = 'snake_case' +export default snakeCase diff --git a/test/get-esm-exports/v20-get-esm-exports.js b/test/get-esm-exports/v20-get-esm-exports.js index 0d03214..ad003cd 100644 --- a/test/get-esm-exports/v20-get-esm-exports.js +++ b/test/get-esm-exports/v20-get-esm-exports.js @@ -15,7 +15,7 @@ fixture.split('\n').forEach(line => { if (expectedNames[0] === '') { expectedNames.length = 0 } - const names = getEsmExports(mod) + const names = getEsmExports({ moduleSource: mod }) assert.deepEqual(expectedNames, names) console.log(`${mod}\n ✅ contains exports: ${testStr}`) }) diff --git a/test/hook/static-import-gotalike.mjs b/test/hook/static-import-gotalike.mjs index 1d3af42..81946b0 100644 --- a/test/hook/static-import-gotalike.mjs +++ b/test/hook/static-import-gotalike.mjs @@ -3,17 +3,31 @@ import Hook from '../../index.js' Hook((exports, name) => { if (/got-alike\.mjs/.test(name) === false) return - const bar = exports.default - exports.default = function barWrapped () { + const bar = exports.something + exports.something = function barWrapped () { return bar() + '-wrapped' } + + const renamedDefaultExport = exports.renamedDefaultExport + exports.renamedDefaultExport = function bazWrapped () { + return renamedDefaultExport() + '-wrapped' + } }) import { - default as bar, - got + default as Got, + something, + defaultClass as DefaultClass, + snake_case, + renamedDefaultExport } from '../fixtures/got-alike.mjs' -strictEqual(bar(), '42-wrapped') +strictEqual(something(), '42-wrapped') +const got = new Got() strictEqual(got.foo, 'foo') +const dc = new DefaultClass +strictEqual(dc.value, 'DefaultClass') + +strictEqual(snake_case, 'snake_case') +strictEqual(renamedDefaultExport(), 'baz-wrapped')