import-in-the-middle 3.3.0 → 3.3.2

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.3.2](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v3.3.1...import-in-the-middle-v3.3.2) (2026-07-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * delegate tagged modules to their owning loader ([#276](https://github.com/nodejs/import-in-the-middle/issues/276)) ([e0c57bd](https://github.com/nodejs/import-in-the-middle/commit/e0c57bd810d89d74e6a22a14c243d650cbda8a3c))
9
+ * escape loader URL in generated wrappers ([#275](https://github.com/nodejs/import-in-the-middle/issues/275)) ([86ca0e5](https://github.com/nodejs/import-in-the-middle/commit/86ca0e579cb8b592b0ae49cef9bc834b3971592d))
10
+ * skip code generation on broken maglev ([#271](https://github.com/nodejs/import-in-the-middle/issues/271)) ([ae914ac](https://github.com/nodejs/import-in-the-middle/commit/ae914ac41a4cc9215cbaf4fa5ec48fbe3fa8e7b3))
11
+
12
+ ## [3.3.1](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v3.3.0...import-in-the-middle-v3.3.1) (2026-07-07)
13
+
14
+
15
+ ### Performance Improvements
16
+
17
+ * hoist generated wrapper binding runtime ([#267](https://github.com/nodejs/import-in-the-middle/issues/267)) ([4f41140](https://github.com/nodejs/import-in-the-middle/commit/4f41140d44f91448899580cbb97f558539005657))
18
+ * read each wrapped module from disk once ([#265](https://github.com/nodejs/import-in-the-middle/issues/265)) ([22b40aa](https://github.com/nodejs/import-in-the-middle/commit/22b40aa86003934c944b0f586be3889e8c230616))
19
+
3
20
  ## [3.3.0](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v3.2.0...import-in-the-middle-v3.3.0) (2026-07-02)
4
21
 
5
22
 
package/README.md CHANGED
@@ -228,3 +228,12 @@ On Node.js versions where type stripping is not enabled by default, run with
228
228
  * While bindings to module exports end up being "re-bound" when modified in a
229
229
  hook, dynamically imported modules cannot be altered after they're loaded.
230
230
  * Modules loaded via `require` are not affected at all.
231
+ * A module's set of export *names* is assumed to be stable for the lifetime of
232
+ the process. `import-in-the-middle` reads a module's source once to lex its
233
+ exports and reuses that export set on later loads of the same URL. An upstream
234
+ loader that returns a *different set of exports* for the same URL across calls
235
+ — a stateful codegen loader, or one that varies its output by `context` — is
236
+ not supported and will be instrumented with the export set from its first
237
+ load. Idempotent transforms (type stripping, AST instrumentation, minifiers)
238
+ are unaffected, and the actual module still executes the source its real load
239
+ returns; only the interceptable export *names* are memoized.
package/create-hook.mjs CHANGED
@@ -17,7 +17,6 @@ import { supportsSyncHooks } from './supports-sync-hooks.mjs'
17
17
  // this file's acorn / cjs-module-lexer dependency graph.
18
18
  export { supportsSyncHooks }
19
19
 
20
- const specifiers = new Map()
21
20
  const isWin = process.platform === 'win32'
22
21
 
23
22
  // Depth at which `processModule` starts tracking visited URLs to break an
@@ -37,6 +36,9 @@ const HANDLED_FORMATS = new Set([
37
36
  ])
38
37
  const TRACE_WARNINGS = process.execArgv.includes('--trace-warnings')
39
38
 
39
+ /** @typedef {import('node:module').LoadHookContext} LoadContext */
40
+ /** @typedef {import('node:module').LoadFnOutput} LoadResult */
41
+
40
42
  function hasIitm (url) {
41
43
  // Fast path: avoid URL parsing on the hot path when there's clearly no iitm.
42
44
  if (typeof url !== 'string' || url.indexOf('iitm') === -1) {
@@ -214,9 +216,9 @@ function buildSetter (n, srcUrl, namespaceVar) {
214
216
  const objectKey = JSON.stringify(n)
215
217
  const reExportedName = n === 'default' ? n : objectKey
216
218
 
217
- // `1` makes __bind fall back to namespace['default'] for the module.exports
218
- // synthetic export, which builtins don't expose on the native ESM namespace.
219
- const fallback = n === 'module.exports' ? ', 1' : ''
219
+ // Fall back to namespace['default'] for the module.exports synthetic export,
220
+ // which builtins don't expose on the native ESM namespace.
221
+ const useFallback = n === 'module.exports'
220
222
 
221
223
  // Builtins don't expose the module.exports synthetic name, so skip its re-export.
222
224
  const reExportLine = (n === 'module.exports' && (srcUrl.startsWith('node:') || builtinModules.includes(srcUrl)))
@@ -224,7 +226,7 @@ function buildSetter (n, srcUrl, namespaceVar) {
224
226
  : `export { ${variableName} as ${reExportedName} }`
225
227
 
226
228
  return `let ${variableName}
227
- __bind(${objectKey}, ${namespaceVar}, v => { ${variableName} = v }, () => ${variableName}${fallback})
229
+ __binder.bind(${objectKey}, ${namespaceVar}, v => { ${variableName} = v }, () => ${variableName}, ${useFallback})
228
230
  ${reExportLine}`
229
231
  }
230
232
 
@@ -404,7 +406,11 @@ function addIitm (url) {
404
406
  return urlObj.href
405
407
  }
406
408
 
409
+ /**
410
+ * @param {{ url: string }} meta
411
+ */
407
412
  export function createHook (meta) {
413
+ const specifiers = new Map()
408
414
  let cachedResolve
409
415
  const iitmURL = new URL('lib/register.js', meta.url).toString()
410
416
  let includeModules, excludeModules
@@ -651,98 +657,16 @@ export function createHook (meta) {
651
657
  }
652
658
 
653
659
  return `
654
- import { register } from '${iitmURL}'
660
+ import { register, ModuleBinder } from ${JSON.stringify(iitmURL)}
655
661
  import * as namespace from ${JSON.stringify(realUrl)}
656
662
  ${originImports}
657
- // Mimic a Module object (https://tc39.es/ecma262/#sec-module-namespace-objects).
658
- const _ = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } })
659
- const set = {}
660
- const get = {}
661
- const __overridden = Object.create(null)
662
- let __pending = []
663
-
664
- function __makeUpdater (key, read, assign) {
665
- return () => {
666
- if (__overridden[key] === true) return true
667
- try {
668
- const v = read()
669
- if (v !== undefined) {
670
- assign(v)
671
- return true
672
- }
673
- return false
674
- } catch (err) {
675
- if (err instanceof ReferenceError) return false
676
- throw err
677
- }
678
- }
679
- }
680
-
681
- function __flushPendingOnce () {
682
- if (__pending.length === 0) return
683
- const next = []
684
- for (const fn of __pending) {
685
- // If it still throws ReferenceError, keep it for the (single) next attempt.
686
- if (fn() !== true) next.push(fn)
687
- }
688
- __pending = next
689
- }
690
-
691
- function __bind (key, source, write, read, useFallback) {
692
- const readSource = useFallback
693
- ? () => source[key] ?? source['default']
694
- : () => source[key]
695
- __overridden[key] = false
696
- let deferred = false
697
- try {
698
- const v = readSource()
699
- write(v)
700
- _[key] = v
701
- } catch (err) {
702
- if (!(err instanceof ReferenceError)) throw err
703
- deferred = true
704
- }
705
- if (deferred || read() === undefined) {
706
- __pending.push(__makeUpdater(key, readSource, (v) => { write(v); _[key] = v }))
707
- }
708
- set[key] = (v) => {
709
- __overridden[key] = true
710
- write(v)
711
- return true
712
- }
713
- get[key] = read
714
- }
663
+ const __binder = new ModuleBinder()
715
664
 
716
665
  ${Array.from(setters.values()).join('\n')}
717
666
 
718
- if (__pending.length > 0) {
719
- queueMicrotask(() => {
720
- __flushPendingOnce()
721
-
722
- if (__pending.length > 0) {
723
- const __retryDelays = [0, 10, 50]
724
- const __schedulePending = (i) => {
725
- if (__pending.length === 0) return
726
- if (i >= __retryDelays.length) {
727
- // Give up: leave exports as-is to avoid unbounded retries.
728
- __pending = []
729
- return
730
- }
667
+ __binder.flush()
731
668
 
732
- const t = setTimeout(() => {
733
- __flushPendingOnce()
734
- __schedulePending(i + 1)
735
- }, __retryDelays[i])
736
- // Don't keep the process alive just for best-effort retries.
737
- if (t && typeof t.unref === 'function') t.unref()
738
- }
739
-
740
- __schedulePending(0)
741
- }
742
- })
743
- }
744
-
745
- register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpecifier)})
669
+ register(${JSON.stringify(realUrl)}, __binder.namespace, __binder.set, __binder.get, ${JSON.stringify(originalSpecifier)})
746
670
  `
747
671
  }
748
672
 
@@ -771,10 +695,19 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
771
695
  emitWarning(err)
772
696
  }
773
697
 
698
+ /**
699
+ * @param {string} url
700
+ * @param {LoadContext} context
701
+ * @param {(url: string, context?: Partial<LoadContext>) => LoadResult | Promise<LoadResult>} parentGetSource
702
+ */
774
703
  async function getSource (url, context, parentGetSource) {
775
704
  if (hasIitm(url)) {
776
705
  const realUrl = deleteIitm(url)
777
706
  const originalSpecifier = specifiers.get(realUrl)
707
+ if (originalSpecifier === undefined) {
708
+ specifiers.delete(url)
709
+ return parentGetSource(url, context)
710
+ }
778
711
 
779
712
  try {
780
713
  const { setters, originNamespaces } = await driveAsync(
@@ -795,10 +728,19 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
795
728
  // Synchronous counterpart to `getSource`, for `module.registerHooks`. Drives
796
729
  // `processModule` straight through; all bookkeeping and source generation is
797
730
  // shared with `getSource`.
731
+ /**
732
+ * @param {string} url
733
+ * @param {LoadContext} context
734
+ * @param {(url: string, context?: Partial<LoadContext>) => LoadResult} nextLoad
735
+ */
798
736
  function getSourceSync (url, context, nextLoad) {
799
737
  if (hasIitm(url)) {
800
738
  const realUrl = deleteIitm(url)
801
739
  const originalSpecifier = specifiers.get(realUrl)
740
+ if (originalSpecifier === undefined) {
741
+ specifiers.delete(url)
742
+ return nextLoad(url, context)
743
+ }
802
744
 
803
745
  try {
804
746
  const { setters, originNamespaces } = driveSync(
@@ -15,8 +15,13 @@ const require = createRequire(import.meta.url)
15
15
  // generation from strings is disallowed. Resolve the parser once at load time
16
16
  // so the per-module path stays a bare `parse(source)` call.
17
17
  const flag = '--disallow-code-generation-from-strings'
18
+ // Node.js versions with broken maglev compiler
19
+ const hasBrokenMaglev = process.version.startsWith('v20.') &&
20
+ process.version[5] === '.' &&
21
+ Number(process.version[4]) < 9
18
22
  const disallowCodegen = process.execArgv.includes(flag) ||
19
- (process.env.NODE_OPTIONS?.includes(flag) ?? false)
23
+ (process.env.NODE_OPTIONS?.includes(flag) ?? false) ||
24
+ hasBrokenMaglev
20
25
 
21
26
  // initSync compiles the Wasm module up front so `parse` can run inside
22
27
  // synchronous loader hooks (`module.registerHooks`) as well as the off-thread
@@ -73,6 +73,24 @@ function getExportsForNodeBuiltIn (name) {
73
73
 
74
74
  const urlsBeingProcessed = new Set() // Guard against circular imports.
75
75
 
76
+ // Memoizes an ESM module's export names by URL. A leaf reached from several
77
+ // barrels (`export *`) would otherwise be read and lexed once per barrel, and
78
+ // the same URL reached from separate top-level wraps would be lexed once per
79
+ // wrap; the first lex serves every later one.
80
+ //
81
+ // This assumes a module's export names are stable for the process: the memo
82
+ // serves a later, independent scan without re-running the loader chain's
83
+ // `load()`, so a loader that returns a *different export set* for the same URL
84
+ // across calls (a stateful codegen loader, or one keyed off `context`) is a
85
+ // deliberately unsupported case — it would be served the first scan's names.
86
+ // Idempotent transforms (type stripping, AST instrumentation such as
87
+ // OrchestrionJS, minifiers) are stable and unaffected; the wrapper's real
88
+ // namespace `load()` still runs every time, so per-load source differences in
89
+ // the *executed* module are preserved (see the wrapper's `import * as
90
+ // namespace`). Only the pure ESM result is cached: the CommonJS path mutates
91
+ // `context.format` and resolves re-exports, and builtins memoize via BUILT_INS.
92
+ const esmExportsCache = new Map()
93
+
76
94
  /**
77
95
  * This function looks for the package.json which contains the specifier trying to resolve.
78
96
  * Once the package.json file has been found, we extract the file path from the specifier
@@ -212,6 +230,11 @@ function * getCjsExports (url, context, source) {
212
230
  * be included in the result set.
213
231
  */
214
232
  export function * getExports (url, context) {
233
+ const cached = esmExportsCache.get(url)
234
+ if (cached !== undefined) {
235
+ return cached.exportNames
236
+ }
237
+
215
238
  // `[LOAD, ...]` gives us the possibility of getting the source from an
216
239
  // upstream loader. This doesn't always work though, so later on we fall back
217
240
  // to reading it from disk.
@@ -265,6 +288,7 @@ export function * getExports (url, context) {
265
288
  const { exportNames, hasModuleSyntax } = lexEsm(source)
266
289
 
267
290
  if (moduleFormat === 'module') {
291
+ esmExportsCache.set(url, { exportNames })
268
292
  return exportNames
269
293
  }
270
294
 
@@ -275,6 +299,7 @@ export function * getExports (url, context) {
275
299
  if (!exportNames.size && !hasModuleSyntax) {
276
300
  return yield * getCjsExports(url, context, source)
277
301
  }
302
+ esmExportsCache.set(url, { exportNames })
278
303
  return exportNames
279
304
  } catch (cause) {
280
305
  const err = new Error(`Failed to parse '${url}'`)
package/lib/register.js CHANGED
@@ -55,7 +55,143 @@ function register (name, namespace, set, get, specifier) {
55
55
  toHook.push([name, proxy, specifier])
56
56
  }
57
57
 
58
+ // Delays (ms) for re-reading exports that were still in their temporal dead zone
59
+ // when the wrapper first ran (circular imports). Retried on a microtask first,
60
+ // then at these intervals; unref'd so best-effort retries never hold the process
61
+ // open. Frozen once at module load rather than rebuilt per wrapper.
62
+ const RETRY_DELAYS = [0, 10, 50]
63
+
64
+ /**
65
+ * Per-wrapped-module state a generated wrapper builds once to expose its exports
66
+ * through iitm's proxy. Each wrapper holds a local binding per export plus
67
+ * `write`/`read` closures over it; `bind` seeds that binding from the real
68
+ * module and installs the proxy's `set`/`get` for the name, and `flush` resolves
69
+ * any export that was undefined (circular import) once it becomes available.
70
+ *
71
+ * This is the boilerplate the wrapper used to inline in full per module. Hoisting
72
+ * it here compiles it once instead of once per wrapped module and keeps the
73
+ * per-export bind call site monomorphic.
74
+ */
75
+ class ModuleBinder {
76
+ // Mimics a Module namespace object (https://tc39.es/ecma262/#sec-module-namespace-objects).
77
+ namespace = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } })
78
+ set = {}
79
+ get = {}
80
+ #overridden = Object.create(null)
81
+ #pending = []
82
+
83
+ /**
84
+ * Seeds `key` from `source` and installs its proxy accessors. A value that is
85
+ * undefined or throws `ReferenceError` (temporal dead zone during a circular
86
+ * import) is deferred to `flush`; any other throw propagates.
87
+ *
88
+ * @param {string} key The export name.
89
+ * @param {object} source The real module namespace to read the value from.
90
+ * @param {(value: unknown) => void} write Assigns the wrapper's local binding.
91
+ * @param {() => unknown} read Reads the wrapper's local binding.
92
+ * @param {boolean} useFallback Fall back to `source.default` (the synthetic
93
+ * `module.exports` name a builtin does not expose on its ESM namespace).
94
+ * @returns {void}
95
+ */
96
+ bind (key, source, write, read, useFallback) {
97
+ const readSource = useFallback
98
+ ? () => source[key] ?? source.default
99
+ : () => source[key]
100
+ this.#overridden[key] = false
101
+ let deferred = false
102
+ try {
103
+ const value = readSource()
104
+ write(value)
105
+ this.namespace[key] = value
106
+ } catch (error) {
107
+ if (!(error instanceof ReferenceError)) throw error
108
+ deferred = true
109
+ }
110
+ if (deferred || read() === undefined) {
111
+ this.#pending.push(this.#makeUpdater(key, readSource, write))
112
+ }
113
+ this.set[key] = (value) => {
114
+ this.#overridden[key] = true
115
+ write(value)
116
+ return true
117
+ }
118
+ this.get[key] = read
119
+ }
120
+
121
+ /**
122
+ * @param {string} key The export name to update.
123
+ * @param {() => unknown} readSource Reads the current value from the real module.
124
+ * @param {(value: unknown) => void} write Assigns the wrapper's local binding.
125
+ * @returns {() => boolean} Updater returning whether the value is now settled.
126
+ */
127
+ #makeUpdater (key, readSource, write) {
128
+ return () => {
129
+ if (this.#overridden[key] === true) return true
130
+ try {
131
+ const value = readSource()
132
+ if (value !== undefined) {
133
+ write(value)
134
+ this.namespace[key] = value
135
+ return true
136
+ }
137
+ return false
138
+ } catch (error) {
139
+ if (error instanceof ReferenceError) return false
140
+ // Only reached if a getter starts throwing a non-ReferenceError after the
141
+ // initial bind read already succeeded or deferred; surfaces in flush's
142
+ // microtask. Kept as-is from the inline wrapper.
143
+ /* c8 ignore next */
144
+ throw error
145
+ }
146
+ }
147
+ }
148
+
149
+ #flushOnce () {
150
+ const next = []
151
+ for (const updater of this.#pending) {
152
+ // If it still throws ReferenceError, keep it for the (single) next attempt.
153
+ if (updater() !== true) next.push(updater)
154
+ }
155
+ this.#pending = next
156
+ }
157
+
158
+ /**
159
+ * Resolves exports deferred by `bind` (undefined or TDZ at wrapper-eval time).
160
+ * Retries on a microtask, then at `RETRY_DELAYS`, giving up afterwards to avoid
161
+ * unbounded retries. A no-op when nothing was deferred.
162
+ *
163
+ * @returns {void}
164
+ */
165
+ flush () {
166
+ if (this.#pending.length === 0) return
167
+ queueMicrotask(() => {
168
+ this.#flushOnce()
169
+ this.#scheduleRetry(0)
170
+ })
171
+ }
172
+
173
+ /**
174
+ * @param {number} attempt Index into `RETRY_DELAYS` for the next retry.
175
+ * @returns {void}
176
+ */
177
+ #scheduleRetry (attempt) {
178
+ if (this.#pending.length === 0) return
179
+ if (attempt >= RETRY_DELAYS.length) {
180
+ // Give up: leave exports as-is to avoid unbounded retries.
181
+ this.#pending = []
182
+ return
183
+ }
184
+ const timer = setTimeout(() => {
185
+ this.#flushOnce()
186
+ this.#scheduleRetry(attempt + 1)
187
+ }, RETRY_DELAYS[attempt])
188
+ // Don't keep the process alive just for best-effort retries.
189
+ if (timer && typeof timer.unref === 'function') timer.unref()
190
+ }
191
+ }
192
+
58
193
  exports.register = register
194
+ exports.ModuleBinder = ModuleBinder
59
195
  exports.importHooks = importHooks
60
196
  exports.specifiers = specifiers
61
197
  exports.toHook = toHook
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "import-in-the-middle",
3
- "version": "3.3.0",
3
+ "version": "3.3.2",
4
4
  "description": "Intercept imports in Node.js",
5
5
  "engines": {
6
6
  "node": ">=18"