Skip to content

Commit

Permalink
Address default export issues
Browse files Browse the repository at this point in the history
  • Loading branch information
jsumners-nr committed Dec 28, 2023
1 parent 5c00d76 commit 1abc010
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 28 deletions.
124 changes: 111 additions & 13 deletions hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProcessedModule>}
*/
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
Expand All @@ -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) {
Expand Down Expand Up @@ -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))})
`
}
Expand Down
50 changes: 46 additions & 4 deletions lib/get-esm-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <identifier> as <defaultAsValue>`. 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) {
Expand All @@ -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)
Expand Down
29 changes: 26 additions & 3 deletions lib/get-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.
*/
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.
Expand All @@ -30,15 +53,15 @@ 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)
}

// 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.
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/default-class.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default class DefaultClass {
value = 'DefaultClass'
}
7 changes: 5 additions & 2 deletions test/fixtures/got-alike.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions test/fixtures/lib/baz.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export function baz() {
return 'baz'
}
export default baz
2 changes: 2 additions & 0 deletions test/fixtures/snake_case.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const snakeCase = 'snake_case'
export default snakeCase
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 = getEsmExports({ moduleSource: mod })
assert.deepEqual(expectedNames, names)
console.log(`${mod}\n ✅ contains exports: ${testStr}`)
})
Expand Down
24 changes: 19 additions & 5 deletions test/hook/static-import-gotalike.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')

0 comments on commit 1abc010

Please sign in to comment.