Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions create-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/get-esm-exports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
26 changes: 24 additions & 2 deletions lib/get-exports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Array, Set<string>>}
*/
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
}
70 changes: 68 additions & 2 deletions lib/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions test/get-esm-exports/get-exports-compat.mjs
Original file line number Diff line number Diff line change
@@ -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'])
52 changes: 52 additions & 0 deletions test/low-level/register-compat.mjs
Original file line number Diff line number Diff line change
@@ -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()
}