import-in-the-middle 3.0.1 → 3.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.1.0](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v3.0.2...import-in-the-middle-v3.1.0) (2026-06-17)
4
+
5
+
6
+ ### Features
7
+
8
+ * add synchronous loader hooks via module.registerHooks ([#253](https://github.com/nodejs/import-in-the-middle/issues/253)) ([dd7e550](https://github.com/nodejs/import-in-the-middle/commit/dd7e5501f0cb901b8e4979f819e9deda6c84e2fc))
9
+
10
+ ## [3.0.2](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v3.0.1...import-in-the-middle-v3.0.2) (2026-06-08)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * Updated proxying logic for builtins that are used as CJS ([#250](https://github.com/nodejs/import-in-the-middle/issues/250)) ([f69eb8f](https://github.com/nodejs/import-in-the-middle/commit/f69eb8f13c91b7088003e7390b4f54c5076aa50b))
16
+
3
17
  ## [3.0.1](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v3.0.0...import-in-the-middle-v3.0.1) (2026-04-07)
4
18
 
5
19
 
package/README.md CHANGED
@@ -115,6 +115,36 @@ fs.readFileSync('file.txt')
115
115
  node --import=./instrument.mjs ./my-app.mjs
116
116
  ```
117
117
 
118
+ ## Synchronous loader hooks
119
+
120
+ On Node.js versions that provide
121
+ [`module.registerHooks()`](https://nodejs.org/api/module.html#moduleregisterhooksoptions)
122
+ (>= 22.15.0 / >= 24.0.0) the loader can run *synchronously*, on the application
123
+ thread, instead of on the separate thread that `module.register()` uses. Running
124
+ in-thread removes the message channel: `Hook()` registrations are visible to the
125
+ loader directly, so the `createAddHookMessageChannel` /
126
+ `waitForAllMessagesAcknowledged` step shown above is unnecessary.
127
+
128
+ `instrument.mjs`
129
+
130
+ ```js
131
+ import { register } from 'import-in-the-middle/register-hooks.mjs'
132
+ import { Hook } from 'import-in-the-middle'
133
+
134
+ register({ include: ['package-i-want-to-include'] })
135
+
136
+ Hook(['package-i-want-to-include'], (exported, name, baseDir) => {
137
+ // Instrument the module
138
+ })
139
+ ```
140
+
141
+ ```shell
142
+ node --import=./instrument.mjs ./my-app.mjs
143
+ ```
144
+
145
+ `register()` accepts the same `include` / `exclude` options as the asynchronous
146
+ loader and throws on a Node.js version without `module.registerHooks()`.
147
+
118
148
  ## Limitations
119
149
 
120
150
  * You cannot add new exports to a module. You can only modify existing ones.
package/create-hook.mjs CHANGED
@@ -6,9 +6,10 @@ import { URL, fileURLToPath } from 'url'
6
6
  import { inspect } from 'util'
7
7
  import { builtinModules } from 'module'
8
8
  import {
9
- getExports as getExportsImpl,
9
+ getExports,
10
10
  hasModuleExportsCJSDefault
11
11
  } from './lib/get-exports.mjs'
12
+ import { RESOLVE, driveSync, driveAsync } from './lib/io.mjs'
12
13
 
13
14
  const specifiers = new Map()
14
15
  const isWin = process.platform === 'win32'
@@ -16,17 +17,34 @@ const isWin = process.platform === 'win32'
16
17
  // FIXME: Typescript extensions are added temporarily until we find a better
17
18
  // way of supporting arbitrary extensions
18
19
  const EXTENSION_RE = /\.(js|mjs|cjs|ts|mts|cts)$/
19
- const NODE_VERSION = process.versions.node.split('.')
20
- const NODE_MAJOR = Number(NODE_VERSION[0])
21
- const NODE_MINOR = Number(NODE_VERSION[1])
22
20
  const HANDLED_FORMATS = new Set(['builtin', 'module', 'commonjs'])
23
21
  const TRACE_WARNINGS = process.execArgv.includes('--trace-warnings')
24
22
 
25
- let getExports
26
- if (NODE_MAJOR > 16 || (NODE_MAJOR === 16 && NODE_MINOR >= 16)) {
27
- getExports = getExportsImpl
28
- } else {
29
- getExports = (url) => import(url).then(Object.keys)
23
+ // process.versions.node is always "major.minor.patch" (nightlies add a suffix
24
+ // the regex ignores).
25
+ const [, NODE_MAJOR, NODE_MINOR, NODE_PATCH] =
26
+ process.versions.node.match(/^(\d+)\.(\d+)\.(\d+)/).map(Number)
27
+
28
+ /**
29
+ * Whether the running Node.js can correctly run the synchronous loader via
30
+ * `module.registerHooks`.
31
+ *
32
+ * `module.registerHooks` exists since v22.15, but its synchronous load hook
33
+ * rejected the nullish CommonJS `source` the loader returns for `require()`s
34
+ * pulled into the ESM graph (throwing `ERR_INVALID_RETURN_PROPERTY_VALUE`) until
35
+ * https://github.com/nodejs/node/pull/59929, released in 22.22.3, 24.11.1,
36
+ * 25.1.0 and 26.0.0. Earlier 24.x (<= 24.11.0) and 25.0.0 ship `registerHooks`
37
+ * but predate the fix, so the synchronous loader must fall back to the
38
+ * asynchronous one there.
39
+ *
40
+ * @returns {boolean}
41
+ */
42
+ export function supportsSyncHooks () {
43
+ if (NODE_MAJOR >= 26) return true
44
+ if (NODE_MAJOR === 25) return NODE_MINOR >= 1
45
+ if (NODE_MAJOR === 24) return NODE_MINOR > 11 || (NODE_MINOR === 11 && NODE_PATCH >= 1)
46
+ if (NODE_MAJOR === 22) return NODE_MINOR > 22 || (NODE_MINOR === 22 && NODE_PATCH >= 3)
47
+ return false
30
48
  }
31
49
 
32
50
  function hasIitm (url) {
@@ -181,21 +199,81 @@ function emitWarning (err) {
181
199
  process.emitWarning(warnMessage)
182
200
  }
183
201
 
202
+ /**
203
+ * Builds the setter/getter/re-export block injected into the wrapper module for
204
+ * a single named export. This is pure string generation, identical regardless
205
+ * of how the loader is driven, so both the synchronous and asynchronous paths
206
+ * share it.
207
+ *
208
+ * @param {string} n The exported name.
209
+ * @param {string} srcUrl The URL of the module the export belongs to.
210
+ * @returns {string}
211
+ */
212
+ function buildSetter (n, srcUrl) {
213
+ const variableName = `$${n.replace(/[^a-zA-Z0-9_$]/g, '_')}`
214
+ const objectKey = JSON.stringify(n)
215
+ const reExportedName = n === 'default' ? n : objectKey
216
+
217
+ // For the module.exports synthetic export (Node 23+), fall back to $default
218
+ // when namespace['module.exports'] is not exposed by the native ESM namespace
219
+ // (builtins don't expose it). This ensures the IITM hook proxy returns the
220
+ // actual CJS value (e.g. EventEmitter) when an instrumentor reads
221
+ // capturedExports['module.exports'], rather than undefined.
222
+ const moduleExportsFallback = n === 'module.exports' ? ' ?? $default' : ''
223
+
224
+ const reExportLine = (n === 'module.exports' && (srcUrl.startsWith('node:') || builtinModules.includes(srcUrl)))
225
+ ? ''
226
+ : `export { ${variableName} as ${reExportedName} }`
227
+
228
+ return `
229
+ let ${variableName}
230
+ __overridden[${objectKey}] = false
231
+ let ${variableName}Defer = false
232
+ try {
233
+ ${variableName} = _[${objectKey}] = namespace[${objectKey}]${moduleExportsFallback}
234
+ } catch (err) {
235
+ if (!(err instanceof ReferenceError)) throw err
236
+ ${variableName}Defer = true
237
+ }
238
+
239
+ if (${variableName}Defer || ${variableName} === undefined) {
240
+ __pending.push(__makeUpdater(
241
+ ${objectKey},
242
+ () => namespace[${objectKey}]${moduleExportsFallback},
243
+ (v) => { ${variableName} = _[${objectKey}] = v }
244
+ ))
245
+ }
246
+ ${reExportLine}
247
+ set[${objectKey}] = (v) => {
248
+ __overridden[${objectKey}] = true
249
+ ${variableName} = v
250
+ return true
251
+ }
252
+ get[${objectKey}] = () => ${variableName}
253
+ `
254
+ }
255
+
184
256
  /**
185
257
  * Processes a module's exports and builds a set of setter blocks.
186
258
  *
259
+ * Written as a "sans-io" generator (see `lib/io.mjs`): instead of calling the
260
+ * loader's resolve/load hooks directly it `yield`s `[RESOLVE, ...]` to resolve
261
+ * star re-exports and `[LOAD, ...]` (via {@link getExports}) to read source,
262
+ * and is driven by either {@link driveSync} (for
263
+ * `module.registerHooks`) or {@link driveAsync} (for `module.register`). The
264
+ * body is identical for both, so there is a single implementation to maintain.
265
+ *
187
266
  * @param {object} params
188
267
  * @param {string} params.srcUrl The full URL to the module to process.
189
268
  * @param {object} params.context Provided by the loaders API.
190
- * @param {Function} params.parentGetSource Provides the source code for the parent module.
191
- * @param {Function} params.parentResolve Provides the resolve function for the parent module.
192
269
  * @param {boolean} [params.excludeDefault = false] Exclude the default export.
193
270
  *
194
- * @returns {Promise<Map<string, string>>} The shimmed setters for all the exports
271
+ * @returns {Generator<Array, Map<string, string>>} A generator that yields I/O
272
+ * operations and ultimately returns the shimmed setters for all the exports
195
273
  * from the module and any transitive export all modules.
196
274
  */
197
- async function processModule ({ srcUrl, context, parentGetSource, parentResolve, excludeDefault = false }) {
198
- const exportNames = await getExports(srcUrl, context, parentGetSource)
275
+ function * processModule ({ srcUrl, context, excludeDefault = false }) {
276
+ const exportNames = yield * getExports(srcUrl, context)
199
277
  const starExports = new Set()
200
278
  const setters = new Map()
201
279
 
@@ -243,17 +321,14 @@ async function processModule ({ srcUrl, context, parentGetSource, parentResolve,
243
321
 
244
322
  // Relative paths need to be resolved relative to the parent module
245
323
  const newSpecifier = isBareSpecifier(modFile) ? modFile : new URL(modFile, srcUrl).href
246
- // We need to call `parentResolve` to resolve bare specifiers to a full
247
- // URL. We also need to call `parentResolve` for all sub-modules to get
248
- // the `format`. We can't rely on the parents `format` to know if this
249
- // sub-module is ESM or CJS!
250
- const result = await parentResolve(newSpecifier, { parentURL: srcUrl })
324
+ // We need to resolve bare specifiers to a full URL. We also need to
325
+ // resolve all sub-modules to get the `format`. We can't rely on the
326
+ // parent's `format` to know if this sub-module is ESM or CJS!
327
+ const result = yield [RESOLVE, newSpecifier, { parentURL: srcUrl }]
251
328
 
252
- const subSetters = await processModule({
329
+ const subSetters = yield * processModule({
253
330
  srcUrl: result.url,
254
331
  context: { ...context, format: result.format },
255
- parentGetSource,
256
- parentResolve,
257
332
  excludeDefault: true
258
333
  })
259
334
 
@@ -261,36 +336,7 @@ async function processModule ({ srcUrl, context, parentGetSource, parentResolve,
261
336
  addSetter(name, setter, true)
262
337
  }
263
338
  } else {
264
- const variableName = `$${n.replace(/[^a-zA-Z0-9_$]/g, '_')}`
265
- const objectKey = JSON.stringify(n)
266
- const reExportedName = n === 'default' ? n : objectKey
267
-
268
- addSetter(n, `
269
- let ${variableName}
270
- __overridden[${objectKey}] = false
271
- let ${variableName}Defer = false
272
- try {
273
- ${variableName} = _[${objectKey}] = namespace[${objectKey}]
274
- } catch (err) {
275
- if (!(err instanceof ReferenceError)) throw err
276
- ${variableName}Defer = true
277
- }
278
-
279
- if (${variableName}Defer || ${variableName} === undefined) {
280
- __pending.push(__makeUpdater(
281
- ${objectKey},
282
- () => namespace[${objectKey}],
283
- (v) => { ${variableName} = _[${objectKey}] = v }
284
- ))
285
- }
286
- export { ${variableName} as ${reExportedName} }
287
- set[${objectKey}] = (v) => {
288
- __overridden[${objectKey}] = true
289
- ${variableName} = v
290
- return true
291
- }
292
- get[${objectKey}] = () => ${variableName}
293
- `)
339
+ addSetter(n, buildSetter(n, srcUrl))
294
340
  }
295
341
  }
296
342
 
@@ -308,55 +354,59 @@ export function createHook (meta) {
308
354
  const iitmURL = new URL('lib/register.js', meta.url).toString()
309
355
  let includeModules, excludeModules
310
356
 
311
- async function initialize (data) {
312
- if (global.__import_in_the_middle_initialized__) {
313
- process.emitWarning("The 'import-in-the-middle' hook has already been initialized")
314
- }
315
-
316
- global.__import_in_the_middle_initialized__ = true
317
-
318
- if (data) {
319
- includeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.include, 'include')
320
- excludeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.exclude, 'exclude')
357
+ // Track CJS module URLs that IITM has wrapped. On Node 24+, CJS modules loaded
358
+ // via loadCJSModule (in an ESM import chain) have their require() calls for
359
+ // builtins routed through the ESM resolver. Without this guard, IITM would
360
+ // intercept those require() calls and return an ESM namespace object instead
361
+ // of the native CJS module value (e.g. EventEmitter constructor), breaking
362
+ // patterns like `class App extends require('events') {}`.
363
+ const cjsInIitmChain = new Set()
364
+
365
+ // Applies the include/exclude/message-port configuration. Shared by the
366
+ // asynchronous `initialize` (off-thread `module.register`, which receives
367
+ // `data` over the registration boundary) and by synchronous registration
368
+ // (`module.registerHooks`), which has no `initialize` step and passes the
369
+ // same options directly.
370
+ function applyOptions (data) {
371
+ includeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.include, 'include')
372
+ excludeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.exclude, 'exclude')
373
+
374
+ if (data.addHookMessagePort) {
375
+ data.addHookMessagePort.on('message', (modules) => {
376
+ if (includeModules === undefined) {
377
+ includeModules = []
378
+ }
321
379
 
322
- if (data.addHookMessagePort) {
323
- data.addHookMessagePort.on('message', (modules) => {
324
- if (includeModules === undefined) {
325
- includeModules = []
380
+ for (const each of modules) {
381
+ if (!each.startsWith('node:') && builtinModules.includes(each)) {
382
+ includeModules.push(`node:${each}`)
326
383
  }
327
384
 
328
- for (const each of modules) {
329
- if (!each.startsWith('node:') && builtinModules.includes(each)) {
330
- includeModules.push(`node:${each}`)
331
- }
332
-
333
- includeModules.push(each)
334
- }
385
+ includeModules.push(each)
386
+ }
335
387
 
336
- data.addHookMessagePort.postMessage('ack')
337
- }).unref()
338
- }
388
+ data.addHookMessagePort.postMessage('ack')
389
+ }).unref()
339
390
  }
340
391
  }
341
392
 
342
- async function resolve (specifier, context, parentResolve) {
343
- cachedResolve = parentResolve
344
-
345
- // See https://github.com/nodejs/import-in-the-middle/pull/76.
346
- if (specifier === iitmURL) {
347
- return {
348
- url: specifier,
349
- shortCircuit: true
350
- }
393
+ async function initialize (data) {
394
+ if (global.__import_in_the_middle_initialized__) {
395
+ process.emitWarning("The 'import-in-the-middle' hook has already been initialized")
351
396
  }
352
397
 
353
- const { parentURL = '' } = context
354
- const newSpecifier = deleteIitm(specifier)
355
- if (isWin && parentURL.indexOf('file:node') === 0) {
356
- context.parentURL = ''
398
+ global.__import_in_the_middle_initialized__ = true
399
+
400
+ if (data) {
401
+ applyOptions(data)
357
402
  }
358
- const result = await parentResolve(newSpecifier, context)
403
+ }
359
404
 
405
+ // Shared post-processing for the `resolve` hook: everything that happens
406
+ // once the parent loader has turned the specifier into a resolved URL. The
407
+ // only difference between the asynchronous and synchronous hooks is whether
408
+ // that resolution was awaited, so all the wrapping decisions live here.
409
+ function finishResolve (result, specifier, context, parentURL) {
360
410
  // Do not wrap the entrypoint module. Many CLIs check whether they are the
361
411
  // "main" module (e.g. require.main === module). Wrapping changes how they
362
412
  // are evaluated, and can make them exit without doing anything.
@@ -367,6 +417,19 @@ export function createHook (meta) {
367
417
  return result
368
418
  }
369
419
 
420
+ // The synchronous hooks (`module.registerHooks`) fire for `require()` as well
421
+ // as `import`, but iitm only owns the ESM graph: CommonJS modules are
422
+ // instrumented separately through require-in-the-middle, and `require()` must
423
+ // return the native, mutable module value (e.g. graceful-fs does
424
+ // `Object.defineProperty(require('fs'), ...)`, which throws on a frozen ESM
425
+ // namespace). Node reports the active module system in `context.conditions`
426
+ // ('require' vs 'import'), so leave any require() resolution untouched. The
427
+ // asynchronous hook never sees the 'require' condition, so this is a no-op
428
+ // there and only affects the synchronous path.
429
+ if (context.conditions?.includes('require')) {
430
+ return result
431
+ }
432
+
370
433
  // For included/excluded modules, we check the specifier to match libraries
371
434
  // that are loaded with bare specifiers from node_modules.
372
435
  //
@@ -405,6 +468,17 @@ export function createHook (meta) {
405
468
  return result
406
469
  }
407
470
 
471
+ // When a CJS module is loaded by an IITM shim, its require() calls for
472
+ // builtins may be routed through the ESM resolver on Node 24+. Skip IITM
473
+ // wrapping in that case so require() returns the native module value.
474
+ // We also propagate the membership to the resolved child so that its own
475
+ // transitive require() calls are likewise skipped (the entire synchronous
476
+ // CJS require chain must remain unwrapped to avoid ERR_VM_MODULE_LINK_FAILURE).
477
+ if (cjsInIitmChain.has(parentURL)) {
478
+ cjsInIitmChain.add(result.url)
479
+ return result
480
+ }
481
+
408
482
  // We don't want to attempt to wrap native modules
409
483
  if (result.url.endsWith('.node')) {
410
484
  return result
@@ -430,26 +504,63 @@ export function createHook (meta) {
430
504
  return {
431
505
  url: addIitm(result.url),
432
506
  shortCircuit: true,
433
- format: result.format
507
+ // Node's synchronous resolver drops `format: 'builtin'` for bare builtin
508
+ // specifiers (`require('crypto')` -> `node:crypto`), so restore it;
509
+ // otherwise the load hook reads `node:crypto` from disk and throws ENOENT.
510
+ format: result.format ?? (result.url.startsWith('node:') ? 'builtin' : undefined)
434
511
  }
435
512
  }
436
513
 
437
- async function getSource (url, context, parentGetSource) {
438
- if (hasIitm(url)) {
439
- const realUrl = deleteIitm(url)
440
- const originalSpecifier = specifiers.get(realUrl)
514
+ async function resolve (specifier, context, parentResolve) {
515
+ cachedResolve = parentResolve
441
516
 
442
- try {
443
- const setters = await processModule({
444
- srcUrl: realUrl,
445
- context,
446
- parentGetSource,
447
- parentResolve: cachedResolve
448
- })
449
- // If the module loaded successfully, we can remove the specifier to reduce memory usage early.
450
- specifiers.delete(realUrl)
451
- return {
452
- source: `
517
+ // See https://github.com/nodejs/import-in-the-middle/pull/76.
518
+ if (specifier === iitmURL) {
519
+ return {
520
+ url: specifier,
521
+ shortCircuit: true
522
+ }
523
+ }
524
+
525
+ const { parentURL = '' } = context
526
+ const newSpecifier = deleteIitm(specifier)
527
+ if (isWin && parentURL.indexOf('file:node') === 0) {
528
+ context.parentURL = ''
529
+ }
530
+ const result = await parentResolve(newSpecifier, context)
531
+
532
+ return finishResolve(result, specifier, context, parentURL)
533
+ }
534
+
535
+ // Synchronous counterpart to `resolve`, for `module.registerHooks`. The
536
+ // synchronous `nextResolve` returns its result directly. We stash it so the
537
+ // synchronous `load` hook can resolve star re-exports later, mirroring how
538
+ // `resolve` caches `parentResolve`.
539
+ function resolveSync (specifier, context, nextResolve) {
540
+ cachedResolve = nextResolve
541
+
542
+ if (specifier === iitmURL) {
543
+ return {
544
+ url: specifier,
545
+ shortCircuit: true
546
+ }
547
+ }
548
+
549
+ const { parentURL = '' } = context
550
+ const newSpecifier = deleteIitm(specifier)
551
+ if (isWin && parentURL.indexOf('file:node') === 0) {
552
+ context.parentURL = ''
553
+ }
554
+ const result = nextResolve(newSpecifier, context)
555
+
556
+ return finishResolve(result, specifier, context, parentURL)
557
+ }
558
+
559
+ // Builds the wrapper module source that re-exports the real module through
560
+ // iitm's proxy. Pure string generation shared by the asynchronous and
561
+ // synchronous `load` paths.
562
+ function buildWrapperSource (realUrl, setters, originalSpecifier) {
563
+ return `
453
564
  import { register } from '${iitmURL}'
454
565
  import * as namespace from ${JSON.stringify(realUrl)}
455
566
 
@@ -518,26 +629,46 @@ if (__pending.length > 0) {
518
629
 
519
630
  register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpecifier)})
520
631
  `
521
- }
522
- } catch (cause) {
523
- // If the module failed loading, the specifier will not be used again, so
524
- // we can remove it to prevent a memory leak.
525
- specifiers.delete(realUrl)
526
- // If there are other ESM loader hooks registered as well as iitm,
527
- // depending on the order they are registered, source might not be
528
- // JavaScript.
529
- //
530
- // If we fail to parse a module for exports, we should fall back to the
531
- // parent loader. These modules will not be wrapped with proxies and
532
- // cannot be Hook'ed but at least this does not take down the entire app
533
- // and block iitm from being used.
534
- //
535
- // We log the error because there might be bugs in iitm and without this
536
- // it would be very tricky to debug
537
- const err = new Error(`'import-in-the-middle' failed to wrap '${realUrl}'`)
538
- err.cause = cause
539
- emitWarning(err)
632
+ }
633
+
634
+ // Bookkeeping shared by the async and sync wrap paths once `processModule`
635
+ // succeeds: free the specifier entry early, and remember CJS modules so their
636
+ // transitive require() chain bypasses iitm (see `load`). Returns the wrapper
637
+ // module source.
638
+ function onWrapSuccess (realUrl, context, originalSpecifier, setters) {
639
+ specifiers.delete(realUrl)
640
+ // context.format is set to 'commonjs' by getCjsExports during processModule.
641
+ if (context.format === 'commonjs') {
642
+ cjsInIitmChain.add(realUrl)
643
+ }
644
+ return buildWrapperSource(realUrl, setters, originalSpecifier)
645
+ }
646
+
647
+ // Bookkeeping shared by the async and sync wrap paths when `processModule`
648
+ // throws. iitm falls back to the parent loader so the module loads unwrapped
649
+ // (it just can't be Hook'ed) rather than taking down the whole app. We free
650
+ // the specifier entry to avoid a leak, and log because a failure here is
651
+ // usually an iitm bug and would otherwise be very tricky to debug.
652
+ function onWrapFailure (realUrl, cause) {
653
+ specifiers.delete(realUrl)
654
+ const err = new Error(`'import-in-the-middle' failed to wrap '${realUrl}'`)
655
+ err.cause = cause
656
+ emitWarning(err)
657
+ }
540
658
 
