Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow passing of include or exclude list via module.register() #124

Merged
merged 9 commits into from
Jul 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 67 additions & 13 deletions hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -234,39 +274,52 @@ 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
if (
(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
}
}

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
4 changes: 2 additions & 2 deletions test/generic-loader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
15 changes: 15 additions & 0 deletions test/register/v18.19-exclude.mjs
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions test/register/v18.19-include.mjs
Original file line number Diff line number Diff line change
@@ -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')
Loading