import-in-the-middle 3.0.2 → 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,12 @@
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
+
3
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)
4
11
 
5
12
 
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,43 +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
- // For the module.exports synthetic export (Node 23+), fall back to $default
269
- // when namespace['module.exports'] is not exposed by the native ESM namespace
270
- // (builtins don't expose it). This ensures the IITM hook proxy returns the
271
- // actual CJS value (e.g. EventEmitter) when an instrumentor reads
272
- // capturedExports['module.exports'], rather than undefined.
273
- const moduleExportsFallback = n === 'module.exports' ? ' ?? $default' : ''
274
-
275
- addSetter(n, `
276
- let ${variableName}
277
- __overridden[${objectKey}] = false
278
- let ${variableName}Defer = false
279
- try {
280
- ${variableName} = _[${objectKey}] = namespace[${objectKey}]${moduleExportsFallback}
281
- } catch (err) {
282
- if (!(err instanceof ReferenceError)) throw err
283
- ${variableName}Defer = true
284
- }
285
-
286
- if (${variableName}Defer || ${variableName} === undefined) {
287
- __pending.push(__makeUpdater(
288
- ${objectKey},
289
- () => namespace[${objectKey}]${moduleExportsFallback},
290
- (v) => { ${variableName} = _[${objectKey}] = v }
291
- ))
292
- }
293
- ${(n === 'module.exports' && (srcUrl.startsWith('node:') || builtinModules.includes(srcUrl))) ? '' : `export { ${variableName} as ${reExportedName} }`}
294
- set[${objectKey}] = (v) => {
295
- __overridden[${objectKey}] = true
296
- ${variableName} = v
297
- return true
298
- }
299
- get[${objectKey}] = () => ${variableName}
300
- `)
339
+ addSetter(n, buildSetter(n, srcUrl))
301
340
  }
302
341
  }
303
342
 
@@ -323,55 +362,51 @@ export function createHook (meta) {
323
362
  // patterns like `class App extends require('events') {}`.
324
363
  const cjsInIitmChain = new Set()
325
364
 
326
- async function initialize (data) {
327
- if (global.__import_in_the_middle_initialized__) {
328
- process.emitWarning("The 'import-in-the-middle' hook has already been initialized")
329
- }
330
-
331
- global.__import_in_the_middle_initialized__ = true
332
-
333
- if (data) {
334
- includeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.include, 'include')
335
- excludeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.exclude, 'exclude')
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
+ }
336
379
 
337
- if (data.addHookMessagePort) {
338
- data.addHookMessagePort.on('message', (modules) => {
339
- if (includeModules === undefined) {
340
- includeModules = []
380
+ for (const each of modules) {
381
+ if (!each.startsWith('node:') && builtinModules.includes(each)) {
382
+ includeModules.push(`node:${each}`)
341
383
  }
342
384
 
343
- for (const each of modules) {
344
- if (!each.startsWith('node:') && builtinModules.includes(each)) {
345
- includeModules.push(`node:${each}`)
346
- }
347
-
348
- includeModules.push(each)
349
- }
385
+ includeModules.push(each)
386
+ }
350
387
 
351
- data.addHookMessagePort.postMessage('ack')
352
- }).unref()
353
- }
388
+ data.addHookMessagePort.postMessage('ack')
389
+ }).unref()
354
390
  }
355
391
  }
356
392
 
