diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71d3bfc..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.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/hook.js b/hook.js index 3639fbf..df8a4f7 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" @@ -14,7 +15,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) @@ -76,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) @@ -100,7 +175,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 } @@ -116,23 +195,53 @@ 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 + } + `) + } + 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/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": { 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/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/hook/static-import-star.mjs b/test/hook/static-import-star.mjs new file mode 100644 index 0000000..5cf427f --- /dev/null +++ b/test/hook/static-import-star.mjs @@ -0,0 +1,21 @@ +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 aFunc = exports.aFunc + exports.aFunc = function wrappedAFunc() { + return aFunc() + '-wrapped' + } +}) + +import { default as bar, aFunc, baz } from '../fixtures/bundle.mjs' + +strictEqual(bar(), '42-wrapped') +strictEqual(aFunc(), 'a-wrapped') +strictEqual(baz(), 'baz') diff --git a/test/other/import-executable.mjs b/test/other/import-executable.mjs index 5979db4..baa3ddf 100644 --- a/test/other/import-executable.mjs +++ b/test/other/import-executable.mjs @@ -4,6 +4,14 @@ import { rejects } from 'assert' (async () => { + const [processMajor, processMinor] = process.versions.node.split('.').map(Number) + const extensionlessSupported = processMajor >= 21 || + (processMajor === 20 && processMinor >= 10) || + (processMajor === 18 && processMinor >= 19) + if (extensionlessSupported) { + // Files without extension are supported in Node.js ^21, ^20.10.0, and ^18.19.0 + return + } await rejects(() => import('./executable'), { name: 'TypeError', code: 'ERR_UNKNOWN_FILE_EXTENSION' 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); }