From 40dd93474307b640e9476d5852a74651e05f56f3 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 10 Sep 2026 00:07:41 +0200 Subject: [PATCH] fix: restore legacy bundler compatibility Bundler integrations written against the pre-3.5 internals expect getExports() to return a Set and register() to accept namespace setter/getter maps. The 3.5.0 shape changes otherwise fail during wrapper generation and runtime registration. Keep both old shapes behind compatibility entry points while retaining the optimized loader path for current wrappers. Refs: https://github.com/DataDog/dd-trace-js/pull/10241 --- create-hook.mjs | 6 +- lib/get-esm-exports.mjs | 2 +- lib/get-exports.mjs | 26 ++++++- lib/register.js | 70 ++++++++++++++++++- .../get-exports-builtin-non-enumerable.mjs | 2 +- test/get-esm-exports/get-exports-compat.mjs | 26 +++++++ test/low-level/register-compat.mjs | 52 ++++++++++++++ 7 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 test/get-esm-exports/get-exports-compat.mjs create mode 100644 test/low-level/register-compat.mjs diff --git a/create-hook.mjs b/create-hook.mjs index 1ca5384..362e252 100644 --- a/create-hook.mjs +++ b/create-hook.mjs @@ -5,7 +5,7 @@ import { URL, fileURLToPath } from 'url' import { inspect } from 'util' import { builtinModules } from 'module' -import { getExports } from './lib/get-exports.mjs' +import { getModuleExports } from './lib/get-exports.mjs' import { RESOLVE, driveSync, driveAsync } from './lib/io.mjs' import { supportsSyncHooks } from './supports-sync-hooks.mjs' @@ -204,7 +204,7 @@ function shouldExcludeExport (name, sourceUrl) { * * Written as a "sans-io" generator (see `lib/io.mjs`): instead of calling the * loader's resolve/load hooks directly it `yield`s `[RESOLVE, ...]` to resolve - * star re-exports and `[LOAD, ...]` (via {@link getExports}) to read source, + * star re-exports and `[LOAD, ...]` (via {@link getModuleExports}) to read source, * and is driven by either {@link driveSync} (for * `module.registerHooks`) or {@link driveAsync} (for `module.register`). The * body is identical for both, so there is a single implementation to maintain. @@ -228,7 +228,7 @@ function shouldExcludeExport (name, sourceUrl) { * for a module with no `export *`. */ function * processModule ({ srcUrl, context, excludeDefault = false, depth = 0, seen }) { - const { exportNames, starReexports } = yield * getExports(srcUrl, context) + const { exportNames, starReexports } = yield * getModuleExports(srcUrl, context) // Most modules have no export star. Keep that path array-backed so it pays // neither merge bookkeeping nor a Map lookup for each direct export. diff --git a/lib/get-esm-exports.mjs b/lib/get-esm-exports.mjs index 4d2c275..8813f6c 100644 --- a/lib/get-esm-exports.mjs +++ b/lib/get-esm-exports.mjs @@ -49,7 +49,7 @@ export default function getEsmExports (moduleSource) { /** * Lexes ESM source code once and reports both the exported identifiers and * whether the source uses ESM syntax. Sharing a single `parse` lets the - * unknown-format path in `getExports` decide between ESM and CommonJS without a + * unknown-format path in `getModuleExports` decide between ESM and CommonJS without a * second pass over the source. * * `hasModuleSyntax` is es-module-lexer's own signal: static `import`/`export` diff --git a/lib/get-exports.mjs b/lib/get-exports.mjs index c309079..bdaf5c6 100644 --- a/lib/get-exports.mjs +++ b/lib/get-exports.mjs @@ -200,7 +200,7 @@ function * getCjsExports (url, context, source) { continue } - const child = yield * getExports(newUrl, context) + const child = yield * getModuleExports(newUrl, context) for (const each of child.exportNames) { full.add(each) } @@ -244,7 +244,7 @@ function * getCjsExports (url, context, source) { * operations and ultimately returns the identifiers and star re-exports of the * module. */ -export function * getExports (url, context) { +export function * getModuleExports (url, context) { const cached = esmExportsCache.get(url) if (cached !== undefined) { return cached @@ -322,3 +322,25 @@ export function * getExports (url, context) { throw err } } + +/** + * Preserves the iterable export result used before import-in-the-middle 3.5.0. + * + * @param {string} url A file URL string pointing to the module to inspect. + * @param {object} context Context object as provided by the loaders API. + * @returns {Generator>} + */ +export function * getExports (url, context) { + const { exportNames, starReexports } = yield * getModuleExports(url, context) + const legacyExports = starReexports === undefined && exportNames instanceof Set + ? exportNames + : new Set(exportNames) + + if (starReexports !== undefined) { + for (const { specifier } of starReexports) { + legacyExports.add(`* from ${specifier}`) + } + } + + return legacyExports +} diff --git a/lib/register.js b/lib/register.js index ff5f167..7be6526 100644 --- a/lib/register.js +++ b/lib/register.js @@ -4,6 +4,9 @@ const importHooks = [] // TODO should this be a Set? const binders = new WeakMap() +let legacySetters +let legacyGetters +let legacyProxyHandler const specifiers = new Map() const toHook = [] @@ -41,12 +44,75 @@ function defineExport (target, name, descriptor) { const proxyHandler = { defineProperty: defineExport, set: setExport } +/** + * @param {object} target The proxy target. + * @param {string | symbol} name The export name. + * @param {unknown} value The replacement value. + */ +function setLegacyExport (target, name, value) { + const setter = legacySetters.get(target)?.[name] + return typeof setter === 'function' ? setter(value) : true +} + +/** + * @param {object} target The proxy target. + * @param {string | symbol} name The export name. + */ +function getLegacyExport (target, name) { + if (name === Symbol.toStringTag) return 'Module' + + const getter = legacyGetters.get(target)[name] + if (typeof getter === 'function') return getter() +} + +/** + * @param {object} target The proxy target. + * @param {string | symbol} name The export name. + * @param {PropertyDescriptor} descriptor The replacement descriptor. + */ +function defineLegacyExport (target, name, descriptor) { + if (!('value' in descriptor)) { + throw new Error('Getters/setters are not supported for exports property descriptors.') + } + return setLegacyExport(target, name, descriptor.value) +} + /** * @param {string} name The wrapped module URL. - * @param {ModuleBinder} binder The wrapper's binding state. + * @param {object} namespace The wrapper's module namespace. + * @param {object} set The wrapper's export setters. + * @param {object} get The wrapper's export getters. * @param {string} specifier The original import specifier. */ -function register (name, binder, specifier) { +function registerLegacy (name, namespace, set, get, specifier) { + legacySetters ??= new WeakMap() + legacyGetters ??= new WeakMap() + legacyProxyHandler ??= { + defineProperty: defineLegacyExport, + get: getLegacyExport, + set: setLegacyExport + } + specifiers.set(name, specifier) + legacySetters.set(namespace, set) + legacyGetters.set(namespace, get) + const proxy = new Proxy(namespace, legacyProxyHandler) + importHooks.forEach(hook => hook(name, proxy, specifier)) + toHook.push([name, proxy, specifier]) +} + +/** + * @param {string} name The wrapped module URL. + * @param {ModuleBinder | object} binder The wrapper's binding state or legacy namespace. + * @param {string | object} specifier The original import specifier or legacy setters. + * @param {object} [get] The legacy export getters. + * @param {string} [legacySpecifier] The legacy original import specifier. + */ +function register (name, binder, specifier, get, legacySpecifier) { + if (arguments.length === 5) { + registerLegacy(name, binder, specifier, get, legacySpecifier) + return + } + const { namespace } = binder specifiers.set(name, specifier) binders.set(namespace, binder) diff --git a/test/get-esm-exports/get-exports-builtin-non-enumerable.mjs b/test/get-esm-exports/get-exports-builtin-non-enumerable.mjs index 0c1f8f0..7fc924c 100644 --- a/test/get-esm-exports/get-exports-builtin-non-enumerable.mjs +++ b/test/get-esm-exports/get-exports-builtin-non-enumerable.mjs @@ -24,7 +24,7 @@ const nonEnumerableNames = Object.getOwnPropertyNames(moduleValue) const io = { load: async () => ({ source: null, format: 'builtin' }) } -const { exportNames } = await driveAsync(getExports(builtin, { format: 'builtin' }), io) +const exportNames = await driveAsync(getExports(builtin, { format: 'builtin' }), io) // The whole point: non-enumerable own properties (e.g. `prototype`) that // Object.keys would miss must still be discovered. diff --git a/test/get-esm-exports/get-exports-compat.mjs b/test/get-esm-exports/get-exports-compat.mjs new file mode 100644 index 0000000..839c966 --- /dev/null +++ b/test/get-esm-exports/get-exports-compat.mjs @@ -0,0 +1,26 @@ +import { deepStrictEqual, strictEqual } from 'assert' + +import { getExports } from '../../lib/get-exports.mjs' +import { driveAsync } from '../../lib/io.mjs' + +const modules = new Map([ + ['file:///direct.mjs', 'export const direct = 1'], + ['file:///star.mjs', 'export const direct = 1; export * from "./dependency.mjs"'] +]) + +const io = { + /** + * @param {string} url The module URL. + */ + async load (url) { + return { source: modules.get(url), format: 'module' } + } +} + +const directExports = await driveAsync(getExports('file:///direct.mjs', { format: 'module' }), io) +strictEqual(directExports instanceof Set, true) +deepStrictEqual([...directExports], ['direct']) + +const starExports = await driveAsync(getExports('file:///star.mjs', { format: 'module' }), io) +strictEqual(starExports instanceof Set, true) +deepStrictEqual([...starExports], ['direct', '* from ./dependency.mjs']) diff --git a/test/low-level/register-compat.mjs b/test/low-level/register-compat.mjs new file mode 100644 index 0000000..a553f8a --- /dev/null +++ b/test/low-level/register-compat.mjs @@ -0,0 +1,52 @@ +import { createRequire } from 'module' +import { strictEqual, throws } from 'assert' + +const require = createRequire(import.meta.url) +const { importHooks, register, specifiers } = require('../../lib/register.js') + +let value = 42 +let legacyExports + +/** @param {unknown} nextValue The replacement export value. */ +function setValue (nextValue) { + value = nextValue + return true +} + +function getValue () { + return value +} + +/** + * @param {string} name The module URL. + * @param {object} exports The intercepted exports. + * @param {string} specifier The original import specifier. + */ +function onImport (name, exports, specifier) { + strictEqual(name, 'file:///legacy.mjs') + strictEqual(specifier, 'legacy') + legacyExports = exports +} + +importHooks.push(onImport) +try { + const namespace = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } }) + namespace.foo = value + register('file:///legacy.mjs', namespace, { foo: setValue }, { foo: getValue }, 'legacy') + + strictEqual(specifiers.get('file:///legacy.mjs'), 'legacy') + strictEqual(legacyExports.foo, 42) + strictEqual(legacyExports[Symbol.toStringTag], 'Module') + strictEqual(Reflect.set(legacyExports, 'foo', 43), true) + strictEqual(legacyExports.foo, 43) + strictEqual(Reflect.defineProperty(legacyExports, 'foo', { value: 44 }), true) + strictEqual(legacyExports.foo, 44) + strictEqual(Reflect.set(legacyExports, 'missing', 1), true) + strictEqual(legacyExports.missing, undefined) + throws( + () => Object.defineProperty(legacyExports, 'foo', { get: getValue }), + /Getters\/setters are not supported/ + ) +} finally { + importHooks.pop() +}