import-in-the-middle 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [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
+
5
+
6
+ ### Features
7
+
8
+ * 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))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * 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))
14
+ * 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))
15
+ * 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))
16
+
17
+
18
+ ### Performance Improvements
19
+
20
+ * 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))
21
+ * 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))
22
+
23
+ ## [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)
24
+
25
+
26
+ ### Features
27
+
28
+ * add shouldInclude predicate for consumer-owned module matching ([#255](https://github.com/nodejs/import-in-the-middle/issues/255)) ([b2590d0](https://github.com/nodejs/import-in-the-middle/commit/b2590d054a5cfc6d6820c06edb4ff4987a10b5c4))
29
+
3
30
  ## [3.1.0](https://github.com/nodejs/import-in-the-middle/compare/import-in-the-middle-v3.0.2...import-in-the-middle-v3.1.0) (2026-06-17)
4
31
 
5
32
 
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,65 @@ 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`.
166
+
167
+ ### Custom matching with `shouldInclude`
168
+
169
+ Instead of `include` / `exclude` lists, you can pass a `shouldInclude(url, specifier)`
170
+ predicate to decide which modules are intercepted. It is called for every resolved
171
+ module with the resolved URL and the import specifier; return a truthy value to
172
+ intercept the module. When a predicate is provided it takes over the decision and
173
+ the `include` / `exclude` options are ignored.
174
+
175
+ This is useful when matching doesn't map cleanly onto bare specifiers, file URLs and
176
+ regular expressions — for example a matcher built from your own configuration, or a
177
+ decision that depends on more than the specifier.
178
+
179
+ ```js
180
+ import { register } from 'import-in-the-middle/register-hooks.mjs'
181
+
182
+ register({
183
+ shouldInclude (url, specifier) {
184
+ return specifier === 'package-i-want-to-include' ||
185
+ url.includes('/node_modules/some-scope/')
186
+ }
187
+ })
188
+ ```
189
+
190
+ The predicate receives only the URL and the specifier, never a resolved file path.
191
+ Because `module.register()` transfers its `data` to the loader thread by structured
192
+ clone — which cannot carry a function — `shouldInclude` is supported for synchronous
193
+ registration (`register-hooks.mjs`, shown above) and for predicates constructed on
194
+ the loader thread; it is not accepted through the `data` option of the asynchronous
195
+ `module.register('import-in-the-middle/hook.mjs', ...)`.
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.
147
224
 
148
225
  ## Limitations
149
226
 
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
+ // `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' : ''
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
+ __bind(${objectKey}, ${namespaceVar}, v => { ${variableName} = v }, () => ${variableName}${fallback})
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) {
@@ -353,6 +408,7 @@ export function createHook (meta) {
353
408
  let cachedResolve
354
409
  const iitmURL = new URL('lib/register.js', meta.url).toString()
355
410
  let includeModules, excludeModules
411
+ let shouldInclude = defaultShouldInclude
356
412
 
357
413
  // Track CJS module URLs that IITM has wrapped. On Node 24+, CJS modules loaded
358
414
  // via loadCJSModule (in an ESM import chain) have their require() calls for
@@ -362,6 +418,43 @@ export function createHook (meta) {
362
418
  // patterns like `class App extends require('events') {}`.
363
419
  const cjsInIitmChain = new Set()
364
420
 
421
+ // Default matcher, used unless the consumer supplies its own `shouldInclude`
422
+ // (see applyOptions). It applies the include/exclude lists, so finishResolve
423
+ // always has a predicate to call and never has to special-case its absence.
424
+ //
425
+ // We check the specifier to match libraries loaded with bare specifiers from
426
+ // node_modules, and the full file URL for non-bare specifier imports (relative
427
+ // paths would be error prone). An absolute path entry added via Hook over the
428
+ // message port matches the resolved file path, so it is resolved here.
429
+ function defaultShouldInclude (url, specifier) {
430
+ let resultPath
431
+ if (url.startsWith('file:')) {
432
+ const stackTraceLimit = Error.stackTraceLimit
433
+ Error.stackTraceLimit = 0
434
+ try {
435
+ resultPath = fileURLToPath(url)
436
+ } catch {}
437
+ Error.stackTraceLimit = stackTraceLimit
438
+ }
439
+ function match (each) {
440
+ if (each instanceof RegExp) {
441
+ return each.test(url)
442
+ }
443
+
444
+ return each === specifier || each === url || (resultPath && each === resultPath)
445
+ }
446
+
447
+ if (includeModules && !includeModules.some(match)) {
448
+ return false
449
+ }
450
+
451
+ if (excludeModules && excludeModules.some(match)) {
452
+ return false
453
+ }
454
+
455
+ return true
456
+ }
457
+
365
458
  // Applies the include/exclude/message-port configuration. Shared by the
366
459
  // asynchronous `initialize` (off-thread `module.register`, which receives
367
460
  // `data` over the registration boundary) and by synchronous registration
@@ -371,6 +464,13 @@ export function createHook (meta) {
371
464
  includeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.include, 'include')
372
465
  excludeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.exclude, 'exclude')
373
466
 
467
+ // A consumer can supply its own matcher as `shouldInclude(url, specifier)`,
468
+ // taking ownership of the include/exclude decision instead of expressing it
469
+ // as bare-specifier / file-URL / regex lists. It replaces the default list
470
+ // matcher and is called with the resolved URL and specifier; otherwise the
471
+ // default applies the include/exclude options.
472
+ shouldInclude = typeof data.shouldInclude === 'function' ? data.shouldInclude : defaultShouldInclude
473
+
374
474
  if (data.addHookMessagePort) {
375
475
  data.addHookMessagePort.on('message', (modules) => {
376
476
  if (includeModules === undefined) {
@@ -417,6 +517,12 @@ export function createHook (meta) {
417
517
  return result
418
518
  }
419
519
 
520
+ // Never wrap a module whose format we don't handle (e.g. json, wasm); this
521
+ // holds regardless of how inclusion is decided below.
522
+ if (result.format && !HANDLED_FORMATS.has(result.format)) {
523
+ return result
524
+ }
525
+
420
526
  // The synchronous hooks (`module.registerHooks`) fire for `require()` as well
421
527
  // as `import`, but iitm only owns the ESM graph: CommonJS modules are
422
528
  // instrumented separately through require-in-the-middle, and `require()` must
@@ -430,37 +536,9 @@ export function createHook (meta) {
430
536
  return result
431
537
  }
432
538
 
433
- // For included/excluded modules, we check the specifier to match libraries
434
- // that are loaded with bare specifiers from node_modules.
435
- //
436
- // For non-bare specifier imports, we match to the full file URL because
437
- // using relative paths would be very error prone!
438
- let resultPath
439
- if (result.url.startsWith('file:')) {
440
- const stackTraceLimit = Error.stackTraceLimit
441
- Error.stackTraceLimit = 0
442
- try {
443
- resultPath = fileURLToPath(result.url)
444
- } catch {}
445
- Error.stackTraceLimit = stackTraceLimit
446
- }
447
- function match (each) {
448
- if (each instanceof RegExp) {
449
- return each.test(result.url)
450
- }
451
-
452
- return each === specifier || each === result.url || (resultPath && each === resultPath)
453
- }
454
-
455
- if (result.format && !HANDLED_FORMATS.has(result.format)) {
456
- return result
457
- }
458
-
459
- if (includeModules && !includeModules.some(match)) {
460
- return result
461
- }
462
-
463
- if (excludeModules && excludeModules.some(match)) {
539
+ // `shouldInclude` is always set (the include/exclude list matcher by default,
540
+ // a consumer-provided predicate otherwise), so no nullish check is needed.
541
+ if (!shouldInclude(result.url, specifier)) {
464
542
  return result
465
543
  }
466
544
 
@@ -559,11 +637,23 @@ export function createHook (meta) {
559
637
  // Builds the wrapper module source that re-exports the real module through
560
638
  // iitm's proxy. Pure string generation shared by the asynchronous and
561
639
  // synchronous `load` paths.
562
- function buildWrapperSource (realUrl, setters, originalSpecifier) {
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`
650
+ }
651
+ }
652
+
563
653
  return `
564
654
  import { register } from '${iitmURL}'
565
655
  import * as namespace from ${JSON.stringify(realUrl)}
566
-
656
+ ${originImports}
567
657
  // Mimic a Module object (https://tc39.es/ecma262/#sec-module-namespace-objects).
568
658
  const _ = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } })
569
659
  const set = {}
@@ -598,6 +688,31 @@ function __flushPendingOnce () {
598
688
  __pending = next
599
689
  }
600
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
+ }
715
+
601
716
  ${Array.from(setters.values()).join('\n')}
602
717
 
603
718
  if (__pending.length > 0) {
@@ -635,13 +750,13 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
635
750
  // succeeds: free the specifier entry early, and remember CJS modules so their
636
751
  // transitive require() chain bypasses iitm (see `load`). Returns the wrapper
637
752
  // module source.
638
- function onWrapSuccess (realUrl, context, originalSpecifier, setters) {
753
+ function onWrapSuccess (realUrl, context, originalSpecifier, setters, originNamespaces) {
639
754
  specifiers.delete(realUrl)
640
755
  // context.format is set to 'commonjs' by getCjsExports during processModule.
641
756
  if (context.format === 'commonjs') {
642
757
  cjsInIitmChain.add(realUrl)
643
758
  }
644
- return buildWrapperSource(realUrl, setters, originalSpecifier)
759
+ return buildWrapperSource(realUrl, setters, originalSpecifier, originNamespaces)
645
760
  }
646
761
 
647
762
  // Bookkeeping shared by the async and sync wrap paths when `processModule`
@@ -662,11 +777,11 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
662
777
  const originalSpecifier = specifiers.get(realUrl)
663
778
 
664
779
  try {
665
- const setters = await driveAsync(
780
+ const { setters, originNamespaces } = await driveAsync(
666
781
  processModule({ srcUrl: realUrl, context }),
667
782
  { resolve: cachedResolve, load: parentGetSource }
668
783
  )
669
- return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters) }
784
+ return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters, originNamespaces) }
670
785
  } catch (cause) {
671
786
  onWrapFailure(realUrl, cause)
672
787
  // Revert back to the non-iitm URL
@@ -686,11 +801,11 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci
686
801
  const originalSpecifier = specifiers.get(realUrl)
687
802
 
688
803
  try {
689
- const setters = driveSync(
804
+ const { setters, originNamespaces } = driveSync(
690
805
  processModule({ srcUrl: realUrl, context }),
691
806
  { resolve: cachedResolve, load: nextLoad }
692
807
  )
693
- return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters) }
808
+ return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters, originNamespaces) }
694
809
  } catch (cause) {
695
810
  onWrapFailure(realUrl, cause)
696
811
  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
 
@@ -362,28 +246,36 @@ export function * getExports (url, context) {
362
246
  }
363
247
 
364
248
  try {
365
- if (format === 'module') {
366
- return getEsmExports(source)
249
+ // Node hands the load hook the original TypeScript source, so strip the
250
+ // types before the JS parsers run, then treat the module as the JS it
251
+ // compiles to. Early type-stripping releases tag the format but lack the
252
+ // API; the un-stripped parse then fails cleanly into onWrapFailure.
253
+ let moduleFormat = format
254
+ if (format === 'module-typescript' || format === 'commonjs-typescript') {
255
+ if (stripTypeScriptTypes !== undefined) {
256
+ source = stripTypeScriptTypes(source, { mode: 'strip' })
257
+ }
258
+ moduleFormat = format === 'module-typescript' ? 'module' : 'commonjs'
367
259
  }
368
260
 
369
- if (format === 'commonjs') {
261
+ if (moduleFormat === 'commonjs') {
370
262
  return yield * getCjsExports(url, context, source)
371
263
  }
372
264
 
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
- }
265
+ const { exportNames, hasModuleSyntax } = lexEsm(source)
266
+
267
+ if (moduleFormat === 'module') {
268
+ return exportNames
269
+ }
270
+
271
+ // At this point our `format` is either undefined or not known by us. When
272
+ // there are no exports and no ESM syntax, fall back to CommonJS detection.
273
+ // Strong evidence of ESM (static import/import.meta) keeps the empty ESM
274
+ // export set rather than incorrectly treating the module as CJS.
275
+ if (!exportNames.size && !hasModuleSyntax) {
276
+ return yield * getCjsExports(url, context, source)
385
277
  }
386
- return esmExports
278
+ return exportNames
387
279
  } catch (cause) {
388
280
  const err = new Error(`Failed to parse '${url}'`)
389
281
  err.cause = cause
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "import-in-the-middle",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
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
+ }