From 5ec0576c83879c13d3c97f19f5fae5f06db30d62 Mon Sep 17 00:00:00 2001 From: James Sumners Date: Wed, 13 Dec 2023 14:55:17 -0500 Subject: [PATCH] Support `export * from 'module'` ESM syntax (#43) This change adds support for modules that export entities through the `export * from 'module'` ESM syntax. This resolves issue #31. --- hook.js | 94 ++++++++++++++++++++++++++++---- lib/get-esm-exports.js | 2 +- package.json | 2 +- test/fixtures/a.mjs | 7 +++ test/fixtures/b.mjs | 5 ++ test/fixtures/bundle.mjs | 4 ++ test/fixtures/esm-exports.txt | 2 +- test/fixtures/foo.mjs | 5 ++ test/fixtures/lib/baz.mjs | 3 + test/hook/static-import-star.mjs | 32 +++++++++++ 10 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 test/fixtures/a.mjs create mode 100644 test/fixtures/b.mjs create mode 100644 test/fixtures/bundle.mjs create mode 100644 test/fixtures/foo.mjs create mode 100644 test/fixtures/lib/baz.mjs create mode 100644 test/hook/static-import-star.mjs diff --git a/hook.js b/hook.js index 5e412da..d2ce686 100644 --- a/hook.js +++ b/hook.js @@ -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" @@ -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} + */ +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) @@ -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))}) ` } } diff --git a/lib/get-esm-exports.js b/lib/get-esm-exports.js index 3b4fa30..c04799e 100644 --- a/lib/get-esm-exports.js +++ b/lib/get-esm-exports.js @@ -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: diff --git a/package.json b/package.json index 69937f7..8d2ef52 100644 --- a/package.json +++ b/package.json @@ -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'" }, diff --git a/test/fixtures/a.mjs b/test/fixtures/a.mjs new file mode 100644 index 0000000..1381e65 --- /dev/null +++ b/test/fixtures/a.mjs @@ -0,0 +1,7 @@ +export const a = 'a' + +export function aFunc() { + return a +} + +export * from './foo.mjs' diff --git a/test/fixtures/b.mjs b/test/fixtures/b.mjs new file mode 100644 index 0000000..2bb4e36 --- /dev/null +++ b/test/fixtures/b.mjs @@ -0,0 +1,5 @@ +export const b = 'b' + +export function bFunc() { + return b +} diff --git a/test/fixtures/bundle.mjs b/test/fixtures/bundle.mjs new file mode 100644 index 0000000..fc2af44 --- /dev/null +++ b/test/fixtures/bundle.mjs @@ -0,0 +1,4 @@ +import bar from './something.mjs' +export default bar +export * from './a.mjs' +export * from './b.mjs' diff --git a/test/fixtures/esm-exports.txt b/test/fixtures/esm-exports.txt index 341fa53..9d5337b 100644 --- a/test/fixtures/esm-exports.txt +++ b/test/fixtures/esm-exports.txt @@ -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 diff --git a/test/fixtures/foo.mjs b/test/fixtures/foo.mjs new file mode 100644 index 0000000..f494858 --- /dev/null +++ b/test/fixtures/foo.mjs @@ -0,0 +1,5 @@ +export function foo() { + return 'foo' +} + +export * from './lib/baz.mjs' diff --git a/test/fixtures/lib/baz.mjs b/test/fixtures/lib/baz.mjs new file mode 100644 index 0000000..210d922 --- /dev/null +++ b/test/fixtures/lib/baz.mjs @@ -0,0 +1,3 @@ +export function baz() { + return 'baz' +} diff --git a/test/hook/static-import-star.mjs b/test/hook/static-import-star.mjs new file mode 100644 index 0000000..f928973 --- /dev/null +++ b/test/hook/static-import-star.mjs @@ -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')