357
- async function resolve (specifier, context, parentResolve) {
358
- cachedResolve = parentResolve
359
-
360
- // See https://github.com/nodejs/import-in-the-middle/pull/76.
361
- if (specifier === iitmURL) {
362
- return {
363
- url: specifier,
364
- shortCircuit: true
365
- }
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")
366
396
  }
367
397
 
368
- const { parentURL = '' } = context
369
- const newSpecifier = deleteIitm(specifier)
370
- if (isWin && parentURL.indexOf('file:node') === 0) {
371
- context.parentURL = ''
398
+ global.__import_in_the_middle_initialized__ = true
399
+
400
+ if (data) {
401
+ applyOptions(data)
372
402
  }
373
- const result = await parentResolve(newSpecifier, context)
403
+ }
374
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) {
375
410
  // Do not wrap the entrypoint module. Many CLIs check whether they are the
376
411
  // "main" module (e.g. require.main === module). Wrapping changes how they
377
412
  // are evaluated, and can make them exit without doing anything.
@@ -382,6 +417,19 @@ export function createHook (meta) {
382
417
  return result
383
418
  }
384
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
+
385
433
  // For included/excluded modules, we check the specifier to match libraries
386
434
  // that are loaded with bare specifiers from node_modules.
387
435
  //
@@ -456,31 +504,63 @@ export function createHook (meta) {
456
504
  return {
457
505
  url: addIitm(result.url),
458
506
  shortCircuit: true,
459
- 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)
460
511
  }
461
512
  }
462
513
 
463
- async function getSource (url, context, parentGetSource) {
464
- if (hasIitm(url)) {
465
- const realUrl = deleteIitm(url)
466
- const originalSpecifier = specifiers.get(realUrl)
514
+ async function resolve (specifier, context, parentResolve) {
515
+ cachedResolve = parentResolve
467
516
 
468
- try {
469
- const setters = await processModule({
470
- srcUrl: realUrl,
471
- context,
472
- parentGetSource,
473
- parentResolve: cachedResolve
474
- })
475
- // If the module loaded successfully, we can remove the specifier to reduce memory usage early.
476
- specifiers.delete(realUrl)
477
- // Track CJS modules so their transitive require() calls bypass IITM.
478
- // context.format is set to 'commonjs' by getCjsExports during processModule.
479
- if (context.format === 'commonjs') {
480
- cjsInIitmChain.add(realUrl)
481
- }
482
- return {
483
- 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 `
484
564
  import { register } from '${iitmURL}'
485
565
  import * as namespace from ${JSON.stringify(realUrl)}
486
566
 
@@ -549,26 +629,46 @@ if (__pending.length > 0) {
549
629
 
550
630
  register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpecifier)})
551
631
  `
552
- }
553
- } catch (cause) {
554
- // If the module failed loading, the specifier will not be used again, so
555
- // we can remove it to prevent a memory leak.
556
- specifiers.delete(realUrl)
557
- // If there are other ESM loader hooks registered as well as iitm,
558
- // depending on the order they are registered, source might not be
559
- // JavaScript.
560
- //
561
- // If we fail to parse a module for exports, we should fall back to the
562
- // parent loader. These modules will not be wrapped with proxies and
563
- // cannot be Hook'ed but at least this does not take down the entire app
564
- // and block iitm from being used.
565
- //
566
- // We log the error because there might be bugs in iitm and without this
567
- // it would be very tricky to debug
568
- const err = new Error(`'import-in-the-middle' failed to wrap '${realUrl}'`)
569
- err.cause = cause
570
- 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
+ }
571
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
+ }
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)
572
672
  // Revert back to the non-iitm URL
573
673
  url = realUrl
574
674
  }
@@ -577,6 +677,29 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
577
677
  return parentGetSource(url, context)
578
678
  }
579
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
+
580
703
  async function load (url, context, parentLoad) {
581
704
  if (hasIitm(url)) {
582
705
  const result = await getSource(url, context, parentLoad)
@@ -615,5 +738,39 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
615
738
  return parentLoad(url, context)
616
739
  }
617
740
 
618
- 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 }
619
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,16 +154,29 @@ 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
178
  // get all properties both enumerable and non-enumerable
154
- exports = new Set(addDefault(Object.getOwnPropertyNames(require(name))))
179
+ exports = new Set(addDefault(Object.getOwnPropertyNames(loadBuiltin(name))))
155
180
  // added in node 23 as alias for default in cjs modules
