import-in-the-middle 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.9.1"
2
+ ".": "1.11.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.0](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v1.10.0...import-in-the-middle-v1.11.0) (2024-07-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * Optionally only wrap modules hooked in `--import` ([#146](https://github.com/nodejs/import-in-the-middle/issues/146)) ([71c8d7b](https://github.com/nodejs/import-in-the-middle/commit/71c8d7bac512df94566d12c96fc2e438b4de2e2a))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * `node:` prefixed build-in modules with `include`/`exclude` ([#149](https://github.com/nodejs/import-in-the-middle/issues/149)) ([736a944](https://github.com/nodejs/import-in-the-middle/commit/736a9446e209bc8649801a27cb431df663551dc5))
14
+
15
+ ## [1.10.0](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v1.9.1...import-in-the-middle-v1.10.0) (2024-07-22)
16
+
17
+
18
+ ### Features
19
+
20
+ * Allow regex for `include` and `exclude` options ([#148](https://github.com/nodejs/import-in-the-middle/issues/148)) ([697b0d2](https://github.com/nodejs/import-in-the-middle/commit/697b0d239b9a738f4952bb0f77c521c4a398ac79))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * Use correct `format` when resolving exports from relative paths ([#145](https://github.com/nodejs/import-in-the-middle/issues/145)) ([632802f](https://github.com/nodejs/import-in-the-middle/commit/632802f4e7c797215b4e052ffdfa0fbda1780166))
26
+
3
27
  ## [1.9.1](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v1.9.0...import-in-the-middle-v1.9.1) (2024-07-15)
4
28
 
5
29
 
package/README.md CHANGED
@@ -34,14 +34,14 @@ console.log(foo) // 1 more than whatever that module exported
34
34
  This requires the use of an ESM loader hook, which can be added with the following
35
35
  command-line option.
36
36
 
37
- ```
38
- --loader=import-in-the-middle/hook.mjs
37
+ ```shell
38
+ node --loader=import-in-the-middle/hook.mjs my-app.mjs
39
39
  ```
40
40
 
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
42
42
  [`module.register()`](https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options)
43
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.
44
+ registered before your app code is evaluated via the `--import` command-line option.
45
45
 
46
46
  `my-loader.mjs`
47
47
  ```js
@@ -54,9 +54,12 @@ node --import=./my-loader.mjs ./my-code.mjs
54
54
  ```
55
55
 
56
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.
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
59
+ compatible with the loader hook.
60
+
61
+ > **Note:** This feature is incompatible with the `{internals: true}` Hook option
62
+
60
63
  ```js
61
64
  import * as module from 'module'
62
65
 
@@ -71,6 +74,41 @@ module.register('import-in-the-middle/hook.mjs', import.meta.url, {
71
74
  })
72
75
  ```
73
76
 
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
+
74
112
  ## Limitations
75
113
 
76
114
  * You cannot add new exports to a module. You can only modify existing ones.
package/hook.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  const { URL } = require('url')
6
6
  const { inspect } = require('util')
7
+ const { builtinModules } = require('module')
7
8
  const specifiers = new Map()
8
9
  const isWin = process.platform === 'win32'
9
10
 
@@ -116,7 +117,16 @@ function isBareSpecifier (specifier) {
116
117
  }
117
118
  }
118
119
 
119
- function isBareSpecifierOrFileUrl (input) {
120
+ /**
121
+ * Determines whether the input is a bare specifier, file URL or a regular expression.
122
+ *
123
+ * - node: prefixed URL strings are considered bare specifiers in this context.
124
+ */
125
+ function isBareSpecifierFileUrlOrRegex (input) {
126
+ if (input instanceof RegExp) {
127
+ return true
128
+ }
129
+
120
130
  // Relative and absolute paths
121
131
  if (
122
132
  input.startsWith('.') ||
@@ -127,22 +137,38 @@ function isBareSpecifierOrFileUrl (input) {
127
137
  try {
128
138
  // eslint-disable-next-line no-new
129
139
  const url = new URL(input)
130
- return url.protocol === 'file:'
140
+ // We consider node: URLs bare specifiers in this context
141
+ return url.protocol === 'file:' || url.protocol === 'node:'
131
142
  } catch (err) {
132
143
  // Anything that fails parsing is a bare specifier
133
144
  return true
134
145
  }
135
146
  }
136
147
 
137
- function ensureArrayWithBareSpecifiersAndFileUrls (array, type) {
148
+ /**
149
+ * Ensure an array only contains bare specifiers, file URLs or regular expressions.
150
+ *
151
+ * - We consider node: prefixed URL string as bare specifiers in this context.
152
+ * - For node built-in modules, we add additional node: prefixed modules to the
153
+ * output array.
154
+ */
155
+ function ensureArrayWithBareSpecifiersFileUrlsAndRegex (array, type) {
138
156
  if (!Array.isArray(array)) {
139
157
  return undefined
140
158
  }
141
159
 
142
- const invalid = array.filter(s => !isBareSpecifierOrFileUrl(s))
160
+ const invalid = array.filter(s => !isBareSpecifierFileUrlOrRegex(s))
143
161
 
144
162
  if (invalid.length) {
145
- throw new Error(`'${type}' option only supports bare specifiers and file URLs. Invalid entries: ${inspect(invalid)}`)
163
+ throw new Error(`'${type}' option only supports bare specifiers, file URLs or regular expressions. Invalid entries: ${inspect(invalid)}`)
164
+ }
165
+
166
+ // Rather than evaluate whether we have a node: scoped built-in-module for
167
+ // every call to resolve, we just add them to include/exclude now.
168
+ for (const each of array) {
169
+ if (typeof each === 'string' && !each.startsWith('node:') && builtinModules.includes(each)) {
170
+ array.push(`node:${each}`)
171
+ }
146
172
  }
147
173
 
148
174
  return array
@@ -206,19 +232,24 @@ async function processModule ({ srcUrl, context, parentGetSource, parentResolve,
206
232
  if (isStarExportLine(n) === true) {
207
233
  const [, modFile] = n.split('* from ')
208
234
 
209
- async function processSubModule (url, ctx) {
210
- const setters = await processModule({ srcUrl: url, context: ctx, parentGetSource, parentResolve, excludeDefault: true })
211
- for (const [name, setter] of setters.entries()) {
212
- addSetter(name, setter, true)
213
- }
214
- }
215
-
216
- if (isBareSpecifier(modFile)) {
217
- // Bare specifiers need to be resolved relative to the parent module.
218
- const result = await parentResolve(modFile, { parentURL: srcUrl })
219
- await processSubModule(result.url, { ...context, format: result.format })
220
- } else {
221
- await processSubModule(new URL(modFile, srcUrl).href, context)
235
+ // Relative paths need to be resolved relative to the parent module
236
+ const newSpecifier = isBareSpecifier(modFile) ? modFile : new URL(modFile, srcUrl).href
237
+ // We need to call `parentResolve` to resolve bare specifiers to a full
238
+ // URL. We also need to call `parentResolve` for all sub-modules to get
239
+ // the `format`. We can't rely on the parents `format` to know if this
240
+ // sub-module is ESM or CJS!
241
+ const result = await parentResolve(newSpecifier, { parentURL: srcUrl })
242
+
243
+ const subSetters = await processModule({
244
+ srcUrl: result.url,
245
+ context: { ...context, format: result.format },
246
+ parentGetSource,
247
+ parentResolve,
248
+ excludeDefault: true
249
+ })
250
+
251
+ for (const [name, setter] of subSetters.entries()) {
252
+ addSetter(name, setter, true)
222
253
  }
223
254
  } else {
224
255
  addSetter(n, `
@@ -248,15 +279,33 @@ function createHook (meta) {
248
279
 
249
280
  async function initialize (data) {
250
281
  if (data) {
251
- includeModules = ensureArrayWithBareSpecifiersAndFileUrls(data.include, 'include')
252
- excludeModules = ensureArrayWithBareSpecifiersAndFileUrls(data.exclude, 'exclude')
282
+ includeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.include, 'include')
283
+ 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
+ }
253
302
  }
254
303
  }
255
304
 
256
305
  async function resolve (specifier, context, parentResolve) {
257
306
  cachedResolve = parentResolve
258
307
 
259
- // See github.com/nodejs/import-in-the-middle/pull/76.
308
+ // See https://github.com/nodejs/import-in-the-middle/pull/76.
260
309
  if (specifier === iitmURL) {
261
310
  return {
262
311
  url: specifier,
@@ -278,13 +327,21 @@ function createHook (meta) {
278
327
  // For included/excluded modules, we check the specifier to match libraries
279
328
  // that are loaded with bare specifiers from node_modules.
280
329
  //
281
- // For non-bare specifier imports, we only support matching file URL strings
282
- // because using relative paths would be very error prone!
283
- if (includeModules && !includeModules.some(lib => lib === specifier || lib === result.url.url)) {
330
+ // For non-bare specifier imports, we match to the full file URL because
331
+ // using relative paths would be very error prone!
332
+ function match (each) {
333
+ if (each instanceof RegExp) {
334
+ return each.test(result.url)
335
+ }
336
+
337
+ return each === specifier || each === result.url
338
+ }
339
+
340
+ if (includeModules && !includeModules.some(match)) {
284
341
  return result
285
342
  }
286
343
 
287
- if (excludeModules && excludeModules.some(lib => lib === specifier || lib === result.url.url)) {
344
+ if (excludeModules && excludeModules.some(match)) {
288
345
  return result
289
346
  }
290
347
 
package/index.d.ts CHANGED
@@ -84,3 +84,39 @@ export declare function addHook(hookFn: HookFunction): void
84
84
  * @param {HookFunction} hookFn The function to be removed.
85
85
  */
86
86
  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>;
package/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  const path = require('path')
6
6
  const parse = require('module-details-from-path')
7
7
  const { fileURLToPath } = require('url')
8
+ const { MessageChannel } = require('worker_threads')
8
9
 
9
10
  const {
10
11
  importHooks,
@@ -31,6 +32,75 @@ function callHookFn (hookFn, namespace, name, baseDir) {
31
32
  }
32
33
  }
33
34
 
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
+
34
104
  function Hook (modules, options, hookFn) {
35
105
  if ((this instanceof Hook) === false) return new Hook(modules, options, hookFn)
36
106
  if (typeof modules === 'function') {
@@ -43,6 +113,10 @@ function Hook (modules, options, hookFn) {
43
113
  }
44
114
  const internals = options ? options.internals === true : false
45
115
 
116
+ if (sendModulesToLoader && Array.isArray(modules)) {
117
+ sendModulesToLoader(modules)
118
+ }
119
+
46
120
  this._iitmHook = (name, namespace) => {
47
121
  const filename = name
48
122
  const isBuiltin = name.startsWith('node:')
@@ -92,3 +166,4 @@ module.exports = Hook
92
166
  module.exports.Hook = Hook
93
167
  module.exports.addHook = addHook
94
168
  module.exports.removeHook = removeHook
169
+ module.exports.createAddHookMessageChannel = createAddHookMessageChannel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "import-in-the-middle",
3
- "version": "1.9.1",
3
+ "version": "1.11.0",
4
4
  "description": "Intercept imports in Node.js",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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')
@@ -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()
@@ -1,5 +1,8 @@
1
- // https://github.com/nodejs/import-in-the-middle/issues/139
2
1
  import { strictEqual } from 'assert'
3
- import * as lib from 'vue/server-renderer'
2
+ // https://github.com/nodejs/import-in-the-middle/issues/139
3
+ import * as libServer from 'vue/server-renderer'
4
+ // https://github.com/nodejs/import-in-the-middle/issues/144
5
+ import * as lib from 'vue'
4
6
 
5
- strictEqual(typeof lib.renderToString, 'function')
7
+ strictEqual(typeof libServer.renderToString, 'function')
8
+ strictEqual(typeof lib.ref, 'function')
@@ -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: { exclude: [/openai/] } })
6
+
7
+ const hooked = new Set()
8
+
9
+ Hook((_, name) => {
10
+ hooked.add(name)
11
+ })
12
+
13
+ await import('openai')
14
+
15
+ strictEqual(hooked.has('openai'), false)
16
+ strictEqual(hooked.has('fs'), true)
@@ -0,0 +1,20 @@
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: ['node:util', 'os'] } })
6
+
7
+ const hooked = []
8
+
9
+ Hook((exports, name) => {
10
+ hooked.push(name)
11
+ })
12
+
13
+ await import('util')
14
+ await import('node:os')
15
+ await import('fs')
16
+ await import('path')
17
+
18
+ strictEqual(hooked.length, 2)
19
+ strictEqual(hooked[0], 'util')
20
+ strictEqual(hooked[1], 'os')
@@ -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)