From 155491e4b87819c5f487d9161b1b0a6f5f64abde Mon Sep 17 00:00:00 2001 From: Bryan English Date: Thu, 30 Nov 2023 13:53:42 -0500 Subject: [PATCH 1/7] Support Node.js v21 (#40) * Renaming a context property * Importing extensionless now works --- .github/workflows/ci.yml | 2 +- hook.js | 6 +++++- test/other/import-executable.mjs | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71d3bfc..c684dfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x, 16.10.0, 16.16.0, 16.17.0, 16.x, 17.x, 18.5.0, 18.x, 20.x] + node-version: [12.x, 14.x, 16.10.0, 16.16.0, 16.17.0, 16.x, 17.x, 18.5.0, 18.x, 20.9.0, 20.x, 21.x] os: [ubuntu-latest, macos-latest, windows-latest] steps: diff --git a/hook.js b/hook.js index 3639fbf..97a9c51 100644 --- a/hook.js +++ b/hook.js @@ -100,7 +100,11 @@ function createHook (meta) { return url } - if (context.importAssertions && context.importAssertions.type === 'json') { + // Node.js v21 renames importAssertions to importAttributes + if ( + (context.importAssertions && context.importAssertions.type === 'json') || + (context.importAttributes && context.importAttributes.type === 'json') + ) { return url } diff --git a/test/other/import-executable.mjs b/test/other/import-executable.mjs index 5979db4..11d2ad9 100644 --- a/test/other/import-executable.mjs +++ b/test/other/import-executable.mjs @@ -4,6 +4,13 @@ import { rejects } from 'assert' (async () => { + const [processMajor, processMinor] = process.versions.node.split('.').map(Number) + const extensionlessSupported = processMajor >= 21 || + (processMajor === 20 && processMinor >= 10) + if (extensionlessSupported) { + // Files without extension are supported in Node.js >= 20.10.0 + return + } await rejects(() => import('./executable'), { name: 'TypeError', code: 'ERR_UNKNOWN_FILE_EXTENSION' From 4ca382453464146fe4751ca05b661c0c6faa6d3d Mon Sep 17 00:00:00 2001 From: Bryan English Date: Thu, 30 Nov 2023 16:00:02 -0500 Subject: [PATCH 2/7] 1.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9af84f6..562c1ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "import-in-the-middle", - "version": "1.4.2", + "version": "1.5.0", "description": "Intercept imports in Node.js", "main": "index.js", "scripts": { From 3cafa994024e0e506bc426cc8e0ced998d9419e6 Mon Sep 17 00:00:00 2001 From: Bryan English Date: Fri, 1 Dec 2023 12:56:37 -0500 Subject: [PATCH 3/7] Extensionless support in 18.19 (#41) Node.js 18.19 backported the extensionless supported added ion Node.js 21.0.0 and 20.10.0, so the test needed to be fixed for that. --- .github/workflows/ci.yml | 21 +++++++++++++++++++-- test/other/import-executable.mjs | 5 +++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c684dfe..1c41dad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,25 @@ jobs: strategy: matrix: - node-version: [12.x, 14.x, 16.10.0, 16.16.0, 16.17.0, 16.x, 17.x, 18.5.0, 18.x, 20.9.0, 20.x, 21.x] - os: [ubuntu-latest, macos-latest, windows-latest] + node-version: + - 12.x + - 14.x + - 16.10.0 + - 16.16.0 + - 16.17.0 + - 16.x + - 17.x + - 18.5.0 + - 18.18.0 + - 18.19.0 + - 18.x + - 20.9.0 + - 20.x + - 21.x + os: + - ubuntu-latest + - macos-latest + - windows-latest steps: - uses: actions/checkout@v2 diff --git a/test/other/import-executable.mjs b/test/other/import-executable.mjs index 11d2ad9..baa3ddf 100644 --- a/test/other/import-executable.mjs +++ b/test/other/import-executable.mjs @@ -6,9 +6,10 @@ import { rejects } from 'assert' (async () => { const [processMajor, processMinor] = process.versions.node.split('.').map(Number) const extensionlessSupported = processMajor >= 21 || - (processMajor === 20 && processMinor >= 10) + (processMajor === 20 && processMinor >= 10) || + (processMajor === 18 && processMinor >= 19) if (extensionlessSupported) { - // Files without extension are supported in Node.js >= 20.10.0 + // Files without extension are supported in Node.js ^21, ^20.10.0, and ^18.19.0 return } await rejects(() => import('./executable'), { From 96c506e58263df8ec7c6a40a74533f91ed5c00f5 Mon Sep 17 00:00:00 2001 From: James Sumners Date: Mon, 11 Dec 2023 11:49:26 -0500 Subject: [PATCH 4/7] Fix ESM in Node v18.19.0 (#44) Node v18.19.0 includes a backport of ESM loading on a separate thread -- https://github.com/nodejs/node/commit/bac9b1758f36fb3504589ffc5ed610d574dca47d. This PR updates the version check to use AST parsing on Node >= 18.19.0. --- hook.js | 3 ++- test/get-esm-exports/v18.19-get-esm-exports.js | 3 +++ test/runtest | 15 ++++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 test/get-esm-exports/v18.19-get-esm-exports.js diff --git a/hook.js b/hook.js index 97a9c51..5e412da 100644 --- a/hook.js +++ b/hook.js @@ -14,7 +14,8 @@ const NODE_MINOR = Number(NODE_VERSION[1]) let entrypoint -if (NODE_MAJOR >= 20) { +let getExports +if (NODE_MAJOR >= 20 || (NODE_MAJOR == 18 && NODE_MINOR >= 19)) { getExports = require('./lib/get-exports.js') } else { getExports = (url) => import(url).then(Object.keys) diff --git a/test/get-esm-exports/v18.19-get-esm-exports.js b/test/get-esm-exports/v18.19-get-esm-exports.js new file mode 100644 index 0000000..3126e5c --- /dev/null +++ b/test/get-esm-exports/v18.19-get-esm-exports.js @@ -0,0 +1,3 @@ +// v18.19.0 backported ESM hook execution to a separate thread, +// thus being equivalent to >=v20. +require('./v20-get-esm-exports') diff --git a/test/runtest b/test/runtest index 555ca75..c78a381 100755 --- a/test/runtest +++ b/test/runtest @@ -16,14 +16,19 @@ const args = [ ...process.argv.slice(2) ] -const [processMajor] = process.versions.node.split('.').map(Number) +const [processMajor, processMinor] = process.versions.node.split('.').map(Number) -const match = filename.match(/v([0-9]+)/) +const match = filename.match(/v([0-9]+)(?:\.([0-9]+))?/) -const versionRequirement = match ? match[1] : 0; +const majorRequirement = match ? match[1] : 0; +const minorRequirement = match && match[2]; -if (processMajor < versionRequirement) { - console.log(`skipping ${filename} as this is Node.js v${processMajor} and test wants v${versionRequirement}`); +if (processMajor < majorRequirement && minorRequirement === undefined) { + console.log(`skipping ${filename} as this is Node.js v${processMajor} and test wants v${majorRequirement}`); + process.exit(0); +} +if (processMajor < majorRequirement && processMinor < minorRequirement) { + console.log(`skipping ${filename} as this is Node.js v${processMajor}.${processMinor} and test wants >=v${majorRequirement}.${minorRequirement}`); process.exit(0); } From 2751faa45aa5a2f251ee57bfc0d2ca84d9569f51 Mon Sep 17 00:00:00 2001 From: James Sumners Date: Thu, 7 Dec 2023 10:21:11 -0500 Subject: [PATCH 5/7] 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 6/7] 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 7/7] 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"