import-in-the-middle 3.3.0 → 3.3.1

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [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)
4
+
5
+
6
+ ### Performance Improvements
7
+
8
+ * 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))
9
+ * 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))
10
+
3
11
  ## [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
12
 
5
13
 
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
@@ -214,9 +214,9 @@ function buildSetter (n, srcUrl, namespaceVar) {
214
214
  const objectKey = JSON.stringify(n)
215
215
  const reExportedName = n === 'default' ? n : objectKey
216
216
 
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' : ''
217
+ // Fall back to namespace['default'] for the module.exports synthetic export,
218
+ // which builtins don't expose on the native ESM namespace.
219
+ const useFallback = n === 'module.exports'
220
220
 
221
221
  // Builtins don't expose the module.exports synthetic name, so skip its re-export.
222
222
  const reExportLine = (n === 'module.exports' && (srcUrl.startsWith('node:') || builtinModules.includes(srcUrl)))
@@ -224,7 +224,7 @@ function buildSetter (n, srcUrl, namespaceVar) {
224
224
  : `export { ${variableName} as ${reExportedName} }`
225
225
 
226
226
  return `let ${variableName}
227
- __bind(${objectKey}, ${namespaceVar}, v => { ${variableName} = v }, () => ${variableName}${fallback})
227
+ __binder.bind(${objectKey}, ${namespaceVar}, v => { ${variableName} = v }, () => ${variableName}, ${useFallback})
228
228
  ${reExportLine}`
229
229
  }
230
230
 
@@ -651,98 +651,16 @@ export function createHook (meta) {
651
651
  }
652
652
 
653
653
  return `
654
- import { register } from '${iitmURL}'
654
+ import { register, ModuleBinder } from '${iitmURL}'
655
655
  import * as namespace from ${JSON.stringify(realUrl)}
656
656
  ${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
- }
657
+ const __binder = new ModuleBinder()
715
658
 
716
659
  ${Array.from(setters.values()).join('\n')}
717
660
 
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
- }
731
-
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
- }
661
+ __binder.flush()
744
662
 
745
- register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpecifier)})
663
+ register(${JSON.stringify(realUrl)}, __binder.namespace, __binder.set, __binder.get, ${JSON.stringify(originalSpecifier)})
746
664
  `
747
665
  }
748
666
 
@@ -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.1",
4
4
  "description": "Intercept imports in Node.js",
5
5
  "engines": {
6
6
  "node": ">=18"