659
+ async function getSource (url, context, parentGetSource) {
660
+ if (hasIitm(url)) {
661
+ const realUrl = deleteIitm(url)
662
+ const originalSpecifier = specifiers.get(realUrl)
663
+
664
+ try {
665
+ const setters = await driveAsync(
666
+ processModule({ srcUrl: realUrl, context }),
667
+ { resolve: cachedResolve, load: parentGetSource }
668
+ )
669
+ return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters) }
670
+ } catch (cause) {
671
+ onWrapFailure(realUrl, cause)
541
672
  // Revert back to the non-iitm URL
542
673
  url = realUrl
543
674
  }
@@ -546,6 +677,29 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
546
677
  return parentGetSource(url, context)
547
678
  }
548
679
 
680
+ // Synchronous counterpart to `getSource`, for `module.registerHooks`. Drives
681
+ // `processModule` straight through; all bookkeeping and source generation is
682
+ // shared with `getSource`.
683
+ function getSourceSync (url, context, nextLoad) {
684
+ if (hasIitm(url)) {
685
+ const realUrl = deleteIitm(url)
686
+ const originalSpecifier = specifiers.get(realUrl)
687
+
688
+ try {
689
+ const setters = driveSync(
690
+ processModule({ srcUrl: realUrl, context }),
691
+ { resolve: cachedResolve, load: nextLoad }
692
+ )
693
+ return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters) }
694
+ } catch (cause) {
695
+ onWrapFailure(realUrl, cause)
696
+ url = realUrl
697
+ }
698
+ }
699
+
700
+ return nextLoad(url, context)
701
+ }
702
+
549
703
  async function load (url, context, parentLoad) {
550
704
  if (hasIitm(url)) {
551
705
  const result = await getSource(url, context, parentLoad)
@@ -563,8 +717,60 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
563
717
  return parentLoad(deleteIitm(url), context)
564
718
  }
565
719
 
720
+ // On Node 22+, when a CJS module is loaded through the ESM translator and
721
+ // another loader hook provides its source (instead of leaving source null
722
+ // for Node to read natively), require() calls inside that CJS module for
723
+ // packages using the "module-sync" exports condition fail with
724
+ // ERR_VM_MODULE_LINK_FAILURE. Work around this Node bug by stripping
725
+ // hook-provided source for CJS modules in the synchronous require chain,
726
+ // forcing Node to use its native CJS loader which handles this correctly.
727
+ if (cjsInIitmChain.has(url)) {
728
+ const result = await parentLoad(url, context)
729
+ if (result.format === 'commonjs' && result.source != null) {
730
+ return {
731
+ format: result.format,
732
+ source: undefined
733
+ }
734
+ }
735
+ return result
736
+ }
737
+
566
738
  return parentLoad(url, context)
567
739
  }
