Skip to content

Commit

Permalink
Support export * from 'module' ESM syntax (#43)
Browse files Browse the repository at this point in the history
This change adds support for modules that export entities through the
`export * from 'module'` ESM syntax. This resolves issue #31.
  • Loading branch information
jsumners-nr committed Dec 13, 2023
1 parent 9d1f3c4 commit 5ec0576
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 14 deletions.
94 changes: 83 additions & 11 deletions hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//
// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc.

const { randomBytes } = require('crypto')
const specifiers = new Map()
const isWin = process.platform === "win32"

Expand Down Expand Up @@ -77,6 +78,76 @@ function needsToAddFileProtocol(urlObj) {
return !isFileProtocol(urlObj) && NODE_MAJOR < 18
}

/**
* Determines if a specifier represents an export all ESM line.
* Note that the expected `line` isn't 100% valid ESM. It is derived
* from the `getExports` function wherein we have recognized the true
* line and re-mapped it to one we expect.
*
* @param {string} line
* @returns {boolean}
*/
function isStarExportLine(line) {
return /^\* from /.test(line)
}

/**
* @typedef {object} ProcessedModule
* @property {string[]} imports A set of ESM import lines to be added to the
* shimmed module source.
* @property {string[]} namespaces A set of identifiers representing the
* modules in `imports`, e.g. for `import * as foo from 'bar'`, "foo" will be
* present in this array.
* @property {string[]} setters The shimmed setters for all the exports
* from the module and any transitive export all modules.
*/

/**
* Processes a module's exports and builds a set of new import statements,
* namespace names, and setter blocks. If an export all export if encountered,
* the target exports will be hoisted to the current module via a generated
* namespace.
*
* @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.
* @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']
const setters = []

for (const n of exportNames) {
if (isStarExportLine(n) === true) {
const [_, modFile] = n.split('* from ')
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 })
Array.prototype.push.apply(setters, data.setters)

continue
}

setters.push(`
let $${n} = _.${n}
export { $${n} as ${n} }
set.${n} = (v) => {
$${n} = v
return true
}
`)
}

return { imports, namespaces, setters }
}

function addIitm (url) {
const urlObj = new URL(url)
Expand Down Expand Up @@ -123,21 +194,22 @@ function createHook (meta) {
async function getSource (url, context, parentGetSource) {
if (hasIitm(url)) {
const realUrl = deleteIitm(url)
const exportNames = await getExports(realUrl, context, parentGetSource)
const { imports, namespaces, setters } = await processModule({
srcUrl: realUrl,
context,
parentGetSource
})

return {
source: `
import { register } from '${iitmURL}'
import * as namespace from ${JSON.stringify(url)}
${imports.join('\n')}
const _ = Object.assign({}, ...[${namespaces.join(', ')}])
const set = {}
${exportNames.map((n) => `
let $${n} = namespace.${n}
export { $${n} as ${n} }
set.${n} = (v) => {
$${n} = v
return true
}
`).join('\n')}
register(${JSON.stringify(realUrl)}, namespace, set, ${JSON.stringify(specifiers.get(realUrl))})
${setters.join('\n')}
register(${JSON.stringify(realUrl)}, _, set, ${JSON.stringify(specifiers.get(realUrl))})
`
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/get-esm-exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function getEsmExports (moduleStr) {
if (node.exported) {
exportedNames.add(node.exported.name)
} else {
exportedNames.add('*')
exportedNames.add(`* from ${node.source.value}`)
}
break
default:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "Intercept imports in Node.js",
"main": "index.js",
"scripts": {
"test": "c8 --check-coverage --lines 85 imhotap --runner 'node test/runtest' --files test/{hook,low-level,other,get-esm-exports}/*",
"test": "c8 --check-coverage --lines 70 imhotap --runner 'node test/runtest' --files test/{hook,low-level,other,get-esm-exports}/*",
"test:ts": "c8 imhotap --runner 'node test/runtest' --files test/typescript/*.test.mts",
"coverage": "c8 --reporter html imhotap --runner 'node test/runtest' --files test/{hook,low-level,other,get-esm-exports}/* && echo '\nNow open coverage/index.html\n'"
},
Expand Down
7 changes: 7 additions & 0 deletions test/fixtures/a.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const a = 'a'

export function aFunc() {
return a
}

export * from './foo.mjs'
5 changes: 5 additions & 0 deletions test/fixtures/b.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const b = 'b'

export function bFunc() {
return b
}
4 changes: 4 additions & 0 deletions test/fixtures/bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import bar from './something.mjs'
export default bar
export * from './a.mjs'
export * from './b.mjs'
2 changes: 1 addition & 1 deletion test/fixtures/esm-exports.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default class { /* … */ } //| default
export default function* () { /* … */ } //| default

// Aggregating modules
export * from "module-name"; //| *
export * from "module-name"; //| * from module-name
export * as name1 from "module-name"; //| name1
export { name1, /* …, */ nameN } from "module-name"; //| name1,nameN
export { import1 as name1, import2 as name2, /* …, */ nameN } from "module-name"; //| name1,name2,nameN
Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/foo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function foo() {
return 'foo'
}

export * from './lib/baz.mjs'
3 changes: 3 additions & 0 deletions test/fixtures/lib/baz.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function baz() {
return 'baz'
}
32 changes: 32 additions & 0 deletions test/hook/static-import-star.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { strictEqual } from 'assert'
import Hook from '../../index.js'
Hook((exports, name) => {
if (/bundle\.mjs/.test(name) === false) return

const bar = exports.default
exports.default = function wrappedBar() {
return bar() + '-wrapped'
}

const foo = exports.foo
exports.foo = function wrappedFoo() {
return foo() + '-wrapped'
}

const aFunc = exports.aFunc
exports.aFunc = function wrappedAFunc() {
return aFunc() + '-wrapped'
}
})

import {
default as bar,
foo,
aFunc,
baz
} from '../fixtures/bundle.mjs'

strictEqual(bar(), '42-wrapped')
strictEqual(foo(), 'foo-wrapped')
strictEqual(aFunc(), 'a-wrapped')
strictEqual(baz(), 'baz')

0 comments on commit 5ec0576

Please sign in to comment.