Skip to content

Commit 381f48c

Browse files
authored
feat: Allow passing of include or exclude list via module.register() (#124)
1 parent 4e35d85 commit 381f48c

File tree

7 files changed

+136
-18
lines changed

7 files changed

+136
-18
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,39 @@ command-line option.
3838
--loader=import-in-the-middle/hook.mjs
3939
```
4040

41+
It's also possible to register the loader hook programmatically via the Node
42+
[`module.register()`](https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options)
43+
API. However, for this to be able to hook non-dynamic imports, it needs to be
44+
loaded before your app code is evaluated via the `--import` command-line option.
45+
46+
`my-loader.mjs`
47+
```js
48+
import * as module from 'module'
49+
50+
module.register('import-in-the-middle/hook.mjs', import.meta.url)
51+
```
52+
```shell
53+
node --import=./my-loader.mjs ./my-code.mjs
54+
```
55+
56+
When registering the loader hook programmatically, it's possible to pass a list
57+
of modules or file URLs to either exclude or specifically include which modules
58+
are intercepted. This is useful if a module is not compatible with the loader
59+
hook.
60+
```js
61+
import * as module from 'module'
62+
63+
// Exclude intercepting a specific module by name
64+
module.register('import-in-the-middle/hook.mjs', import.meta.url, {
65+
data: { exclude: ['package-i-want-to-exclude'] }
66+
})
67+
68+
// Only intercept a specific module by name
69+
module.register('import-in-the-middle/hook.mjs', import.meta.url, {
70+
data: { include: ['package-i-want-to-include'] }
71+
})
72+
```
73+
4174
## Limitations
4275
4376
* You cannot add new exports to a module. You can only modify existing ones.

hook.js

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,38 @@ function isBareSpecifier (specifier) {
116116
}
117117
}
118118

119+
function isBareSpecifierOrFileUrl (input) {
120+
// Relative and absolute paths
121+
if (
122+
input.startsWith('.') ||
123+
input.startsWith('/')) {
124+
return false
125+
}
126+
127+
try {
128+
// eslint-disable-next-line no-new
129+
const url = new URL(input)
130+
return url.protocol === 'file:'
131+
} catch (err) {
132+
// Anything that fails parsing is a bare specifier
133+
return true
134+
}
135+
}
136+
137+
function ensureArrayWithBareSpecifiersAndFileUrls (array, type) {
138+
if (!Array.isArray(array)) {
139+
return undefined
140+
}
141+
142+
const invalid = array.filter(s => !isBareSpecifierOrFileUrl(s))
143+
144+
if (invalid.length) {
145+
throw new Error(`'${type}' option only supports bare specifiers and file URLs. Invalid entries: ${inspect(invalid)}`)
146+
}
147+
148+
return array
149+
}
150+
119151
function emitWarning (err) {
120152
// Unfortunately, process.emitWarning does not output the full error
121153
// with error.cause like console.warn does so we need to inspect it when
@@ -217,6 +249,14 @@ function addIitm (url) {
217249
function createHook (meta) {
218250
let cachedResolve
219251
const iitmURL = new URL('lib/register.js', meta.url).toString()
252+
let includeModules, excludeModules
253+
254+
async function initialize (data) {
255+
if (data) {
256+
includeModules = ensureArrayWithBareSpecifiersAndFileUrls(data.include, 'include')
257+
excludeModules = ensureArrayWithBareSpecifiersAndFileUrls(data.exclude, 'exclude')
258+
}
259+
}
220260

221261
async function resolve (specifier, context, parentResolve) {
222262
cachedResolve = parentResolve
@@ -234,39 +274,52 @@ function createHook (meta) {
234274
if (isWin && parentURL.indexOf('file:node') === 0) {
235275
context.parentURL = ''
236276
}
237-
const url = await parentResolve(newSpecifier, context, parentResolve)
238-
if (parentURL === '' && !EXTENSION_RE.test(url.url)) {
239-
entrypoint = url.url
240-
return { url: url.url, format: 'commonjs' }
277+
const result = await parentResolve(newSpecifier, context, parentResolve)
278+
if (parentURL === '' && !EXTENSION_RE.test(result.url)) {
279+
entrypoint = result.url
280+
return { url: result.url, format: 'commonjs' }
281+
}
282+
283+
// For included/excluded modules, we check the specifier to match libraries
284+
// that are loaded with bare specifiers from node_modules.
285+
//
286+
// For non-bare specifier imports, we only support matching file URL strings
287+
// because using relative paths would be very error prone!
288+
if (includeModules && !includeModules.some(lib => lib === specifier || lib === result.url.url)) {
289+
return result
290+
}
291+
292+
if (excludeModules && excludeModules.some(lib => lib === specifier || lib === result.url.url)) {
293+
return result
241294
}
242295

243296
if (isIitm(parentURL, meta) || hasIitm(parentURL)) {
244-
return url
297+
return result
245298
}
246299

247300
// Node.js v21 renames importAssertions to importAttributes
248301
if (
249302
(context.importAssertions && context.importAssertions.type === 'json') ||
250303
(context.importAttributes && context.importAttributes.type === 'json')
251304
) {
252-
return url
305+
return result
253306
}
254307

255308
// If the file is referencing itself, we need to skip adding the iitm search params
256-
if (url.url === parentURL) {
309+
if (result.url === parentURL) {
257310
return {
258-
url: url.url,
311+
url: result.url,
259312
shortCircuit: true,
260-
format: url.format
313+
format: result.format
261314
}
262315
}
263316

264-
specifiers.set(url.url, specifier)
317+
specifiers.set(result.url, specifier)
265318

266319
return {
267-
url: addIitm(url.url),
320+
url: addIitm(result.url),
268321
shortCircuit: true,
269-
format: url.format
322+
format: result.format
270323
}
271324
}
272325

@@ -337,9 +390,10 @@ register(${JSON.stringify(realUrl)}, _, set, ${JSON.stringify(specifiers.get(rea
337390
}
338391

339392
if (NODE_MAJOR >= 17 || (NODE_MAJOR === 16 && NODE_MINOR >= 12)) {
340-
return { load, resolve }
393+
return { initialize, load, resolve }
341394
} else {
342395
return {
396+
initialize,
343397
load,
344398
resolve,
345399
getSource,

hook.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import { createHook } from './hook.js'
66

7-
const { load, resolve, getFormat, getSource } = createHook(import.meta)
7+
const { initialize, load, resolve, getFormat, getSource } = createHook(import.meta)
88

9-
export { load, resolve, getFormat, getSource }
9+
export { initialize, load, resolve, getFormat, getSource }

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "Intercept imports in Node.js",
55
"main": "index.js",
66
"scripts": {
7-
"test": "c8 --reporter lcov --check-coverage --lines 50 imhotap --files test/{hook,low-level,other,get-esm-exports}/*",
7+
"test": "c8 --reporter lcov --check-coverage --lines 50 imhotap --files test/{hook,low-level,other,get-esm-exports,register}/*",
88
"test:e2e": "node test/check-exports/test.mjs",
99
"test:ts": "c8 --reporter lcov imhotap --files test/typescript/*.test.mts",
1010
"coverage": "c8 --reporter html imhotap --files test/{hook,low-level,other,get-esm-exports}/* && echo '\nNow open coverage/index.html\n'",

test/generic-loader.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import path from 'path'
88

99
const filename = process.env.IITM_TEST_FILE
1010

11-
export const { load, resolve, getFormat, getSource } =
12-
filename.includes('disabled')
11+
export const { initialize, load, resolve, getFormat, getSource } =
12+
filename.includes('disabled') || filename.includes('register')
1313
? {}
1414
: (path.extname(filename).slice(-2) === 'ts' ? tsLoader : regularLoader)

test/register/v18.19-exclude.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { register } from 'module'
2+
import Hook from '../../index.js'
3+
import { strictEqual } from 'assert'
4+
5+
register('../../hook.mjs', import.meta.url, { data: { exclude: ['util'] } })
6+
7+
const hooked = []
8+
9+
Hook((exports, name) => {
10+
hooked.push(name)
11+
})
12+
13+
await import('openai')
14+
15+
strictEqual(hooked.includes('util'), false)

test/register/v18.19-include.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { register } from 'module'
2+
import Hook from '../../index.js'
3+
import { strictEqual } from 'assert'
4+
5+
register('../../hook.mjs', import.meta.url, { data: { include: ['openai'] } })
6+
7+
const hooked = []
8+
9+
Hook((exports, name) => {
10+
hooked.push(name)
11+
})
12+
13+
await import('openai')
14+
15+
strictEqual(hooked.length, 1)
16+
strictEqual(hooked[0], 'openai')

0 commit comments

Comments
 (0)