568
740
 
569
- return { initialize, load, resolve }
741
+ // Synchronous counterpart to `load`, for `module.registerHooks`. Mirrors the
742
+ // async `load` exactly — wrapping via `getSourceSync` and applying the same
743
+ // CJS-in-iitm-chain source stripping — only without awaiting.
744
+ function loadSync (url, context, nextLoad) {
745
+ if (hasIitm(url)) {
746
+ const result = getSourceSync(url, context, nextLoad)
747
+ // If wrapping failed, `getSourceSync()` may have fallen back to `nextLoad`,
748
+ // which can legally return `source: null` (e.g. for non-JS formats).
749
+ if (result && typeof result === 'object' && result.source != null) {
750
+ return {
751
+ source: result.source,
752
+ shortCircuit: true,
753
+ format: 'module'
754
+ }
755
+ }
756
+
757
+ // Fall back to the parent loader with the original (non-iitm) URL.
758
+ return nextLoad(deleteIitm(url), context)
759
+ }
760
+
761
+ if (cjsInIitmChain.has(url)) {
762
+ const result = nextLoad(url, context)
763
+ if (result.format === 'commonjs' && result.source != null) {
764
+ return {
765
+ format: result.format,
766
+ source: undefined
767
+ }
768
+ }
769
+ return result
770
+ }
771
+
772
+ return nextLoad(url, context)
773
+ }
774
+
775
+ return { initialize, load, resolve, resolveSync, loadSync, applyOptions }
570
776
  }
