diff --git a/README.md b/README.md index 50851f7..c746c63 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,39 @@ command-line option. --loader=import-in-the-middle/hook.mjs ``` +It's also possible to register the loader hook programmatically via the Node +[`module.register()`](https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options) +API. However, for this to be able to hook non-dynamic imports, it needs to be +loaded before your app code is evaluated via the `--import` command-line option. + +`my-loader.mjs` +```js +import * as module from 'module' + +module.register('import-in-the-middle/hook.mjs', import.meta.url) +``` +```shell +node --import=./my-loader.mjs ./my-code.mjs +``` + +When registering the loader hook programmatically, it's possible to pass a list +of modules or file URLs to either exclude or specifically include which modules +are intercepted. This is useful if a module is not compatible with the loader +hook. +```js +import * as module from 'module' + +// Exclude intercepting a specific module by name +module.register('import-in-the-middle/hook.mjs', import.meta.url, { + data: { exclude: ['package-i-want-to-exclude'] } +}) + +// Only intercept a specific module by name +module.register('import-in-the-middle/hook.mjs', import.meta.url, { + data: { include: ['package-i-want-to-include'] } +}) +``` + ## Limitations * You cannot add new exports to a module. You can only modify existing ones. diff --git a/hook.js b/hook.js index 4b6caee..1752bcf 100644 --- a/hook.js +++ b/hook.js @@ -116,6 +116,38 @@ function isBareSpecifier (specifier) { } } +function isBareSpecifierOrFileUrl (input) { + // Relative and absolute paths + if ( + input.startsWith('.') || + input.startsWith('/')) { + return false + } + + try { + // eslint-disable-next-line no-new + const url = new URL(input) + return url.protocol === 'file:' + } catch (err) { + // Anything that fails parsing is a bare specifier + return true + } +} + +function ensureArrayWithBareSpecifiersAndFileUrls (array, type) { + if (!Array.isArray(array)) { + return undefined + } + + const invalid = array.filter(s => !isBareSpecifierOrFileUrl(s)) + + if (invalid.length) { + throw new Error(`'${type}' option only supports bare specifiers and file URLs. Invalid entries: ${inspect(invalid)}`) + } + + return array +} + function emitWarning (err) { // Unfortunately, process.emitWarning does not output the full error // with error.cause like console.warn does so we need to inspect it when @@ -217,6 +249,14 @@ function addIitm (url) { function createHook (meta) { let cachedResolve const iitmURL = new URL('lib/register.js', meta.url).toString() + let includeModules, excludeModules + + async function initialize (data) { + if (data) { + includeModules = ensureArrayWithBareSpecifiersAndFileUrls(data.include, 'include') + excludeModules = ensureArrayWithBareSpecifiersAndFileUrls(data.exclude, 'exclude') + } + } async function resolve (specifier, context, parentResolve) { cachedResolve = parentResolve @@ -234,14 +274,27 @@ function createHook (meta) { if (isWin && parentURL.indexOf('file:node') === 0) { context.parentURL = '' } - const url = await parentResolve(newSpecifier, context, parentResolve) - if (parentURL === '' && !EXTENSION_RE.test(url.url)) { - entrypoint = url.url - return { url: url.url, format: 'commonjs' } + const result = await parentResolve(newSpecifier, context, parentResolve) + if (parentURL === '' && !EXTENSION_RE.test(result.url)) { + entrypoint = result.url + return { url: result.url, format: 'commonjs' } + } + + // For included/excluded modules, we check the specifier to match libraries + // that are loaded with bare specifiers from node_modules. + // + // For non-bare specifier imports, we only support matching file URL strings + // because using relative paths would be very error prone! + if (includeModules && !includeModules.some(lib => lib === specifier || lib === result.url.url)) { + return result + } + + if (excludeModules && excludeModules.some(lib => lib === specifier || lib === result.url.url)) { + return result } if (isIitm(parentURL, meta) || hasIitm(parentURL)) { - return url + return result } // Node.js v21 renames importAssertions to importAttributes @@ -249,24 +302,24 @@ function createHook (meta) { (context.importAssertions && context.importAssertions.type === 'json') || (context.importAttributes && context.importAttributes.type === 'json') ) { - return url + return result } // If the file is referencing itself, we need to skip adding the iitm search params - if (url.url === parentURL) { + if (result.url === parentURL) { return { - url: url.url, + url: result.url, shortCircuit: true, - format: url.format + format: result.format } } - specifiers.set(url.url, specifier) + specifiers.set(result.url, specifier) return { - url: addIitm(url.url), + url: addIitm(result.url), shortCircuit: true, - format: url.format + format: result.format } } @@ -337,9 +390,10 @@ register(${JSON.stringify(realUrl)}, _, set, ${JSON.stringify(specifiers.get(rea } if (NODE_MAJOR >= 17 || (NODE_MAJOR === 16 && NODE_MINOR >= 12)) { - return { load, resolve } + return { initialize, load, resolve } } else { return { + initialize, load, resolve, getSource, diff --git a/hook.mjs b/hook.mjs index 7e21d84..37acec3 100644 --- a/hook.mjs +++ b/hook.mjs @@ -4,6 +4,6 @@ import { createHook } from './hook.js' -const { load, resolve, getFormat, getSource } = createHook(import.meta) +const { initialize, load, resolve, getFormat, getSource } = createHook(import.meta) -export { load, resolve, getFormat, getSource } +export { initialize, load, resolve, getFormat, getSource } diff --git a/package.json b/package.json index 093a6e7..bed6728 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Intercept imports in Node.js", "main": "index.js", "scripts": { - "test": "c8 --reporter lcov --check-coverage --lines 50 imhotap --files test/{hook,low-level,other,get-esm-exports}/*", + "test": "c8 --reporter lcov --check-coverage --lines 50 imhotap --files test/{hook,low-level,other,get-esm-exports,register}/*", "test:e2e": "node test/check-exports/test.mjs", "test:ts": "c8 --reporter lcov imhotap --files test/typescript/*.test.mts", "coverage": "c8 --reporter html imhotap --files test/{hook,low-level,other,get-esm-exports}/* && echo '\nNow open coverage/index.html\n'", diff --git a/test/generic-loader.mjs b/test/generic-loader.mjs index 8d02761..ff49f67 100644 --- a/test/generic-loader.mjs +++ b/test/generic-loader.mjs @@ -8,7 +8,7 @@ import path from 'path' const filename = process.env.IITM_TEST_FILE -export const { load, resolve, getFormat, getSource } = - filename.includes('disabled') +export const { initialize, load, resolve, getFormat, getSource } = + filename.includes('disabled') || filename.includes('register') ? {} : (path.extname(filename).slice(-2) === 'ts' ? tsLoader : regularLoader) diff --git a/test/register/v18.19-exclude.mjs b/test/register/v18.19-exclude.mjs new file mode 100644 index 0000000..73105ce --- /dev/null +++ b/test/register/v18.19-exclude.mjs @@ -0,0 +1,15 @@ +import { register } from 'module' +import Hook from '../../index.js' +import { strictEqual } from 'assert' + +register('../../hook.mjs', import.meta.url, { data: { exclude: ['util'] } }) + +const hooked = [] + +Hook((exports, name) => { + hooked.push(name) +}) + +await import('openai') + +strictEqual(hooked.includes('util'), false) diff --git a/test/register/v18.19-include.mjs b/test/register/v18.19-include.mjs new file mode 100644 index 0000000..7ba2c72 --- /dev/null +++ b/test/register/v18.19-include.mjs @@ -0,0 +1,16 @@ +import { register } from 'module' +import Hook from '../../index.js' +import { strictEqual } from 'assert' + +register('../../hook.mjs', import.meta.url, { data: { include: ['openai'] } }) + +const hooked = [] + +Hook((exports, name) => { + hooked.push(name) +}) + +await import('openai') + +strictEqual(hooked.length, 1) +strictEqual(hooked[0], 'openai')