import-in-the-middle 3.2.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,33 @@
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
+
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)
12
+
13
+
14
+ ### Features
15
+
16
+ * instrument type-stripped TypeScript modules ([#258](https://github.com/nodejs/import-in-the-middle/issues/258)) ([3c6b456](https://github.com/nodejs/import-in-the-middle/commit/3c6b4568a3d23028576ffb728e908a4d06e48523))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * guard against circular export-star recursion ([#261](https://github.com/nodejs/import-in-the-middle/issues/261)) ([b957de3](https://github.com/nodejs/import-in-the-middle/commit/b957de3667292ec2ea3b8729704664d1324eaa8f))
22
+ * keep get-esm-exports synchronous so require() works ([#266](https://github.com/nodejs/import-in-the-middle/issues/266)) ([4244b25](https://github.com/nodejs/import-in-the-middle/commit/4244b2512f6256af0ff916f6dc98cc65d988187a))
23
+ * resolve re-exported values from their defining module ([#263](https://github.com/nodejs/import-in-the-middle/issues/263)) ([5ef34cb](https://github.com/nodejs/import-in-the-middle/commit/5ef34cbb59fb65cd0e25b27e06236d6c95c69176))
24
+
25
+
26
+ ### Performance Improvements
27
+
28
+ * replace acorn with es-module-lexer for ESM parsing ([#259](https://github.com/nodejs/import-in-the-middle/issues/259)) ([b8cc8af](https://github.com/nodejs/import-in-the-middle/commit/b8cc8af150f46d09b1f447e01a89344d2e647c2c))
29
+ * shrink generated wrapper source ([#264](https://github.com/nodejs/import-in-the-middle/issues/264)) ([491d968](https://github.com/nodejs/import-in-the-middle/commit/491d968d5e80cf0f0add2caeb0f447106caf32c3))
30
+
3
31
  ## [3.2.0](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v3.1.0...import-in-the-middle-v3.2.0) (2026-06-22)
4
32
 
5
33
 
package/README.md CHANGED
@@ -117,13 +117,32 @@ node --import=./instrument.mjs ./my-app.mjs
117
117
 
118
118
  ## Synchronous loader hooks
119
119
 
120
- On Node.js versions that provide
120
+ On Node.js versions that support
121
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.
122
+ the loader can run *synchronously*, on the application thread, instead of on the
123
+ separate thread that `module.register()` uses. Running in-thread removes the
124
+ message channel: `Hook()` registrations are visible to the loader directly, so
125
+ the `createAddHookMessageChannel` / `waitForAllMessagesAcknowledged` step shown
126
+ above is unnecessary.
127
+
128
+ `module.registerHooks()` was added in 22.15.0 / 24.0.0, but its synchronous load
129
+ hook rejected the nullish CommonJS `source` the loader returns for `require()`s
130
+ pulled into the ESM graph until [nodejs/node#59929][]. The fix shipped in
131
+ **22.22.3, 24.11.1, 25.1.0 and 26.0.0**; earlier versions ship
132
+ `module.registerHooks()` but cannot run the synchronous loader. Use
133
+ `supportsSyncHooks()` to branch on this rather than a hand-written version check:
134
+
135
+ ```js
136
+ import { register, supportsSyncHooks } from 'import-in-the-middle/register-hooks.mjs'
137
+
138
+ if (supportsSyncHooks()) {
139
+ register({ include: ['package-i-want-to-include'] })
140
+ } else {
141
+ // Fall back to the asynchronous loader, e.g. module.register('import-in-the-middle/hook.mjs').
142
+ }
143
+ ```
144
+
145
+ [nodejs/node#59929]: https://github.com/nodejs/node/pull/59929
127
146
 
128
147
  `instrument.mjs`
129
148
 
@@ -143,7 +162,7 @@ node --import=./instrument.mjs ./my-app.mjs
143
162
  ```
144
163
 
145
164
  `register()` accepts the same `include` / `exclude` options as the asynchronous
146
- loader and throws on a Node.js version without `module.registerHooks()`.
165
+ loader and throws on a Node.js version where `supportsSyncHooks()` is `false`.
147
166
 
148
167
  ### Custom matching with `shouldInclude`
149
168
 
@@ -175,9 +194,46 @@ registration (`register-hooks.mjs`, shown above) and for predicates constructed
175
194
  the loader thread; it is not accepted through the `data` option of the asynchronous
176
195
  `module.register('import-in-the-middle/hook.mjs', ...)`.
177
196
 
197
+ ## TypeScript modules
198
+
199
+ On Node.js versions that strip TypeScript types natively (those exposing
200
+ [`module.stripTypeScriptTypes()`](https://nodejs.org/api/module.html#modulestriptypescripttypescode-options),
201
+ >= 22.13.0 / >= 23.9.0 / >= 24.0.0), `import-in-the-middle` intercepts `.ts`,
202
+ `.mts` and `.cts` modules just like their JavaScript counterparts. The types are
203
+ stripped before the module's exports are read, so type-only exports
204
+ (`export type`, `export interface`) are not present on the intercepted
205
+ namespace; value exports are.
206
+
207
+ ```ts
208
+ // math.mts
209
+ export type Op = '+' | '-'
210
+ export function add (a: number, b: number): number {
211
+ return a + b
212
+ }
213
+ ```
214
+
215
+ ```js
216
+ Hook(['./math.mts'], (exported) => {
217
+ // `exported.add` is interceptable; the `Op` type is not part of the namespace
218
+ })
219
+ ```
220
+
221
+ On Node.js versions where type stripping is not enabled by default, run with
222
+ `--experimental-strip-types`. Older versions that predate
223
+ `module.stripTypeScriptTypes()` leave TypeScript modules untouched.
224
+
178
225
  ## Limitations
179
226
 
180
227
  * You cannot add new exports to a module. You can only modify existing ones.
181
228
  * While bindings to module exports end up being "re-bound" when modified in a
182
229
  hook, dynamically imported modules cannot be altered after they're loaded.
183
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
@@ -10,43 +10,33 @@ import {
10
10
  hasModuleExportsCJSDefault
11
11
  } from './lib/get-exports.mjs'
12
12
  import { RESOLVE, driveSync, driveAsync } from './lib/io.mjs'
13
+ import { supportsSyncHooks } from './supports-sync-hooks.mjs'
14
+
15
+ // Re-exported for backwards compatibility: `supportsSyncHooks` now lives in its
16
+ // own import-free module so a CommonJS preloader can check it without loading
17
+ // this file's acorn / cjs-module-lexer dependency graph.
18
+ export { supportsSyncHooks }
13
19
 
14
20
  const specifiers = new Map()
15
21
  const isWin = process.platform === 'win32'
16
22
 
23
+ // Depth at which `processModule` starts tracking visited URLs to break an
24
+ // `export *` cycle. Real re-export chains are only a few levels deep, so this
25
+ // is far beyond any legitimate graph yet well below the call-stack limit a
26
+ // cycle would otherwise hit. Below it the recursion pays only an integer
27
+ // compare per level and allocates no set.
28
+ const STAR_CYCLE_DEPTH = 100
29
+
17
30
  // FIXME: Typescript extensions are added temporarily until we find a better
18
31
  // way of supporting arbitrary extensions
19
32
  const EXTENSION_RE = /\.(js|mjs|cjs|ts|mts|cts)$/
20
- const HANDLED_FORMATS = new Set(['builtin', 'module', 'commonjs'])
33
+ // The `-typescript` formats are listed unconditionally; getExports strips the
34
+ // types when the runtime supports it and otherwise falls back to onWrapFailure.
35
+ const HANDLED_FORMATS = new Set([
36
+ 'builtin', 'module', 'commonjs', 'module-typescript', 'commonjs-typescript'
37
+ ])
21
38
  const TRACE_WARNINGS = process.execArgv.includes('--trace-warnings')
22
39
 
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
48
- }
49
-
50
40
  function hasIitm (url) {
51
41
  // Fast path: avoid URL parsing on the hot path when there's clearly no iitm.
52
42
  if (typeof url !== 'string' || url.indexOf('iitm') === -1) {
@@ -205,52 +195,37 @@ function emitWarning (err) {
205
195
  * of how the loader is driven, so both the synchronous and asynchronous paths
206
196
  * share it.
207
197
  *
198
+ * The value is read from `namespaceVar`, the wrapper's namespace binding for the
199
+ * module that *defines* the export. For a module's own exports that is the
200
+ * wrapped module itself; for a name re-exported through `export *` it is the
201
+ * leaf that declares it. Reading from the defining module rather than the
202
+ * aggregating one keeps the value resolvable when the same binding reaches the
203
+ * aggregator through more than one re-export chain — Node sees those chains as
204
+ * distinct wrapper modules and leaves the name ambiguous (hence `undefined`) on
205
+ * the aggregate namespace, while the defining module always holds it (#171).
206
+ *
208
207
  * @param {string} n The exported name.
209
208
  * @param {string} srcUrl The URL of the module the export belongs to.
209
+ * @param {string} namespaceVar The wrapper binding holding `srcUrl`'s namespace.
210
210
  * @returns {string}
211
211
  */
212
- function buildSetter (n, srcUrl) {
212
+ function buildSetter (n, srcUrl, namespaceVar) {
213
213
  const variableName = `$${n.replace(/[^a-zA-Z0-9_$]/g, '_')}`
214
214
  const objectKey = JSON.stringify(n)
215
215
  const reExportedName = n === 'default' ? n : objectKey
216
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' : ''
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'
223
220
 
221
+ // Builtins don't expose the module.exports synthetic name, so skip its re-export.
224
222
  const reExportLine = (n === 'module.exports' && (srcUrl.startsWith('node:') || builtinModules.includes(srcUrl)))
225
223
  ? ''
226
224
  : `export { ${variableName} as ${reExportedName} }`
227
225
 
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
- `
226
+ return `let ${variableName}
227
+ __binder.bind(${objectKey}, ${namespaceVar}, v => { ${variableName} = v }, () => ${variableName}, ${useFallback})
228
+ ${reExportLine}`
254
229
  }
255
230
 
256
231
  /**
@@ -267,37 +242,82 @@ function buildSetter (n, srcUrl) {
267
242
  * @param {string} params.srcUrl The full URL to the module to process.
268
243
  * @param {object} params.context Provided by the loaders API.
269
244
  * @param {boolean} [params.excludeDefault = false] Exclude the default export.
245
+ * @param {number} [params.depth = 0] Star-re-export recursion depth. Used to
246
+ * detect `export *` cycles (`a` re-exports `b`, `b` re-exports `a`) cheaply:
247
+ * the acyclic common case pays only an integer compare per level, and the
248
+ * cycle-tracking set is allocated only once recursion is implausibly deep.
249
+ * @param {Set<string>} [params.seen] URLs currently on the recursion stack,
250
+ * created lazily once `depth` crosses {@link STAR_CYCLE_DEPTH}. A URL is added
251
+ * before descending into its subtree and removed once that subtree finishes, so
252
+ * it tracks the active path rather than every URL ever visited.
253
+ * @param {Map<string, string>} [params.originNamespaces] Shared registry mapping
254
+ * a defining-module URL to the wrapper namespace alias a same-origin `export *`
255
+ * collision must read it from. Absent until the first such collision; then
256
+ * threaded through the recursion so one defining module yields one alias and
257
+ * {@link buildWrapperSource} imports each once. Only `*`-collided names use it;
258
+ * every other export reads from the wrapped module's own `namespace`.
270
259
  *
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
273
- * from the module and any transitive export all modules.
260
+ * @returns {Generator<Array, { setters: Map<string, string>, origins: (Map<string, string> | undefined), originNamespaces: (Map<string, string> | undefined) }>}
261
+ * A generator that yields I/O operations and ultimately returns the shimmed
262
+ * setters for all the exports from the module and any transitive export all
263
+ * modules. `origins` (the defining module per `*`-sourced name) is `undefined`
264
+ * for a module with no `export *`; `originNamespaces` stays `undefined` unless a
265
+ * same-origin `*` collision actually needed an alias.
274
266
  */
275
- function * processModule ({ srcUrl, context, excludeDefault = false }) {
267
+ function * processModule ({ srcUrl, context, excludeDefault = false, depth = 0, seen, originNamespaces }) {
276
268
  const exportNames = yield * getExports(srcUrl, context)
277
- const starExports = new Set()
278
269
  const setters = new Map()
279
270
 
280
- const addSetter = (name, setter, isStarExport = false) => {
271
+ // Maps each live `*`-sourced name to the module that defined it. Its keys
272
+ // double as "this name came from a `*` re-export" (so an explicit export can
273
+ // override it), and its values let two `*` re-exports of the same name be told
274
+ // apart. Allocated on the first `export *`, never for a module without one; a
275
+ // single Map carries both facts so a star with no collision pays one structure
276
+ // and one write per name, not two.
277
+ let starOrigins
278
+
279
+ // A name pulled in through more than one `export *` chain that all bottom out
280
+ // at the same module stays exported (ECMAScript ResolveExport;
281
+ // tc39/ecma262#3715), but the *aggregate* namespace this wrapper imports drops
282
+ // it: under iitm the chains are distinct wrapped modules, so Node sees the
283
+ // re-export as ambiguous and the name reads back undefined. Only those names
284
+ // must instead read from their defining module's own namespace, which always
285
+ // holds the value. `originNamespaces` maps such a defining module to the alias
286
+ // the wrapper imports for it; it is allocated on the first surviving
287
+ // collision, so a module without one emits no extra import (#171).
288
+ const ensureOriginNamespace = (origin) => {
289
+ originNamespaces ??= new Map()
290
+ let alias = originNamespaces.get(origin)
291
+ if (alias === undefined) {
292
+ alias = `__ns${originNamespaces.size}`
293
+ originNamespaces.set(origin, alias)
294
+ }
295
+ return alias
296
+ }
297
+
298
+ const addSetter = (name, setter, isStarExport, origin) => {
281
299
  if (setters.has(name)) {
282
300
  if (isStarExport) {
283
- // If there's already a matching star export, delete it
284
- if (starExports.has(name)) {
285
- setters.delete(name)
301
+ // `starOrigins.has(name)` means the existing entry also came from a `*`
302
+ // re-export (an explicit export would not be tracked here).
303
+ if (starOrigins.has(name)) {
304
+ if (starOrigins.get(name) === origin) {
305
+ // The same binding reached through two `*` re-export chains. It
306
+ // stays exported, but the aggregate namespace dropped it, so point
307
+ // its setter at the defining module's namespace instead.
308
+ setters.set(name, buildSetter(name, origin, ensureOriginNamespace(origin)))
309
+ } else {
310
+ // Genuinely ambiguous: two `*` re-exports name it from different
311
+ // modules. Per ResolveExport the name is excluded entirely.
312
+ setters.delete(name)
313
+ starOrigins.delete(name)
314
+ }
286
315
  }
287
- // and return so this is excluded
288
- return
289
- }
290
-
291
- // if we already have this export but it is from a * export, overwrite it
292
- if (starExports.has(name)) {
293
- starExports.delete(name)
294
- setters.set(name, setter)
316
+ // An explicit export already shadows the `*` re-export; leave it.
295
317
  }
296
318
  } else {
297
- // Store export * exports so we know they can be overridden by explicit
298
- // named exports
299
319
  if (isStarExport) {
300
- starExports.add(name)
320
+ starOrigins.set(name, origin)
301
321
  }
302
322
 
303
323
  setters.set(name, setter)
@@ -326,21 +346,56 @@ function * processModule ({ srcUrl, context, excludeDefault = false }) {
326
346
  // parent's `format` to know if this sub-module is ESM or CJS!
327
347
  const result = yield [RESOLVE, newSpecifier, { parentURL: srcUrl }]
328
348
 
329
- const subSetters = yield * processModule({
330
- srcUrl: result.url,
331
- context: { ...context, format: result.format },
332
- excludeDefault: true
333
- })
349
+ // First `*` re-export: allocate the origin bookkeeping lazily.
350
+ starOrigins ??= new Map()
351
+
352
+ // `export *` graphs are normally only a handful of levels deep. A cycle
353
+ // (`a` re-exports `b`, `b` re-exports `a`) instead recurses without bound
354
+ // and exhausts memory. Rather than track every URL on the common shallow
355
+ // path, only start recording once the depth is implausibly large for a
356
+ // real graph; from there a re-export pointing back at a module already on
357
+ // the recursion stack is the cycle, and is skipped (its exports are
358
+ // collected by the in-progress ancestor frame). `seen` mirrors the stack,
359
+ // not every URL visited: a module reached and fully processed through one
360
+ // sibling branch must stay reachable through a later, more direct branch,
361
+ // so it is removed again once its subtree finishes.
362
+ if (depth >= STAR_CYCLE_DEPTH) {
363
+ seen ??= new Set()
364
+ if (seen.has(result.url)) continue
365
+ seen.add(result.url)
366
+ }
334
367
 
335
- for (const [name, setter] of subSetters.entries()) {
336
- addSetter(name, setter, true)
368
+ try {
369
+ const sub = yield * processModule({
370
+ srcUrl: result.url,
371
+ context: { ...context, format: result.format },
372
+ excludeDefault: true,
373
+ depth: depth + 1,
374
+ seen,
375
+ originNamespaces
376
+ })
377
+
378
+ // Adopt any registry a nested `export *` minted before processing this
379
+ // child's results, so a collision detected here extends the same Map the
380
+ // child's setters already reference (one alias per defining module across
381
+ // the whole tree) rather than orphaning the child's into a second Map.
382
+ originNamespaces ??= sub.originNamespaces
383
+
384
+ // Star targets build their setters against `namespace` like any other
385
+ // module; only a surviving same-origin collision (in addSetter) rewrites
386
+ // the affected name to read from its defining module's alias.
387
+ for (const [name, setter] of sub.setters) {
388
+ addSetter(name, setter, true, sub.origins?.get(name) ?? result.url)
389
+ }
390
+ } finally {
391
+ seen?.delete(result.url)
337
392
  }
338
393
  } else {
339
- addSetter(n, buildSetter(n, srcUrl))
394
+ addSetter(n, buildSetter(n, srcUrl, 'namespace'), false)
340
395
  }
341
396
  }
342
397
 
343
- return setters
398
+ return { setters, origins: starOrigins, originNamespaces }
344
399
  }
345
400
 
346
401
  function addIitm (url) {
@@ -582,75 +637,30 @@ export function createHook (meta) {
582
637
  // Builds the wrapper module source that re-exports the real module through
583
638
  // iitm's proxy. Pure string generation shared by the asynchronous and
584
639
  // synchronous `load` paths.
585
- function buildWrapperSource (realUrl, setters, originalSpecifier) {
586
- return `
587
- import { register } from '${iitmURL}'
588
- import * as namespace from ${JSON.stringify(realUrl)}
589
-
590
- // Mimic a Module object (https://tc39.es/ecma262/#sec-module-namespace-objects).
591
- const _ = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } })
592
- const set = {}
593
- const get = {}
594
- const __overridden = Object.create(null)
595
- let __pending = []
596
-
597
- function __makeUpdater (key, read, assign) {
598
- return () => {
599
- if (__overridden[key] === true) return true
600
- try {
601
- const v = read()
602
- if (v !== undefined) {
603
- assign(v)
604
- return true
640
+ function buildWrapperSource (realUrl, setters, originalSpecifier, originNamespaces) {
641
+ // The wrapped module imports its namespace as `namespace`, which serves
642
+ // every export but the ones a same-origin `export *` collision forced onto
643
+ // their defining module (#171): the aggregate namespace drops those as
644
+ // ambiguous under iitm, so each such defining module gets its own alias the
645
+ // wrapper imports. Absent the registry (no such collision) nothing is added.
646
+ let originImports = ''
647
+ if (originNamespaces !== undefined) {
648
+ for (const [originUrl, alias] of originNamespaces) {
649
+ originImports += `import * as ${alias} from ${JSON.stringify(originUrl)}\n`
605
650
  }
606
- return false
607
- } catch (err) {
608
- if (err instanceof ReferenceError) return false
609
- throw err
610
651
  }
611
- }
612
- }
613
652
 
614
- function __flushPendingOnce () {
615
- if (__pending.length === 0) return
616
- const next = []
617
- for (const fn of __pending) {
618
- // If it still throws ReferenceError, keep it for the (single) next attempt.
619
- if (fn() !== true) next.push(fn)
620
- }
621
- __pending = next
622
- }
653
+ return `
654
+ import { register, ModuleBinder } from '${iitmURL}'
655
+ import * as namespace from ${JSON.stringify(realUrl)}
656
+ ${originImports}
657
+ const __binder = new ModuleBinder()
623
658
 
624
659
  ${Array.from(setters.values()).join('\n')}
625
660
 
626
- if (__pending.length > 0) {
627
- queueMicrotask(() => {
628
- __flushPendingOnce()
629
-
630
- if (__pending.length > 0) {
631
- const __retryDelays = [0, 10, 50]
632
- const __schedulePending = (i) => {
633
- if (__pending.length === 0) return
634
- if (i >= __retryDelays.length) {
635
- // Give up: leave exports as-is to avoid unbounded retries.
636
- __pending = []
637
- return
638
- }
639
-
640
- const t = setTimeout(() => {
641
- __flushPendingOnce()
642
- __schedulePending(i + 1)
643
- }, __retryDelays[i])
644
- // Don't keep the process alive just for best-effort retries.
645
- if (t && typeof t.unref === 'function') t.unref()
646
- }
647
-
648
- __schedulePending(0)
649
- }
650
- })
651
- }
661
+ __binder.flush()
652
662
 
653
- register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpecifier)})
663
+ register(${JSON.stringify(realUrl)}, __binder.namespace, __binder.set, __binder.get, ${JSON.stringify(originalSpecifier)})
654
664
  `
655
665
  }
656
666
 
@@ -658,13 +668,13 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
658
668
  // succeeds: free the specifier entry early, and remember CJS modules so their
659
669
  // transitive require() chain bypasses iitm (see `load`). Returns the wrapper
660
670
  // module source.
661
- function onWrapSuccess (realUrl, context, originalSpecifier, setters) {
671
+ function onWrapSuccess (realUrl, context, originalSpecifier, setters, originNamespaces) {
662
672
  specifiers.delete(realUrl)
663
673
  // context.format is set to 'commonjs' by getCjsExports during processModule.
664
674
  if (context.format === 'commonjs') {
665
675
  cjsInIitmChain.add(realUrl)
666
676
  }
667
- return buildWrapperSource(realUrl, setters, originalSpecifier)
677
+ return buildWrapperSource(realUrl, setters, originalSpecifier, originNamespaces)
668
678
  }
669
679
 
670
680
  // Bookkeeping shared by the async and sync wrap paths when `processModule`
@@ -685,11 +695,11 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
685
695
  const originalSpecifier = specifiers.get(realUrl)
686
696
 
687
697
  try {
688
- const setters = await driveAsync(
698
+ const { setters, originNamespaces } = await driveAsync(
689
699
  processModule({ srcUrl: realUrl, context }),
690
700
  { resolve: cachedResolve, load: parentGetSource }
691
701
  )
692
- return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters) }
702
+ return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters, originNamespaces) }
693
703
  } catch (cause) {
694
704
  onWrapFailure(realUrl, cause)
695
705
  // Revert back to the non-iitm URL
@@ -709,11 +719,11 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
709
719
  const originalSpecifier = specifiers.get(realUrl)
710
720
 
711
721
  try {
712
- const setters = driveSync(
722
+ const { setters, originNamespaces } = driveSync(
713
723
  processModule({ srcUrl: realUrl, context }),
714
724
  { resolve: cachedResolve, load: nextLoad }
715
725
  )
716
- return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters) }
726
+ return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters, originNamespaces) }
717
727
  } catch (cause) {
718
728
  onWrapFailure(realUrl, cause)
719
729
  url = realUrl
@@ -1,116 +1,146 @@
1
1
  'use strict'
2
2
 
3
- import { Parser } from 'acorn'
4
- import { importAttributesOrAssertions } from 'acorn-import-attributes'
3
+ import { readFileSync } from 'fs'
4
+ import { createRequire, Module } from 'module'
5
+ import { initSync, parse as parseWasm } from 'es-module-lexer'
5
6
 
6
- const acornOpts = {
7
- ecmaVersion: 'latest',
8
- sourceType: 'module'
9
- }
7
+ const require = createRequire(import.meta.url)
8
+
9
+ // The default es-module-lexer build is WebAssembly backed and decodes string
10
+ // literals (import specifiers, quoted export names) with an internal `eval`.
11
+ // Under `--disallow-code-generation-from-strings` that `eval` silently no-ops,
12
+ // so specifiers and quoted names come back undecoded (`* from undefined`,
13
+ // `"string name"` with the quotes still attached). The eval-free asm.js build
14
+ // (`es-module-lexer/js`) does the same work, so fall back to it when code
15
+ // generation from strings is disallowed. Resolve the parser once at load time
16
+ // so the per-module path stays a bare `parse(source)` call.
17
+ const flag = '--disallow-code-generation-from-strings'
18
+ const disallowCodegen = process.execArgv.includes(flag) ||
19
+ (process.env.NODE_OPTIONS?.includes(flag) ?? false)
10
20
 
11
- const parser = Parser.extend(importAttributesOrAssertions)
21
+ // initSync compiles the Wasm module up front so `parse` can run inside
22
+ // synchronous loader hooks (`module.registerHooks`) as well as the off-thread
23
+ // loader; it is a one-time cost on the first ESM module either way. It stays the
24
+ // default and remains callable even under the flag, so a parse before the asm.js
25
+ // swap still works (with the flag's known specifier-decoding limitation) rather
26
+ // than throwing.
27
+ initSync()
12
28
 
13
- function warn (txt) {
14
- process.emitWarning(txt, 'get-esm-exports')
29
+ let parse = parseWasm
30
+
31
+ if (disallowCodegen) {
32
+ parse = loadAsmParse()
15
33
  }
16
34
 
17
35
  /**
18
- * Utilizes an AST parser to interpret ESM source code and build a list of
19
- * exported identifiers. In the baseline case, the list of identifiers will be
20
- * the simple identifier names as written in the source code of the module.
21
- * However, there is a special case:
36
+ * Loads the eval-free asm.js parser synchronously, on every Node version.
22
37
  *
23
- * When an `export * from './foo.js'` line is encountered it is rewritten
24
- * as `* from ./foo.js`. This allows the interpreting code to recognize a
25
- * transitive export and recursively parse the indicated module. The returned
26
- * identifier list will have "* from ./foo.js" as an item.
38
+ * The asm.js build (`es-module-lexer/js`) is ESM-only with no CommonJS variant,
39
+ * and its only export is a top-level `parse` (no async wasm `init` to await).
40
+ * `require`-ing it is not portable: before require(esm) (Node < 20.19 / < 22.12)
41
+ * it throws `ERR_REQUIRE_ESM`, and `import()`-ing it from inside a loader hook
42
+ * deadlocks the loader on itself. Compiling the source as CommonJS sidesteps
43
+ * both: it runs synchronously, keeps this module require()-able for the
44
+ * synchronous `module.registerHooks` path, and never re-enters the ESM loader.
27
45
  *
28
- * @param {object} params
29
- * @param {string} params.moduleSource The source code of the module to parse
30
- * and interpret.
46
+ * `Module._compile` uses V8's script compiler, not `eval`, so it is unaffected
47
+ * by `--disallow-code-generation-from-strings` the same reason the asm.js
48
+ * build itself decodes specifiers under the flag. The only export is rewritten
49
+ * to a CommonJS one; the source is a vendored build we control.
31
50
  *
32
- * @returns {Set<string>} The identifiers exported by the module along with any
33
- * custom directives.
51
+ * @returns {typeof parseWasm} The asm.js parse function.
34
52
  */
35
- export default function getEsmExports (moduleSource) {
36
- const exportedNames = new Set()
37
- const tree = parser.parse(moduleSource, acornOpts)
38
- for (const node of tree.body) {
39
- if (!node.type.startsWith('Export')) continue
40
- switch (node.type) {
41
- case 'ExportNamedDeclaration':
42
- if (node.declaration) {
43
- parseDeclaration(node, exportedNames)
44
- } else {
45
- parseSpecifiers(node, exportedNames)
46
- }
47
- break
48
-
49
- case 'ExportDefaultDeclaration': {
50
- exportedNames.add('default')
51
- break
52
- }
53
+ function loadAsmParse () {
54
+ const asmPath = require.resolve('es-module-lexer/js')
55
+ const source = readFileSync(asmPath, 'utf8')
56
+ .replace('export function parse', 'function parse') +
57
+ '\nmodule.exports = { parse }\n'
58
+ const mod = new Module(asmPath)
59
+ mod.filename = asmPath
60
+ mod._compile(source, asmPath)
61
+ return mod.exports.parse
62
+ }
53
63
 
54
- case 'ExportAllDeclaration':
55
- if (node.exported) {
56
- exportedNames.add(node.exported.name)
57
- } else {
58
- exportedNames.add(`* from ${node.source.value}`)
59
- }
60
- break
61
- default:
62
- warn('unrecognized export type: ' + node.type)
63
- }
64
+ /**
65
+ * Decodes an exported identifier the way the JS engine would. es-module-lexer
66
+ * leaves Unicode escapes in bare identifier exports (`export const \u0061 = 1`)
67
+ * as their raw spelling, while the module namespace exposes the cooked name
68
+ * (`a`). Quoted export names are already decoded by the lexer and start with a
69
+ * quote in the source, so the cheap quote check keeps them on the fast path;
70
+ * only a bare identifier carrying a backslash needs cooking. A malformed escape
71
+ * falls back to the raw name rather than throwing inside the loader.
72
+ *
73
+ * @param {string} name The export name as reported by the lexer.
74
+ * @returns {string} The cooked export name.
75
+ */
76
+ function decodeExportName (name) {
77
+ const first = name.charCodeAt(0)
78
+ if (first === 0x22 /* " */ || first === 0x27 /* ' */ || !name.includes('\\')) {
79
+ return name
80
+ }
81
+ try {
82
+ return JSON.parse(`"${name}"`)
83
+ } catch {
84
+ return name
64
85
  }
65
- return exportedNames
66
86
  }
67
87
 
68
- function parseDeclaration (node, exportedNames) {
69
- switch (node.declaration.type) {
70
- case 'FunctionDeclaration':
71
- exportedNames.add(node.declaration.id.name)
72
- break
73
- case 'VariableDeclaration':
74
- for (const varDecl of node.declaration.declarations) {
75
- parseVariableDeclaration(varDecl, exportedNames)
76
- }
77
- break
78
- case 'ClassDeclaration':
79
- exportedNames.add(node.declaration.id.name)
80
- break
81
- default:
82
- warn('unknown declaration type: ' + node.delcaration.type)
83
- }
88
+ // es-module-lexer reports a bare `export * from <mod>` only as an import with no
89
+ // matching export entry, indistinguishable from `import <mod>` except by the
90
+ // statement text. This matches that text to rewrite it as the transitive
91
+ // `* from <specifier>` marker the interpreting code recognizes. `export * as ns
92
+ // from` binds a real name and is reported as a normal export, so it must not
93
+ // match here. `GAP` allows whitespace and comments between the tokens, the way
94
+ // the parser does (e.g. `export /* c */ * from`).
95
+ const GAP = '(?:\\s|/\\*[^]*?\\*/|//[^\\n]*\\n)*'
96
+ const STAR_REEXPORT = new RegExp(`^export${GAP}\\*${GAP}from`)
97
+
98
+ /**
99
+ * Lexes ESM source code with es-module-lexer and builds a list of exported
100
+ * identifiers. In the baseline case the list is the simple identifier names as
101
+ * written in the source. There is one special case:
102
+ *
103
+ * When an `export * from './foo.js'` line is encountered it is rewritten as
104
+ * `* from ./foo.js`. This lets the interpreting code recognize a transitive
105
+ * export and recursively parse the indicated module. The returned identifier
106
+ * list will have "* from ./foo.js" as an item.
107
+ *
108
+ * @param {string} moduleSource The source code of the module to lex.
109
+ * @returns {Set<string>} The identifiers exported by the module along with any
110
+ * custom directives.
111
+ */
112
+ export default function getEsmExports (moduleSource) {
113
+ return lexEsm(moduleSource).exportNames
84
114
  }
85
115
 
86
- function parseVariableDeclaration (node, exportedNames) {
87
- switch (node.id.type) {
88
- case 'Identifier':
89
- exportedNames.add(node.id.name)
90
- break
91
- case 'ObjectPattern':
92
- for (const prop of node.id.properties) {
93
- exportedNames.add(prop.value.name)
94
- }
95
- break
96
- case 'ArrayPattern':
97
- for (const elem of node.id.elements) {
98
- exportedNames.add(elem.name)
99
- }
100
- break
101
- default:
102
- warn('unknown variable declaration type: ' + node.id.type)
116
+ /**
117
+ * Lexes ESM source code once and reports both the exported identifiers and
118
+ * whether the source uses ESM syntax. Sharing a single `parse` lets the
119
+ * unknown-format path in `getExports` decide between ESM and CommonJS without a
120
+ * second pass over the source.
121
+ *
122
+ * `hasModuleSyntax` is es-module-lexer's own signal: static `import`/`export`
123
+ * and `import.meta` set it, while a lone dynamic `import(...)` (valid in CJS)
124
+ * does not.
125
+ *
126
+ * @param {string} moduleSource The source code of the module to lex.
127
+ * @returns {{ exportNames: Set<string>, hasModuleSyntax: boolean }}
128
+ */
129
+ export function lexEsm (moduleSource) {
130
+ const exportNames = new Set()
131
+ const [imports, exports, , hasModuleSyntax] = parse(moduleSource)
132
+
133
+ for (const exported of exports) {
134
+ exportNames.add(decodeExportName(exported.n))
103
135
  }
104
- }
105
136
 
106
- function parseSpecifiers (node, exportedNames) {
107
- for (const specifier of node.specifiers) {
108
- if (specifier.exported.type === 'Identifier') {
109
- exportedNames.add(specifier.exported.name)
110
- } else if (specifier.exported.type === 'Literal') {
111
- exportedNames.add(specifier.exported.value)
112
- } else {
113
- warn('unrecognized specifier type: ' + specifier.exported.type)
137
+ // Bare `export * from <mod>` re-exports report no export name; reconstruct
138
+ // the transitive marker from the import statement that carries the specifier.
139
+ for (const imported of imports) {
140
+ if (STAR_REEXPORT.test(moduleSource.slice(imported.ss, imported.se))) {
141
+ exportNames.add(`* from ${imported.n}`)
114
142
  }
115
143
  }
144
+
145
+ return { exportNames, hasModuleSyntax }
116
146
  }
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- import getEsmExports from './get-esm-exports.mjs'
3
+ import { lexEsm } from './get-esm-exports.mjs'
4
4
  import { parse as parseCjs, initSync } from 'cjs-module-lexer'
5
5
  import { readFileSync, existsSync } from 'fs'
6
6
  import { builtinModules, createRequire } from 'module'
@@ -11,6 +11,11 @@ import { LOAD } from './io.mjs'
11
11
  const nodeMajor = Number(process.versions.node.split('.')[0])
12
12
  export const hasModuleExportsCJSDefault = nodeMajor >= 23
13
13
 
14
+ // Resolve `stripTypeScriptTypes` (Node >= 22.13) via `getBuiltinModule` rather
15
+ // than a static named import (throws on older runtimes) or `require` (re-enters
16
+ // iitm's own loader hooks). `undefined` on runtimes that lack it.
17
+ const stripTypeScriptTypes = process.getBuiltinModule?.('module')?.stripTypeScriptTypes
18
+
14
19
  let parserInitialized = false
15
20
 
16
21
  // The CJS export scanner is backed by WebAssembly. `initSync` compiles it
@@ -28,127 +33,6 @@ function addDefault (arr) {
28
33
  return new Set(['default', ...arr])
29
34
  }
30
35
 
31
- function hasEsmSyntax (source) {
32
- // Lightweight scan (no full parse) to determine if the *source code*
33
- // contains ESM-specific syntax. This is used only when:
34
- // - the loader chain didn't tell us a `format`, and
35
- // - `getEsmExports()` found no exports.
36
- //
37
- // Notes:
38
- // - We ignore comments and strings to reduce false positives.
39
- // - We treat `import.meta` and static `import ...` as ESM.
40
- // - We do NOT treat `import(` (dynamic import) as ESM because it is allowed
41
- // in CJS as an expression.
42
- if (source.indexOf('import') === -1) return false
43
-
44
- const isIdentCharCode = (code) => (
45
- (code >= 48 && code <= 57) || // 0-9
46
- (code >= 65 && code <= 90) || // A-Z
47
- (code >= 97 && code <= 122) || // a-z
48
- code === 95 || // _
49
- code === 36 // $
50
- )
51
-
52
- const skipWhitespace = (idx) => {
53
- while (idx < source.length) {
54
- const c = source.charCodeAt(idx)
55
- // space, tab, cr, lf
56
- if (c !== 32 && c !== 9 && c !== 13 && c !== 10) break
57
- idx++
58
- }
59
- return idx
60
- }
61
-
62
- let i = 0
63
- while (i < source.length) {
64
- const ch = source[i]
65
-
66
- // Line comment
67
- if (ch === '/' && source[i + 1] === '/') {
68
- i += 2
69
- while (i < source.length && source[i] !== '\n') i++
70
- continue
71
- }
72
-
73
- // Block comment
74
- if (ch === '/' && source[i + 1] === '*') {
75
- i += 2
76
- while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++
77
- i += 2
78
- continue
79
- }
80
-
81
- // Strings: '...' or "..."
82
- if (ch === '\'' || ch === '"') {
83
- const quote = ch
84
- i++
85
- while (i < source.length) {
86
- const c = source[i]
87
- if (c === '\\') {
88
- i += 2
89
- continue
90
- }
91
- if (c === quote) {
92
- i++
93
- break
94
- }
95
- i++
96
- }
97
- continue
98
- }
99
-
100
- // Template strings: `...`
101
- if (ch === '`') {
102
- i++
103
- while (i < source.length) {
104
- const c = source[i]
105
- if (c === '\\') {
106
- i += 2
107
- continue
108
- }
109
- if (c === '`') {
110
- i++
111
- break
112
- }
113
- i++
114
- }
115
- continue
116
- }
117
-
118
- // Keyword scan (word-boundary): import
119
- if (ch === 'i') {
120
- const prev = source.charCodeAt(i - 1)
121
- if (i > 0 && isIdentCharCode(prev)) {
122
- i++
123
- continue
124
- }
125
-
126
- if (source.startsWith('import', i)) {
127
- const next = source.charCodeAt(i + 6)
128
- if (isIdentCharCode(next)) {
129
- i++
130
- continue
131
- }
132
-
133
- const j = skipWhitespace(i + 6)
134
- // `import.meta` is ESM-only
135
- if (source[j] === '.') return true
136
- // `import(` is dynamic import, allowed in CJS
137
- if (source[j] === '(') {
138
- i = j + 1
139
- continue
140
- }
141
- // Otherwise assume it's a static import form
142
- return true
143
- }
144
- }
145
-
146
- i++
147
- }
148
-
149
- return false
150
- }
151
-
152
36
  // Cached exports for Node built-in modules
153
37
  const BUILT_INS = new Map()
154
38
 
@@ -189,6 +73,24 @@ function getExportsForNodeBuiltIn (name) {
189
73
 
190
74
  const urlsBeingProcessed = new Set() // Guard against circular imports.
191
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
+
192
94
  /**
193
95
  * This function looks for the package.json which contains the specifier trying to resolve.
194
96
  * Once the package.json file has been found, we extract the file path from the specifier
@@ -328,6 +230,11 @@ function * getCjsExports (url, context, source) {
328
230
  * be included in the result set.
329
231
  */
330
232
  export function * getExports (url, context) {
233
+ const cached = esmExportsCache.get(url)
234
+ if (cached !== undefined) {
235
+ return cached.exportNames
236
+ }
237
+
331
238
  // `[LOAD, ...]` gives us the possibility of getting the source from an
332
239
  // upstream loader. This doesn't always work though, so later on we fall back
333
240
  // to reading it from disk.
@@ -362,28 +269,38 @@ export function * getExports (url, context) {
362
269
  }
363
270
 
364
271
  try {
365
- if (format === 'module') {
366
- return getEsmExports(source)
272
+ // Node hands the load hook the original TypeScript source, so strip the
273
+ // types before the JS parsers run, then treat the module as the JS it
274
+ // compiles to. Early type-stripping releases tag the format but lack the
275
+ // API; the un-stripped parse then fails cleanly into onWrapFailure.
276
+ let moduleFormat = format
277
+ if (format === 'module-typescript' || format === 'commonjs-typescript') {
278
+ if (stripTypeScriptTypes !== undefined) {
279
+ source = stripTypeScriptTypes(source, { mode: 'strip' })
280
+ }
281
+ moduleFormat = format === 'module-typescript' ? 'module' : 'commonjs'
367
282
  }
368
283
 
369
- if (format === 'commonjs') {
284
+ if (moduleFormat === 'commonjs') {
370
285
  return yield * getCjsExports(url, context, source)
371
286
  }
372
287
 
373
- // At this point our `format` is either undefined or not known by us. Fall
374
- // back to parsing as ESM/CJS.
375
- const esmExports = getEsmExports(source)
376
- if (!esmExports.size) {
377
- // If there's strong evidence this is ESM (static import/import.meta),
378
- // prefer returning the empty ESM export set over incorrectly treating it
379
- // as CJS.
380
- if (!hasEsmSyntax(source)) {
381
- // It might be possible to get here if the format
382
- // isn't set at first and yet we have an ESM module with no exports.
383
- return yield * getCjsExports(url, context, source)
384
- }
288
+ const { exportNames, hasModuleSyntax } = lexEsm(source)
289
+
290
+ if (moduleFormat === 'module') {
291
+ esmExportsCache.set(url, { exportNames })
292
+ return exportNames
293
+ }
294
+
295
+ // At this point our `format` is either undefined or not known by us. When
296
+ // there are no exports and no ESM syntax, fall back to CommonJS detection.
297
+ // Strong evidence of ESM (static import/import.meta) keeps the empty ESM
298
+ // export set rather than incorrectly treating the module as CJS.
299
+ if (!exportNames.size && !hasModuleSyntax) {
300
+ return yield * getCjsExports(url, context, source)
385
301
  }
386
- return esmExports
302
+ esmExportsCache.set(url, { exportNames })
303
+ return exportNames
387
304
  } catch (cause) {
388
305
  const err = new Error(`Failed to parse '${url}'`)
389
306
  err.cause = cause
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.2.0",
3
+ "version": "3.3.1",
4
4
  "description": "Intercept imports in Node.js",
5
5
  "engines": {
6
6
  "node": ">=18"
@@ -59,9 +59,8 @@
59
59
  "vue": "^3.5.26"
60
60
  },
61
61
  "dependencies": {
62
- "acorn": "^8.15.0",
63
- "acorn-import-attributes": "^1.9.5",
64
62
  "cjs-module-lexer": "^2.2.0",
63
+ "es-module-lexer": "^2.2.0",
65
64
  "module-details-from-path": "^1.0.4"
66
65
  }
67
66
  }
@@ -17,7 +17,8 @@ export type RegisterHooksOptions = {
17
17
  * synchronous hooks run on the application thread, so `Hook()` registrations are
18
18
  * visible to the loader directly and no acknowledgement step is required.
19
19
  *
20
- * Requires a Node.js version with `module.registerHooks()` (>= 22.15.0 / >= 24).
20
+ * Requires a Node.js version where {@link supportsSyncHooks} is `true`
21
+ * (>= 22.22.3, >= 24.11.1, >= 25.1.0, or >= 26.0.0).
21
22
  *
22
23
  * ```ts
23
24
  * import { register } from 'import-in-the-middle/register-hooks.mjs'
@@ -30,6 +31,14 @@ export type RegisterHooksOptions = {
30
31
  * })
31
32
  * ```
32
33
  *
33
- * @throws If `module.registerHooks()` is unavailable in the running Node.js.
34
+ * @throws If {@link supportsSyncHooks} is `false` in the running Node.js.
34
35
  */
35
36
  export declare function register(options?: RegisterHooksOptions): void
37
+
38
+ /**
39
+ * Whether the running Node.js can correctly run the synchronous loader via
40
+ * `module.registerHooks()`. `false` on versions that ship `module.registerHooks()`
41
+ * but predate the nullish-CommonJS-`source` fix (nodejs/node#59929); branch on it
42
+ * to fall back to the asynchronous `module.register()` loader.
43
+ */
44
+ export declare function supportsSyncHooks(): boolean
@@ -1,5 +1,6 @@
1
1
  import * as module from 'module'
2
- import { createHook, supportsSyncHooks } from './create-hook.mjs'
2
+ import { createHook } from './create-hook.mjs'
3
+ import { supportsSyncHooks } from './supports-sync-hooks.mjs'
3
4
 
4
5
  export { supportsSyncHooks }
5
6
 
@@ -0,0 +1,47 @@
1
+ // Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2.0 License.
2
+ //
3
+ // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc.
4
+
5
+ // This module intentionally has no imports. `supportsSyncHooks` is a pure
6
+ // Node.js version check, and consumers (e.g. a CommonJS preloader deciding
7
+ // whether to require the ESM loader at all) need to call it without pulling in
8
+ // `create-hook.mjs` and its acorn / cjs-module-lexer dependency graph.
9
+
10
+ // `process.versions.node` is always "major.minor.patch" (nightlies append a
11
+ // "-suffix" that parseInt stops at). Only release lines 22/24/25 need the minor
12
+ // and patch, so parse the major eagerly and read those lazily.
13
+ const version = process.versions.node
14
+ const NODE_MAJOR = parseInt(version, 10)
15
+ let NODE_MINOR
16
+ let NODE_PATCH
17
+
18
+ function readMinorAndPatch () {
19
+ const firstDot = version.indexOf('.')
20
+ const secondDot = version.indexOf('.', firstDot + 1)
21
+ NODE_MINOR = parseInt(version.slice(firstDot + 1, secondDot), 10)
22
+ NODE_PATCH = parseInt(version.slice(secondDot + 1), 10)
23
+ }
24
+
25
+ /**
26
+ * Whether the running Node.js can correctly run the synchronous loader via
27
+ * `module.registerHooks`.
28
+ *
29
+ * `module.registerHooks` exists since v22.15, but its synchronous load hook
30
+ * rejected the nullish CommonJS `source` the loader returns for `require()`s
31
+ * pulled into the ESM graph (throwing `ERR_INVALID_RETURN_PROPERTY_VALUE`) until
32
+ * https://github.com/nodejs/node/pull/59929, released in 22.22.3, 24.11.1,
33
+ * 25.1.0 and 26.0.0. Earlier 24.x (<= 24.11.0) and 25.0.0 ship `registerHooks`
34
+ * but predate the fix, so the synchronous loader must fall back to the
35
+ * asynchronous one there.
36
+ *
37
+ * @returns {boolean}
38
+ */
39
+ export function supportsSyncHooks () {
40
+ if (NODE_MAJOR >= 26) return true
41
+ if (NODE_MAJOR < 22 || NODE_MAJOR === 23) return false
42
+
43
+ readMinorAndPatch()
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
+ return NODE_MINOR > 22 || (NODE_MINOR === 22 && NODE_PATCH >= 3)
47
+ }