@@ -1,17 +1,29 @@
1
1
  'use strict'
2
2
 
3
3
  import getEsmExports from './get-esm-exports.mjs'
4
- import { parse as parseCjs, init as parserInit } from 'cjs-module-lexer'
4
+ import { parse as parseCjs, initSync } from 'cjs-module-lexer'
5
5
  import { readFileSync, existsSync } from 'fs'
6
6
  import { builtinModules, createRequire } from 'module'
7
7
  import { fileURLToPath, pathToFileURL } from 'url'
8
8
  import { dirname, join } from 'path'
9
+ import { LOAD } from './io.mjs'
9
10
 
10
11
  const nodeMajor = Number(process.versions.node.split('.')[0])
11
12
  export const hasModuleExportsCJSDefault = nodeMajor >= 23
12
13
 
13
14
  let parserInitialized = false
14
15
 
16
+ // The CJS export scanner is backed by WebAssembly. `initSync` compiles it
17
+ // up front so the scanner can run inside synchronous loader hooks
18
+ // (`module.registerHooks`) as well as the off-thread loader; it is a one-time
19
+ // cost on the first CommonJS module either way.
20
+ function ensureParserInitialized () {
21
+ if (!parserInitialized) {
22
+ initSync()
23
+ parserInitialized = true
24
+ }
25
+ }
26
+
15
27
  function addDefault (arr) {
16
28
  return new Set(['default', ...arr])
17
29
  }
