Skip to content

Commit

Permalink
feat: Allow passing of include or exclude list via `module.regist…
Browse files Browse the repository at this point in the history
…er()` (#124)
  • Loading branch information
timfish committed Jul 4, 2024
1 parent 4e35d85 commit 381f48c
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 18 deletions.
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')

0 comments on commit 381f48c

Please sign in to comment.