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() +}