156
181
  if (hasModuleExportsCJSDefault) {
157
182
  exports.add('module.exports')
@@ -220,52 +245,54 @@ function resolvePackageImports (specifier, fromUrl) {
220
245
  return null
221
246
  }
222
247
 
223
- async function getCjsExports (url, context, parentLoad, source) {
248
+ function * getCjsExports (url, context, source) {
224
249
  if (urlsBeingProcessed.has(url)) {
225
250
  return new Set()
226
251
  }
227
252
  urlsBeingProcessed.add(url)
228
253
 
229
254
  try {
230
- if (!parserInitialized) {
231
- await parserInit()
232
- parserInitialized = true
233
- }
255
+ ensureParserInitialized()
234
256
  const result = parseCjs(source)
235
257
  const full = addDefault(result.exports)
236
258
 
237
- await Promise.all(result.reexports.map(async re => {
238
- if (re.startsWith('node:') || builtinModules.includes(re)) {
239
- 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)) {
240
262
  full.add(each)
241
263
  }
242
- } else {
243
- if (re === '.') {
244
- re = './'
245
- }
264
+ continue
265
+ }
246
266
 
247
- // Entries in the import field should always start with #
248
- if (re.startsWith('#')) {
249
- const resolved = resolvePackageImports(re, url)
250
- if (!resolved) return
251
- [url, re] = resolved
252
- }
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
+ }
253
280
 
254
- // Resolve the re-exported module relative to the current module.
255
- if (!require) {
256
- require = createRequire(import.meta.url)
257
- }
258
- 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
259
287
 
260
- if (newUrl.endsWith('.node') || newUrl.endsWith('.json')) {
261
- return
262
- }
288
+ if (newUrl.endsWith('.node') || newUrl.endsWith('.json')) {
289
+ continue
290
+ }
263
291
 
264
- for (const each of await getExports(newUrl, context, parentLoad)) {
265
- full.add(each)
266
- }
292
+ for (const each of yield * getExports(newUrl, context)) {
293
+ full.add(each)
267
294
  }
268
- }))
295
+ }
269
296
 
270
297
  // added in node 23 as alias for default in cjs modules
271
298
  if (full.has('default') && hasModuleExportsCJSDefault) {
@@ -281,26 +308,30 @@ async function getCjsExports (url, context, parentLoad, source) {
281
308
  }
282
309
 
283
310
  /**
284
- * Inspects a module for its type (commonjs or module), attempts to get the
285
- * source code for said module from the loader API, and parses the result
286
- * 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`.
287
319
  *
288
- * @param {string} url A file URL string pointing to the module that
289
- * we should get the exports of.
290
- * @param {object} context Context object as provided by the `load`
291
- * hook from the loaders API.
292
- * @param {Function} parentLoad Next hook function in the loaders API
293
- * 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.
294
324
  *
295
- * @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.
296
327
  * Please see {@link getEsmExports} for caveats on special identifiers that may
297
328
  * be included in the result set.
298
329
  */
299
- export async function getExports (url, context, parentLoad) {
300
- // `parentLoad` gives us the possibility of getting the source
301
- // from an upstream loader. This doesn't always work though,
302
- // so later on we fall back to reading it from disk.
303
- 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]
304
335
  let source = parentCtx.source
305
336
  const format = parentCtx.format
306
337
 
@@ -336,7 +367,7 @@ export async function getExports (url, context, parentLoad) {
336
367
  }
337
368
 
338
369
  if (format === 'commonjs') {
339
- return await getCjsExports(url, context, parentLoad, source)
370
+ return yield * getCjsExports(url, context, source)
340
371
  }
341
372
 
342
373
  // At this point our `format` is either undefined or not known by us. Fall
@@ -349,7 +380,7 @@ export async function getExports (url, context, parentLoad) {
349
380
  if (!hasEsmSyntax(source)) {
350
381
  // It might be possible to get here if the format
351
382
  // isn't set at first and yet we have an ESM module with no exports.
352
- return await getCjsExports(url, context, parentLoad, source)
383
+ return yield * getCjsExports(url, context, source)
353
384
  }
354
385
  }
355
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.2",
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
+ }