@@ -142,15 +154,33 @@ const BUILT_INS = new Map()
142
154
 
143
155
  let require
144
156
 
145
- function getExportsForNodeBuiltIn (name) {
146
- let exports = BUILT_INS.get(name)
147
-
157
+ // Returns a builtin's exports object. `process.getBuiltinModule` (Node >=
158
+ // 20.16 / >= 22.3) bypasses registered loader hooks; `require` does not. Under
159
+ // the in-thread `module.registerHooks` loader a plain `require(name)` here
160
+ // re-enters iitm's own hooks and resolves to the half-built wrapper instead of
161
+ // the native module. The off-thread `module.register` loader runs `require` on
162
+ // the loader thread where the hooks aren't installed, so the fallback stays
163
+ // correct on older Node that lacks getBuiltinModule.
164
+ function loadBuiltin (name) {
165
+ if (typeof process.getBuiltinModule === 'function') {
166
+ return process.getBuiltinModule(name)
167
+ }
148
168
  if (!require) {
149
169
  require = createRequire(import.meta.url)
150
170
  }
171
+ return require(name)
172
+ }
173
+
174
+ function getExportsForNodeBuiltIn (name) {
175
+ let exports = BUILT_INS.get(name)
151
176
 
152
177
  if (!exports) {
153
- exports = new Set(addDefault(Object.keys(require(name))))
178
+ // get all properties both enumerable and non-enumerable
179
+ exports = new Set(addDefault(Object.getOwnPropertyNames(loadBuiltin(name))))
180
+ // added in node 23 as alias for default in cjs modules
181
+ if (hasModuleExportsCJSDefault) {
182
+ exports.add('module.exports')
183
+ }
154
184
  BUILT_INS.set(name, exports)
155
185
  }
156
186
 
@@ -215,52 +245,54 @@ function resolvePackageImports (specifier, fromUrl) {
215
245
  return null
216
246
  }
217
247
 
218
- async function getCjsExports (url, context, parentLoad, source) {
248
+ function * getCjsExports (url, context, source) {
219
249
  if (urlsBeingProcessed.has(url)) {
220
250
  return new Set()
221
251
  }
222
252
  urlsBeingProcessed.add(url)
223
253
 
224
254
  try {
225
- if (!parserInitialized) {
226
- await parserInit()
227
- parserInitialized = true
228
- }
255
+ ensureParserInitialized()
229
256
  const result = parseCjs(source)
230
257
  const full = addDefault(result.exports)
231
258
 
232
- await Promise.all(result.reexports.map(async re => {
233
- if (re.startsWith('node:') || builtinModules.includes(re)) {
234
- for (const each of getExportsForNodeBuiltIn(re)) {
259
+ for (const reexport of result.reexports) {
260
+ if (reexport.startsWith('node:') || builtinModules.includes(reexport)) {
261
+ for (const each of getExportsForNodeBuiltIn(reexport)) {
235
262
  full.add(each)
236
263
  }
237
- } else {
238
- if (re === '.') {
239
- re = './'
240
- }
264
+ continue
265
+ }
241
266
 
242
- // Entries in the import field should always start with #
243
- if (re.startsWith('#')) {
244
- const resolved = resolvePackageImports(re, url)
245
- if (!resolved) return
246
- [url, re] = resolved
247
- }
267
+ // Resolve each re-export relative to the current module. Keep the
268
+ // resolution scoped to this iteration: a `#`-import rewrites both the
269
+ // base URL and the specifier, and that rewrite must not leak into the
270
+ // next re-export.
271
+ let reUrl = url
272
+ let reSpecifier = reexport === '.' ? './' : reexport
273
+
274
+ // Entries in the import field should always start with #
275
+ if (reSpecifier.startsWith('#')) {
276
+ const resolved = resolvePackageImports(reSpecifier, url)
277
+ if (!resolved) continue
278
+ ;[reUrl, reSpecifier] = resolved
279
+ }
248
280
 
249
- // Resolve the re-exported module relative to the current module.
250
- if (!require) {
251
- require = createRequire(import.meta.url)
252
- }
253
- const newUrl = pathToFileURL(require.resolve(re, { paths: [dirname(fileURLToPath(url))] })).href
281
+ if (!require) {
282
+ require = createRequire(import.meta.url)
283
+ }
284
+ const newUrl = pathToFileURL(
285
+ require.resolve(reSpecifier, { paths: [dirname(fileURLToPath(reUrl))] })
286
+ ).href
254
287
 
255
- if (newUrl.endsWith('.node') || newUrl.endsWith('.json')) {
256
- return
257
- }
288
+ if (newUrl.endsWith('.node') || newUrl.endsWith('.json')) {
289
+ continue
290
+ }
258
291
 
259
- for (const each of await getExports(newUrl, context, parentLoad)) {
260
- full.add(each)
261
- }
292
+ for (const each of yield * getExports(newUrl, context)) {
293
+ full.add(each)
262
294
  }
263
- }))
295
+ }
264
296
 
265
297
  // added in node 23 as alias for default in cjs modules
266
298
  if (full.has('default') && hasModuleExportsCJSDefault) {
@@ -276,26 +308,30 @@ async function getCjsExports (url, context, parentLoad, source) {
276
308
  }
277
309
 
278
310
  /**
279
- * Inspects a module for its type (commonjs or module), attempts to get the
280
- * source code for said module from the loader API, and parses the result
281
- * for the entities exported from that module.
311
+ * Inspects a module for its type (commonjs or module), obtains the source code
312
+ * for said module from the loader API, and parses the result for the entities
313
+ * exported from that module.
314
+ *
315
+ * This is a "sans-io" generator: instead of calling the loader's `load` hook
316
+ * directly, it `yield`s `[LOAD, url, context]` and is driven by either
317
+ * {@link driveSync} or {@link driveAsync} (see `lib/io.mjs`). The same body
318
+ * therefore serves both the off-thread loader and `module.registerHooks`.
282
319
  *
283
- * @param {string} url A file URL string pointing to the module that
284
- * we should get the exports of.
285
- * @param {object} context Context object as provided by the `load`
286
- * hook from the loaders API.
287
- * @param {Function} parentLoad Next hook function in the loaders API
288
- * hook chain.
320
+ * @param {string} url A file URL string pointing to the module that we should
321
+ * get the exports of.
322
+ * @param {object} context Context object as provided by the `load` hook from
323
+ * the loaders API.
289
324
  *
290
- * @returns {Promise<Set<string>>} An array of identifiers exported by the module.
325
+ * @returns {Generator<Array, Set<string>>} A generator that yields I/O
326
+ * operations and ultimately returns the identifiers exported by the module.
291
327
  * Please see {@link getEsmExports} for caveats on special identifiers that may
292
328
  * be included in the result set.
293
329
  */
294
- export async function getExports (url, context, parentLoad) {
295
- // `parentLoad` gives us the possibility of getting the source
296
- // from an upstream loader. This doesn't always work though,
297
- // so later on we fall back to reading it from disk.
298
- const parentCtx = await parentLoad(url, context)
330
+ export function * getExports (url, context) {
331
+ // `[LOAD, ...]` gives us the possibility of getting the source from an
332
+ // upstream loader. This doesn't always work though, so later on we fall back
333
+ // to reading it from disk.
334
+ const parentCtx = yield [LOAD, url, context]
299
335
  let source = parentCtx.source
300
336
  const format = parentCtx.format
301
337
 
@@ -331,7 +367,7 @@ export async function getExports (url, context, parentLoad) {
331
367
  }
332
368
 
333
369
  if (format === 'commonjs') {
334
- return await getCjsExports(url, context, parentLoad, source)
370
+ return yield * getCjsExports(url, context, source)
335
371
  }
336
372
 
337
373
  // At this point our `format` is either undefined or not known by us. Fall
@@ -344,7 +380,7 @@ export async function getExports (url, context, parentLoad) {
344
380
  if (!hasEsmSyntax(source)) {
345
381
  // It might be possible to get here if the format
346
382
  // isn't set at first and yet we have an ESM module with no exports.
347
- return await getCjsExports(url, context, parentLoad, source)
383
+ return yield * getCjsExports(url, context, source)
348
384
  }
349
385
  }
350
386
  return esmExports
package/lib/io.mjs ADDED
@@ -0,0 +1,78 @@
1
+ 'use strict'
2
+
3
+ // The export-collection logic (resolving star re-exports, reading source,
4
+ // parsing exports) is identical whether `import-in-the-middle` runs as an
5
+ // off-thread loader (`module.register`, asynchronous `nextResolve`/`nextLoad`)
6
+ // or as an in-thread synchronous loader (`module.registerHooks`). To keep a
7
+ // single implementation of that logic — instead of two copies that drift — it
8
+ // is written as "sans-io" generators that `yield` the I/O they need and let a
9
+ // driver fulfil it. The async driver awaits; the sync driver calls straight
10
+ // through. Everything between the yields is shared.
11
+
12
+ // Operation kinds a loader generator may yield. Each is `[KIND, ...args]`.
13
+ export const LOAD = 0 // [LOAD, url, context] -> resolves to { source, format }
14
+ export const RESOLVE = 1 // [RESOLVE, specifier, context] -> resolves to { url, format }
15
+
16
+ function runOp (op, io) {
17
+ if (op[0] === RESOLVE) {
18
+ return io.resolve(op[1], op[2])
19
+ }
20
+ return io.load(op[1], op[2])
21
+ }
22
+
23
+ /**
24
+ * Drives a loader generator to completion, fulfilling each yielded I/O
25
+ * operation synchronously. Used with `module.registerHooks`, whose
26
+ * `nextResolve`/`nextLoad` return their result directly.
27
+ *
28
+ * Errors from I/O are thrown back into the generator (via `gen.throw`) so its
29
+ * `try`/`finally` blocks run exactly as they would for an `await` rejection.
30
+ *
31
+ * @template T
32
+ * @param {Generator<Array, T>} gen
33
+ * @param {{ load: Function, resolve?: Function }} io
34
+ * @returns {T}
35
+ */
36
+ export function driveSync (gen, io) {
37
+ let next = gen.next()
38
+ while (next.done === false) {
39
+ let result
40
+ let error
41
+ let threw = false
42
+ try {
43
+ result = runOp(next.value, io)
44
+ } catch (err) {
45
+ threw = true
46
+ error = err
47
+ }
48
+ next = threw ? gen.throw(error) : gen.next(result)
49
+ }
50
+ return next.value
51
+ }
52
+
53
+ /**
54
+ * Drives a loader generator to completion, awaiting each yielded I/O
55
+ * operation. Used with the off-thread `module.register` loader, whose
56
+ * `nextResolve`/`nextLoad` are asynchronous.
57
+ *
58
+ * @template T
59
+ * @param {Generator<Array, T>} gen
60
+ * @param {{ load: Function, resolve?: Function }} io
61
+ * @returns {Promise<T>}
62
+ */
63
+ export async function driveAsync (gen, io) {
64
+ let next = gen.next()
65
+ while (next.done === false) {
66
+ let result
67
+ let error
68
+ let threw = false
69
+ try {
70
+ result = await runOp(next.value, io)
71
+ } catch (err) {
72
+ threw = true
73
+ error = err
74
+ }
75
+ next = threw ? gen.throw(error) : gen.next(result)
76
+ }
77
+ return next.value
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "import-in-the-middle",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Intercept imports in Node.js",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Options for {@link register}. `include`/`exclude` accept bare specifiers,
3
+ * `file:` URLs or regular expressions, matched against the module being
4
+ * resolved.
5
+ */
6
+ export type RegisterHooksOptions = {
7
+ include?: Array<string | RegExp>
8
+ exclude?: Array<string | RegExp>
9
+ }
10
+
11
+ /**
12
+ * Registers `import-in-the-middle` as a *synchronous*, in-thread loader hook via
13
+ * [`module.registerHooks()`](https://nodejs.org/api/module.html#moduleregisterhooksoptions).
14
+ *
15
+ * Unlike `module.register('import-in-the-middle/hook.mjs', ...)`, which runs the
16
+ * loader on a separate thread and pays an IPC round-trip per resolved module,
17
+ * synchronous hooks run on the application thread, so `Hook()` registrations are
18
+ * visible to the loader directly and no acknowledgement step is required.
19
+ *
20
+ * Requires a Node.js version with `module.registerHooks()` (>= 22.15.0 / >= 24).
21
+ *
22
+ * ```ts
23
+ * import { register } from 'import-in-the-middle/register-hooks.mjs'
24
+ * import { Hook } from 'import-in-the-middle'
25
+ *
26
+ * register({ include: ['package-i-want-to-include'] })
27
+ *
28
+ * Hook(['package-i-want-to-include'], (exported, name, baseDir) => {
29
+ * // Instrument the module
30
+ * })
31
+ * ```
32
+ *
33
+ * @throws If `module.registerHooks()` is unavailable in the running Node.js.
34
+ */
35
+ export declare function register(options?: RegisterHooksOptions): void
@@ -0,0 +1,63 @@
1
+ import * as module from 'module'
2
+ import { createHook, supportsSyncHooks } from './create-hook.mjs'
3
+
4
+ export { supportsSyncHooks }
5
+
6
+ const hook = createHook(import.meta)
7
+
8
+ let registered = false
9
+
10
+ /**
11
+ * Registers `import-in-the-middle` as a *synchronous*, in-thread loader hook via
12
+ * [`module.registerHooks()`](https://nodejs.org/api/module.html#moduleregisterhooksoptions).
13
+ *
14
+ * Unlike `module.register('import-in-the-middle/hook.mjs', ...)`, which runs the
15
+ * loader on a separate thread and pays an IPC round-trip per resolved module,
16
+ * synchronous hooks run in the application thread. There is no message channel
17
+ * to bridge, so `Hook()` registrations from the main `import-in-the-middle`
18
+ * entry point are visible to the loader directly and no acknowledgement step is
19
+ * required.
20
+ *
21
+ * Requires a Node.js version whose `module.registerHooks` accepts the nullish
22
+ * CommonJS source the loader relies on: >= 22.22.3, >= 24.11.1, >= 25.1.0, or
23
+ * >= 26.0.0 (see `supportsSyncHooks`). Use that predicate to fall back to the
24
+ * asynchronous `module.register` loader on unsupported versions.
25
+ *
26
+ * ```js
27
+ * import { register } from 'import-in-the-middle/register-hooks.mjs'
28
+ * import { Hook } from 'import-in-the-middle'
29
+ *
30
+ * register({ include: ['package-i-want-to-include'] })
31
+ *
32
+ * Hook(['package-i-want-to-include'], (exported, name, baseDir) => {
33
+ * // Instrument the module
34
+ * })
35
+ * ```
36
+ *
37
+ * @param {object} [options]
38
+ * @param {Array<string|RegExp>} [options.include] Only intercept these modules.
39
+ * @param {Array<string|RegExp>} [options.exclude] Never intercept these modules.
40
+ * @returns {void}
41
+ */
42
+ export function register (options) {
43
+ if (!supportsSyncHooks()) {
44
+ throw new Error(
45
+ "'import-in-the-middle' synchronous hooks require a Node.js version whose " +
46
+ 'module.registerHooks accepts nullish CommonJS source ' +
47
+ '(>= 22.22.3, >= 24.11.1, >= 25.1.0, or >= 26.0.0); ' +
48
+ 'see https://github.com/nodejs/node/pull/59929'
49
+ )
50
+ }
51
+
52
+ if (registered) {
53
+ process.emitWarning("'import-in-the-middle' synchronous hooks have already been registered")
54
+ return
55
+ }
56
+ registered = true
57
+
58
+ if (options) {
59
+ hook.applyOptions(options)
60
+ }
61
+
62
+ module.registerHooks({ resolve: hook.resolveSync, load: hook.loadSync })
63
+ }