From 2751faa45aa5a2f251ee57bfc0d2ca84d9569f51 Mon Sep 17 00:00:00 2001 From: James Sumners Date: Thu, 7 Dec 2023 10:21:11 -0500 Subject: [PATCH 1/5] Support `export * from 'module'` ESM syntax This change adds support for modules that export entities through the `export * from 'module'` ESM syntax. This resolves issue #31. --- hook.js | 58 +++++++++++++++++++++++++++----- lib/get-esm-exports.js | 2 +- test/fixtures/bundle.mjs | 3 ++ test/fixtures/esm-exports.txt | 2 +- test/fixtures/fantasia.mjs | 5 +++ test/hook/static-import-star.mjs | 20 +++++++++++ 6 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/bundle.mjs create mode 100644 test/fixtures/fantasia.mjs create mode 100644 test/hook/static-import-star.mjs diff --git a/hook.js b/hook.js index 5e412da..638b970 100644 --- a/hook.js +++ b/hook.js @@ -121,23 +121,63 @@ function createHook (meta) { const iitmURL = new URL('lib/register.js', meta.url).toString() async function getSource (url, context, parentGetSource) { + const imports = [] + const namespaceIds = [] + if (hasIitm(url)) { const realUrl = deleteIitm(url) const exportNames = await getExports(realUrl, context, parentGetSource) + const isExportAllLine = /^\* from / + const setters = [] + for (const n of exportNames) { + if (isExportAllLine.test(n) === true) { + // Encountered a `export * from 'module'` line. Thus, we need to + // get all exports from the specified module and shim them into the + // current module. + const [_, modFile] = n.split('* from ') + const modName = Buffer.from(modFile, 'hex') + Date.now() + const modUrl = new URL(modFile, url).toString() + const innerExports = await getExports(modUrl, context, parentGetSource) + const innerSetters = [] + + for (const _n of innerExports) { + innerSetters.push(` + let $${_n} = _.${_n} + export { $${_n} as ${_n} } + set.${_n} = (v) => { + $${_n} = v + return true + } + `) + } + + imports.push(`import * as $${modName} from ${JSON.stringify(modUrl)}`) + namespaceIds.push(`$${modName}`) + setters.push(innerSetters.join('\n')) + continue + } + + setters.push(` + let $${n} = _.${n} + export { $${n} as ${n} } + set.${n} = (v) => { + $${n} = v + return true + } + `) + } + return { source: ` import { register } from '${iitmURL}' import * as namespace from ${JSON.stringify(url)} +${imports.join('\n')} + +const _ = Object.assign({}, ...[namespace, ${namespaceIds.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/test/fixtures/bundle.mjs b/test/fixtures/bundle.mjs new file mode 100644 index 0000000..45812bf --- /dev/null +++ b/test/fixtures/bundle.mjs @@ -0,0 +1,3 @@ +import bar from './something.mjs' +export default bar +export * from './fantasia.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/fantasia.mjs b/test/fixtures/fantasia.mjs new file mode 100644 index 0000000..3413721 --- /dev/null +++ b/test/fixtures/fantasia.mjs @@ -0,0 +1,5 @@ +export function sayName() { + return 'Moon Child' +} + +export const Morla = 'Ancient one' diff --git a/test/hook/static-import-star.mjs b/test/hook/static-import-star.mjs new file mode 100644 index 0000000..fe04ea9 --- /dev/null +++ b/test/hook/static-import-star.mjs @@ -0,0 +1,20 @@ +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 sayName = exports.sayName + exports.sayName = function wrappedSayName() { + return `Bastion: "${sayName()}"` + } +}) + +import { default as bar, sayName } from '../fixtures/bundle.mjs' + +strictEqual(bar(), '42-wrapped') +strictEqual(sayName(), 'Bastion: "Moon Child"') From 93ecc705fff2dd81ce38dfa38c4940b982a2ade5 Mon Sep 17 00:00:00 2001 From: James Sumners Date: Thu, 7 Dec 2023 15:54:30 -0500 Subject: [PATCH 2/5] Support nested `* from` exports --- hook.js | 106 +++++++++++++++++++++++++------ test/fixtures/a.mjs | 7 ++ test/fixtures/b.mjs | 5 ++ test/fixtures/bundle.mjs | 3 +- test/fixtures/fantasia.mjs | 5 -- test/fixtures/foo.mjs | 5 ++ test/fixtures/lib/baz.mjs | 3 + test/hook/static-import-star.mjs | 11 ++-- 8 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 test/fixtures/a.mjs create mode 100644 test/fixtures/b.mjs delete mode 100644 test/fixtures/fantasia.mjs create mode 100644 test/fixtures/foo.mjs create mode 100644 test/fixtures/lib/baz.mjs diff --git a/hook.js b/hook.js index 638b970..43341ce 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('node:crypto') const specifiers = new Map() const isWin = process.platform === "win32" @@ -77,6 +78,79 @@ 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} ProcessedStarExport + * @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[]} settings The shimmed setters for all of the exports + * from the `imports`. + */ + +/** + * Processes a module that has been exported via the ESM "export all" syntax. + * It gets all of the exports from the designated "get all exports from" module + * and maps them into the shimmed setters syntax. + * + * @param {object} params + * @param {string} params.exportLine The text indicating the module to import, + * e.g. "* from foo". + * @param {string} params.srcUrl The full URL to the module that contains the + * `exportLine`. + * @param {object} params.context Provided by the loaders API. + * @param {function} parentGetSource Provides the source code for the parent + * module. + * @returns {Promise} + */ +async function processStarExport({exportLine, srcUrl, context, parentGetSource}) { + const [_, modFile] = exportLine.split('* from ') + const modName = Buffer.from(modFile, 'hex') + Date.now() + randomBytes(4).toString('hex') + const modUrl = new URL(modFile, srcUrl).toString() + const innerExports = await getExports(modUrl, context, parentGetSource) + + const imports = [`import * as $${modName} from ${JSON.stringify(modUrl)}`] + const namespaces = [`$${modName}`] + const setters = [] + for (const n of innerExports) { + if (isStarExportLine(n) === true) { + const data = await processStarExport({ + exportLine: n, + srcUrl: modUrl, + context, + parentGetSource + }) + Array.prototype.push.apply(imports, data.imports) + Array.prototype.push.apply(namespaces, data.namespaces) + 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) @@ -127,33 +201,23 @@ function createHook (meta) { if (hasIitm(url)) { const realUrl = deleteIitm(url) const exportNames = await getExports(realUrl, context, parentGetSource) - const isExportAllLine = /^\* from / const setters = [] + for (const n of exportNames) { - if (isExportAllLine.test(n) === true) { + if (isStarExportLine(n) === true) { // Encountered a `export * from 'module'` line. Thus, we need to // get all exports from the specified module and shim them into the // current module. - const [_, modFile] = n.split('* from ') - const modName = Buffer.from(modFile, 'hex') + Date.now() - const modUrl = new URL(modFile, url).toString() - const innerExports = await getExports(modUrl, context, parentGetSource) - const innerSetters = [] - - for (const _n of innerExports) { - innerSetters.push(` - let $${_n} = _.${_n} - export { $${_n} as ${_n} } - set.${_n} = (v) => { - $${_n} = v - return true - } - `) - } + const data = await processStarExport({ + exportLine: n, + srcUrl: url, + context, + parentGetSource + }) + Array.prototype.push.apply(imports, data.imports) + Array.prototype.push.apply(namespaceIds, data.namespaces) + Array.prototype.push.apply(setters, data.setters) - imports.push(`import * as $${modName} from ${JSON.stringify(modUrl)}`) - namespaceIds.push(`$${modName}`) - setters.push(innerSetters.join('\n')) continue } 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 index 45812bf..fc2af44 100644 --- a/test/fixtures/bundle.mjs +++ b/test/fixtures/bundle.mjs @@ -1,3 +1,4 @@ import bar from './something.mjs' export default bar -export * from './fantasia.mjs' +export * from './a.mjs' +export * from './b.mjs' diff --git a/test/fixtures/fantasia.mjs b/test/fixtures/fantasia.mjs deleted file mode 100644 index 3413721..0000000 --- a/test/fixtures/fantasia.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export function sayName() { - return 'Moon Child' -} - -export const Morla = 'Ancient one' 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 index fe04ea9..5cf427f 100644 --- a/test/hook/static-import-star.mjs +++ b/test/hook/static-import-star.mjs @@ -8,13 +8,14 @@ Hook((exports, name) => { return bar() + '-wrapped' } - const sayName = exports.sayName - exports.sayName = function wrappedSayName() { - return `Bastion: "${sayName()}"` + const aFunc = exports.aFunc + exports.aFunc = function wrappedAFunc() { + return aFunc() + '-wrapped' } }) -import { default as bar, sayName } from '../fixtures/bundle.mjs' +import { default as bar, aFunc, baz } from '../fixtures/bundle.mjs' strictEqual(bar(), '42-wrapped') -strictEqual(sayName(), 'Bastion: "Moon Child"') +strictEqual(aFunc(), 'a-wrapped') +strictEqual(baz(), 'baz') From 88d15b6d450004d513b1cdcf43a4da0320dffd0b Mon Sep 17 00:00:00 2001 From: James Sumners Date: Fri, 8 Dec 2023 09:28:48 -0500 Subject: [PATCH 3/5] Fix crypto require --- hook.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hook.js b/hook.js index 43341ce..df8a4f7 100644 --- a/hook.js +++ b/hook.js @@ -2,7 +2,7 @@ // // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. -const { randomBytes } = require('node:crypto') +const { randomBytes } = require('crypto') const specifiers = new Map() const isWin = process.platform === "win32" From 8b3a02f50fd843883762d2011b76c80bd7818c3d Mon Sep 17 00:00:00 2001 From: James Sumners Date: Mon, 11 Dec 2023 13:31:23 -0500 Subject: [PATCH 4/5] Drop coverage requirement --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 562c1ec..e5acbb6 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'" }, From f8fa605db7160b5551cd3b6adb2e4043109cb67b Mon Sep 17 00:00:00 2001 From: James Sumners Date: Tue, 12 Dec 2023 08:16:55 -0500 Subject: [PATCH 5/5] Address feedback --- hook.js | 94 +++++++++++--------------------- test/hook/static-import-star.mjs | 13 ++++- 2 files changed, 43 insertions(+), 64 deletions(-) diff --git a/hook.js b/hook.js index df8a4f7..d2ce686 100644 --- a/hook.js +++ b/hook.js @@ -92,53 +92,50 @@ function isStarExportLine(line) { } /** - * @typedef {object} ProcessedStarExport + * @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[]} settings The shimmed setters for all of the exports - * from the `imports`. + * @property {string[]} setters The shimmed setters for all the exports + * from the module and any transitive export all modules. */ /** - * Processes a module that has been exported via the ESM "export all" syntax. - * It gets all of the exports from the designated "get all exports from" module - * and maps them into the shimmed setters syntax. + * 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.exportLine The text indicating the module to import, - * e.g. "* from foo". - * @param {string} params.srcUrl The full URL to the module that contains the - * `exportLine`. + * @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} + * @returns {Promise} */ -async function processStarExport({exportLine, srcUrl, context, parentGetSource}) { - const [_, modFile] = exportLine.split('* from ') - const modName = Buffer.from(modFile, 'hex') + Date.now() + randomBytes(4).toString('hex') - const modUrl = new URL(modFile, srcUrl).toString() - const innerExports = await getExports(modUrl, context, parentGetSource) - - const imports = [`import * as $${modName} from ${JSON.stringify(modUrl)}`] - const namespaces = [`$${modName}`] +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 innerExports) { + + for (const n of exportNames) { if (isStarExportLine(n) === true) { - const data = await processStarExport({ - exportLine: n, - srcUrl: modUrl, - context, - parentGetSource - }) - Array.prototype.push.apply(imports, data.imports) - Array.prototype.push.apply(namespaces, data.namespaces) + 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} } @@ -195,49 +192,20 @@ function createHook (meta) { const iitmURL = new URL('lib/register.js', meta.url).toString() async function getSource (url, context, parentGetSource) { - const imports = [] - const namespaceIds = [] - if (hasIitm(url)) { const realUrl = deleteIitm(url) - const exportNames = await getExports(realUrl, context, parentGetSource) - const setters = [] - - for (const n of exportNames) { - if (isStarExportLine(n) === true) { - // Encountered a `export * from 'module'` line. Thus, we need to - // get all exports from the specified module and shim them into the - // current module. - const data = await processStarExport({ - exportLine: n, - srcUrl: url, - context, - parentGetSource - }) - Array.prototype.push.apply(imports, data.imports) - Array.prototype.push.apply(namespaceIds, data.namespaces) - Array.prototype.push.apply(setters, data.setters) - - continue - } - - setters.push(` - let $${n} = _.${n} - export { $${n} as ${n} } - set.${n} = (v) => { - $${n} = v - return true - } - `) - } + 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({}, ...[namespace, ${namespaceIds.join(', ')}]) +const _ = Object.assign({}, ...[${namespaces.join(', ')}]) const set = {} ${setters.join('\n')} diff --git a/test/hook/static-import-star.mjs b/test/hook/static-import-star.mjs index 5cf427f..f928973 100644 --- a/test/hook/static-import-star.mjs +++ b/test/hook/static-import-star.mjs @@ -8,14 +8,25 @@ Hook((exports, name) => { 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, aFunc, baz } from '../fixtures/bundle.mjs' +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')