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 +8 -0
- package/README.md +9 -0
- package/create-hook.mjs +8 -90
- package/lib/get-exports.mjs +25 -0
- package/lib/register.js +136 -0
- package/package.json +1 -1
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)},
|
|
663
|
+
register(${JSON.stringify(realUrl)}, __binder.namespace, __binder.set, __binder.get, ${JSON.stringify(originalSpecifier)})
|
|
746
664
|
`
|
|
747
665
|
}
|
|
748
666
|
|
package/lib/get-exports.mjs
CHANGED
|
@@ -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
|