Skip to content

Commit 71c8d7b

Browse files
authored
feat: Optionally only wrap modules hooked in --import (#146)
1 parent 736a944 commit 71c8d7b

File tree

7 files changed

+227
-7
lines changed

7 files changed

+227
-7
lines changed

README.md

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ console.log(foo) // 1 more than whatever that module exported
3434
This requires the use of an ESM loader hook, which can be added with the following
3535
command-line option.
3636

37-
```
38-
--loader=import-in-the-middle/hook.mjs
37+
```shell
38+
node --loader=import-in-the-middle/hook.mjs my-app.mjs
3939
```
4040

41-
It's also possible to register the loader hook programmatically via the Node
41+
Since `--loader` has been deprecated you can also register the loader hook programmatically via the Node
4242
[`module.register()`](https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options)
4343
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.
44+
registered before your app code is evaluated via the `--import` command-line option.
4545

4646
`my-loader.mjs`
4747
```js
@@ -54,9 +54,12 @@ node --import=./my-loader.mjs ./my-code.mjs
5454
```
5555
5656
When registering the loader hook programmatically, it's possible to pass a list
57-
of modules, file URLs or regular expressions to either exclude or specifically
58-
include which modules are intercepted. This is useful if a module is not
57+
of modules, file URLs or regular expressions to either `exclude` or specifically
58+
`include` which modules are intercepted. This is useful if a module is not
5959
compatible with the loader hook.
60+
61+
> **Note:** This feature is incompatible with the `{internals: true}` Hook option
62+
6063
```js
6164
import * as module from 'module'
6265

@@ -71,6 +74,41 @@ module.register('import-in-the-middle/hook.mjs', import.meta.url, {
7174
})
7275
```
7376
77+
### Only Intercepting Hooked modules
78+
> **Note:** This feature is experimental and is incompatible with the `{internals: true}` Hook option
79+
80+
If you are `Hook`'ing all modules before they are imported, for example in a
81+
module loaded via the Node.js `--import` CLI argument, you can configure the
82+
loader to intercept only modules that were specifically hooked.
83+
84+
`instrument.mjs`
85+
```js
86+
import { register } from 'module'
87+
import { Hook, createAddHookMessageChannel } from 'import-in-the-middle'
88+
89+
const { registerOptions, waitForAllMessagesAcknowledged } = createAddHookMessageChannel()
90+
91+
register('import-in-the-middle/hook.mjs', import.meta.url, registerOptions)
92+
93+
Hook(['fs'], (exported, name, baseDir) => {
94+
// Instrument the fs module
95+
})
96+
97+
// Ensure that the loader has acknowledged all the modules
98+
// before we allow execution to continue
99+
await waitForAllMessagesAcknowledged()
100+
```
101+
`my-app.mjs`
102+
```js
103+
import * as fs from 'fs'
104+
// fs will be instrumented!
105+
fs.readFileSync('file.txt')
106+
```
107+
108+
```shell
109+
node --import=./instrument.mjs ./my-app.mjs
110+
```
111+
74112
## Limitations
75113
76114
* You cannot add new exports to a module. You can only modify existing ones.

hook.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,13 +281,31 @@ function createHook (meta) {
281281
if (data) {
282282
includeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.include, 'include')
283283
excludeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.exclude, 'exclude')
284+
285+
if (data.addHookMessagePort) {
286+
data.addHookMessagePort.on('message', (modules) => {
287+
if (includeModules === undefined) {
288+
includeModules = []
289+
}
290+
291+
for (const each of modules) {
292+
if (!each.startsWith('node:') && builtinModules.includes(each)) {
293+
includeModules.push(`node:${each}`)
294+
}
295+
296+
includeModules.push(each)
297+
}
298+
299+
data.addHookMessagePort.postMessage('ack')
300+
}).unref()
301+
}
284302
}
285303
}
286304

287305
async function resolve (specifier, context, parentResolve) {
288306
cachedResolve = parentResolve
289307

290-
// See github.com/nodejs/import-in-the-middle/pull/76.
308+
// See https://github.com/nodejs/import-in-the-middle/pull/76.
291309
if (specifier === iitmURL) {
292310
return {
293311
url: specifier,

index.d.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,39 @@ export declare function addHook(hookFn: HookFunction): void
8484
* @param {HookFunction} hookFn The function to be removed.
8585
*/
8686
export declare function removeHook(hookFn: HookFunction): void
87+
88+
type CreateAddHookMessageChannelReturn<Data> = {
89+
addHookMessagePort: MessagePort,
90+
waitForAllMessagesAcknowledged: Promise<void>
91+
registerOptions: { data?: Data; transferList?: any[]; }
92+
}
93+
94+
/**
95+
* EXPERIMENTAL
96+
* This feature is experimental and may change in minor versions.
97+
* **NOTE** This feature is incompatible with the {internals: true} Hook option.
98+
*
99+
* Creates a message channel with a port that can be used to add hooks to the
100+
* list of exclusively included modules.
101+
*
102+
* This can be used to only wrap modules that are Hook'ed, however modules need
103+
* to be hooked before they are imported.
104+
*
105+
* ```ts
106+
* import { register } from 'module'
107+
* import { Hook, createAddHookMessageChannel } from 'import-in-the-middle'
108+
*
109+
* const { registerOptions, waitForAllMessagesAcknowledged } = createAddHookMessageChannel()
110+
*
111+
* register('import-in-the-middle/hook.mjs', import.meta.url, registerOptions)
112+
*
113+
* Hook(['fs'], (exported, name, baseDir) => {
114+
* // Instrument the fs module
115+
* })
116+
*
117+
* // Ensure that the loader has acknowledged all the modules
118+
* // before we allow execution to continue
119+
* await waitForAllMessagesAcknowledged()
120+
* ```
121+
*/
122+
export declare function createAddHookMessageChannel<Data = any>(): CreateAddHookMessageChannelReturn<Data>;

index.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
const path = require('path')
66
const parse = require('module-details-from-path')
77
const { fileURLToPath } = require('url')
8+
const { MessageChannel } = require('worker_threads')
89

910
const {
1011
importHooks,
@@ -31,6 +32,75 @@ function callHookFn (hookFn, namespace, name, baseDir) {
3132
}
3233
}
3334

35+
let sendModulesToLoader
36+
37+
/**
38+
* EXPERIMENTAL
39+
* This feature is experimental and may change in minor versions.
40+
* **NOTE** This feature is incompatible with the {internals: true} Hook option.
41+
*
42+
* Creates a message channel with a port that can be used to add hooks to the
43+
* list of exclusively included modules.
44+
*
45+
* This can be used to only wrap modules that are Hook'ed, however modules need
46+
* to be hooked before they are imported.
47+
*
48+
* ```ts
49+
* import { register } from 'module'
50+
* import { Hook, createAddHookMessageChannel } from 'import-in-the-middle'
51+
*
52+
* const { registerOptions, waitForAllMessagesAcknowledged } = createAddHookMessageChannel()
53+
*
54+
* register('import-in-the-middle/hook.mjs', import.meta.url, registerOptions)
55+
*
56+
* Hook(['fs'], (exported, name, baseDir) => {
57+
* // Instrument the fs module
58+
* })
59+
*
60+
* // Ensure that the loader has acknowledged all the modules
61+
* // before we allow execution to continue
62+
* await waitForAllMessagesAcknowledged()
63+
* ```
64+
*/
65+
function createAddHookMessageChannel () {
66+
const { port1, port2 } = new MessageChannel()
67+
let pendingAckCount = 0
68+
let resolveFn
69+
70+
sendModulesToLoader = (modules) => {
71+
pendingAckCount++
72+
port1.postMessage(modules)
73+
}
74+
75+
port1.on('message', () => {
76+
pendingAckCount--
77+
78+
if (resolveFn && pendingAckCount <= 0) {
79+
resolveFn()
80+
}
81+
}).unref()
82+
83+
function waitForAllMessagesAcknowledged () {
84+
// This timer is to prevent the process from exiting with code 13:
85+
// 13: Unsettled Top-Level Await.
86+
const timer = setInterval(() => { }, 1000)
87+
const promise = new Promise((resolve) => {
88+
resolveFn = resolve
89+
}).then(() => { clearInterval(timer) })
90+
91+
if (pendingAckCount === 0) {
92+
resolveFn()
93+
}
94+
95+
return promise
96+
}
97+
98+
const addHookMessagePort = port2
99+
const registerOptions = { data: { addHookMessagePort, include: [] }, transferList: [addHookMessagePort] }
100+
101+
return { registerOptions, addHookMessagePort, waitForAllMessagesAcknowledged }
102+
}
103+
34104
function Hook (modules, options, hookFn) {
35105
if ((this instanceof Hook) === false) return new Hook(modules, options, hookFn)
36106
if (typeof modules === 'function') {
@@ -43,6 +113,10 @@ function Hook (modules, options, hookFn) {
43113
}
44114
const internals = options ? options.internals === true : false
45115

116+
if (sendModulesToLoader && Array.isArray(modules)) {
117+
sendModulesToLoader(modules)
118+
}
119+
46120
this._iitmHook = (name, namespace) => {
47121
const filename = name
48122
const isBuiltin = name.startsWith('node:')
@@ -92,3 +166,4 @@ module.exports = Hook
92166
module.exports.Hook = Hook
93167
module.exports.addHook = addHook
94168
module.exports.removeHook = removeHook
169+
module.exports.createAddHookMessageChannel = createAddHookMessageChannel

test/fixtures/import-after.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { strictEqual } from 'assert'
2+
import { sep } from 'path'
3+
import * as os from 'node:os'
4+
import { Hook } from '../../index.js'
5+
6+
const hooked = []
7+
8+
Hook((_, name) => {
9+
hooked.push(name)
10+
})
11+
12+
strictEqual(hooked.length, 2)
13+
strictEqual(hooked[0], 'path')
14+
strictEqual(hooked[1], 'os')
15+
strictEqual(sep, '@')
16+
strictEqual(os.arch(), 'new_crazy_arch')

test/fixtures/import.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { register } from 'module'
2+
import { Hook, createAddHookMessageChannel } from '../../index.js'
3+
// We've imported path here to ensure that the hook is still applied later even
4+
// if the library is used here.
5+
import * as path from 'path'
6+
7+
const { registerOptions, waitForAllMessagesAcknowledged } = createAddHookMessageChannel()
8+
9+
register('../../hook.mjs', import.meta.url, registerOptions)
10+
11+
Hook(['path'], (exports) => {
12+
exports.sep = '@'
13+
})
14+
15+
Hook(['os'], (exports) => {
16+
exports.arch = function () {
17+
return 'new_crazy_arch'
18+
}
19+
})
20+
21+
console.assert(path.sep !== '@')
22+
23+
await waitForAllMessagesAcknowledged()
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { spawnSync } from 'child_process'
2+
3+
const out = spawnSync(process.execPath,
4+
['--import', './test/fixtures/import.mjs', './test/fixtures/import-after.mjs'],
5+
{ stdio: 'inherit', env: {} }
6+
)
7+
8+
if (out.error) {
9+
console.error(out.error)
10+
}
11+
if (out.status !== 0) {
12+
console.error(`Expected exit code 0, got ${out.status}`)
13+
}
14+
process.exit(out.status)

0 commit comments

Comments
 (0)