diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e81ced9..8f7c3af7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,11 +35,6 @@ jobs: - 21.x - 22.x - 24.x - os: - - ubuntu-latest - - macos-latest - - windows-latest - steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use Node.js ${{ matrix.node-version }} @@ -50,19 +45,41 @@ jobs: - run: npm test - name: Rename coverage file run: > - mv coverage/lcov.info coverage/${{ matrix.node-version }}_${{ matrix.os }}_lcov.info + mv coverage/lcov.info coverage/${{ matrix.node-version }}_lcov.info - name: Archive code coverage results if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: coverage_${{ matrix.os }}_${{ matrix.node-version}} + name: coverage_${{ matrix.node-version}} if-no-files-found: ignore - path: coverage/${{ matrix.node-version }}_${{ matrix.os }}_lcov.info + path: coverage/${{ matrix.node-version }}_lcov.info # This will clobber any coverage generated by the previous `npm test`. # We are opting to omit TS coverage and stick to pass or fail only for TS. - run: npm run test:ts + bundlers: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + node-version: + - 18.5.0 + - 24.x + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npx imhotap --files test/low-level/bundler.mjs test/other/v18-bundlers.mjs + coverage: runs-on: ubuntu-latest diff --git a/README.md b/README.md index eae9f47e..c84af101 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # import-in-the-middle **`import-in-the-middle`** is a module loading interceptor inspired by -[`require-in-the-middle`](https://npm.im/require-in-the-middle), but -specifically for ESM modules. In fact, it can even modify modules after loading -time. +[`require-in-the-middle`](https://npm.im/require-in-the-middle). It intercepts +ESM modules and can opt into CommonJS when synchronous hooks are available. ## Usage @@ -115,6 +114,88 @@ fs.readFileSync('file.txt') node --import=./instrument.mjs ./my-app.mjs ``` +## Bundler integrations + +> **Note:** The bundler integration API is experimental. It may change in minor versions. + +Bundlers can generate ESM and CommonJS wrappers with +`createWrapperModule`: + +```js +import { createWrapperModule } from 'import-in-the-middle/bundler.mjs' + +const wrapper = await createWrapperModule({ + module: { url, format, source, specifier, data, passthroughExports }, + resolve, + load +}) +``` + +`format` is optional. When omitted, IITM detects ESM or CommonJS from the source before it creates the wrapper. + +CommonJS integrations can use the lazy-loading facade. Both entry points expose +`getPackageDetails`, which finds the nearest named package for a resolved file. +The CommonJS facade also exposes the module format detection used by the Node +loader: + +```js +const { + createWrapperModule, + getNodeModuleFormat, + getPackageDetails +} = require('import-in-the-middle/bundler') + +const packageDetails = getPackageDetails(url) +const format = getNodeModuleFormat(url, packageDetails?.packageJsonUrl, packageDetails?.type) +``` + +`getPackageDetails` accepts a resolved `file:` URL. It returns the package +`name`, optional `version` and `type`, package and `package.json` URLs, and the +slash-separated module `path`. It walks past unnamed package scopes, which lets +bundlers identify linked workspace packages without relying on a `node_modules` +path. It returns `undefined` when no named package owns the URL. + +`url` is the canonical `file:` or `node:` URL reported to hooks. `resolve` and +`load` adapt the bundler's resolver and source loader to the same URL-based +module graph. `resolve` always receives the declaring module's `parentURL`. +Both callbacks can be omitted when an explicitly formatted CommonJS module +supplies its source. Only `load` is required when that source is omitted. + +`getNodeModuleFormat` returns `undefined` for typeless `.js` and `.ts` files. +The bundler must determine their format from the source. + +The package helpers read current metadata on each call. A bundler can cache +their results for one build and discard that cache before a watch rebuild. + +The optional `data` value must be JSON-serializable. It is embedded in the +wrapper and passed as the fourth argument to `Hook` callbacks, allowing package +metadata needed by instrumentation to reach the bundled runtime. + +`passthroughExports` identifies ESM exports that must keep their original live +bindings. It accepts an iterable of names or a selector that receives all +resolved exports after IITM loads the module graph. Each resolved export has +the public `name`, the defining module `url`, and its `localName` when it has a +local ESM binding. IITM emits direct re-exports for selected names and exposes +their current values to `Hook` callbacks. Assignments from a callback do not +replace these bindings. Other exports remain patchable. This option has no +effect on CommonJS modules, and names that the module does not export are +ignored. + +The result contains generated `code`, an `imports` manifest, `watchFiles`, and +`sideEffects: true`. The code imports only relative placeholder specifiers. The +bundler adapter provides it as a virtual module and maps each placeholder using +the manifest, so filesystem paths, virtual IDs, external modules, and cache +invalidation remain owned by the bundler. `watchFiles` are file URLs that the +adapter converts to its native watch-dependency format. + +CommonJS results also contain `sourceLineOffset`. It specifies the number of +generated lines before the original source. The source starts at column zero, +so an adapter can shift an existing source map without parsing the wrapper. + +The runtime import in the manifest must be bundled with the wrapper. Keeping it +external can create a second hook registry at runtime. It is CommonJS and must +go through the bundler's normal CommonJS transform. + ## Synchronous loader hooks On Node.js versions that support @@ -150,7 +231,7 @@ if (supportsSyncHooks()) { import { register } from 'import-in-the-middle/register-hooks.mjs' import { Hook } from 'import-in-the-middle' -register({ include: ['package-i-want-to-include'] }) +register({ include: ['package-i-want-to-include'], commonjs: true }) Hook(['package-i-want-to-include'], (exported, name, baseDir) => { // Instrument the module @@ -163,6 +244,9 @@ node --import=./instrument.mjs ./my-app.mjs `register()` accepts the same `include` / `exclude` options as the asynchronous loader and throws on a Node.js version where `supportsSyncHooks()` is `false`. +Set `commonjs: true` to intercept both `require()` and ESM loaded through +`require()`. This is opt-in so consumers can continue using +`require-in-the-middle` for CommonJS without double instrumentation. ### Custom matching with `shouldInclude` @@ -254,7 +338,8 @@ On Node.js versions where type stripping is not enabled by default, run with * You cannot add new exports to a module. You can only modify existing ones. * While bindings to module exports end up being "re-bound" when modified in a hook, dynamically imported modules cannot be altered after they're loaded. -* Modules loaded via `require` are not affected at all. +* Modules loaded via `require` are only affected by synchronous registration + with `commonjs: true`. * A module's set of export *names* is assumed to be stable for the lifetime of the process. `import-in-the-middle` reads a module's source once to lex its exports and reuses that export set on later loads of the same URL. An upstream diff --git a/bundler.d.mts b/bundler.d.mts new file mode 100644 index 00000000..99036e30 --- /dev/null +++ b/bundler.d.mts @@ -0,0 +1,140 @@ +export type WrapperSource = string | ArrayBuffer | ArrayBufferView + +export type PackageDetails = { + name: string + packageJsonUrl: string + packageUrl: string + path: string + type?: string + version?: string +} + +export type JsonValue = + | boolean + | null + | number + | string + | JsonValue[] + | { [key: string]: JsonValue } + +export type JsonCompatible = + Value extends boolean | null | number | string + ? Value + : Value extends readonly unknown[] + ? { [Key in keyof Value]: JsonCompatible } + : Value extends object + ? { [Key in keyof Value]: JsonCompatible } + : never + +export type WrapperExport = { + name: string + url: string + localName?: string +} + +export type PassthroughExports = + | Iterable + | ((exports: readonly WrapperExport[]) => Iterable) + +export type BundlerModule = { + url: string + format?: string + specifier: string + source?: WrapperSource + data?: JsonCompatible + passthroughExports?: PassthroughExports +} + +export type ModuleContext = { + format?: string + parentURL?: string +} + +export type ResolveContext = ModuleContext & { + parentURL: string +} + +export type ModuleTarget = { + url: string + format?: string +} + +export type ResolveResult = ModuleTarget & { + watchFiles?: Iterable +} + +export type LoadResult = { + source?: WrapperSource + format?: string + watchFiles?: Iterable +} + +export type WrapperImport = { + specifier: string + kind: 'module' | 'runtime' + target: ModuleTarget + external: boolean +} + +export type WrapperModule = { + code: string + imports: WrapperImport[] + watchFiles: string[] + sideEffects: true + sourceLineOffset?: number +} + +type CommonJSFormat = 'commonjs' | 'commonjs-typescript' + +type Resolve = ( + specifier: string, + context: ResolveContext +) => ResolveResult | Promise + +type Load = ( + url: string, + context: ModuleContext +) => LoadResult | Promise + +type InlineCommonJSOptions = { + module: BundlerModule & { + format: CommonJSFormat + source: WrapperSource + } + resolve?: Resolve + load?: Load +} + +type LoadedCommonJSOptions = { + module: BundlerModule & { + format: CommonJSFormat + source?: WrapperSource + } + resolve?: Resolve + load: Load +} + +type AdapterBackedOptions = { + module: BundlerModule + resolve: Resolve + load: Load +} + +export type CreateWrapperModuleOptions = + | InlineCommonJSOptions + | LoadedCommonJSOptions + | AdapterBackedOptions + +/** + * EXPERIMENTAL + * This API is experimental and may change in minor versions. + */ +export declare function createWrapperModule( + options: CreateWrapperModuleOptions +): Promise + +/** + * EXPERIMENTAL + * This API is experimental and may change in minor versions. + */ +export declare function getPackageDetails(url: string): PackageDetails | undefined diff --git a/bundler.d.ts b/bundler.d.ts new file mode 100644 index 00000000..61e42f00 --- /dev/null +++ b/bundler.d.ts @@ -0,0 +1,11 @@ +export * from './bundler.mjs' + +/** + * EXPERIMENTAL + * This API is experimental and may change in minor versions. + */ +export declare function getNodeModuleFormat( + url: string, + packageJsonUrl?: string, + packageType?: string +): 'builtin' | 'module' | 'module-typescript' | 'commonjs' | 'commonjs-typescript' | undefined diff --git a/bundler.js b/bundler.js new file mode 100644 index 00000000..19929bd1 --- /dev/null +++ b/bundler.js @@ -0,0 +1,37 @@ +'use strict' + +const { readFileSync } = require('node:fs') + +const createGetNodeModuleFormat = require('./lib/get-node-module-format.js') +const createGetPackageDetails = require('./lib/get-package-details.js') + +const getNodeModuleFormat = createGetNodeModuleFormat(readFileSync, false) +const getPackageDetails = createGetPackageDetails(readFileSync) + +/** @type {typeof import('./bundler.mjs').createWrapperModule|undefined} */ +let createWrapperModuleImplementation + +/** + * EXPERIMENTAL + * This API is experimental and may change in minor versions. + * + * @param {Parameters[0]} options + */ +async function createWrapperModule (options) { + createWrapperModuleImplementation ??= (await import('./bundler.mjs')).createWrapperModule + return createWrapperModuleImplementation(options) +} + +exports.createWrapperModule = createWrapperModule + +/** + * EXPERIMENTAL + * This API is experimental and may change in minor versions. + */ +exports.getPackageDetails = getPackageDetails + +/** + * EXPERIMENTAL + * This API is experimental and may change in minor versions. + */ +exports.getNodeModuleFormat = getNodeModuleFormat diff --git a/bundler.mjs b/bundler.mjs new file mode 100644 index 00000000..68a2db82 --- /dev/null +++ b/bundler.mjs @@ -0,0 +1,288 @@ +'use strict' + +import { readFileSync } from 'fs' +import { builtinModules } from 'module' + +import createGetPackageDetails from './lib/get-package-details.js' +import { driveAsync } from './lib/io.mjs' +import { + buildCommonJSWrapperSource, + buildWrapperSourceWithData, + processModule, + resolveExportBindings +} from './lib/wrapper.mjs' + +const RUNTIME_SPECIFIER = './__iitm_runtime__.js' +const MODULE_SPECIFIER_PREFIX = './__iitm_module_' +const runtimeUrl = new URL('./lib/bundler-runtime.js', import.meta.url).href + +/** + * EXPERIMENTAL + * This API is experimental and may change in minor versions. + */ +export const getPackageDetails = createGetPackageDetails(readFileSync) + +/** + * @typedef {object} BundlerModule + * @property {string} url + * @property {string} [format] + * @property {string} specifier + * @property {string | ArrayBuffer | ArrayBufferView} [source] + * @property {unknown} [data] + * @property {Iterable | ((exports: ReadonlyArray<{ + * name: string, + * url: string, + * localName?: string + * }>) => Iterable)} [passthroughExports] + */ + +/** + * @typedef {object} ModuleContext + * @property {string} [format] + * @property {string} [parentURL] + */ + +/** + * @typedef {object} ResolveContext + * @property {string} [format] + * @property {string} parentURL + */ + +/** + * @typedef {object} ResolveResult + * @property {string} url + * @property {string} [format] + * @property {Iterable} [watchFiles] + */ + +/** + * @typedef {object} WrapperImport + * @property {string} specifier + * @property {'module' | 'runtime'} kind + * @property {{ url: string, format?: string }} target + * @property {boolean} external + */ + +/** + * @typedef {object} LoadResult + * @property {string | ArrayBuffer | ArrayBufferView} [source] + * @property {string} [format] + * @property {Iterable} [watchFiles] + */ + +/** + * EXPERIMENTAL + * This API is experimental and may change in minor versions. + * + * Creates an ESM wrapper without embedding bundler-specific module identifiers. + * + * @param {object} options + * @param {BundlerModule} options.module + * @param {(specifier: string, context: ResolveContext) => + * (ResolveResult | Promise)} [options.resolve] Required unless the module has an explicit CommonJS + * format. + * @param {(url: string, context: ModuleContext) => (LoadResult | Promise)} [options.load] Required unless + * an explicitly formatted CommonJS module supplies its source. + * @returns {Promise<{ + * code: string, + * imports: WrapperImport[], + * watchFiles: string[], + * sideEffects: true, + * sourceLineOffset?: number + * }>} + */ +export async function createWrapperModule ({ module: moduleData, resolve, load }) { + const context = { format: moduleData.format, cache: false } + const watchFiles = new Set() + const formats = new Map([[moduleData.url, moduleData.format]]) + let source = moduleData.source + + if (moduleData.url.startsWith('file:')) { + watchFiles.add(moduleData.url) + } + + /** + * @param {string} url + * @param {ModuleContext} loadContext + * @returns {Promise} + */ + const loadModule = async (url, loadContext) => { + if (url === moduleData.url && source !== undefined) { + return { + source, + format: moduleData.format + } + } + + const result = await load(url, loadContext) + if (url === moduleData.url && result.source !== undefined) { + source = result.source + } + if (result.format !== undefined) { + formats.set(url, result.format) + } + if (url.startsWith('file:')) { + watchFiles.add(url) + } + if (result.watchFiles !== undefined) { + for (const watchFile of normalizeStringIterable(result.watchFiles)) { + watchFiles.add(watchFile) + } + } + return result + } + + /** + * @param {string} specifier + * @param {ResolveContext} resolveContext + * @returns {Promise} + */ + const resolveModule = async (specifier, resolveContext) => { + const result = await resolve(specifier, resolveContext) + if (result.format !== undefined) { + formats.set(result.url, result.format) + } + if (result.watchFiles !== undefined) { + for (const watchFile of normalizeStringIterable(result.watchFiles)) { + watchFiles.add(watchFile) + } + } + return result + } + + if (moduleData.format === 'commonjs' || moduleData.format === 'commonjs-typescript') { + if (source === undefined) { + const result = await loadModule(moduleData.url, context) + source = result.source + } + if (source === undefined) { + throw new TypeError(`The bundler load adapter returned no source for '${moduleData.url}'`) + } + + return createCommonJSWrapper(moduleData, source, watchFiles) + } + + const io = { resolve: resolveModule, load: loadModule } + const selectPassthroughExports = typeof moduleData.passthroughExports === 'function' + ? moduleData.passthroughExports + : undefined + const moduleExportsCache = selectPassthroughExports === undefined ? undefined : new Map() + const { bindings } = await driveAsync(processModule({ + srcUrl: moduleData.url, + context, + moduleExportsCache + }), io) + if (context.format === 'commonjs' || context.format === 'commonjs-typescript') { + return createCommonJSWrapper(moduleData, source, watchFiles) + } + let selectedPassthroughExports = moduleData.passthroughExports + if (selectPassthroughExports !== undefined) { + const exportNames = Array.isArray(bindings) ? bindings.slice() : Array.from(bindings.keys()) + const exports = await driveAsync(resolveExportBindings({ + srcUrl: moduleData.url, + context, + exportNames, + moduleExportsCache + }), io) + selectedPassthroughExports = selectPassthroughExports(exports) + } + const passthroughExports = selectedPassthroughExports === undefined + ? undefined + : new Set(normalizeStringIterable(selectedPassthroughExports)) + + /** @type {WrapperImport[]} */ + const imports = [{ + specifier: RUNTIME_SPECIFIER, + kind: 'runtime', + target: { + url: runtimeUrl, + format: 'commonjs' + }, + external: false + }] + const moduleSpecifiers = new Map() + + /** + * @param {string} url + * @returns {string} + */ + const mapImport = (url) => { + let specifier = moduleSpecifiers.get(url) + if (specifier === undefined) { + specifier = `${MODULE_SPECIFIER_PREFIX}${moduleSpecifiers.size}__.js` + moduleSpecifiers.set(url, specifier) + imports.push({ + specifier, + kind: 'module', + target: { + url, + format: formats.get(url) + }, + external: url.startsWith('node:') || builtinModules.includes(url) + }) + } + return specifier + } + + const code = buildWrapperSourceWithData({ + realUrl: moduleData.url, + bindings, + originalSpecifier: moduleData.specifier, + data: moduleData.data, + runtimeSpecifier: RUNTIME_SPECIFIER, + mapImport, + passthroughExports + }) + + return { + code, + imports, + watchFiles: Array.from(watchFiles), + sideEffects: true + } +} + +/** + * @param {BundlerModule} moduleData + * @param {string | ArrayBuffer | ArrayBufferView} source + * @param {Set} watchFiles + * @returns {{ + * code: string, + * imports: WrapperImport[], + * watchFiles: string[], + * sideEffects: true, + * sourceLineOffset: number + * }} + */ +function createCommonJSWrapper (moduleData, source, watchFiles) { + return { + code: buildCommonJSWrapperSource({ + realUrl: moduleData.url, + source, + originalSpecifier: moduleData.specifier, + data: moduleData.data, + runtimeSpecifier: RUNTIME_SPECIFIER, + preserveOuterBindings: true + }), + imports: [{ + specifier: RUNTIME_SPECIFIER, + kind: 'runtime', + target: { + url: runtimeUrl, + format: 'commonjs' + }, + external: false + }], + watchFiles: Array.from(watchFiles), + sideEffects: true, + sourceLineOffset: 1 + } +} + +/** + * @param {Iterable} values + * @returns {Iterable} + */ +function normalizeStringIterable (values) { + return typeof values === 'string' ? [values] : values +} diff --git a/create-hook.mjs b/create-hook.mjs index 1ca53840..cd9144a8 100644 --- a/create-hook.mjs +++ b/create-hook.mjs @@ -5,8 +5,15 @@ import { URL, fileURLToPath } from 'url' import { inspect } from 'util' import { builtinModules } from 'module' -import { getExports } from './lib/get-exports.mjs' -import { RESOLVE, driveSync, driveAsync } from './lib/io.mjs' +import { readFileSync } from 'fs' +import createGetNodeModuleFormat from './lib/get-node-module-format.js' +import { driveSync, driveAsync } from './lib/io.mjs' +import { + buildCommonJSWrapperSource, + buildWrapperSourceWithData, + processModule, + sourceToString +} from './lib/wrapper.mjs' import { supportsSyncHooks } from './supports-sync-hooks.mjs' // Re-exported for backwards compatibility: `supportsSyncHooks` now lives in its @@ -15,19 +22,14 @@ import { supportsSyncHooks } from './supports-sync-hooks.mjs' export { supportsSyncHooks } const isWin = process.platform === 'win32' - -// Depth at which `processModule` starts tracking visited URLs to break an -// `export *` cycle. Real re-export chains are only a few levels deep, so this -// is far beyond any legitimate graph yet well below the call-stack limit a -// cycle would otherwise hit. Below it the recursion pays only an integer -// compare per level and allocates no set. -const STAR_CYCLE_DEPTH = 100 +const getNodeModuleFormat = createGetNodeModuleFormat(readFileSync) // FIXME: Typescript extensions are added temporarily until we find a better // way of supporting arbitrary extensions const EXTENSION_RE = /\.(js|mjs|cjs|ts|mts|cts)$/ -// The full es-module-lexer build handles erasable TypeScript syntax in the same -// pass as JavaScript, so the `-typescript` formats use the normal export path. +const DATA_JAVASCRIPT_RE = /^(?:application|text)\/javascript(?:[;,])/i +// The `-typescript` formats are listed unconditionally; getExports strips the +// types when the runtime supports it and otherwise falls back to onWrapFailure. const HANDLED_FORMATS = new Set([ 'builtin', 'module', 'commonjs', 'module-typescript', 'commonjs-typescript' ]) @@ -36,12 +38,7 @@ const TRACE_WARNINGS = process.execArgv.includes('--trace-warnings') /** @typedef {import('node:module').LoadHookContext} LoadContext */ /** @typedef {import('node:module').LoadFnOutput} LoadResult */ /** @typedef {string | { specifier: string, format: 'module-typescript' | 'commonjs-typescript' }} SpecifierData */ -/** @typedef {{ name: string, origin: string }} StarBinding */ -/** - * @typedef {object} ProcessResult - * @property {string[] | Map} bindings - * @property {Map | undefined} origins - */ +/** @typedef {{ specifier: string, format?: string, originalUrl: string }} RequireSpecifierData */ function hasIitm (url) { // Fast path: avoid URL parsing on the hot path when there's clearly no iitm. @@ -85,34 +82,6 @@ function deleteIitm (url) { return resultUrl } -function isBareSpecifier (specifier) { - // Relative and absolute paths are not bare specifiers. - if ( - specifier.startsWith('.') || - specifier.startsWith('/')) { - return false - } - - // Valid URLs are not bare specifiers. (file:, http:, node:, etc.) - - // eslint-disable-next-line no-prototype-builtins - if (URL.hasOwnProperty('canParse')) { - return !URL.canParse(specifier) - } - - const stackTraceLimit = Error.stackTraceLimit - try { - Error.stackTraceLimit = 0 - // eslint-disable-next-line no-new - new URL(specifier) - return false - } catch (err) { - return true - } finally { - Error.stackTraceLimit = stackTraceLimit - } -} - /** * Determines whether the input is a bare specifier, file URL or a regular expression. * @@ -182,180 +151,30 @@ function emitWarning (err) { process.emitWarning(warnMessage) } -/** - * @param {string} name The exported name. - * @param {string} sourceUrl The URL of the module that defines the export. - */ -function shouldReexport (name, sourceUrl) { - return name !== 'module.exports' || - (!sourceUrl.startsWith('node:') && !builtinModules.includes(sourceUrl)) -} - -/** - * @param {string} name The exported name. - * @param {string} sourceUrl The URL of the module that defines the export. - */ -function shouldExcludeExport (name, sourceUrl) { - return name === 'default' || !shouldReexport(name, sourceUrl) +function addIitm (url) { + const urlObj = new URL(url) + urlObj.searchParams.set('iitm', 'true') + return urlObj.href } /** - * Processes a module's exports and builds its wrapper bindings. - * - * 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, - * 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. - * - * @param {object} params - * @param {string} params.srcUrl The full URL to the module to process. - * @param {LoadContext} params.context Provided by the loaders API. - * @param {boolean} [params.excludeDefault = false] Exclude the default export. - * @param {number} [params.depth = 0] Star-re-export recursion depth. Used to - * detect `export *` cycles (`a` re-exports `b`, `b` re-exports `a`) cheaply: - * the acyclic common case pays only an integer compare per level, and the - * cycle-tracking set is allocated only once recursion is implausibly deep. - * @param {Set} [params.seen] URLs currently on the recursion stack, - * created lazily once `depth` crosses {@link STAR_CYCLE_DEPTH}. A URL is added - * before descending into its subtree and removed once that subtree finishes, so - * it tracks the active path rather than every URL ever visited. - * @returns {Generator} - * A generator that yields I/O operations and ultimately returns the shimmed - * bindings for all the exports from the module and any transitive export all - * modules. `origins` (the defining module per `*`-sourced name) is `undefined` - * for a module with no `export *`. + * @param {string} url + * @returns {boolean} */ -function * processModule ({ srcUrl, context, excludeDefault = false, depth = 0, seen }) { - const { exportNames, starReexports } = yield * getExports(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. - if (starReexports === undefined) { - if (!excludeDefault) { - return { bindings: exportNames, origins: undefined } - } - - const bindings = [] - for (const name of exportNames) { - if (shouldExcludeExport(name, srcUrl)) continue - bindings.push(name) - } - return { bindings, origins: undefined } - } - - const bindings = new Map() - - // Maps each live `*`-sourced name to the module that defined it. Its keys - // double as "this name came from a `*` re-export" (so an explicit export can - // override it), and its values let two `*` re-exports of the same name be told - // apart. Allocated on the first `export *`, never for a module without one; a - // single Map carries both facts so a star with no collision pays one structure - // and one write per name, not two. - let starOrigins - let ambiguousStars - let firstStarUrl - let processedStarUrls - - for (const name of exportNames) { - if (excludeDefault && shouldExcludeExport(name, srcUrl)) continue - bindings.set(name, name) - } - - for (const { specifier, parentURL } of starReexports) { - // Relative paths need to be resolved relative to the module declaring the star. - const newSpecifier = isBareSpecifier(specifier) ? specifier : new URL(specifier, parentURL).href - // We need to resolve bare specifiers to a full URL. We also need to - // resolve all sub-modules to get the `format`. We can't rely on the - // parent's `format` to know if this sub-module is ESM or CJS! - const result = yield [RESOLVE, newSpecifier, { parentURL }] - - // Most star modules have one target. Defer the collection until a second - // distinct target while still ignoring repeated declarations. - if (firstStarUrl === undefined) { - firstStarUrl = result.url - } else if (processedStarUrls === undefined) { - if (result.url === firstStarUrl) continue - processedStarUrls = [firstStarUrl, result.url] - } else { - if (processedStarUrls.includes(result.url)) continue - processedStarUrls.push(result.url) - } - - // First `*` re-export: allocate the origin bookkeeping lazily. - starOrigins ??= new Map() - - // `export *` graphs are normally only a handful of levels deep. A cycle - // (`a` re-exports `b`, `b` re-exports `a`) instead recurses without bound - // and exhausts memory. Rather than track every URL on the common shallow - // path, only start recording once the depth is implausibly large for a - // real graph; from there a re-export pointing back at a module already on - // the recursion stack is the cycle, and is skipped (its exports are - // collected by the in-progress ancestor frame). `seen` mirrors the stack, - // not every URL visited: a module reached and fully processed through one - // sibling branch must stay reachable through a later, more direct branch, - // so it is removed again once its subtree finishes. - if (depth >= STAR_CYCLE_DEPTH) { - seen ??= new Set() - if (seen.has(result.url)) continue - seen.add(result.url) - } - - try { - const sub = yield * processModule({ - srcUrl: result.url, - context: { ...context, format: result.format }, - excludeDefault: true, - depth: depth + 1, - seen - }) - - for (const binding of sub.bindings.values()) { - const directName = typeof binding === 'string' ? binding : undefined - const name = directName ?? binding.name - if (ambiguousStars?.has(name)) continue - - const origin = directName === undefined ? binding.origin : sub.origins?.get(name) ?? result.url - if (bindings.has(name)) { - // An explicit export shadows every star re-export. - if (!starOrigins.has(name)) continue - - if (starOrigins.get(name) === origin) { - // IITM's aggregate namespace sees the wrapped paths as ambiguous. - // Retain the defining URL so source generation can import it once. - bindings.set(name, { name, origin }) - } else { - bindings.delete(name) - starOrigins.delete(name) - ambiguousStars ??= new Set() - ambiguousStars.add(name) - } - } else { - starOrigins.set(name, origin) - bindings.set(name, binding) - } - } - } finally { - seen?.delete(result.url) - } - } - - return { bindings, origins: starOrigins } -} - -function addIitm (url) { +function isJavaScriptUrl (url) { const urlObj = new URL(url) - urlObj.searchParams.set('iitm', 'true') - return urlObj.href + return urlObj.protocol === 'node:' || EXTENSION_RE.test(urlObj.pathname) || + (urlObj.protocol === 'data:' && DATA_JAVASCRIPT_RE.test(urlObj.pathname)) } /** * @param {{ url: string }} meta + * @param {boolean} [commonjs] Whether to create CommonJS-specific synchronous hooks. */ -export function createHook (meta) { +export function createHook (meta, commonjs) { /** @type {Map} */ const specifiers = new Map() + let commonJsSpecifiers let cachedResolve const iitmURL = new URL('lib/register.js', meta.url).toString() let includeModules, excludeModules @@ -415,6 +234,7 @@ export function createHook (meta) { function applyOptions (data) { includeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.include, 'include') excludeModules = ensureArrayWithBareSpecifiersFileUrlsAndRegex(data.exclude, 'exclude') + disableCjsSourceStripping = data.disableCjsSourceStripping === true // A consumer can supply its own matcher as `shouldInclude(url, specifier)`, // taking ownership of the include/exclude decision instead of expressing it @@ -423,10 +243,6 @@ export function createHook (meta) { // default applies the include/exclude options. shouldInclude = typeof data.shouldInclude === 'function' ? data.shouldInclude : defaultShouldInclude - if (data.disableCjsSourceStripping === true) { - disableCjsSourceStripping = true - } - if (data.addHookMessagePort) { data.addHookMessagePort.on('message', (modules) => { if (includeModules === undefined) { @@ -549,6 +365,60 @@ export function createHook (meta) { } } + /** + * @param {{ url: string, format?: string }} result + * @param {string} specifier + * @param {object} context + * @param {string} parentURL + * @returns {object} + */ + let finishRequireResolve + if (commonjs === true) { + finishRequireResolve = (result, specifier, context, parentURL) => { + if (parentURL === '') { + if (!EXTENSION_RE.test(result.url) && !hasIitm(result.url)) { + return { url: result.url, format: 'commonjs' } + } + return result + } + + if (result.format && !HANDLED_FORMATS.has(result.format)) return result + if (!shouldInclude(result.url, specifier)) return result + if (isIitm(parentURL, meta) || (parentURL && hasIitm(parentURL))) return result + if (cjsInIitmChain.has(parentURL)) { + cjsInIitmChain.add(result.url) + return result + } + if (result.url.endsWith('.node')) return result + + const importAttributes = context.importAttributes || context.importAssertions + if (importAttributes && importAttributes.type === 'json') return result + if (result.url === parentURL) { + return { + url: result.url, + shortCircuit: true, + format: result.format + } + } + + const format = result.format ?? getNodeModuleFormat(result.url) + if (format === 'module' || format === 'module-typescript') { + const specifierData = format === 'module-typescript' ? { specifier, format } : specifier + specifiers.set(result.url, specifierData) + return { + url: addIitm(result.url), + shortCircuit: true, + format + } + } + + commonJsSpecifiers ??= new Map() + const wrapperUrl = format === undefined && isJavaScriptUrl(result.url) ? addIitm(result.url) : result.url + commonJsSpecifiers.set(wrapperUrl, { specifier, format, originalUrl: result.url }) + return wrapperUrl === result.url ? result : { ...result, url: wrapperUrl } + } + } + async function resolve (specifier, context, parentResolve) { cachedResolve = parentResolve @@ -595,99 +465,53 @@ export function createHook (meta) { } /** - * Builds the wrapper module source shared by the asynchronous and synchronous hooks. - * - * @param {string} realUrl The URL of the wrapped module. - * @param {string[] | Map} bindings Its exported bindings. - * @param {string} originalSpecifier The specifier used to import the module. + * @param {string} specifier + * @param {object} context + * @param {Function} nextResolve + * @returns {object} */ - function buildWrapperSource (realUrl, bindings, originalSpecifier) { - // The wrapped module imports its namespace as `namespace`, which serves - // every export but the ones a same-origin `export *` collision forced onto - // their defining module (#171): the aggregate namespace drops those as - // ambiguous under iitm, so each such defining module gets its own alias the - // wrapper imports. Without such a collision nothing is added. - let originImports = '' - let originNamespaces - let declarationNames = '' - let bindingNames = '' - let bindingSources - let exportSpecifiers = '' - let writeCases = '' - let index = 0 - for (const binding of bindings.values()) { - const directName = typeof binding === 'string' ? binding : undefined - const name = directName ?? binding.name - let namespaceName = 'namespace' - if (directName === undefined) { - originNamespaces ??= new Map() - namespaceName = originNamespaces.get(binding.origin) - if (namespaceName === undefined) { - namespaceName = `__ns${originNamespaces.size}` - originNamespaces.set(binding.origin, namespaceName) - originImports += `import * as ${namespaceName} from ${JSON.stringify(binding.origin)}\n` + let resolveSyncCommonJS + if (commonjs === true) { + resolveSyncCommonJS = (specifier, context, nextResolve) => { + cachedResolve = nextResolve + + if (specifier === iitmURL) { + return { + url: specifier, + shortCircuit: true } } - const variableName = `$${index}` - const objectKey = JSON.stringify(name) - declarationNames += declarationNames === '' ? variableName : `, ${variableName}` - bindingNames += bindingNames === '' ? objectKey : `, ${objectKey}` - if (bindingSources !== undefined) bindingSources += ', ' - if (namespaceName !== 'namespace') { - bindingSources ??= 'undefined, '.repeat(index) - bindingSources += namespaceName - } else if (bindingSources !== undefined) { - bindingSources += 'undefined' + + const { parentURL = '' } = context + const newSpecifier = deleteIitm(specifier) + if (process.platform === 'win32' && parentURL.indexOf('file:node') === 0) { + context.parentURL = '' } - writeCases += ` case ${index++}: ${variableName} = value; break\n` - if (shouldReexport(name, realUrl)) { - const exportName = name === 'default' ? name : objectKey - exportSpecifiers += exportSpecifiers === '' - ? `${variableName} as ${exportName}` - : `, ${variableName} as ${exportName}` + const result = nextResolve(newSpecifier, context) + if (!context.conditions?.includes('require')) { + return finishResolve(result, specifier, context, parentURL) } + return finishRequireResolve(result, specifier, context, parentURL) } - const binder = declarationNames === '' - ? 'const __binder = new ModuleBinder(namespace)\n' - : `let ${declarationNames} -function __write (index, value) { - switch (index) { -${writeCases} } -} -const __binder = new ModuleBinder(namespace, [${bindingNames}], __write${bindingSources === undefined - ? '' - : `, [${bindingSources}]`}) -` - const reexports = exportSpecifiers === '' ? '' : `export { ${exportSpecifiers} }\n` - - return ` -import { register, ModuleBinder } from ${JSON.stringify(iitmURL)} -import * as namespace from ${JSON.stringify(realUrl)} -${originImports} -${binder} -${reexports} - -__binder.flush() - -register(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifier)}) -` } - /** - * Finalizes a successful wrap and builds its module source. - * - * @param {string} realUrl The URL of the wrapped module. - * @param {LoadContext} context Its loader context. - * @param {string} originalSpecifier The original import specifier. - * @param {string[] | Map} bindings Its exported bindings. - */ + // Bookkeeping shared by the async and sync wrap paths once `processModule` + // succeeds: free the specifier entry early, and remember CJS modules so their + // transitive require() chain bypasses iitm (see `load`). Returns the wrapper + // module source. function onWrapSuccess (realUrl, context, originalSpecifier, bindings) { specifiers.delete(realUrl) // context.format is set to 'commonjs' by getCjsExports during processModule. if (context.format === 'commonjs') { cjsInIitmChain.add(realUrl) } - return buildWrapperSource(realUrl, bindings, originalSpecifier) + return buildWrapperSourceWithData({ + realUrl, + bindings, + originalSpecifier, + data: undefined, + runtimeSpecifier: iitmURL + }) } // Bookkeeping shared by the async and sync wrap paths when `processModule` @@ -695,10 +519,6 @@ register(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifie // (it just can't be Hook'ed) rather than taking down the whole app. We free // the specifier entry to avoid a leak, and log because a failure here is // usually an iitm bug and would otherwise be very tricky to debug. - /** - * @param {string} realUrl The URL whose wrapper could not be built. - * @param {unknown} cause The parse or wrapper-generation failure. - */ function onWrapFailure (realUrl, cause) { specifiers.delete(realUrl) const err = new Error(`'import-in-the-middle' failed to wrap '${realUrl}'`) @@ -706,6 +526,95 @@ register(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifie emitWarning(err) } + /** + * @param {string} url + * @param {LoadContext} context + * @param {LoadResult} result + * @param {RequireSpecifierData} specifierData + * @param {(url: string, context?: Partial) => LoadResult} nextLoad + * @returns {LoadResult} + */ + let wrapRequireLoad + if (commonjs === true) { + wrapRequireLoad = (url, context, result, specifierData, nextLoad) => { + let format = result.format ?? specifierData.format + let source = result.source + + if (format === undefined && source == null && url.startsWith('file:')) { + source = process.getBuiltinModule('fs').readFileSync(fileURLToPath(url)) + } + + if (format === 'module' || format === 'module-typescript' || + (format === undefined && isJavaScriptUrl(url))) { + const processContext = { ...context, format } + const loaded = source === result.source ? result : { ...result, source } + /** + * @param {string} loadUrl + * @param {Partial} loadContext + * @returns {LoadResult} + */ + const loadModule = (loadUrl, loadContext) => { + return loadUrl === url ? loaded : nextLoad(loadUrl, loadContext) + } + try { + const { bindings } = driveSync( + processModule({ srcUrl: url, context: processContext }), + { resolve: cachedResolve, load: loadModule } + ) + if (processContext.format === 'commonjs') { + format = 'commonjs' + cjsInIitmChain.add(url) + } else { + return { + ...result, + format: 'module', + source: onWrapSuccess(url, processContext, specifierData.specifier, bindings), + shortCircuit: true + } + } + } catch (cause) { + onWrapFailure(url, cause) + return result + } + } + + if (url.startsWith('node:')) { + source = `module.exports = process.getBuiltinModule(${JSON.stringify(url)})\n` + } else if ((format === 'commonjs' || format === 'commonjs-typescript') && source == null && + url.startsWith('file:')) { + source = process.getBuiltinModule('fs').readFileSync(fileURLToPath(url)) + } + + if (source == null || (format !== 'commonjs' && format !== 'commonjs-typescript' && + !url.startsWith('node:'))) { + return result + } + + try { + if (format === 'commonjs-typescript') { + const stripTypeScriptTypes = process.getBuiltinModule('module').stripTypeScriptTypes + if (stripTypeScriptTypes !== undefined) { + source = stripTypeScriptTypes(sourceToString(source), { mode: 'strip' }) + } + } + return { + ...result, + format: 'commonjs', + source: buildCommonJSWrapperSource({ + realUrl: url, + source, + originalSpecifier: specifierData.specifier, + runtimeSpecifier: fileURLToPath(iitmURL) + }), + shortCircuit: true + } + } catch (cause) { + onWrapFailure(url, cause) + return result + } + } + } + /** * @param {string} url * @param {LoadContext} context @@ -727,10 +636,36 @@ register(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifie processContext = { ...context, format: specifierData.format } } + let loadModule = parentGetSource + if (processContext.format === undefined && !isJavaScriptUrl(realUrl)) { + let result + try { + result = await parentGetSource(realUrl, processContext) + } catch (cause) { + specifiers.delete(realUrl) + throw cause + } + if (result.format !== undefined && !HANDLED_FORMATS.has(result.format)) { + specifiers.delete(realUrl) + return result + } + if (result.format !== undefined) processContext = { ...processContext, format: result.format } + + /** + * @param {string} loadUrl + * @param {Partial} loadContext + * @returns {LoadResult|Promise} + */ + const loadPreloadedModule = (loadUrl, loadContext) => { + return loadUrl === realUrl ? result : parentGetSource(loadUrl, loadContext) + } + loadModule = loadPreloadedModule + } + try { const { bindings } = await driveAsync( processModule({ srcUrl: realUrl, context: processContext }), - { resolve: cachedResolve, load: parentGetSource } + { resolve: cachedResolve, load: loadModule } ) return { source: onWrapSuccess(realUrl, processContext, originalSpecifier, bindings) } } catch (cause) { @@ -767,10 +702,36 @@ register(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifie processContext = { ...context, format: specifierData.format } } + let loadModule = nextLoad + if (processContext.format === undefined && !isJavaScriptUrl(realUrl)) { + let result + try { + result = nextLoad(realUrl, processContext) + } catch (cause) { + specifiers.delete(realUrl) + throw cause + } + if (result.format !== undefined && !HANDLED_FORMATS.has(result.format)) { + specifiers.delete(realUrl) + return result + } + if (result.format !== undefined) processContext = { ...processContext, format: result.format } + + /** + * @param {string} loadUrl + * @param {Partial} loadContext + * @returns {LoadResult} + */ + const loadPreloadedModule = (loadUrl, loadContext) => { + return loadUrl === realUrl ? result : nextLoad(loadUrl, loadContext) + } + loadModule = loadPreloadedModule + } + try { const { bindings } = driveSync( processModule({ srcUrl: realUrl, context: processContext }), - { resolve: cachedResolve, load: nextLoad } + { resolve: cachedResolve, load: loadModule } ) return { source: onWrapSuccess(realUrl, processContext, originalSpecifier, bindings) } } catch (cause) { @@ -785,6 +746,7 @@ register(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifie async function load (url, context, parentLoad) { if (hasIitm(url)) { const result = await getSource(url, context, parentLoad) + if (result?.format && !HANDLED_FORMATS.has(result.format)) return result // If wrapping failed, `getSource()` may have fallen back to `parentLoad`, // which can legally return `source: null` (e.g. for non-JS formats). if (result && typeof result === 'object' && result.source != null) { @@ -826,6 +788,7 @@ register(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifie function loadSync (url, context, nextLoad) { if (hasIitm(url)) { const result = getSourceSync(url, context, nextLoad) + if (result?.format && !HANDLED_FORMATS.has(result.format)) return result // If wrapping failed, `getSourceSync()` may have fallen back to `nextLoad`, // which can legally return `source: null` (e.g. for non-JS formats). if (result && typeof result === 'object' && result.source != null) { @@ -854,5 +817,45 @@ register(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifie return nextLoad(url, context) } + /** + * @param {string} url + * @param {LoadContext} context + * @param {(url: string, context?: Partial) => LoadResult} nextLoad + * @returns {LoadResult} + */ + let loadSyncCommonJS + if (commonjs === true) { + loadSyncCommonJS = (url, context, nextLoad) => { + const specifierData = commonJsSpecifiers?.get(url) + if (specifierData !== undefined) { + let result + try { + result = nextLoad(specifierData.originalUrl, context) + } catch (error) { + commonJsSpecifiers.delete(url) + throw error + } + commonJsSpecifiers.delete(url) + return wrapRequireLoad(specifierData.originalUrl, context, result, specifierData, nextLoad) + } + + if (hasIitm(url)) return loadSync(url, context, nextLoad) + + return loadSync(url, context, nextLoad) + } + } + + if (commonjs === true) { + return { + initialize, + load, + resolve, + resolveSync, + resolveSyncCommonJS, + loadSync, + loadSyncCommonJS, + applyOptions + } + } return { initialize, load, resolve, resolveSync, loadSync, applyOptions } } diff --git a/index.d.ts b/index.d.ts index d3152482..5fb1dc8a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -18,16 +18,26 @@ export type Namespace = { [key: string]: any } * starting from the package name. * @param {baseDir} string The absolute path of the module, if not provided in * `name`. + * The `data` and `format` arguments are experimental and may change in minor versions. + * @param {data} Data Optional metadata embedded by a bundler. + * @param {format} string The intercepted module format, when available. * @return any A value that can will be assigned to `exports.default`. This is - * equivalent to doing that assignment in the body of this function. + * equivalent to doing that assignment in the body of this function. For + * CommonJS modules, the value replaces `module.exports`. */ -export type HookFn = (exported: Namespace, name: string, baseDir: string|void) => any +export type HookFn = ( + exported: Exports, + name: string, + baseDir: string|void, + data?: Data, + format?: 'module'|'commonjs' +) => any export type Options = { internals?: boolean } -export declare class Hook { +export declare class Hook { /** * Creates a hook to be run on any already loaded modules and any that will * be loaded in the future. It will be run once per loaded module. If @@ -41,9 +51,9 @@ export declare class Hook { * unless they are mentioned specifically in the modules array. * @param {HookFunction} hookFn The function to be run on each module. */ - constructor (modules: Array, options: Options, hookFn: HookFn) - constructor (modules: Array, hookFn: HookFn) - constructor (hookFn: HookFn) + constructor (modules: Array, options: Options, hookFn: HookFn) + constructor (modules: Array, hookFn: HookFn) + constructor (hookFn: HookFn) /** * Disables this hook. It will no longer be run against any subsequently @@ -60,8 +70,18 @@ export default Hook * @param {url} string The absolute path of the module, as a `file:` URL string. * @param {exported} { [string]: any } An object representing the exported * items of a module. + * @param {specifier} string The original import or require specifier. + * The `data` and `format` arguments are experimental and may change in minor versions. + * @param {data} Data Optional metadata embedded by a bundler. + * @param {format} string The intercepted module format, when available. */ -export type HookFunction = (url: string, exported: Namespace) => void +export type HookFunction = ( + url: string, + exported: Exports, + specifier: string, + data?: Data, + format?: 'module'|'commonjs' +) => unknown /** * Adds a hook to be run on any already loaded modules and any that will be @@ -73,7 +93,7 @@ export type HookFunction = (url: string, exported: Namespace) => void * single imported module, rather than with any filtering. * @param {HookFunction} hookFn The function to be run on each module. */ -export declare function addHook(hookFn: HookFunction): void +export declare function addHook(hookFn: HookFunction): void /** * Removes a hook that has been previously added with `addHook`. It will no @@ -83,7 +103,7 @@ export declare function addHook(hookFn: HookFunction): void * `Hook` class. * @param {HookFunction} hookFn The function to be removed. */ -export declare function removeHook(hookFn: HookFunction): void +export declare function removeHook(hookFn: HookFunction): void type CreateAddHookMessageChannelReturn = { addHookMessagePort: MessagePort, diff --git a/index.js b/index.js index 563fb240..088ce765 100644 --- a/index.js +++ b/index.js @@ -13,9 +13,11 @@ if (!isBuiltin) { } const { + extendedHooks, importHooks, specifiers, - toHook + toHook, + toHookExtended } = require('./lib/register') /** @@ -35,12 +37,26 @@ function isTurbopackSpecifier (specifier, baseDir) { if (!usingTurbopack) return false const specifierWithoutTurbopackHash = specifier.slice(0, specifier.lastIndexOf('-')) - return baseDir.endsWith(specifierWithoutTurbopackHash) + return baseDir.endsWith(specifierWithoutTurbopackHash.replaceAll('/', path.sep)) } -function addHook (hook) { +/** + * @param {string} specifier + * @param {string} baseDir + */ +function matchesPackageDirectory (specifier, baseDir) { + return baseDir.endsWith(specifier.replaceAll('/', path.sep)) || isTurbopackSpecifier(specifier, baseDir) +} + +function addHook (hook, extendedHook = hook) { importHooks.push(hook) toHook.forEach(([name, namespace, specifier]) => hook(name, namespace, specifier)) + extendedHooks.set(hook, extendedHook) + for (const entry of toHookExtended.values()) { + const namespace = entry.module === undefined ? entry.namespace : entry.module.exports + const replacement = extendedHook(entry.name, namespace, entry.specifier, entry.data, entry.format) + if (entry.module !== undefined && replacement !== undefined) entry.module.exports = replacement + } } function removeHook (hook) { @@ -48,6 +64,7 @@ function removeHook (hook) { if (index > -1) { importHooks.splice(index, 1) } + extendedHooks.delete(hook) } function callHookFn (hookFn, namespace, name, baseDir) { @@ -62,6 +79,91 @@ function callHookFn (hookFn, namespace, name, baseDir) { } } +/** + * @param {(namespace: unknown, name: string, baseDir: string|undefined, data: unknown) => unknown} hookFn + * @param {unknown} namespace + * @param {string} name + * @param {string|undefined} baseDir + * @param {unknown} data + * @param {'module'|'commonjs'} format + * @returns {unknown} + */ +function callExtendedHookFn (hookFn, namespace, name, baseDir, data, format) { + const replacement = hookFn(namespace, name, baseDir, data, format) + if (format === 'commonjs') return replacement + if (replacement && replacement !== namespace && 'default' in namespace) { + namespace.default = replacement + } +} + +/** + * @param {(namespace: unknown, name: string, baseDir: string|undefined, data: unknown) => unknown} hookFn + * @param {Array|null} modules + * @param {boolean} internals + * @param {string} name + * @param {unknown} namespace + * @param {string} specifier + * @param {unknown} data + * @param {'module'|'commonjs'} format + * @returns {unknown} + */ +function callExtendedHook (hookFn, modules, internals, name, namespace, specifier, data, format) { + const loadUrl = name + const isNodeUrl = loadUrl.startsWith('node:') + let filePath, baseDir + + if (isNodeUrl) { + const unprefixed = name.slice(5) + if (isBuiltin(unprefixed)) { + name = unprefixed + } + } else if (loadUrl.startsWith('file://')) { + const stackTraceLimit = Error.stackTraceLimit + Error.stackTraceLimit = 0 + try { + filePath = fileURLToPath(name) + name = filePath + } catch {} + Error.stackTraceLimit = stackTraceLimit + + if (filePath) { + const details = moduleDetailsFromPath(filePath) + if (details) { + name = details.name + baseDir = details.basedir + } + } + } + + let replacement + if (modules) { + for (const matchArg of modules) { + let result + if (filePath && matchArg === filePath) { + result = callExtendedHookFn(hookFn, namespace, filePath, undefined, data, format) + } else if (matchArg === name) { + if (!baseDir) { + result = callExtendedHookFn(hookFn, namespace, name, baseDir, data, format) + } else if (matchesPackageDirectory(specifier, baseDir)) { + result = callExtendedHookFn(hookFn, namespace, name, baseDir, data, format) + } else if (internals) { + const internalPath = name + path.sep + path.relative(baseDir, filePath) + result = callExtendedHookFn(hookFn, namespace, internalPath, baseDir, data, format) + } + } else if (matchArg === specifier) { + result = callExtendedHookFn(hookFn, namespace, specifier, baseDir, data, format) + } + if (format === 'commonjs' && result !== undefined) { + namespace = result + replacement = result + } + } + return replacement + } + + return callExtendedHookFn(hookFn, namespace, name, baseDir, data, format) +} + let sendModulesToLoader /** @@ -187,7 +289,7 @@ function Hook (modules, options, hookFn) { if (!baseDir) { // built-in module (or unexpected non file:// name?) callHookFn(hookFn, namespace, name, baseDir) - } else if (baseDir.endsWith(specifiers.get(loadUrl)) || isTurbopackSpecifier(specifiers.get(loadUrl), baseDir)) { + } else if (matchesPackageDirectory(specifiers.get(loadUrl), baseDir)) { // An import of the top-level module (e.g. `import 'ioredis'`). // Note: Slight behaviour difference from RITM. RITM uses // `require.resolve(name)` to see if filename is the module @@ -207,7 +309,8 @@ function Hook (modules, options, hookFn) { } } - addHook(this._iitmHook) + const extendedHook = callExtendedHook.bind(undefined, hookFn, modules, internals) + addHook(this._iitmHook, extendedHook) } Hook.prototype.unhook = function () { diff --git a/lib/bundler-runtime.js b/lib/bundler-runtime.js new file mode 100644 index 00000000..25009bff --- /dev/null +++ b/lib/bundler-runtime.js @@ -0,0 +1,7 @@ +'use strict' + +const { ModuleBinder, registerCommonJS, registerWithData } = require('./register.js') + +exports.ModuleBinder = ModuleBinder +exports.registerCommonJS = registerCommonJS +exports.registerWithData = registerWithData diff --git a/lib/get-esm-exports.mjs b/lib/get-esm-exports.mjs index 4d2c275e..6bed8555 100644 --- a/lib/get-esm-exports.mjs +++ b/lib/get-esm-exports.mjs @@ -5,10 +5,20 @@ import { parse } from 'es-module-lexer' /** * @typedef {object} LexedExports * @property {string[] | Set} exportNames + * @property {Map} [exportDeclarations] * @property {Array<{ specifier: string, parentURL: string }> | undefined} starReexports * @property {boolean} hasModuleSyntax */ +/** + * @typedef {object} ExportDeclaration + * @property {'direct' | 'reexport'} type + * @property {string} name + * @property {string} [localName] + * @property {string} [importName] + * @property {string} [specifier] + */ + /** * Decodes an exported identifier the way the JS engine would. es-module-lexer * leaves Unicode escapes in bare identifier exports (`export const \u0061 = 1`) @@ -58,11 +68,13 @@ export default function getEsmExports (moduleSource) { * * @param {string} moduleSource The source code of the module to lex. * @param {string} [parentURL] The URL of the module declaring star re-exports. + * @param {boolean} [includeExportDeclarations] Include local and re-export binding metadata. * @returns {LexedExports} */ -export function lexEsm (moduleSource, parentURL) { +export function lexEsm (moduleSource, parentURL, includeExportDeclarations = false) { const legacy = parentURL === undefined const exportNames = legacy ? new Set() : [] + let exportDeclarations let starReexports const [, exports, , hasModuleSyntax] = parse(moduleSource) @@ -82,8 +94,27 @@ export function lexEsm (moduleSource, parentURL) { } else { exportNames.push(name) } + if (includeExportDeclarations) { + /** @type {ExportDeclaration} */ + const declaration = { type: exported.type, name } + if (exported.type === 'direct') { + if (typeof exported.localName === 'string') { + declaration.localName = decodeExportName(exported.localName) + } + } else { + declaration.specifier = exported.from + if (typeof exported.importName === 'string') { + declaration.importName = decodeExportName(exported.importName) + } + } + exportDeclarations ??= new Map() + exportDeclarations.set(name, declaration) + } } } - return { exportNames, starReexports, hasModuleSyntax } + /** @type {LexedExports} */ + const result = { exportNames, starReexports, hasModuleSyntax } + if (exportDeclarations !== undefined) result.exportDeclarations = exportDeclarations + return result } diff --git a/lib/get-exports.mjs b/lib/get-exports.mjs index c3090798..55b1ed65 100644 --- a/lib/get-exports.mjs +++ b/lib/get-exports.mjs @@ -12,7 +12,18 @@ const nodeMajor = Number(process.versions.node.split('.')[0]) export const hasModuleExportsCJSDefault = nodeMajor >= 23 /** @typedef {{ specifier: string, parentURL: string }} StarReexport */ -/** @typedef {{ exportNames: Iterable, starReexports?: StarReexport[] }} ModuleExports */ +/** + * @typedef {object} ModuleExports + * @property {Iterable} exportNames + * @property {Map} [exportDeclarations] + * @property {StarReexport[]} [starReexports] + */ let parserInitialized = false let stripTypeScriptTypes @@ -239,15 +250,19 @@ function * getCjsExports (url, context, source) { * get the exports of. * @param {object} context Context object as provided by the `load` hook from * the loaders API. + * @param {boolean} [includeExportDeclarations] Include local and re-export binding metadata. * * @returns {Generator} A generator that yields I/O * operations and ultimately returns the identifiers and star re-exports of the * module. */ -export function * getExports (url, context) { - const cached = esmExportsCache.get(url) - if (cached !== undefined) { - return cached +export function * getExports (url, context, includeExportDeclarations = false) { + const useCache = context.cache !== false + if (useCache) { + const cached = esmExportsCache.get(url) + if (cached !== undefined && (!includeExportDeclarations || cached.exportDeclarations !== undefined)) { + return cached + } } // `[LOAD, ...]` gives us the possibility of getting the source from an @@ -272,7 +287,7 @@ export function * getExports (url, context) { } } - if (!source) { + if (source == null) { if (format === 'builtin') { // Builtins don't give us the source property, so we're stuck // just requiring it to get the exports. @@ -299,11 +314,14 @@ export function * getExports (url, context) { return yield * getCjsExports(url, context, source) } - const { exportNames, starReexports, hasModuleSyntax } = lexEsm(source, url) + const { exportNames, exportDeclarations, starReexports, hasModuleSyntax } = + lexEsm(source, url, includeExportDeclarations) + /** @type {ModuleExports} */ const moduleExports = starReexports === undefined ? { exportNames } : { exportNames, starReexports } + if (exportDeclarations !== undefined) moduleExports.exportDeclarations = exportDeclarations if (moduleFormat === 'module') { - esmExportsCache.set(url, moduleExports) + if (useCache) esmExportsCache.set(url, moduleExports) return moduleExports } @@ -314,7 +332,7 @@ export function * getExports (url, context) { if (exportNames.length === 0 && !hasModuleSyntax) { return yield * getCjsExports(url, context, source) } - esmExportsCache.set(url, moduleExports) + if (useCache) esmExportsCache.set(url, moduleExports) return moduleExports } catch (cause) { const err = new Error(`Failed to parse '${url}'`) diff --git a/lib/get-node-module-format.js b/lib/get-node-module-format.js new file mode 100644 index 00000000..289974a5 --- /dev/null +++ b/lib/get-node-module-format.js @@ -0,0 +1,84 @@ +'use strict' + +/** @typedef {'builtin'|'module'|'module-typescript'|'commonjs'|'commonjs-typescript'} NodeModuleFormat */ + +/** + * @param {'.js'|'.ts'} extension + * @param {string|undefined} type + * @returns {NodeModuleFormat|undefined} + */ +function getPackageFormat (extension, type) { + if (type !== 'module' && type !== 'commonjs') return undefined + if (type === 'module') return extension === '.ts' ? 'module-typescript' : 'module' + return extension === '.ts' ? 'commonjs-typescript' : 'commonjs' +} + +/** + * @param {typeof import('node:fs').readFileSync} readFileSync + * @param {boolean} [cachePackageTypes=true] + * @returns {(url: string, packageJsonUrl?: string, packageType?: string) => NodeModuleFormat|undefined} + */ +module.exports = function createGetNodeModuleFormat (readFileSync, cachePackageTypes = true) { + const packageTypes = cachePackageTypes ? new Map() : undefined + + /** + * @param {string} url + * @param {string} [packageJsonUrl] + * @param {string} [packageType] + * @returns {NodeModuleFormat|undefined} + */ + return function getNodeModuleFormat (url, packageJsonUrl, packageType) { + if (url.startsWith('node:')) return 'builtin' + if (!url.startsWith('file:')) return undefined + const pathname = new URL(url).pathname + let extension + if (pathname.endsWith('.mjs')) extension = '.mjs' + else if (pathname.endsWith('.cjs')) extension = '.cjs' + else if (pathname.endsWith('.mts')) extension = '.mts' + else if (pathname.endsWith('.cts')) extension = '.cts' + else if (pathname.endsWith('.js')) extension = '.js' + else if (pathname.endsWith('.ts')) extension = '.ts' + else return undefined + + if (extension === '.mjs') return 'module' + if (extension === '.cjs') return 'commonjs' + if (extension === '.mts') return 'module-typescript' + if (extension === '.cts') return 'commonjs-typescript' + + const packageDirectory = packageJsonUrl === undefined ? undefined : new URL('.', packageJsonUrl).href + const visited = packageTypes === undefined ? undefined : [] + let directory = new URL('.', url) + while (true) { + if (directory.href === packageDirectory) { + return getPackageFormat(extension, packageType) + } + + if (packageTypes?.has(directory.href)) { + const type = packageTypes.get(directory.href) + for (const href of visited) packageTypes.set(href, type) + return getPackageFormat(extension, type) + } + + visited?.push(directory.href) + try { + const source = readFileSync(new URL('package.json', directory), 'utf8') + const type = JSON.parse(source).type + if (packageTypes !== undefined) { + for (const href of visited) packageTypes.set(href, type) + } + return getPackageFormat(extension, type) + } catch (error) { + if (error.code !== 'ENOENT') return undefined + } + + const parent = new URL('../', directory) + if (parent.href === directory.href) { + if (packageTypes !== undefined) { + for (const href of visited) packageTypes.set(href, undefined) + } + return getPackageFormat(extension, undefined) + } + directory = parent + } + } +} diff --git a/lib/get-package-details.js b/lib/get-package-details.js new file mode 100644 index 00000000..07386f27 --- /dev/null +++ b/lib/get-package-details.js @@ -0,0 +1,74 @@ +'use strict' + +const path = require('node:path') +const { fileURLToPath } = require('node:url') + +/** + * @typedef {object} Package + * @property {string} name + * @property {string} packageJsonUrl + * @property {string} packageUrl + * @property {string} [type] + * @property {string} [version] + */ + +/** + * @typedef {Package & { path: string }} PackageDetails + */ + +/** + * @param {typeof import('node:fs').readFileSync} readFileSync + * @returns {(url: string) => PackageDetails|undefined} + */ +module.exports = function createGetPackageDetails (readFileSync) { + /** + * @param {string} url + * @returns {PackageDetails|undefined} + */ + return function getPackageDetails (url) { + if (!url.startsWith('file:')) return + + const moduleUrl = new URL(url) + moduleUrl.hash = '' + moduleUrl.search = '' + let directory = new URL('.', moduleUrl) + + while (true) { + const packageJsonUrl = new URL('package.json', directory) + let packageJson + try { + packageJson = JSON.parse(readFileSync(packageJsonUrl, 'utf8')) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + + if (typeof packageJson?.name === 'string' && packageJson.name !== '') { + const packageData = Object.freeze({ + name: packageJson.name, + packageJsonUrl: packageJsonUrl.href, + packageUrl: directory.href, + type: typeof packageJson.type === 'string' ? packageJson.type : undefined, + version: typeof packageJson.version === 'string' ? packageJson.version : undefined + }) + return createPackageDetails(moduleUrl, packageData) + } + + const parent = new URL('../', directory) + if (parent.href === directory.href) return + directory = parent + } + } +} + +/** + * @param {URL} moduleUrl + * @param {Package} packageData + * @returns {PackageDetails} + */ +function createPackageDetails (moduleUrl, packageData) { + const modulePath = path.relative(fileURLToPath(packageData.packageUrl), fileURLToPath(moduleUrl)) + return { + ...packageData, + path: modulePath.replaceAll(path.sep, '/') + } +} diff --git a/lib/register.js b/lib/register.js index ff5f1676..cde3c3b8 100644 --- a/lib/register.js +++ b/lib/register.js @@ -6,6 +6,22 @@ const importHooks = [] // TODO should this be a Set? const binders = new WeakMap() const specifiers = new Map() const toHook = [] +const registrySymbol = Symbol.for('import-in-the-middle:bundler-registry:v1') +const registry = globalThis[registrySymbol] ??= { + extendedHooks: new Map(), + toHookExtended: new Map() +} +const { extendedHooks, toHookExtended } = registry + +/** + * @typedef {object} HookEntry + * @property {string} name + * @property {object} namespace + * @property {string} specifier + * @property {unknown} data + * @property {'module' | 'commonjs'} format + * @property {{ exports: unknown }} [module] + */ /** * @param {object} source The module namespace. @@ -51,10 +67,52 @@ function register (name, binder, specifier) { specifiers.set(name, specifier) binders.set(namespace, binder) const proxy = new Proxy(namespace, proxyHandler) - importHooks.forEach(hook => hook(name, proxy, specifier)) + for (const hook of importHooks) { + hook(name, proxy, specifier) + } toHook.push([name, proxy, specifier]) } +/** + * @param {string} name The wrapped module URL. + * @param {ModuleBinder} binder The wrapper's binding state. + * @param {string} specifier The original import specifier. + * @param {unknown} data Consumer data associated with the module. + * @returns {void} + */ +function registerWithData (name, binder, specifier, data) { + const { namespace } = binder + binders.set(namespace, binder) + const proxy = new Proxy(namespace, proxyHandler) + for (const hook of extendedHooks.values()) { + hook(name, proxy, specifier, data, 'module') + } + toHookExtended.set(`module\0${name}`, { name, namespace: proxy, specifier, data, format: 'module' }) +} + +/** + * @param {string} name The wrapped module URL. + * @param {{ exports: unknown }} module Its CommonJS module object. + * @param {string} specifier The original require specifier. + * @param {unknown} [data] Consumer data associated with the module. + * @returns {void} + */ +function registerCommonJS (name, module, specifier, data) { + const entry = { + name, + namespace: module.exports, + specifier, + data, + format: 'commonjs', + module + } + for (const hook of extendedHooks.values()) { + const replacement = hook(name, module.exports, specifier, data, 'commonjs') + if (replacement !== undefined) module.exports = replacement + } + toHookExtended.set(`commonjs\0${name}`, entry) +} + // Delays (ms) for re-reading exports that were still in their temporal dead zone // when the wrapper first ran (circular imports). Retried on a microtask first, // then at these intervals; unref'd so best-effort retries never hold the process @@ -84,14 +142,34 @@ class ModuleBinder { * @param {string[]} [keys] Export names in wrapper-binding order. * @param {(index: number, value: unknown) => void} [write] Assigns a wrapper binding by index. * @param {object[]} [sources] Alternate namespaces for star-collision bindings. + * @param {string[]} [passthroughKeys] Read-only export names that retain their source bindings. + * @param {object[]} [passthroughSources] Alternate namespaces for read-only bindings. */ - constructor (source, keys, write, sources) { + constructor (source, keys, write, sources, passthroughKeys, passthroughSources) { this.#write = write if (keys !== undefined) { for (let index = 0; index < keys.length; index++) { this.#bind(keys[index], index, sources?.[index] ?? source) } } + if (passthroughKeys !== undefined) { + for (let index = 0; index < passthroughKeys.length; index++) { + this.#bindReadOnly(passthroughKeys[index], passthroughSources?.[index] ?? source) + } + } + } + + /** + * @param {string} key The export name. + * @param {object} source The binding's source namespace. + * @returns {void} + */ + #bindReadOnly (key, source) { + Object.defineProperty(this.namespace, key, { + configurable: true, + enumerable: true, + get: () => readExport(source, key) + }) } /** @@ -213,7 +291,11 @@ class ModuleBinder { } exports.register = register +exports.registerCommonJS = registerCommonJS +exports.registerWithData = registerWithData exports.ModuleBinder = ModuleBinder +exports.extendedHooks = extendedHooks exports.importHooks = importHooks exports.specifiers = specifiers exports.toHook = toHook +exports.toHookExtended = toHookExtended diff --git a/lib/wrapper.mjs b/lib/wrapper.mjs new file mode 100644 index 00000000..c44e297d --- /dev/null +++ b/lib/wrapper.mjs @@ -0,0 +1,597 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2.0 License. +// +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. + +'use strict' + +import { builtinModules } from 'module' +import { URL } from 'url' + +import { getExports } from './get-exports.mjs' +import { RESOLVE } from './io.mjs' + +// Depth at which `processModule` starts tracking visited URLs to break an +// `export *` cycle. Real re-export chains are only a few levels deep, so this +// is far beyond any legitimate graph yet well below the call-stack limit a +// cycle would otherwise hit. Below it the recursion pays only an integer +// compare per level and allocates no set. +const STAR_CYCLE_DEPTH = 100 +const IDENTIFIER_NAME_REGEXP = /^[$_\p{ID_Start}][$_\u200C\u200D\p{ID_Continue}]*$/u + +/** @typedef {{ name: string, origin: string }} StarBinding */ +/** @typedef {{ name: string, url: string, localName?: string }} ResolvedExportBinding */ +/** + * @typedef {object} ExportDeclaration + * @property {'direct' | 'reexport'} type + * @property {string} name + * @property {string} [localName] + * @property {string} [importName] + * @property {string} [specifier] + */ +/** + * @typedef {object} ModuleExportsMetadata + * @property {Iterable} exportNames + * @property {Map} [exportDeclarations] + * @property {Array<{ specifier: string, parentURL: string }>} [starReexports] + */ +/** + * @typedef {object} ProcessResult + * @property {string[] | Map} bindings + * @property {Map | undefined} origins + */ + +function isBareSpecifier (specifier) { + // Relative and absolute paths are not bare specifiers. + if ( + specifier.startsWith('.') || + specifier.startsWith('/')) { + return false + } + + // Valid URLs are not bare specifiers. (file:, http:, node:, etc.) + + // eslint-disable-next-line no-prototype-builtins + if (URL.hasOwnProperty('canParse')) { + return !URL.canParse(specifier) + } + + const stackTraceLimit = Error.stackTraceLimit + try { + Error.stackTraceLimit = 0 + // eslint-disable-next-line no-new + new URL(specifier) + return false + } catch { + return true + } finally { + Error.stackTraceLimit = stackTraceLimit + } +} + +/** + * @param {string} name The exported name. + * @param {string} sourceUrl The URL of the module that defines the export. + */ +function shouldReexport (name, sourceUrl) { + return name !== 'module.exports' || + (!sourceUrl.startsWith('node:') && !builtinModules.includes(sourceUrl)) +} + +/** + * @param {string} name The exported name. + * @param {string} sourceUrl The URL of the module that defines the export. + */ +function shouldExcludeExport (name, sourceUrl) { + return name === 'default' || !shouldReexport(name, sourceUrl) +} + +/** + * Processes a module's exports and builds its wrapper bindings. + * + * 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, + * 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. + * + * @param {object} params + * @param {string} params.srcUrl The full URL to the module to process. + * @param {LoadContext} params.context Provided by the loaders API. + * @param {boolean} [params.excludeDefault = false] Exclude the default export. + * @param {number} [params.depth = 0] Star-re-export recursion depth. Used to + * detect `export *` cycles (`a` re-exports `b`, `b` re-exports `a`) cheaply: + * the acyclic common case pays only an integer compare per level, and the + * cycle-tracking set is allocated only once recursion is implausibly deep. + * @param {Set} [params.seen] URLs currently on the recursion stack, + * created lazily once `depth` crosses {@link STAR_CYCLE_DEPTH}. A URL is added + * before descending into its subtree and removed once that subtree finishes, so + * it tracks the active path rather than every URL ever visited. + * @param {Map} [params.moduleExportsCache] Parsed exports used by binding resolution. + * @returns {Generator} + * A generator that yields I/O operations and ultimately returns the shimmed + * bindings for all the exports from the module and any transitive export all + * modules. `origins` (the defining module per `*`-sourced name) is `undefined` + * for a module with no `export *`. + */ +export function * processModule ({ + srcUrl, + context, + excludeDefault = false, + depth = 0, + seen, + moduleExportsCache +}) { + const moduleExports = yield * getExports(srcUrl, context, moduleExportsCache !== undefined) + if (moduleExportsCache !== undefined) moduleExportsCache.set(srcUrl, moduleExports) + const { exportNames, starReexports } = moduleExports + + // 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. + if (starReexports === undefined) { + if (!excludeDefault) { + return { bindings: exportNames, origins: undefined } + } + + const bindings = [] + for (const name of exportNames) { + if (shouldExcludeExport(name, srcUrl)) continue + bindings.push(name) + } + return { bindings, origins: undefined } + } + + const bindings = new Map() + + // Maps each live `*`-sourced name to the module that defined it. Its keys + // double as "this name came from a `*` re-export" (so an explicit export can + // override it), and its values let two `*` re-exports of the same name be told + // apart. Allocated on the first `export *`, never for a module without one; a + // single Map carries both facts so a star with no collision pays one structure + // and one write per name, not two. + let starOrigins + let ambiguousStars + let firstStarUrl + let processedStarUrls + + for (const name of exportNames) { + if (excludeDefault && shouldExcludeExport(name, srcUrl)) continue + bindings.set(name, name) + } + + for (const { specifier, parentURL } of starReexports) { + // Relative paths need to be resolved relative to the module declaring the star. + const newSpecifier = isBareSpecifier(specifier) ? specifier : new URL(specifier, parentURL).href + // We need to resolve bare specifiers to a full URL. We also need to + // resolve all sub-modules to get the `format`. We can't rely on the + // parent's `format` to know if this sub-module is ESM or CJS! + const result = yield [RESOLVE, newSpecifier, { parentURL }] + + // Most star modules have one target. Defer the collection until a second + // distinct target while still ignoring repeated declarations. + if (firstStarUrl === undefined) { + firstStarUrl = result.url + } else if (processedStarUrls === undefined) { + if (result.url === firstStarUrl) continue + processedStarUrls = [firstStarUrl, result.url] + } else { + if (processedStarUrls.includes(result.url)) continue + processedStarUrls.push(result.url) + } + + // First `*` re-export: allocate the origin bookkeeping lazily. + starOrigins ??= new Map() + + // `export *` graphs are normally only a handful of levels deep. A cycle + // (`a` re-exports `b`, `b` re-exports `a`) instead recurses without bound + // and exhausts memory. Rather than track every URL on the common shallow + // path, only start recording once the depth is implausibly large for a + // real graph; from there a re-export pointing back at a module already on + // the recursion stack is the cycle, and is skipped (its exports are + // collected by the in-progress ancestor frame). `seen` mirrors the stack, + // not every URL visited: a module reached and fully processed through one + // sibling branch must stay reachable through a later, more direct branch, + // so it is removed again once its subtree finishes. + if (depth >= STAR_CYCLE_DEPTH) { + seen ??= new Set() + if (seen.has(result.url)) continue + seen.add(result.url) + } + + try { + const sub = yield * processModule({ + srcUrl: result.url, + context: { ...context, format: result.format }, + excludeDefault: true, + depth: depth + 1, + seen, + moduleExportsCache + }) + + for (const binding of sub.bindings.values()) { + const directName = typeof binding === 'string' ? binding : undefined + const name = directName ?? binding.name + if (ambiguousStars?.has(name)) continue + + const origin = directName === undefined ? binding.origin : sub.origins?.get(name) ?? result.url + if (bindings.has(name)) { + // An explicit export shadows every star re-export. + if (!starOrigins.has(name)) continue + + if (starOrigins.get(name) === origin) { + // IITM's aggregate namespace sees the wrapped paths as ambiguous. + // Retain the defining URL so source generation can import it once. + bindings.set(name, { name, origin }) + } else { + bindings.delete(name) + starOrigins.delete(name) + ambiguousStars ??= new Set() + ambiguousStars.add(name) + } + } else { + starOrigins.set(name, origin) + bindings.set(name, binding) + } + } + } finally { + seen?.delete(result.url) + } + } + + return { bindings, origins: starOrigins } +} + +/** + * Resolves exported names to the local bindings that define them. + * + * @param {object} params + * @param {string} params.srcUrl The root module URL. + * @param {object} params.context The root module load context. + * @param {Iterable} params.exportNames The root module's resolved export names. + * @param {Map} params.moduleExportsCache Parsed exports collected during graph traversal. + * @returns {Generator} + */ +export function * resolveExportBindings ({ srcUrl, context, exportNames, moduleExportsCache }) { + const memo = new Map() + const pending = new Set() + const resolutionCache = new Map() + const bindings = [] + for (const name of exportNames) { + const binding = yield * resolveExportBinding({ + srcUrl, + name, + context, + moduleExportsCache, + memo, + pending, + resolutionCache + }) + bindings.push(binding ?? { name, url: srcUrl }) + } + return bindings +} + +/** + * @param {object} params + * @param {string} params.srcUrl The module URL. + * @param {string} params.name The exported name to resolve. + * @param {object} params.context The module load context. + * @param {Map} params.moduleExportsCache Parsed module exports. + * @param {Map} params.memo Resolved bindings by module and export name. + * @param {Set} params.pending Bindings on the active resolution path. + * @param {Map} params.resolutionCache Resolved re-export targets. + * @returns {Generator} + */ +function * resolveExportBinding ({ srcUrl, name, context, moduleExportsCache, memo, pending, resolutionCache }) { + const key = `${srcUrl}\0${name}` + if (memo.has(key)) { + const binding = memo.get(key) + return binding === false ? undefined : binding + } + if (pending.has(key)) return + pending.add(key) + + let binding + try { + const moduleExports = yield * loadModuleExports(srcUrl, context, moduleExportsCache) + const declaration = moduleExports.exportDeclarations?.get(name) + if (declaration?.type === 'direct') { + binding = { name, url: srcUrl } + if (declaration.localName !== undefined) binding.localName = declaration.localName + } else if (declaration?.type === 'reexport' && declaration.specifier !== undefined) { + const target = yield * resolveBindingTarget(declaration.specifier, srcUrl, resolutionCache) + if (declaration.importName === undefined) { + binding = { name, url: target.url } + } else { + const imported = yield * resolveExportBinding({ + srcUrl: target.url, + name: declaration.importName, + context: { ...context, format: target.format }, + moduleExportsCache, + memo, + pending, + resolutionCache + }) + if (imported !== undefined) binding = { ...imported, name } + } + } else if (name !== 'default' && moduleExports.starReexports !== undefined) { + binding = yield * resolveStarExport({ + name, + context, + starReexports: moduleExports.starReexports, + moduleExportsCache, + memo, + pending, + resolutionCache + }) + } else if (hasExportName(moduleExports.exportNames, name)) { + binding = { name, url: srcUrl } + } + } finally { + pending.delete(key) + } + + memo.set(key, binding ?? false) + return binding +} + +/** + * @param {object} params + * @param {string} params.name The star-exported name to resolve. + * @param {object} params.context The parent module load context. + * @param {Array<{ specifier: string, parentURL: string }>} params.starReexports Star re-export declarations. + * @param {Map} params.moduleExportsCache Parsed module exports. + * @param {Map} params.memo Resolved bindings by module and export name. + * @param {Set} params.pending Bindings on the active resolution path. + * @param {Map} params.resolutionCache Resolved re-export targets. + * @returns {Generator} + */ +function * resolveStarExport ({ name, context, starReexports, moduleExportsCache, memo, pending, resolutionCache }) { + let binding + for (const { specifier, parentURL } of starReexports) { + const target = yield * resolveBindingTarget(specifier, parentURL, resolutionCache) + const candidate = yield * resolveExportBinding({ + srcUrl: target.url, + name, + context: { ...context, format: target.format }, + moduleExportsCache, + memo, + pending, + resolutionCache + }) + if (candidate === undefined) continue + if (binding === undefined) { + binding = candidate + } else if (binding.url !== candidate.url || binding.localName !== candidate.localName) { + return + } + } + return binding === undefined ? undefined : { ...binding, name } +} + +/** + * @param {string} specifier The re-exported module specifier. + * @param {string} parentURL The declaring module URL. + * @param {Map} resolutionCache Resolved re-export targets. + * @returns {Generator} + */ +function * resolveBindingTarget (specifier, parentURL, resolutionCache) { + const key = `${parentURL}\0${specifier}` + const cached = resolutionCache.get(key) + if (cached !== undefined) return cached + + const request = isBareSpecifier(specifier) ? specifier : new URL(specifier, parentURL).href + const target = yield [RESOLVE, request, { parentURL }] + resolutionCache.set(key, target) + return target +} + +/** + * @param {string} srcUrl The module URL. + * @param {object} context The module load context. + * @param {Map} moduleExportsCache Parsed module exports. + * @returns {Generator} + */ +function * loadModuleExports (srcUrl, context, moduleExportsCache) { + let moduleExports = moduleExportsCache.get(srcUrl) + if (moduleExports === undefined) { + moduleExports = yield * getExports(srcUrl, context, true) + moduleExportsCache.set(srcUrl, moduleExports) + } + return moduleExports +} + +/** + * @param {Iterable} exportNames The module's exported names. + * @param {string} name The expected export name. + * @returns {boolean} + */ +function hasExportName (exportNames, name) { + for (const exportName of exportNames) { + if (exportName === name) return true + } + return false +} + +/** + * @typedef {object} WrapperOptions + * @property {string} realUrl The URL of the wrapped module. + * @property {string[] | Map} bindings Its exported bindings. + * @property {string} originalSpecifier The specifier used to import the module. + * @property {string} runtimeSpecifier The wrapper runtime import. + * @property {(url: string) => string} [mapImport] Maps module URLs to bundler-owned imports. + * @property {ReadonlySet} [passthroughExports] Exports that retain their source bindings. + */ + +/** + * @param {WrapperOptions & { data: unknown }} options + * @returns {string} + */ +function buildESMWrapperSource ({ + realUrl, + bindings, + originalSpecifier, + data, + runtimeSpecifier, + mapImport, + passthroughExports +}) { + const moduleSpecifier = mapImport?.(realUrl) ?? realUrl + // The wrapped module imports its namespace as `namespace`, which serves + // every export but the ones a same-origin `export *` collision forced onto + // their defining module (#171): the aggregate namespace drops those as + // ambiguous under iitm, so each such defining module gets its own alias the + // wrapper imports. Without such a collision nothing is added. + let originImports = '' + let originNamespaces + let declarationNames = '' + let bindingNames = '' + let bindingSources + let exportSpecifiers = '' + let passthroughNames = '' + let passthroughReexports = '' + let passthroughSources + let passthroughIndex = 0 + let writeCases = '' + let index = 0 + for (const binding of bindings.values()) { + const directName = typeof binding === 'string' ? binding : undefined + const name = directName ?? binding.name + let namespaceName = 'namespace' + let sourceSpecifier = moduleSpecifier + if (directName === undefined) { + originNamespaces ??= new Map() + namespaceName = originNamespaces.get(binding.origin) + if (namespaceName === undefined) { + namespaceName = `__ns${originNamespaces.size}` + originNamespaces.set(binding.origin, namespaceName) + const originSpecifier = mapImport?.(binding.origin) ?? binding.origin + originImports += `import * as ${namespaceName} from ${JSON.stringify(originSpecifier)}\n` + sourceSpecifier = originSpecifier + } else { + sourceSpecifier = mapImport?.(binding.origin) ?? binding.origin + } + } + const objectKey = JSON.stringify(name) + const exportName = IDENTIFIER_NAME_REGEXP.test(name) ? name : objectKey + if (passthroughExports?.has(name)) { + passthroughNames += passthroughNames === '' ? objectKey : `, ${objectKey}` + if (passthroughSources !== undefined) passthroughSources += ', ' + if (namespaceName !== 'namespace') { + passthroughSources ??= 'undefined, '.repeat(passthroughIndex) + passthroughSources += namespaceName + } else if (passthroughSources !== undefined) { + passthroughSources += 'undefined' + } + passthroughIndex++ + if (shouldReexport(name, realUrl)) { + passthroughReexports += `export { ${exportName} } from ${JSON.stringify(sourceSpecifier)}\n` + } + continue + } + const variableName = `$${index}` + declarationNames += declarationNames === '' ? variableName : `, ${variableName}` + bindingNames += bindingNames === '' ? objectKey : `, ${objectKey}` + if (bindingSources !== undefined) bindingSources += ', ' + if (namespaceName !== 'namespace') { + bindingSources ??= 'undefined, '.repeat(index) + bindingSources += namespaceName + } else if (bindingSources !== undefined) { + bindingSources += 'undefined' + } + writeCases += ` case ${index++}: ${variableName} = value; break\n` + if (shouldReexport(name, realUrl)) { + exportSpecifiers += exportSpecifiers === '' + ? `${variableName} as ${exportName}` + : `, ${variableName} as ${exportName}` + } + } + const passthroughSourceArguments = passthroughSources === undefined ? '' : `, [${passthroughSources}]` + const binder = declarationNames === '' + ? passthroughNames === '' + ? 'const __binder = new ModuleBinder(namespace)\n' + : `const __binder = new ModuleBinder(namespace, undefined, undefined, undefined, [${passthroughNames}]${passthroughSourceArguments})\n` + : `let ${declarationNames} +function __write (index, value) { + switch (index) { +${writeCases} } +} +const __binder = new ModuleBinder(namespace, [${bindingNames}], __write${bindingSources === undefined + ? '' + : `, [${bindingSources}]`}${passthroughNames === '' + ? '' + : `${bindingSources === undefined ? ', undefined' : ''}, [${passthroughNames}]${passthroughSourceArguments}`}) +` + const reexports = exportSpecifiers === '' ? '' : `export { ${exportSpecifiers} }\n` + return ` +import { registerWithData, ModuleBinder } from ${JSON.stringify(runtimeSpecifier)} +import * as namespace from ${JSON.stringify(moduleSpecifier)} +${originImports} +${binder} +${reexports} +${passthroughReexports} + +__binder.flush() + +registerWithData(${JSON.stringify(realUrl)}, __binder, ${JSON.stringify(originalSpecifier)}, ${JSON.stringify(data)}) +` +} + +/** + * @param {WrapperOptions & { data: unknown }} options + * @returns {string} + */ +export function buildWrapperSourceWithData (options) { + return buildESMWrapperSource(options) +} + +/** + * @param {string | ArrayBuffer | ArrayBufferView} source + * @returns {string} + */ +export function sourceToString (source) { + if (typeof source === 'string') return source + if (Buffer.isBuffer(source)) return source.toString('utf8') + if (ArrayBuffer.isView(source)) { + return Buffer.from(source.buffer, source.byteOffset, source.byteLength).toString('utf8') + } + return Buffer.from(source).toString('utf8') +} + +/** + * @param {string | ArrayBuffer | ArrayBufferView} source + * @returns {string} + */ +function prepareCommonJSSource (source) { + source = sourceToString(source) + return source.startsWith('#!') ? '//' + source.slice(2) : source +} + +/** + * @param {object} options + * @param {string} options.realUrl + * @param {string | ArrayBuffer | ArrayBufferView} options.source + * @param {string} options.originalSpecifier + * @param {unknown} [options.data] + * @param {string} options.runtimeSpecifier + * @param {boolean} [options.preserveOuterBindings] + * @returns {string} + */ +export function buildCommonJSWrapperSource ({ + realUrl, + source, + originalSpecifier, + data, + runtimeSpecifier, + preserveOuterBindings +}) { + source = prepareCommonJSSource(source) + + const parameters = preserveOuterBindings ? '' : 'exports, require, module, __filename, __dirname' + const invocation = 'call(module.exports, module.exports, require, module, ' + + 'typeof __filename === "undefined" ? undefined : __filename, ' + + 'typeof __dirname === "undefined" ? undefined : __dirname)' + return `(function (${parameters}) {\n${source}\n}).${invocation}\n` + + `require(${JSON.stringify(runtimeSpecifier)}).registerCommonJS(` + + `${JSON.stringify(realUrl)}, module, ${JSON.stringify(originalSpecifier)}, ${JSON.stringify(data)})\n` +} diff --git a/package.json b/package.json index 436a25e2..d1d59a81 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@types/node": "^18.0.6", "c8": "^7.14.0", "date-fns": "^3.6.0", + "esbuild": "^0.28.1", "eslint": "^8.57.1", "eslint-config-standard": "^17.1.0", "eslint-plugin-import": "^2.32.0", @@ -56,7 +57,8 @@ "openai": "4.47.2", "ts-node": "^10.9.2", "typescript": "^4.9.5", - "vue": "^3.5.26" + "vue": "^3.5.26", + "webpack": "^5.109.2" }, "dependencies": { "cjs-module-lexer": "^2.2.0", diff --git a/register-hooks.d.ts b/register-hooks.d.ts index e398adb5..80c365a9 100644 --- a/register-hooks.d.ts +++ b/register-hooks.d.ts @@ -7,6 +7,7 @@ export type RegisterHooksOptions = { include?: Array exclude?: Array disableCjsSourceStripping?: boolean + commonjs?: boolean } /** diff --git a/register-hooks.mjs b/register-hooks.mjs index d67de611..0dafbfea 100644 --- a/register-hooks.mjs +++ b/register-hooks.mjs @@ -1,4 +1,5 @@ import * as module from 'module' +import { createRequire } from 'module' import { createHook } from './create-hook.mjs' import { supportsSyncHooks } from './supports-sync-hooks.mjs' @@ -39,6 +40,7 @@ let registered = false * @param {Array} [options.include] Only intercept these modules. * @param {Array} [options.exclude] Never intercept these modules. * @param {boolean} [options.disableCjsSourceStripping] Leave hook-provided CJS source unchanged. + * @param {boolean} [options.commonjs] Intercept CommonJS modules. * @returns {void} */ export function register (options) { @@ -57,8 +59,17 @@ export function register (options) { } registered = true + const commonjs = options?.commonjs === true + const activeHook = commonjs ? createHook(import.meta, true) : hook if (options) { - hook.applyOptions(options) + activeHook.applyOptions(options) + } + + if (commonjs) { + const require = createRequire(import.meta.url) + require('./lib/register.js') + module.registerHooks({ resolve: activeHook.resolveSyncCommonJS, load: activeHook.loadSyncCommonJS }) + return } module.registerHooks({ resolve: hook.resolveSync, load: hook.loadSync }) diff --git a/test/fixtures/test-nextjs-app/app/api/foo/route.ts b/test/fixtures/test-nextjs-app/app/api/foo/route.ts index 7be61e0a..b2cacc33 100644 --- a/test/fixtures/test-nextjs-app/app/api/foo/route.ts +++ b/test/fixtures/test-nextjs-app/app/api/foo/route.ts @@ -1,8 +1,10 @@ import camelcase from 'camelcase' import { NextResponse } from 'next/server' +import { runIitmWrapper } from '../../../iitm-turbopack.mjs' + export async function GET (request: Request) { camelcase('foo bar') - return NextResponse.json({}) + return NextResponse.json(runIitmWrapper()) } diff --git a/test/fixtures/test-nextjs-app/iitm-turbopack.d.mts b/test/fixtures/test-nextjs-app/iitm-turbopack.d.mts new file mode 100644 index 00000000..849a9940 --- /dev/null +++ b/test/fixtures/test-nextjs-app/iitm-turbopack.d.mts @@ -0,0 +1,8 @@ +export type IitmWrapperResult = { + initialLive: number + live: number + stable: number + hookedLive: number +} + +export declare function runIitmWrapper(): IitmWrapperResult diff --git a/test/fixtures/test-nextjs-app/iitm-turbopack.mjs b/test/fixtures/test-nextjs-app/iitm-turbopack.mjs new file mode 100644 index 00000000..e976f124 --- /dev/null +++ b/test/fixtures/test-nextjs-app/iitm-turbopack.mjs @@ -0,0 +1,29 @@ +import Hook from 'import-in-the-middle' +import * as wrapped from './iitm-wrapper.mjs' + +/** + * @returns {{ initialLive: number, live: number, stable: number, hookedLive: number }} + */ +export function runIitmWrapper () { + let hookedExports + /** @param {Record} exports The intercepted exports. */ + function hookExports (exports) { + hookedExports = exports + exports.live = 100 + exports.stable = 43 + } + + const hook = new Hook(['iitm-turbopack-live'], hookExports) + try { + const initialLive = wrapped.live + wrapped.increment() + return { + initialLive, + live: wrapped.live, + stable: wrapped.stable, + hookedLive: hookedExports.live + } + } finally { + hook.unhook() + } +} diff --git a/test/fixtures/test-nextjs-app/next.config.ts b/test/fixtures/test-nextjs-app/next.config.ts index ec446e8f..f5f0fa32 100644 --- a/test/fixtures/test-nextjs-app/next.config.ts +++ b/test/fixtures/test-nextjs-app/next.config.ts @@ -1,7 +1,12 @@ +import path from "node:path"; + import type { NextConfig } from "next"; const nextConfig: NextConfig = { - serverExternalPackages: ['camelcase'] + serverExternalPackages: ['camelcase'], + turbopack: { + root: path.resolve(process.cwd(), '../../..') + } }; export default nextConfig; diff --git a/test/fixtures/test-nextjs-app/package.json b/test/fixtures/test-nextjs-app/package.json index 7cb2acfc..3e014518 100644 --- a/test/fixtures/test-nextjs-app/package.json +++ b/test/fixtures/test-nextjs-app/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "camelcase": "8.0.0", + "import-in-the-middle": "file:../../..", "next": "16.2.1", "react": "19.2.4", "react-dom": "19.2.4" diff --git a/test/get-esm-exports/v20-get-esm-exports.mjs b/test/get-esm-exports/v20-get-esm-exports.mjs index d9d2dc15..4c966225 100644 --- a/test/get-esm-exports/v20-get-esm-exports.mjs +++ b/test/get-esm-exports/v20-get-esm-exports.mjs @@ -32,6 +32,21 @@ assert.deepEqual(lexEsm('export const direct = 1; export * from "dependency"', ' starReexports: [{ specifier: 'dependency', parentURL: 'file:///parent.mjs' }], hasModuleSyntax: true }) +assert.deepEqual(lexEsm(` + export let direct = 1 + const local = 2 + export { local as alias } + export { remote as renamed } from './dependency.mjs' + import { imported as detached } from './dependency.mjs' + export { detached } + export * as namespace from './dependency.mjs' +`, 'file:///parent.mjs', true).exportDeclarations, new Map([ + ['direct', { type: 'direct', name: 'direct', localName: 'direct' }], + ['alias', { type: 'direct', name: 'alias', localName: 'local' }], + ['renamed', { type: 'reexport', name: 'renamed', importName: 'remote', specifier: './dependency.mjs' }], + ['detached', { type: 'reexport', name: 'detached', importName: 'imported', specifier: './dependency.mjs' }], + ['namespace', { type: 'reexport', name: 'namespace', specifier: './dependency.mjs' }] +])) // // Generate fixture data // fixture.split('\n').forEach(line => { diff --git a/test/integration-tests/turbopack-build-start.mjs b/test/integration-tests/turbopack-build-start.mjs index 347326a2..19598904 100644 --- a/test/integration-tests/turbopack-build-start.mjs +++ b/test/integration-tests/turbopack-build-start.mjs @@ -1,81 +1,56 @@ -import { spawn, execSync } from 'child_process' -import { existsSync } from 'fs' -import { strictEqual } from 'assert' -import { fileURLToPath } from 'url' -import path from 'path' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const appDir = path.resolve(__dirname, '..', 'fixtures', 'test-nextjs-app') -const iitmDir = path.resolve(__dirname, '..', '..') -const hookSetup = path.join(appDir, 'iitm-hook-setup.cjs') -const nextBin = path.join(appDir, 'node_modules', '.bin', 'next') -const PORT = 3100 - -if (!existsSync(path.join(appDir, 'node_modules'))) { +import { deepStrictEqual } from 'node:assert/strict' +import { execFileSync, execSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { runTurbopackServer } from './turbopack-server.mjs' +import { prepareTurbopackWrapper, removeTurbopackWrapper } from './turbopack-wrapper.mjs' + +const directory = path.dirname(fileURLToPath(import.meta.url)) +const appDirectory = path.resolve(directory, '..', 'fixtures', 'test-nextjs-app') +const iitmDirectory = path.resolve(directory, '..', '..') +const hookSetup = path.join(appDirectory, 'iitm-hook-setup.cjs') +const nextBin = path.join(appDirectory, 'node_modules', '.bin', 'next') +const port = 3100 + +if (!existsSync(path.join(appDirectory, 'node_modules'))) { console.log('Installing Next.js app dependencies') - execSync('npm install', { cwd: appDir }) + execSync('npm install', { cwd: appDirectory }) } -console.log('Running `next build`') -await new Promise((resolve, reject) => { - const build = spawn(nextBin, ['build'], { - cwd: appDir, - env: { ...process.env, NODE_OPTIONS: '' } - }) - build.on('error', reject) - build.on('close', code => { - if (code === 0) resolve() - else reject(new Error(`next build exited with code ${code}`)) - }) -}) - -const hookTriggered = await new Promise((resolve, reject) => { - console.log(`Starting Next.js server on port ${PORT} via \`next start\``) - const server = spawn(nextBin, ['start', '--port', String(PORT)], { - cwd: appDir, - env: { - ...process.env, - NODE_OPTIONS: `--no-warnings --experimental-loader ${path.join(iitmDir, 'hook.mjs')} --require ${hookSetup}`, - IITM_PATH: path.join(iitmDir, 'index.js') - } +await prepareTurbopackWrapper(appDirectory, 41) +try { + console.log('Running `next build --turbopack`') + execFileSync(nextBin, ['build', '--turbopack'], { + cwd: appDirectory, + env: { ...process.env, NODE_OPTIONS: '' }, + stdio: 'inherit' }) - let output = '' - let hookSeen = false - let requestMade = false - - function onData (chunk) { - const text = chunk.toString() - output += text - - if (!requestMade && /ready/i.test(text)) { - requestMade = true - console.log('Server is ready, hitting /api/foo') - fetch(`http://localhost:${PORT}/api/foo`).catch(() => {}) - } - - if (!hookSeen && output.includes('IITM_HOOK_TRIGGERED:camelcase')) { - hookSeen = true - server.kill() - resolve(true) - } - } + console.log(`Starting Next.js server on port ${port} via \`next start\``) + await runTurbopackServer({ + appDirectory, + arguments: ['start', '--port', String(port)], + hookSetup, + iitmDirectory, + nextBin, + port + }, verifyRoute) +} finally { + await removeTurbopackWrapper(appDirectory) +} - server.stdout.on('data', onData) - server.stderr.on('data', onData) - server.on('error', reject) - server.on('close', () => { - if (!hookSeen) { - reject(new Error(`Hook was not triggered. Output:\n${output}`)) - } +/** + * @param {string} url The application route URL. + * @returns {Promise} + */ +async function verifyRoute (url) { + const response = await fetch(url) + deepStrictEqual(await response.json(), { + initialLive: 41, + live: 42, + stable: 43, + hookedLive: 42 }) - - setTimeout(() => { - if (!hookSeen) { - server.kill() - reject(new Error(`Timed out waiting for hook. Output:\n${output}`)) - } - }, 30_000) -}) - -strictEqual(hookTriggered, true) +} diff --git a/test/integration-tests/turbopack-dev.mjs b/test/integration-tests/turbopack-dev.mjs index ff451cc5..a82bf9e4 100644 --- a/test/integration-tests/turbopack-dev.mjs +++ b/test/integration-tests/turbopack-dev.mjs @@ -1,71 +1,86 @@ -import { spawn, execSync } from 'child_process' -import { existsSync } from 'fs' -import { strictEqual } from 'assert' -import { fileURLToPath } from 'url' -import path from 'path' +import { deepStrictEqual } from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const appDir = path.resolve(__dirname, '..', 'fixtures', 'test-nextjs-app') -const iitmDir = path.resolve(__dirname, '..', '..') -const hookSetup = path.join(appDir, 'iitm-hook-setup.cjs') -const PORT = 3099 +import { runTurbopackServer } from './turbopack-server.mjs' +import { + prepareTurbopackWrapper, + removeTurbopackWrapper, + updateTurbopackSource +} from './turbopack-wrapper.mjs' -if (!existsSync(path.join(appDir, 'node_modules'))) { +const directory = path.dirname(fileURLToPath(import.meta.url)) +const appDirectory = path.resolve(directory, '..', 'fixtures', 'test-nextjs-app') +const iitmDirectory = path.resolve(directory, '..', '..') +const hookSetup = path.join(appDirectory, 'iitm-hook-setup.cjs') +const nextBin = path.join(appDirectory, 'node_modules', '.bin', 'next') +const port = 3099 + +if (!existsSync(path.join(appDirectory, 'node_modules'))) { console.log('Installing Next.js app dependencies') - execSync('npm install', { cwd: appDir }) + execSync('npm install', { cwd: appDirectory }) } -const hookTriggered = await new Promise((resolve, reject) => { - console.log(`Starting Next.js server on port ${PORT} via \`next dev\``) - const server = spawn( - path.join(appDir, 'node_modules', '.bin', 'next'), - ['dev', '--port', String(PORT)], - { - cwd: appDir, - env: { - ...process.env, - NODE_OPTIONS: `--no-warnings --experimental-loader ${path.join(iitmDir, 'hook.mjs')} --require ${hookSetup}`, - IITM_PATH: path.join(iitmDir, 'index.js') - } - } - ) - - let output = '' - let hookSeen = false - let requestMade = false +await prepareTurbopackWrapper(appDirectory, 41) +try { + console.log(`Starting Next.js server on port ${port} via \`next dev --turbopack\``) + await runTurbopackServer({ + appDirectory, + arguments: ['dev', '--turbopack', '--port', String(port)], + hookSetup, + iitmDirectory, + nextBin, + port + }, verifyRoute) +} finally { + await removeTurbopackWrapper(appDirectory) +} - function onData (chunk) { - const text = chunk.toString() - output += text +/** + * @param {string} url The application route URL. + * @returns {Promise} + */ +async function verifyRoute (url) { + deepStrictEqual(await fetchResult(url), expectedResult(41)) + await updateTurbopackSource(appDirectory, 51) + deepStrictEqual(await waitForRebuild(url), expectedResult(51)) +} - if (!requestMade && /ready/i.test(text)) { - requestMade = true - console.log('Server is ready, hitting /api/foo') - fetch(`http://localhost:${PORT}/api/foo`).catch(() => {}) - } +/** + * @param {string} url The application route URL. + * @returns {Promise>} + */ +async function fetchResult (url) { + const response = await fetch(url) + return response.json() +} - if (!hookSeen && output.includes('IITM_HOOK_TRIGGERED:camelcase')) { - hookSeen = true - server.kill() - resolve(true) - } +/** + * @param {string} url The application route URL. + * @returns {Promise>} + */ +async function waitForRebuild (url) { + let result + for (let attempt = 0; attempt < 100; attempt++) { + await delay(100) + result = await fetchResult(url) + if (result.initialLive === 51) return result } + throw new Error(`Turbopack did not rebuild the wrapper dependency: ${JSON.stringify(result)}`) +} - server.stdout.on('data', onData) - server.stderr.on('data', onData) - server.on('error', reject) - server.on('close', () => { - if (!hookSeen) { - reject(new Error(`Hook was not triggered. Output:\n${output}`)) - } - }) - - setTimeout(() => { - if (!hookSeen) { - server.kill() - reject(new Error(`Timed out waiting for hook. Output:\n${output}`)) - } - }, 30_000) -}) - -strictEqual(hookTriggered, true) +/** + * @param {number} initialLive The dependency's initial live export. + * @returns {{ initialLive: number, live: number, stable: number, hookedLive: number }} + */ +function expectedResult (initialLive) { + return { + initialLive, + live: initialLive + 1, + stable: 43, + hookedLive: initialLive + 1 + } +} diff --git a/test/integration-tests/turbopack-server.mjs b/test/integration-tests/turbopack-server.mjs new file mode 100644 index 00000000..228a4bf2 --- /dev/null +++ b/test/integration-tests/turbopack-server.mjs @@ -0,0 +1,86 @@ +import { spawn } from 'node:child_process' +import { join } from 'node:path' + +/** + * @param {object} options The Next.js server options. + * @param {string} options.appDirectory The Next.js application directory. + * @param {string[]} options.arguments The Next.js command arguments. + * @param {string} options.hookSetup The CommonJS hook setup path. + * @param {string} options.iitmDirectory The IITM package directory. + * @param {string} options.nextBin The Next.js executable path. + * @param {number} options.port The server port. + * @param {(url: string) => Promise} verify Verifies the application route. + * @returns {Promise} + */ +export function runTurbopackServer (options, verify) { + return new Promise((resolve, reject) => { + const server = spawn(options.nextBin, options.arguments, { + cwd: options.appDirectory, + env: { + ...process.env, + NODE_OPTIONS: `--no-warnings --experimental-loader ${join(options.iitmDirectory, 'hook.mjs')} --require ${options.hookSetup}`, + IITM_PATH: join(options.iitmDirectory, 'index.js') + } + }) + + let output = '' + let hookSeen = false + let verificationStarted = false + let verificationComplete = false + let settled = false + + /** @param {Error} [error] The verification failure. */ + function finish (error) { + if (settled || (error === undefined && (!hookSeen || !verificationComplete))) return + settled = true + clearTimeout(timeout) + server.kill() + if (error === undefined) resolve() + else reject(error) + } + + async function verifyRoute () { + try { + await verify(`http://localhost:${options.port}/api/foo`) + verificationComplete = true + finish() + } catch (error) { + finish(error) + } + } + + /** @param {Buffer} chunk The server output chunk. */ + function onData (chunk) { + const text = chunk.toString() + output += text + + if (!verificationStarted && /ready/i.test(text)) { + verificationStarted = true + verifyRoute() + } + if (!hookSeen && output.includes('IITM_HOOK_TRIGGERED:camelcase')) { + hookSeen = true + finish() + } + } + + /** @param {Error} error The child-process failure. */ + function onError (error) { + finish(error) + } + + function onClose () { + if (!settled) finish(new Error(`Turbopack server exited before verification completed. Output:\n${output}`)) + } + + function onTimeout () { + finish(new Error(`Timed out waiting for Turbopack. Output:\n${output}`)) + } + + server.stdout.on('data', onData) + server.stderr.on('data', onData) + server.on('error', onError) + server.on('close', onClose) + const timeout = setTimeout(onTimeout, 30_000) + }) +} diff --git a/test/integration-tests/turbopack-wrapper.mjs b/test/integration-tests/turbopack-wrapper.mjs new file mode 100644 index 00000000..f3596f57 --- /dev/null +++ b/test/integration-tests/turbopack-wrapper.mjs @@ -0,0 +1,119 @@ +import { readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, relative, sep } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' + +import { createWrapperModule } from '../../bundler.mjs' + +const generatedNames = [ + 'iitm-dependency.mjs', + 'iitm-original.mjs', + 'iitm-wrapper.mjs' +] + +/** + * @param {string} appDirectory The Next.js application directory. + * @param {number} initialValue The dependency's initial live export. + * @returns {Promise>>} + */ +export async function prepareTurbopackWrapper (appDirectory, initialValue) { + const require = createRequire(`${appDirectory}/package.json`) + const originalPath = `${appDirectory}/iitm-original.mjs` + const wrapperPath = `${appDirectory}/iitm-wrapper.mjs` + const originalSource = `export { live, increment } from './iitm-dependency.mjs' +export const stable = 42 +` + await Promise.all([ + writeFile(originalPath, originalSource), + updateTurbopackSource(appDirectory, initialValue) + ]) + + const wrapper = await createWrapperModule({ + module: { + url: pathToFileURL(originalPath).href, + format: 'module', + source: originalSource, + specifier: 'iitm-turbopack-live', + data: { bundler: 'turbopack' }, + passthroughExports: selectLiveExport + }, + resolve: resolveModule, + load: loadModule + }) + + let code = wrapper.code + for (const entry of wrapper.imports) { + const targetUrl = entry.kind === 'runtime' + ? pathToFileURL(require.resolve('import-in-the-middle/lib/bundler-runtime.js')).href + : entry.target.url + const specifier = relativeImport(wrapperPath, targetUrl) + code = code.replaceAll(JSON.stringify(entry.specifier), JSON.stringify(specifier)) + } + await writeFile(wrapperPath, code) + return wrapper +} + +/** + * @param {string} appDirectory The Next.js application directory. + * @param {number} initialValue The dependency's initial live export. + * @returns {Promise} + */ +export async function updateTurbopackSource (appDirectory, initialValue) { + const source = `export let live = ${initialValue} +export function increment () { live++ } +` + await writeFile(`${appDirectory}/iitm-dependency.mjs`, source) +} + +/** + * @param {string} appDirectory The Next.js application directory. + * @returns {Promise} + */ +export async function removeTurbopackWrapper (appDirectory) { + await Promise.all(generatedNames.map(name => rm(`${appDirectory}/${name}`, { force: true }))) +} + +/** + * @param {string} specifier The imported module specifier. + * @param {{ parentURL?: string }} context The importing module context. + * @returns {{ url: string, format: 'module', watchFiles: string[] }} + */ +function resolveModule (specifier, context) { + const url = new URL(specifier, context.parentURL).href + return { url, format: 'module', watchFiles: [url] } +} + +/** + * @param {string} url The resolved module URL. + * @returns {Promise<{ source: Buffer, format: 'module', watchFiles: string[] }>} + */ +async function loadModule (url) { + return { + source: await readFile(new URL(url)), + format: 'module', + watchFiles: [url] + } +} + +/** + * @param {string} wrapperPath The generated wrapper path. + * @param {string} targetUrl The manifest target URL. + * @returns {string} + */ +function relativeImport (wrapperPath, targetUrl) { + let specifier = relative(dirname(wrapperPath), fileURLToPath(targetUrl)).split(sep).join('/') + if (!specifier.startsWith('.')) specifier = `./${specifier}` + return specifier +} + +/** + * @param {ReadonlyArray<{ name: string, url: string, localName?: string }>} exports The resolved exports. + * @returns {string[]} + */ +function selectLiveExport (exports) { + const binding = exports.find(({ name }) => name === 'live') + if (binding?.localName !== 'live' || !binding.url.endsWith('/iitm-dependency.mjs')) { + throw new Error('IITM did not resolve the live re-export') + } + return [binding.name] +} diff --git a/test/low-level/bundler.mjs b/test/low-level/bundler.mjs new file mode 100644 index 00000000..1949ea9e --- /dev/null +++ b/test/low-level/bundler.mjs @@ -0,0 +1,927 @@ +import { strictEqual, deepStrictEqual, match, doesNotMatch, rejects, throws } from 'assert' +import { spawnSync } from 'child_process' +import { readFile, mkdir, mkdtemp, writeFile, rm } from 'fs/promises' +import { createRequire } from 'module' +import { tmpdir } from 'os' +import { join } from 'path' +import { fileURLToPath, pathToFileURL } from 'url' + +import Hook from '../../index.js' +import { createWrapperModule, getPackageDetails } from '../../bundler.mjs' + +const require = createRequire(import.meta.url) +const { + createWrapperModule: createCommonJSWrapperModule, + getPackageDetails: getCommonJSPackageDetails, + getNodeModuleFormat +} = require('../../bundler.js') +const { ModuleBinder, registerCommonJS, registerWithData } = require('../../lib/bundler-runtime.js') +const createGetNodeModuleFormat = require('../../lib/get-node-module-format.js') +const moduleUrl = new URL('../fixtures/something.mjs', import.meta.url).href +const reexportLeafUrl = new URL('../fixtures/reexport-same-source-leaf.mjs', import.meta.url).href +const source = await readFile(new URL(moduleUrl), 'utf8') + +/** + * @returns {never} + */ +function unexpectedIo () { + throw new Error('I/O should not be used when source is provided') +} + +/** + * @returns {never} + */ +function unexpectedPassthroughSelection () { + throw new Error('Unexpected passthrough export selection') +} + +/** + * @param {ReadonlyArray<{ name: string, url: string, localName?: string }>} exports The resolved exports. + * @returns {string} + */ +function selectLiveExport (exports) { + deepStrictEqual(exports, [ + { name: 'live', url: liveModuleUrl, localName: 'live' }, + { name: 'stable', url: liveModuleUrl, localName: 'stable' }, + { name: 'increment', url: liveModuleUrl, localName: 'increment' } + ]) + return 'live' +} + +/** + * @param {ReadonlyArray<{ name: string, url: string, localName?: string }>} exports The resolved star exports. + * @returns {string[]} + */ +function selectValExport (exports) { + deepStrictEqual(exports, [{ name: 'val', url: reexportLeafUrl, localName: 'val' }]) + return exports.map(({ name }) => name) +} + +/** + * @param {string} name The canonical module URL. + * @param {string} specifier The original import specifier. + * @param {unknown} data Consumer data associated with the module. + */ +function registerModuleWithData (name, specifier, data) { + registerWithData(name, new ModuleBinder({}), specifier, data) +} + +const wrapper = await createCommonJSWrapperModule({ + module: { + url: moduleUrl, + format: 'module', + source, + specifier: './something.mjs', + data: { version: '1.0.0' } + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +strictEqual(wrapper.sideEffects, true) +strictEqual(wrapper.sourceLineOffset, undefined) +deepStrictEqual(wrapper.watchFiles, [moduleUrl]) +strictEqual(wrapper.imports.length, 2) +strictEqual(wrapper.imports[0].specifier, './__iitm_runtime__.js') +strictEqual(wrapper.imports[0].kind, 'runtime') +strictEqual(wrapper.imports[0].external, false) +strictEqual(wrapper.imports[1].specifier, './__iitm_module_0__.js') +strictEqual(wrapper.imports[1].kind, 'module') +strictEqual(wrapper.imports[1].external, false) +match(wrapper.code, /from "\.\/__iitm_runtime__\.js"/) +match(wrapper.code, /from "\.\/__iitm_module_0__\.js"/) +match(wrapper.code, /\nregisterWithData\(/) +match(wrapper.code, /\{"version":"1\.0\.0"\}\)/) +doesNotMatch(wrapper.code, /from "file:/) + +const inferredCommonJsWrapper = await createWrapperModule({ + module: { + url: new URL('../fixtures/typeless-commonjs.js', import.meta.url).href, + source: 'module.exports = class Example {}', + specifier: 'typeless-commonjs', + data: { version: '1.0.0' } + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +strictEqual(inferredCommonJsWrapper.imports.length, 1) +strictEqual(inferredCommonJsWrapper.imports[0].kind, 'runtime') +strictEqual(inferredCommonJsWrapper.sourceLineOffset, 1) +match(inferredCommonJsWrapper.code, /registerCommonJS\(/) +doesNotMatch(inferredCommonJsWrapper.code, /registerWithData\(/) + +const emptyPassthroughWrapper = await createWrapperModule({ + module: { + url: moduleUrl, + format: 'module', + source, + specifier: './something.mjs', + data: { version: '1.0.0' }, + passthroughExports: [] + }, + resolve: unexpectedIo, + load: unexpectedIo +}) +deepStrictEqual(emptyPassthroughWrapper, wrapper) + +const emptySourceUrl = 'virtual:iitm-empty' +const emptySourceWrapper = await createWrapperModule({ + module: { + url: emptySourceUrl, + format: 'module', + source: '', + specifier: emptySourceUrl + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +strictEqual(emptySourceWrapper.imports[1].target.url, emptySourceUrl) +doesNotMatch(emptySourceWrapper.code, /^export /m) + +const formatDirectory = await mkdtemp(join(tmpdir(), 'iitm-bundler-format-')) +try { + const packageJsonUrl = pathToFileURL(join(formatDirectory, 'package.json')).href + strictEqual( + getNodeModuleFormat(pathToFileURL(join(formatDirectory, 'seeded.js')).href, packageJsonUrl, 'module'), + 'module' + ) + await writeFile(join(formatDirectory, 'package.json'), '{"type":"module"}') + const nestedDirectory = join(formatDirectory, 'nested') + await mkdir(nestedDirectory) + await writeFile(join(nestedDirectory, 'package.json'), '{"type":"commonjs"}') + const typelessDirectory = join(formatDirectory, 'typeless') + await mkdir(typelessDirectory) + await writeFile(join(typelessDirectory, 'package.json'), '{}') + strictEqual( + getNodeModuleFormat(pathToFileURL(join(nestedDirectory, 'module.js')).href, packageJsonUrl, 'module'), + 'commonjs' + ) + strictEqual(getNodeModuleFormat(pathToFileURL(join(formatDirectory, 'module.js')).href), 'module') + strictEqual(getNodeModuleFormat(pathToFileURL(join(formatDirectory, 'module.ts')).href), 'module-typescript') + strictEqual(getNodeModuleFormat(pathToFileURL(join(formatDirectory, 'module.mjs')).href), 'module') + strictEqual(getNodeModuleFormat(pathToFileURL(join(formatDirectory, 'module.cjs')).href), 'commonjs') + strictEqual(getNodeModuleFormat(pathToFileURL(join(formatDirectory, 'module.mts')).href), 'module-typescript') + strictEqual(getNodeModuleFormat(pathToFileURL(join(formatDirectory, 'module.cts')).href), 'commonjs-typescript') + strictEqual(getNodeModuleFormat(pathToFileURL(join(typelessDirectory, 'module.js')).href), undefined) + strictEqual(getNodeModuleFormat(pathToFileURL(join(typelessDirectory, 'module.ts')).href), undefined) + await writeFile(join(formatDirectory, 'package.json'), '{"type":"commonjs"}') + strictEqual(getNodeModuleFormat(pathToFileURL(join(formatDirectory, 'module.js')).href), 'commonjs') +} finally { + await rm(formatDirectory, { recursive: true, force: true }) +} + +strictEqual(getNodeModuleFormat('node:fs'), 'builtin') +strictEqual(getNodeModuleFormat(moduleUrl.replace(/\.mjs$/, '.json')), undefined) + +let packageJsonReads = 0 +const getCachedNodeModuleFormat = createGetNodeModuleFormat(() => { + packageJsonReads++ + return '{"type":"module"}' +}) +strictEqual(getCachedNodeModuleFormat('file:///cached/one.js'), 'module') +strictEqual(getCachedNodeModuleFormat('file:///cached/two.js'), 'module') +strictEqual(packageJsonReads, 1) + +const packageDirectory = await mkdtemp(join(tmpdir(), 'iitm-bundler-package-')) +const packageJsonUrl = pathToFileURL(join(packageDirectory, 'package.json')).href +await writeFile(join(packageDirectory, 'package.json'), JSON.stringify({ + name: '@scope/example', + type: 'module', + version: '1.2.3' +})) +await mkdir(join(packageDirectory, 'nested'), { recursive: true }) +await writeFile(join(packageDirectory, 'nested/package.json'), '{"type":"commonjs"}') +const packageModuleUrl = pathToFileURL(join(packageDirectory, 'nested/module.js')).href +const expectedPackageDetails = { + name: '@scope/example', + packageJsonUrl, + packageUrl: new URL('.', packageJsonUrl).href, + path: 'nested/module.js', + type: 'module', + version: '1.2.3' +} + +deepStrictEqual(getPackageDetails(packageModuleUrl), expectedPackageDetails) +deepStrictEqual(getPackageDetails(`${packageModuleUrl}?loader#fragment`), expectedPackageDetails) +deepStrictEqual(getCommonJSPackageDetails(packageModuleUrl), expectedPackageDetails) +await writeFile(join(packageDirectory, 'package.json'), JSON.stringify({ + name: '@scope/example-renamed', + type: 'commonjs', + version: '2.0.0' +})) +const updatedPackageDetails = { + ...expectedPackageDetails, + name: '@scope/example-renamed', + type: 'commonjs', + version: '2.0.0' +} +deepStrictEqual(getPackageDetails(packageModuleUrl), updatedPackageDetails) +deepStrictEqual(getCommonJSPackageDetails(packageModuleUrl), updatedPackageDetails) +strictEqual(getPackageDetails('node:fs'), undefined) +const noPackageUrl = pathToFileURL(join(tmpdir(), 'iitm-no-package/module.js')).href +strictEqual(getPackageDetails(noPackageUrl), undefined) +strictEqual(getPackageDetails(noPackageUrl), undefined) + +const minimalPackageDirectory = await mkdtemp(join(tmpdir(), 'iitm-bundler-minimal-package-')) +const minimalPackageJsonUrl = pathToFileURL(join(minimalPackageDirectory, 'package.json')).href +await writeFile(join(minimalPackageDirectory, 'package.json'), '{"name":"minimal"}') +deepStrictEqual(getPackageDetails(pathToFileURL(join(minimalPackageDirectory, 'module.js')).href), { + name: 'minimal', + packageJsonUrl: minimalPackageJsonUrl, + packageUrl: new URL('.', minimalPackageJsonUrl).href, + path: 'module.js', + type: undefined, + version: undefined +}) + +const invalidPackageDirectory = await mkdtemp(join(tmpdir(), 'iitm-bundler-invalid-package-')) +await writeFile(join(invalidPackageDirectory, 'package.json'), '{') +throws( + () => getPackageDetails(pathToFileURL(join(invalidPackageDirectory, 'module.js')).href), + SyntaxError +) + +/** + * @param {object} exported + * @param {string} name + * @param {string|undefined} baseDir + * @param {object} data + * @param {string} format + */ +function hookFoo (exported, name, baseDir, data, format) { + deepStrictEqual(data, { version: '1.0.0' }) + strictEqual(format, 'module') + exported.foo = 43 + return () => 44 +} + +const hook = new Hook(['./something.mjs'], hookFoo) +let executableCode = wrapper.code +for (const { specifier, target } of wrapper.imports) { + executableCode = executableCode.replaceAll(JSON.stringify(specifier), JSON.stringify(target.url)) +} +const temporaryDirectory = await mkdtemp(join(tmpdir(), 'iitm-bundler-')) +const wrapperUrl = pathToFileURL(join(temporaryDirectory, 'wrapper.mjs')).href +try { + await writeFile(new URL(wrapperUrl), executableCode) + const wrappedNamespace = await import(wrapperUrl) + strictEqual(wrappedNamespace.foo, 43) + strictEqual(wrappedNamespace.default(), 44) +} finally { + hook.unhook() + await rm(temporaryDirectory, { recursive: true, force: true }) +} + +const liveDirectory = await mkdtemp(join(tmpdir(), 'iitm-bundler-live-')) +const liveSource = `export let live = 1 +export const stable = 2 +export function increment () { live++ } +` +const liveModuleUrl = `data:text/javascript,${encodeURIComponent(liveSource)}` +const liveWrapper = await createWrapperModule({ + module: { + url: liveModuleUrl, + format: 'module', + source: liveSource, + specifier: 'iitm-live', + data: { live: true }, + passthroughExports: selectLiveExport + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +match(liveWrapper.code, /export \{ live \} from "\.\/__iitm_module_0__\.js"/) +doesNotMatch(liveWrapper.code, / as live/) + +let liveCode = liveWrapper.code +for (const { specifier, target } of liveWrapper.imports) { + liveCode = liveCode.replaceAll(JSON.stringify(specifier), JSON.stringify(target.url)) +} +try { + const liveWrapperUrl = pathToFileURL(join(liveDirectory, 'wrapper.mjs')).href + const liveRunnerUrl = pathToFileURL(join(liveDirectory, 'runner.mjs')).href + await writeFile(new URL(liveWrapperUrl), liveCode) + await writeFile(new URL(liveRunnerUrl), `import Hook from ${JSON.stringify(new URL('../../index.js', import.meta.url).href)} + +let hookedExports +/** @param {Record} exported The wrapper exports. */ +function hookLive (exported) { + hookedExports = exported + exported.live = 99 + exported.stable = 43 +} + +const hook = new Hook(['iitm-live'], hookLive) +const [wrapper, source] = await Promise.all([ + import(${JSON.stringify(liveWrapperUrl)}), + import(${JSON.stringify(liveModuleUrl)}) +]) +const initial = [wrapper.live, wrapper.stable, hookedExports.live] +const sameIncrement = wrapper.increment === source.increment +wrapper.increment() +console.log(JSON.stringify({ + initial, + sameIncrement, + sourceLive: source.live, + wrapperLive: wrapper.live, + hookedLive: hookedExports.live +})) +hook.unhook() +`) + const result = spawnSync(process.execPath, [fileURLToPath(liveRunnerUrl)], { + encoding: 'utf8', + env: { ...process.env, NODE_OPTIONS: '' } + }) + strictEqual(result.status, 0, result.stderr) + deepStrictEqual(JSON.parse(result.stdout), { + initial: [1, 43, 1], + sameIncrement: true, + sourceLive: 2, + wrapperLive: 2, + hookedLive: 2 + }) +} finally { + await rm(liveDirectory, { recursive: true, force: true }) +} + +const staticPassthroughWrapper = await createWrapperModule({ + module: { + url: 'file:///virtual/static-passthrough.mjs', + format: 'module', + source: "export { live } from './unresolved.mjs'", + specifier: './static-passthrough.mjs', + passthroughExports: 'live' + }, + resolve: unexpectedIo, + load: unexpectedIo +}) +match(staticPassthroughWrapper.code, /export \{ live \} from "\.\/__iitm_module_0__\.js"/) + +await rejects(createWrapperModule({ + module: { + url: 'file:///virtual/passthrough-error.mjs', + format: 'module', + source: 'export const value = 42', + specifier: './passthrough-error.mjs', + passthroughExports: unexpectedPassthroughSelection + }, + resolve: unexpectedIo, + load: unexpectedIo +}), { + message: 'Unexpected passthrough export selection' +}) + +const rebuiltSource = 'export const rebuilt = true' +const rebuiltUrl = `data:text/javascript,${encodeURIComponent(rebuiltSource)}` +const rebuilt = await createWrapperModule({ + module: { + url: rebuiltUrl, + format: 'module', + source: rebuiltSource, + specifier: 'iitm-rebuilt' + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +match(rebuilt.code, /export \{ \$0 as rebuilt \}/) +match(rebuilt.code, /\nregisterWithData\(/) +doesNotMatch(rebuilt.code, /\nregister\(/) +doesNotMatch(rebuilt.code, /"foo"/) + +let rebuiltFormat +/** + * @param {object} exported + * @param {string} name + * @param {string|undefined} baseDir + * @param {unknown} data + * @param {string} format + */ +function captureRebuiltFormat (exported, name, baseDir, data, format) { + strictEqual(exported.rebuilt, true) + strictEqual(name, 'iitm-rebuilt') + strictEqual(baseDir, undefined) + strictEqual(data, undefined) + rebuiltFormat = format +} +const rebuiltHook = new Hook(['iitm-rebuilt'], captureRebuiltFormat) +try { + let rebuiltCode = rebuilt.code + for (const { specifier, target } of rebuilt.imports) { + rebuiltCode = rebuiltCode.replaceAll(JSON.stringify(specifier), JSON.stringify(target.url)) + } + await import(`data:text/javascript,${encodeURIComponent(rebuiltCode)}`) + strictEqual(rebuiltFormat, 'module') +} finally { + rebuiltHook.unhook() +} + +/** + * @param {string} url + */ +function loadBuiltin (url) { + strictEqual(url, 'node:dns/promises') + return { format: 'builtin' } +} + +const builtinWrapper = await createWrapperModule({ + module: { + url: 'node:dns/promises', + format: 'builtin', + specifier: 'node:dns/promises' + }, + resolve: unexpectedIo, + load: loadBuiltin +}) + +strictEqual(builtinWrapper.imports[1].external, true) +strictEqual(builtinWrapper.imports[1].target.url, 'node:dns/promises') +doesNotMatch(builtinWrapper.code, /from "node:dns\/promises"/) + +const commonJsUrl = new URL('../fixtures/something.js', import.meta.url).href +const commonJsWrapper = await createWrapperModule({ + module: { + url: commonJsUrl, + format: 'commonjs', + source: '#!/usr/bin/env node\n' + + 'module.exports = { value: 42, argumentsLength: arguments.length }\n' + + 'return\n' + + 'module.exports.unreachable = true', + specifier: './something.js', + data: { version: '1.0.0' }, + passthroughExports: unexpectedPassthroughSelection + } +}) + +strictEqual(commonJsWrapper.imports.length, 1) +strictEqual(commonJsWrapper.imports[0].kind, 'runtime') +strictEqual(commonJsWrapper.sourceLineOffset, 1) +match(commonJsWrapper.code, /^\(function \(\) \{\n\/\/\/usr\/bin\/env node\n/) +match(commonJsWrapper.code, /registerCommonJS/) +doesNotMatch(commonJsWrapper.code, /^(?:import|export) /m) +doesNotMatch(commonJsWrapper.code, /function \(exports, require, module/) + +const loadedCommonJsWrapper = await createWrapperModule({ + module: { + url: commonJsUrl, + format: 'commonjs', + specifier: './something.js' + }, + resolve: unexpectedIo, + load: async () => ({ source: Buffer.from('module.exports = 42') }) +}) + +match(loadedCommonJsWrapper.code, /module\.exports = 42/) + +const typedArrayCommonJsWrapper = await createWrapperModule({ + module: { + url: commonJsUrl, + format: 'commonjs', + source: new TextEncoder().encode('module.exports = 43'), + specifier: './something.js' + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +match(typedArrayCommonJsWrapper.code, /module\.exports = 43/) + +const arrayBufferCommonJsWrapper = await createWrapperModule({ + module: { + url: commonJsUrl, + format: 'commonjs', + source: new TextEncoder().encode('module.exports = 44').buffer, + specifier: './something.js' + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +match(arrayBufferCommonJsWrapper.code, /module\.exports = 44/) + +await rejects(createWrapperModule({ + module: { + url: commonJsUrl, + format: 'commonjs', + specifier: './something.js' + }, + resolve: unexpectedIo, + load: async () => ({}) +}), { + name: 'TypeError', + message: `The bundler load adapter returned no source for '${commonJsUrl}'` +}) + +/** + * @param {object} exports + * @param {string} name + * @param {string|undefined} baseDir + * @param {object} data + * @param {string} format + */ +const commonJsHookFn = (exports, name, baseDir, data, format) => { + deepStrictEqual(data, { version: '1.0.0' }) + strictEqual(format, 'commonjs') + return { ...exports, hooked: true } +} +const commonJsHook = new Hook(['./something.js'], commonJsHookFn) +let unfilteredCalls = 0 +const unfilteredHook = new Hook((exports, name, baseDir, data) => { + if (data?.version === '1.0.0') unfilteredCalls++ +}) +let commonJsCode = commonJsWrapper.code +for (const { specifier, target } of commonJsWrapper.imports) { + commonJsCode = commonJsCode.replaceAll(JSON.stringify(specifier), JSON.stringify(fileURLToPath(target.url))) +} +const commonJsDirectory = await mkdtemp(join(tmpdir(), 'iitm-bundler-commonjs-')) +try { + const commonJsFilename = join(commonJsDirectory, 'wrapper.cjs') + await writeFile(commonJsFilename, commonJsCode) + deepStrictEqual(require(commonJsFilename), { value: 42, argumentsLength: 5, hooked: true }) + strictEqual(unfilteredCalls, 2) +} finally { + unfilteredHook.unhook() + commonJsHook.unhook() + await rm(commonJsDirectory, { recursive: true, force: true }) +} + +const packageUrl = new URL('../../package.json', import.meta.url).href +const sourceWatchUrl = new URL('../fixtures/', import.meta.url).href + +/** + * @param {string} specifier + * @param {{ parentURL: string }} context + */ +function resolveModule (specifier, context) { + return { + url: new URL(specifier, context.parentURL).href, + format: 'module', + watchFiles: packageUrl + } +} + +const hookedPackageUrl = new URL('../fixtures/node_modules/some-external-module/index.mjs', import.meta.url).href +let packageBaseDirectory +const packageHook = new Hook(['some-external-module'], (exports, name, baseDir, data) => { + packageBaseDirectory = baseDir + deepStrictEqual(data, { version: '2.0.0' }) +}) +registerModuleWithData(hookedPackageUrl, 'some-external-module', { version: '2.0.0' }) +strictEqual(packageBaseDirectory, fileURLToPath(new URL('.', hookedPackageUrl)).slice(0, -1)) +packageHook.unhook() + +const scopedPackageUrl = new URL( + '../fixtures/node_modules/@scope/some-scoped-module/index.mjs', + import.meta.url +).href +let scopedPackageCalls = 0 +const scopedPackageHook = new Hook(['@scope/some-scoped-module'], () => { + scopedPackageCalls++ +}) +registerModuleWithData(scopedPackageUrl, '@scope/some-scoped-module', undefined) +strictEqual(scopedPackageCalls, 1) +scopedPackageHook.unhook() + +const packageInternalUrl = new URL('../fixtures/node_modules/some-external-module/sub.mjs', import.meta.url).href +let packageInternalName +const packageInternalHook = new Hook(['some-external-module'], { internals: true }, (exports, name) => { + packageInternalName = name +}) +registerModuleWithData(packageInternalUrl, 'some-external-module/sub', undefined) +strictEqual(packageInternalName, join('some-external-module', 'sub.mjs')) +packageInternalHook.unhook() + +let commonJsPackageName +const commonJsPackageHook = new Hook(['some-external-module'], (exports, name) => { + commonJsPackageName = name +}) +commonJsPackageName = undefined +registerCommonJS(hookedPackageUrl, { exports: {} }, 'some-external-module', undefined) +strictEqual(commonJsPackageName, 'some-external-module') +commonJsPackageName = undefined +registerCommonJS( + new URL('../fixtures/node_modules/some-external-module/sub.js', import.meta.url).href, + { exports: {} }, + './sub', + undefined +) +strictEqual(commonJsPackageName, undefined) +commonJsPackageHook.unhook() + +registerCommonJS(hookedPackageUrl, { exports: {} }, './sub', undefined) +const sameUrlFormats = [] + +/** + * @param {object} exports The registered exports. + * @param {string} name The package name. + * @param {string|undefined} baseDir The package directory. + * @param {unknown} data Consumer data associated with the module. + * @param {'module'|'commonjs'} format The module format. + */ +function captureSameUrlFormat (exports, name, baseDir, data, format) { + sameUrlFormats.push(format) +} + +const sameUrlHook = new Hook(['some-external-module'], captureSameUrlFormat) +deepStrictEqual(sameUrlFormats, ['module']) +sameUrlHook.unhook() + +const registerPath = require.resolve('../../lib/register.js') +delete require.cache[registerPath] +const bundledRegister = require(registerPath) +let crossCopyCalls = 0 + +/** + * @param {object} exports The module namespace. + * @param {string} name The package name. + * @param {string|undefined} baseDir The package directory. + * @param {unknown} data Consumer data associated with the module. + * @param {'module'|'commonjs'} format The module format. + */ +function captureCrossCopyRegistration (exports, name, baseDir, data, format) { + crossCopyCalls++ + strictEqual(name, 'cross-copy-package') + deepStrictEqual(data, { version: '1.0.0' }) + strictEqual(format, 'module') +} + +const crossCopyHook = new Hook(['cross-copy-package'], captureCrossCopyRegistration) +bundledRegister.registerWithData( + 'file:///tmp/node_modules/cross-copy-package/index.mjs', + new bundledRegister.ModuleBinder({}), + 'cross-copy-package', + { version: '1.0.0' } +) +strictEqual(crossCopyCalls, 1) +crossCopyHook.unhook() + +delete require.cache[registerPath] +const earlyBundledRegister = require(registerPath) +earlyBundledRegister.registerCommonJS( + 'file:///tmp/node_modules/late-cross-copy-package/index.js', + { exports: {} }, + 'late-cross-copy-package', + { version: '2.0.0' } +) +let lateCrossCopyCalls = 0 + +/** + * @param {object} exports The CommonJS exports. + * @param {string} name The package name. + * @param {string|undefined} baseDir The package directory. + * @param {unknown} data Consumer data associated with the module. + * @param {'module'|'commonjs'} format The module format. + */ +function captureLateCrossCopyRegistration (exports, name, baseDir, data, format) { + lateCrossCopyCalls++ + strictEqual(name, 'late-cross-copy-package') + deepStrictEqual(data, { version: '2.0.0' }) + strictEqual(format, 'commonjs') +} + +const lateCrossCopyHook = new Hook(['late-cross-copy-package'], captureLateCrossCopyRegistration) +strictEqual(lateCrossCopyCalls, 1) +lateCrossCopyHook.unhook() + +let commonJsInternalName +const commonJsInternalHook = new Hook(['some-external-module'], { internals: true }, (exports, name) => { + commonJsInternalName = name +}) +strictEqual(commonJsInternalName, join('some-external-module', 'sub.js')) +commonJsInternalHook.unhook() + +const reloadFilename = join(fileURLToPath(new URL('../fixtures/', import.meta.url)), 'reload.cjs') +const reloadUrl = pathToFileURL(reloadFilename).href +registerCommonJS(reloadUrl, { exports: { value: 1 } }, 'reload', undefined) +registerCommonJS(reloadUrl, { exports: { value: 2 } }, 'reload', undefined) +registerCommonJS(reloadUrl, { exports: { value: 3 } }, 'reload', undefined) +registerModuleWithData(reloadUrl, 'reload', undefined) +const reloadCalls = [] +/** + * @param {{ value?: number }} exports The latest module exports. + * @param {string} name The canonical module URL. + * @param {string|undefined} baseDir The package directory. + * @param {unknown} data Consumer data associated with the module. + * @param {'module'|'commonjs'} format The module format. + */ +function captureReloadedValue (exports, name, baseDir, data, format) { + reloadCalls.push({ format, value: exports.value }) +} +const reloadHook = new Hook([reloadFilename], captureReloadedValue) +deepStrictEqual(reloadCalls, [ + { format: 'commonjs', value: 3 }, + { format: 'module', value: undefined } +]) +reloadHook.unhook() + +let invalidFileUrlName +const invalidFileUrlHook = new Hook((exports, name) => { + invalidFileUrlName = name +}) +invalidFileUrlName = undefined +registerModuleWithData('file://%', 'invalid', undefined) +strictEqual(invalidFileUrlName, 'file://%') +invalidFileUrlHook.unhook() + +/** + * @param {string} url + * @param {{ format: string }} context + */ +async function loadModule (url, context) { + return { + source: await readFile(new URL(url), 'utf8'), + format: context.format, + watchFiles: sourceWatchUrl + } +} + +const reexportUrl = new URL('../fixtures/reexport-same-source.mjs', import.meta.url).href +const reexportWrapper = await createWrapperModule({ + module: { + url: reexportUrl, + format: 'module', + specifier: './reexport-same-source.mjs', + passthroughExports: selectValExport + }, + resolve: resolveModule, + load: loadModule +}) + +strictEqual(reexportWrapper.imports[0].kind, 'runtime') +strictEqual(reexportWrapper.imports[1].target.url, reexportUrl) +strictEqual(reexportWrapper.imports[2].specifier, './__iitm_module_1__.js') +strictEqual(reexportWrapper.imports[2].target.format, 'module') +strictEqual(reexportWrapper.watchFiles.includes(reexportUrl), true) +strictEqual(reexportWrapper.watchFiles.includes(packageUrl), true) +strictEqual(reexportWrapper.watchFiles.includes(sourceWatchUrl), true) +doesNotMatch(reexportWrapper.code, /from "file:/) +match(reexportWrapper.code, /export \{ val \} from "\.\/__iitm_module_1__\.js"/) + +const repeatedReexportUrl = 'file:///virtual/repeated-reexport.mjs' +const repeatedLeafUrl = 'file:///virtual/repeated-reexport-leaf.mjs' +let repeatedResolveCalls = 0 +/** + * @param {string} specifier + * @param {{ parentURL: string }} context + */ +function resolveRepeatedReexport (specifier, context) { + strictEqual(specifier, repeatedLeafUrl) + strictEqual(context.parentURL, repeatedReexportUrl) + repeatedResolveCalls++ + return { url: repeatedLeafUrl, format: 'module' } +} + +/** + * @param {string} url + */ +function loadRepeatedReexport (url) { + strictEqual(url, repeatedLeafUrl) + return { format: 'module', source: 'export const first = 1, second = 2, third = 3' } +} + +/** + * @param {ReadonlyArray<{ name: string }>} exports The resolved exports. + */ +function selectRepeatedExports (exports) { + return exports.map(({ name }) => name) +} + +await createWrapperModule({ + module: { + url: repeatedReexportUrl, + format: 'module', + source: "export { first, second, third } from './repeated-reexport-leaf.mjs'", + specifier: 'repeated-reexport', + passthroughExports: selectRepeatedExports + }, + resolve: resolveRepeatedReexport, + load: loadRepeatedReexport +}) +strictEqual(repeatedResolveCalls, 1) + +/** + * @param {string} specifier + */ +function resolveCommonJsReexport (specifier) { + strictEqual(specifier, 'file:///virtual/something.js') + return { + url: commonJsUrl, + format: 'commonjs' + } +} + +const commonJsReexportWrapper = await createWrapperModule({ + module: { + url: 'file:///virtual/commonjs-reexport.mjs', + format: 'module', + source: "export * from './something.js'", + specifier: './commonjs-reexport.mjs' + }, + resolve: resolveCommonJsReexport, + load: loadModule +}) + +match(commonJsReexportWrapper.code, /export \{ \$0 as foo(?:,| \})/) +if (parseInt(process.versions.node, 10) >= 23) { + match(commonJsReexportWrapper.code, /as "module\.exports"/) +} else { + doesNotMatch(commonJsReexportWrapper.code, /as "module\.exports"/) +} +doesNotMatch(commonJsReexportWrapper.code, /as default/) + +const quotedExportWrapper = await createWrapperModule({ + module: { + url: 'file:///virtual/quoted-export.mjs', + format: 'module', + source: 'const value = 42; export { value as "quoted name" }', + specifier: './quoted-export.mjs' + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +match(quotedExportWrapper.code, /export \{ \$0 as "quoted name" \}/) + +const quotedPassthroughWrapper = await createWrapperModule({ + module: { + url: 'file:///virtual/quoted-passthrough.mjs', + format: 'module', + source: 'const value = 42; export { value as "quoted name" }', + specifier: './quoted-passthrough.mjs', + passthroughExports: ['quoted name', 'missing'] + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +match(quotedPassthroughWrapper.code, /export \{ "quoted name" \} from "\.\/__iitm_module_0__\.js"/) +doesNotMatch(quotedPassthroughWrapper.code, /missing/) + +const defaultPassthroughWrapper = await createWrapperModule({ + module: { + url: 'file:///virtual/default-passthrough.mjs', + format: 'module', + source: 'export default 42', + specifier: './default-passthrough.mjs', + passthroughExports: ['default'] + }, + resolve: unexpectedIo, + load: unexpectedIo +}) + +match(defaultPassthroughWrapper.code, /export \{ default \} from "\.\/__iitm_module_0__\.js"/) + +/** + * @param {string} specifier + */ +function resolveWithoutCanParse (specifier) { + return { + url: specifier === 'bare-package' ? moduleUrl : specifier, + format: 'module' + } +} + +await createWrapperModule({ + module: { + url: 'file:///virtual/can-parse-reexport.mjs', + format: 'module', + source: "export * from 'bare-package'", + specifier: './can-parse-reexport.mjs' + }, + resolve: resolveWithoutCanParse, + load: loadModule +}) + +const canParse = URL.canParse +delete URL.canParse +try { + await createWrapperModule({ + module: { + url: 'file:///virtual/bare-reexport.mjs', + format: 'module', + source: "export * from 'bare-package'", + specifier: './bare-reexport.mjs' + }, + resolve: resolveWithoutCanParse, + load: loadModule + }) + await createWrapperModule({ + module: { + url: 'file:///virtual/url-reexport.mjs', + format: 'module', + source: `export * from ${JSON.stringify(moduleUrl)}`, + specifier: './url-reexport.mjs' + }, + resolve: resolveWithoutCanParse, + load: loadModule + }) +} finally { + if (canParse === undefined) { + delete URL.canParse + } else { + URL.canParse = canParse + } +} diff --git a/test/low-level/module-binder.mjs b/test/low-level/module-binder.mjs index bff0f3e5..303a604e 100644 --- a/test/low-level/module-binder.mjs +++ b/test/low-level/module-binder.mjs @@ -64,6 +64,25 @@ function interceptTimeouts () { strictEqual(slot.value, 99, 'flush does not clobber an overridden value') } +// Read-only bindings retain the source value and ignore hook writes. +{ + const source = { foo: 42 } + const binder = new ModuleBinder(source, undefined, undefined, undefined, ['foo']) + strictEqual(binder.namespace.foo, 42) + strictEqual(binder.write('foo', 99), true) + strictEqual(binder.namespace.foo, 42) + source.foo = 43 + strictEqual(binder.namespace.foo, 43) +} + +// Read-only bindings can use the defining namespace for star re-exports. +{ + const source = { foo: 1 } + const definingSource = { foo: 2 } + const binder = new ModuleBinder(source, undefined, undefined, undefined, ['foo'], [definingSource]) + strictEqual(binder.namespace.foo, 2) +} + // useFallback reads source.default when the named export is missing. { const source = { default: 7 } diff --git a/test/low-level/unknown-extension-format.mjs b/test/low-level/unknown-extension-format.mjs new file mode 100644 index 00000000..9c53ea27 --- /dev/null +++ b/test/low-level/unknown-extension-format.mjs @@ -0,0 +1,103 @@ +import { deepStrictEqual, match, rejects, strictEqual, throws } from 'node:assert/strict' + +import { createHook } from '../fixtures/inspectable-create-hook.mjs' + +const parentURL = 'file:///app/entry.mjs' +const moduleURL = 'file:///app/node_modules/example/module.custom' +const moduleSource = 'export const value = 42' + +/** + * @returns {{ url: string }} + */ +function resolveUnknownFormat () { + return { url: moduleURL } +} + +let asyncLoads = 0 + +/** + * @param {string} url + * @returns {Promise<{ format: string, source: string }>} + */ +async function loadUnknownFormat (url) { + strictEqual(url, moduleURL) + asyncLoads++ + return { format: 'module', source: moduleSource } +} + +const asyncHook = createHook(import.meta) +const asyncResolution = await asyncHook.resolve('example', { parentURL }, resolveUnknownFormat) +match(asyncResolution.url, /[?&]iitm=true/) +const asyncResult = await asyncHook.load(asyncResolution.url, {}, loadUnknownFormat) +match(asyncResult.source, /register/) +strictEqual(asyncLoads, 1) +strictEqual(asyncHook.specifiers.size, 0) + +let syncLoads = 0 + +/** + * @param {string} url + * @returns {{ format: string, source: string }} + */ +function loadUnknownFormatSync (url) { + strictEqual(url, moduleURL) + syncLoads++ + return { format: 'module', source: moduleSource } +} + +const syncHook = createHook(import.meta) +const syncResolution = syncHook.resolveSync('example', { parentURL }, resolveUnknownFormat) +match(syncResolution.url, /[?&]iitm=true/) +const syncResult = syncHook.loadSync(syncResolution.url, {}, loadUnknownFormatSync) +match(syncResult.source, /register/) +strictEqual(syncLoads, 1) +strictEqual(syncHook.specifiers.size, 0) + +const unsupportedURL = 'file:///app/node_modules/example/data.custom' +const unsupportedResult = { format: 'json', source: '{}' } + +/** + * @returns {{ url: string }} + */ +function resolveUnsupportedFormat () { + return { url: unsupportedURL } +} + +/** + * @param {string} url + * @returns {Promise<{ format: string, source: string }>} + */ +async function loadUnsupportedFormat (url) { + strictEqual(url, unsupportedURL) + return unsupportedResult +} + +const unsupportedHook = createHook(import.meta) +const unsupportedResolution = await unsupportedHook.resolve('example/data', { parentURL }, resolveUnsupportedFormat) +const unsupported = await unsupportedHook.load(unsupportedResolution.url, {}, loadUnsupportedFormat) +deepStrictEqual(unsupported, unsupportedResult) +strictEqual(unsupportedHook.specifiers.size, 0) + +const asyncFailureHook = createHook(import.meta) +const asyncFailureResolution = await asyncFailureHook.resolve('example', { parentURL }, resolveUnknownFormat) + +async function failAsyncLoad () { + throw new Error('async load failed') +} + +await rejects(asyncFailureHook.load(asyncFailureResolution.url, {}, failAsyncLoad), { + message: 'async load failed' +}) +strictEqual(asyncFailureHook.specifiers.size, 0) + +const syncFailureHook = createHook(import.meta) +const syncFailureResolution = syncFailureHook.resolveSync('example', { parentURL }, resolveUnknownFormat) + +function failSyncLoad () { + throw new Error('sync load failed') +} + +throws(() => syncFailureHook.loadSync(syncFailureResolution.url, {}, failSyncLoad), { + message: 'sync load failed' +}) +strictEqual(syncFailureHook.specifiers.size, 0) diff --git a/test/other/v18-bundlers.mjs b/test/other/v18-bundlers.mjs new file mode 100644 index 00000000..13a7ccb5 --- /dev/null +++ b/test/other/v18-bundlers.mjs @@ -0,0 +1,254 @@ +import { deepStrictEqual, strictEqual } from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import * as esbuild from 'esbuild' +import webpack from 'webpack' + +import { createWrapperModule } from '../../bundler.mjs' + +const packageRoot = fileURLToPath(new URL('../../', import.meta.url)) +const indexPath = join(packageRoot, 'index.js') +const temporaryDirectory = await realpath(await mkdtemp(join(tmpdir(), 'iitm-bundlers-'))) +const expectedResult = { + esmLive: 42, + esmStable: 43, + hookedLive: 42, + commonjs: 44 +} + +try { + const originalPath = join(temporaryDirectory, 'original.mjs') + const originalCommonJsPath = join(temporaryDirectory, 'original.cjs') + const dependencyPath = join(temporaryDirectory, 'dependency.cjs') + const originalSource = `export let live = 41 +export const stable = 42 +export function increment () { live++ } +` + await Promise.all([ + writeFile(originalPath, originalSource), + writeFile(dependencyPath, 'module.exports = 42\n') + ]) + + const wrappers = new Map([ + ['esm', await createWrapperModule({ + module: { + url: pathToFileURL(originalPath).href, + format: 'module', + source: originalSource, + specifier: 'iitm-virtual-esm', + passthroughExports: ['live'] + }, + resolve: unexpectedIo, + load: unexpectedIo + })], + ['commonjs', await createWrapperModule({ + module: { + url: pathToFileURL(originalCommonJsPath).href, + format: 'commonjs', + source: "module.exports = { value: require('./dependency.cjs') }\n", + specifier: 'iitm-virtual-commonjs' + }, + resolve: unexpectedIo, + load: unexpectedIo + })] + ]) + + const entrySource = ` +const Hook = require(${JSON.stringify(indexPath)}) + +let hookedEsm +/** @param {Record} exports The intercepted exports. */ +function hookExports (exports) { + if ('live' in exports) { + hookedEsm = exports + exports.live = 100 + exports.stable++ + return + } + exports.value += 2 +} + +new Hook(hookExports) + +async function main () { + const [esm, commonjs] = await Promise.all([ + import('iitm-virtual-esm'), + Promise.resolve(require('iitm-virtual-commonjs')) + ]) + esm.increment() + console.log(JSON.stringify({ + esmLive: esm.live, + esmStable: esm.stable, + hookedLive: hookedEsm.live, + commonjs: commonjs.value + })) +} + +main() +` + + await testEsbuild(entrySource, wrappers) + await testWebpack(entrySource, wrappers) +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }) +} + +/** + * @returns {never} + */ +function unexpectedIo () { + throw new Error('Unexpected adapter I/O') +} + +/** + * @param {string} entrySource The application entry source. + * @param {Map>>} wrappers Generated wrappers by module format. + */ +async function testEsbuild (entrySource, wrappers) { + await Promise.all([ + buildEsbuildBundle(entrySource, wrappers, 'cjs', false), + buildEsbuildBundle(entrySource, wrappers, 'esm', true) + ]) +} + +/** + * @param {string} entrySource The application entry source. + * @param {Map>>} wrappers Generated wrappers by module format. + * @param {'cjs'|'esm'} format The output module format. + * @param {boolean} minify Whether esbuild minifies the output. + * @returns {Promise} + */ +async function buildEsbuildBundle (entrySource, wrappers, format, minify) { + const extension = format === 'esm' + ? 'mjs' + : 'cjs' + const outfile = join(temporaryDirectory, `esbuild.${extension}`) + await esbuild.build({ + banner: format === 'esm' + ? { + js: `import { createRequire } from 'node:module' +const require = createRequire(import.meta.url)` + } + : undefined, + bundle: true, + format, + minify, + platform: 'node', + outfile, + stdin: { + contents: entrySource, + loader: 'js', + resolveDir: temporaryDirectory + }, + plugins: [{ + name: 'iitm-test-adapter', + setup (build) { + build.onResolve({ filter: /^iitm-virtual-/ }, args => ({ + path: args.path === 'iitm-virtual-esm' ? 'esm' : 'commonjs', + namespace: 'iitm-wrapper' + })) + build.onResolve({ filter: /^\.\/__iitm_/, namespace: 'iitm-wrapper' }, args => { + const wrapper = wrappers.get(args.importer) + const entry = wrapper.imports.find(entry => entry.specifier === args.path) + return { + path: fileURLToPath(entry.target.url), + external: entry.external + } + }) + build.onLoad({ filter: /.*/, namespace: 'iitm-wrapper' }, args => ({ + contents: wrappers.get(args.path).code, + loader: 'js', + resolveDir: temporaryDirectory + })) + } + }] + }) + + deepStrictEqual(runBundle(outfile), expectedResult) +} + +/** + * @param {string} entrySource The application entry source. + * @param {Map>>} wrappers Generated wrappers by module format. + */ +async function testWebpack (entrySource, wrappers) { + const webpackDirectory = join(temporaryDirectory, 'webpack') + const outputDirectory = join(webpackDirectory, 'dist') + const wrappersByContext = new Map() + await mkdir(outputDirectory, { recursive: true }) + + const entryPath = join(webpackDirectory, 'entry.cjs') + await writeFile(entryPath, entrySource) + for (const [name, wrapper] of wrappers) { + const directory = name === 'commonjs' ? temporaryDirectory : join(webpackDirectory, name) + const extension = name === 'commonjs' ? 'cjs' : 'mjs' + const filename = join(directory, `wrapper.${extension}`) + await mkdir(directory, { recursive: true }) + await writeFile(filename, wrapper.code) + wrappersByContext.set(directory, { filename, wrapper }) + } + + const replacement = new webpack.NormalModuleReplacementPlugin( + /^(?:iitm-virtual-|\.\/__iitm_)/, + resource => { + if (resource.request === 'iitm-virtual-esm') { + resource.request = wrappersByContext.get(join(webpackDirectory, 'esm')).filename + return + } + if (resource.request === 'iitm-virtual-commonjs') { + resource.request = wrappersByContext.get(temporaryDirectory).filename + return + } + + const { wrapper } = wrappersByContext.get(resource.context) + const entry = wrapper.imports.find(entry => entry.specifier === resource.request) + resource.request = fileURLToPath(entry.target.url) + } + ) + + const stats = await runWebpack({ + entry: entryPath, + mode: 'development', + target: 'node', + devtool: false, + output: { + path: outputDirectory, + filename: 'bundle.cjs', + chunkFilename: '[name].cjs' + }, + plugins: [replacement] + }) + const errors = stats.toJson({ all: false, errors: true }).errors + deepStrictEqual(errors, []) + deepStrictEqual(runBundle(join(outputDirectory, 'bundle.cjs')), expectedResult) +} + +/** + * @param {import('webpack').Configuration} configuration The webpack configuration. + * @returns {Promise} + */ +function runWebpack (configuration) { + return new Promise((resolve, reject) => { + webpack(configuration, (error, stats) => { + if (error) return reject(error) + resolve(stats) + }) + }) +} + +/** + * @param {string} filename The bundle entry file. + * @returns {{ esmLive: number, esmStable: number, hookedLive: number, commonjs: number }} + */ +function runBundle (filename) { + const result = spawnSync(process.execPath, [filename], { + encoding: 'utf8', + env: { ...process.env, NODE_OPTIONS: '' } + }) + strictEqual(result.status, 0, result.stderr) + return JSON.parse(result.stdout) +} diff --git a/test/register/v18.19-loader-url-escaping.mjs b/test/register/v18.19-loader-url-escaping.mjs index 7b15430b..e8c234d0 100644 --- a/test/register/v18.19-loader-url-escaping.mjs +++ b/test/register/v18.19-loader-url-escaping.mjs @@ -22,8 +22,10 @@ try { 'supports-sync-hooks.mjs', 'lib/get-esm-exports.mjs', 'lib/get-exports.mjs', + 'lib/get-node-module-format.js', 'lib/io.mjs', - 'lib/register.js' + 'lib/register.js', + 'lib/wrapper.mjs' ] const setupPromises = [] for (const filename of packageFiles) { diff --git a/test/register/v22.15-sync-register-hooks-commonjs.mjs b/test/register/v22.15-sync-register-hooks-commonjs.mjs new file mode 100644 index 00000000..e251773c --- /dev/null +++ b/test/register/v22.15-sync-register-hooks-commonjs.mjs @@ -0,0 +1,346 @@ +import { deepStrictEqual, match, strictEqual, throws } from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as nodeModule from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import Hook from '../../index.js' +import { createHook } from '../../create-hook.mjs' +import { register, supportsSyncHooks } from '../../register-hooks.mjs' + +if (!supportsSyncHooks()) { + console.log(`Skipping ${process.env.IITM_TEST_FILE || import.meta.url}: synchronous hooks unsupported on this Node.js`) + process.exit(0) +} + +const hookMeta = { url: new URL('../../register-hooks.mjs', import.meta.url).href } +const lowLevelHook = createHook(hookMeta, true) +const requireContext = { conditions: ['require'], parentURL: import.meta.url } + +/** + * @param {string} url + * @param {string} [format] + * @param {object} [context] + * @returns {object} + */ +function resolveAsRequire (url, format, context = requireContext) { + return lowLevelHook.resolveSyncCommonJS(url, context, () => ({ url, format })) +} + +deepStrictEqual(resolveAsRequire('file:///entry', undefined, { conditions: ['require'] }), { + url: 'file:///entry', + format: 'commonjs' +}) +deepStrictEqual(resolveAsRequire('file:///entry.cjs', undefined, { conditions: ['require'] }), { + url: 'file:///entry.cjs', + format: undefined +}) + +const ignored = { url: 'file:///ignored.json', format: 'json' } +strictEqual(lowLevelHook.resolveSyncCommonJS('ignored', requireContext, () => ignored), ignored) +strictEqual(resolveAsRequire('file:///native.node').url, 'file:///native.node') +strictEqual(resolveAsRequire('file:///data.json', undefined, { + ...requireContext, + importAttributes: { type: 'json' } +}).url, 'file:///data.json') +strictEqual(resolveAsRequire(import.meta.url, 'module', { + conditions: ['require'], + parentURL: import.meta.url +}).shortCircuit, true) +strictEqual(resolveAsRequire('https://example.com/module').url, 'https://example.com/module') +const unknownWasmUrl = 'file:///unknown.wasm' +strictEqual(resolveAsRequire(unknownWasmUrl).url, unknownWasmUrl) + +const filteredHook = createHook(hookMeta, true) +filteredHook.applyOptions({ include: ['included'] }) +const filtered = { url: 'file:///filtered.cjs', format: 'commonjs' } +strictEqual(filteredHook.resolveSyncCommonJS('filtered', requireContext, () => filtered), filtered) +strictEqual(lowLevelHook.resolveSyncCommonJS('nested', { + conditions: ['require'], + parentURL: hookMeta.url +}, () => filtered), filtered) + +const formatDirectory = mkdtempSync(join(tmpdir(), 'iitm-formats-')) +try { + writeFileSync(join(formatDirectory, 'package.json'), '{"type":"module"}') + const moduleUrl = pathToFileURL(join(formatDirectory, 'module.js')).href + const moduleTypeScriptUrl = pathToFileURL(join(formatDirectory, 'module.ts')).href + strictEqual(resolveAsRequire(moduleUrl).format, 'module') + strictEqual(resolveAsRequire(moduleTypeScriptUrl).format, 'module-typescript') + strictEqual(resolveAsRequire(pathToFileURL(join(formatDirectory, 'module.mts')).href).format, 'module-typescript') + + const invalidDirectory = join(formatDirectory, 'invalid') + const invalidPackageUrl = pathToFileURL(join(invalidDirectory, 'module.js')).href + const invalidPackageDirectory = fileURLToPath(new URL('.', invalidPackageUrl)) + process.getBuiltinModule('fs').mkdirSync(invalidPackageDirectory) + writeFileSync(join(invalidPackageDirectory, 'package.json'), '{') + strictEqual(resolveAsRequire(invalidPackageUrl).url, `${invalidPackageUrl}?iitm=true`) +} finally { + rmSync(formatDirectory, { recursive: true, force: true }) +} + +strictEqual(resolveAsRequire('file:///iitm-default/module.ts').url, 'file:///iitm-default/module.ts?iitm=true') +strictEqual(resolveAsRequire('file:///iitm-default/module.js').url, 'file:///iitm-default/module.js?iitm=true') + +const fallbackDirectory = mkdtempSync(join(tmpdir(), 'iitm-commonjs-source-')) +const fallbackFilename = join(fallbackDirectory, 'module.cjs') +const fallbackUrl = pathToFileURL(fallbackFilename).href +try { + writeFileSync(fallbackFilename, 'module.exports = 42') + resolveAsRequire(fallbackUrl) + const fallback = lowLevelHook.loadSyncCommonJS(fallbackUrl, {}, () => ({ + format: 'commonjs', + source: undefined + })) + match(fallback.source, /module\.exports = 42/) + + const typeScriptUrl = pathToFileURL(join(fallbackDirectory, 'module.cts')).href + resolveAsRequire(typeScriptUrl, 'commonjs-typescript') + const typeScript = lowLevelHook.loadSyncCommonJS(typeScriptUrl, {}, () => ({ + format: 'commonjs-typescript', + source: new TextEncoder().encode('const value: number = 43; module.exports = value') + })) + match(typeScript.source, /module\.exports = value/) + + const loadTimeModuleUrl = 'data:text/javascript,export%20const%20value%20%3D%2042' + const loadTimeResolution = resolveAsRequire(loadTimeModuleUrl) + let loadedUrl + const loadTimeModule = lowLevelHook.loadSyncCommonJS(loadTimeResolution.url, {}, url => { + loadedUrl = url + return { + format: 'module', + source: 'export const value = 42' + } + }) + strictEqual(loadedUrl, loadTimeModuleUrl) + strictEqual(loadTimeModule.format, 'module') + match(loadTimeModule.source, /export \{ \$0 as value \}/) + match(loadTimeModule.source, /\nregisterWithData\(/) + + const skippedUrl = pathToFileURL(join(fallbackDirectory, 'skipped.cjs')).href + resolveAsRequire(skippedUrl, 'builtin') + const skipped = { format: 'builtin', source: 'module.exports = 44' } + strictEqual(lowLevelHook.loadSyncCommonJS(skippedUrl, {}, () => skipped), skipped) + + const chainHook = createHook(hookMeta, true) + const chainResolution = chainHook.resolveSyncCommonJS(fallbackUrl, { + conditions: ['import'], + parentURL: import.meta.url + }, () => ({ url: fallbackUrl, format: 'commonjs' })) + const chainLoad = chainHook.loadSyncCommonJS(chainResolution.url, { format: 'commonjs' }, () => ({ + format: 'commonjs', + source: 'module.exports = 45' + })) + match(chainLoad.source, /\nregisterWithData\(/) + const child = { url: pathToFileURL(join(fallbackDirectory, 'child.cjs')).href, format: 'commonjs' } + strictEqual(chainHook.resolveSyncCommonJS('child', { + conditions: ['require'], + parentURL: fallbackUrl + }, () => child), child) + + const invalidSourceUrl = pathToFileURL(join(fallbackDirectory, 'invalid-source.cjs')).href + resolveAsRequire(invalidSourceUrl, 'commonjs') + const invalidSource = { format: 'commonjs', source: {} } + const emitWarning = process.emitWarning + let warning + process.emitWarning = value => { warning = value } + try { + strictEqual(lowLevelHook.loadSyncCommonJS(invalidSourceUrl, {}, () => invalidSource), invalidSource) + } finally { + process.emitWarning = emitWarning + } + strictEqual(warning.cause instanceof TypeError, true) + + const failedUrl = pathToFileURL(join(fallbackDirectory, 'failed.cjs')).href + resolveAsRequire(failedUrl, 'commonjs') + throws(() => lowLevelHook.loadSyncCommonJS(failedUrl, {}, () => { + throw new Error('load failed') + }), /load failed/) + deepStrictEqual(lowLevelHook.loadSyncCommonJS(failedUrl, {}, () => ({ source: undefined })), { + source: undefined + }) +} finally { + rmSync(fallbackDirectory, { recursive: true, force: true }) +} + +const commonJsUrl = new URL('../fixtures/something.js', import.meta.url) +const commonJsTypeScriptUrl = new URL('../fixtures/typescript-cjs-hook.cts', import.meta.url) +const esmUrl = new URL('../fixtures/something.mjs', import.meta.url) +const loadTimeSpecifier = 'iitm-load-time-module' +const loadTimeModuleUrl = 'data:text/javascript,export%20const%20value%20%3D%2042' +const applicationJavaScriptUrl = 'data:application/javascript,export%20const%20value%20%3D%2042' +const nativeFormatDirectory = mkdtempSync(join(tmpdir(), 'iitm-native-formats-')) +const loadTimeJsonFilename = join(nativeFormatDirectory, 'load-time.json') +const loadTimeJsonUrl = pathToFileURL(loadTimeJsonFilename).href +const transformedCommonJsFilename = join(nativeFormatDirectory, 'transformed.js') +const transformedCommonJsUrl = pathToFileURL(transformedCommonJsFilename).href +const loadTimeWasmSpecifier = 'iitm-load-time-wasm' +const loadTimeWasmFilename = join(nativeFormatDirectory, 'load-time.wasm') +const loadTimeWasmUrl = pathToFileURL(loadTimeWasmFilename).href +writeFileSync(loadTimeJsonFilename, '{"value":42}') +writeFileSync(transformedCommonJsFilename, "module.exports = 'disk'\n") +writeFileSync(loadTimeWasmFilename, new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x0a, 0x01, 0x06, 0x61, 0x6e, 0x73, 0x77, 0x65, 0x72, 0x00, 0x00, + 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b +])) + +/** + * @param {string} specifier + * @param {object} context + * @param {Function} nextResolve + * @returns {object} + */ +function resolveLoadTimeModule (specifier, context, nextResolve) { + if (specifier === loadTimeSpecifier || specifier === loadTimeModuleUrl) { + return { url: loadTimeModuleUrl, shortCircuit: true } + } + if (specifier === transformedCommonJsFilename || specifier === transformedCommonJsUrl) { + return { url: transformedCommonJsUrl, format: undefined, shortCircuit: true } + } + if (specifier === loadTimeWasmSpecifier) { + return { url: loadTimeWasmUrl, shortCircuit: true } + } + return nextResolve(specifier, context) +} + +/** + * @param {string} url + * @param {object} context + * @param {Function} nextLoad + * @returns {object} + */ +function loadLoadTimeModule (url, context, nextLoad) { + if (url === loadTimeModuleUrl) { + return { + format: 'module', + source: 'export const value = 42', + shortCircuit: true + } + } + if (url === transformedCommonJsUrl) { + return { + format: undefined, + source: "module.exports = 'loader'\n", + shortCircuit: true + } + } + return nextLoad(url, context) +} + +nodeModule.registerHooks({ resolve: resolveLoadTimeModule, load: loadLoadTimeModule }) +register({ + commonjs: true, + include: [ + commonJsUrl.href, + commonJsTypeScriptUrl.href, + esmUrl.href, + loadTimeSpecifier, + transformedCommonJsUrl, + /^data:application\/javascript,/, + loadTimeJsonUrl, + loadTimeWasmSpecifier, + 'fs', + 'node:test' + ] +}) + +const require = nodeModule.createRequire(import.meta.url) +try { + deepStrictEqual(require(loadTimeJsonFilename), { value: 42 }) + strictEqual(require(transformedCommonJsFilename), 'loader') + const wasmNamespace = await import(loadTimeWasmSpecifier) + strictEqual(wasmNamespace.answer(), 42) +} finally { + rmSync(nativeFormatDirectory, { recursive: true, force: true }) +} +let applicationJavaScriptHookCalls = 0 +/** @param {object} exports The wrapped ESM namespace. */ +function patchApplicationJavaScript (exports) { + applicationJavaScriptHookCalls++ + exports.value = 43 +} +const applicationJavaScriptHook = new Hook([applicationJavaScriptUrl], patchApplicationJavaScript) +try { + const namespace = await import(applicationJavaScriptUrl) + strictEqual(namespace.value, 43) + strictEqual(applicationJavaScriptHookCalls, 1) +} finally { + applicationJavaScriptHook.unhook() +} +let loadTimeHookCalls = 0 +/** @param {object} exports The wrapped ESM namespace. */ +function patchLoadTimeModule (exports) { + loadTimeHookCalls++ + exports.value = 43 +} +const loadTimeHook = new Hook([loadTimeSpecifier], patchLoadTimeModule) +try { + const namespace = require(loadTimeSpecifier) + strictEqual(namespace.value, 43) + strictEqual(loadTimeHookCalls, 1) +} finally { + loadTimeHook.unhook() +} + +const commonJsFilename = fileURLToPath(commonJsUrl) +const commonJsHook = new Hook([commonJsFilename], exports => ({ + value: exports(), + foo: exports.foo +})) + +const first = require(commonJsFilename) +deepStrictEqual(first, { value: 42, foo: 42 }) +strictEqual(require(commonJsFilename), first) + +const lateHook = new Hook([commonJsFilename], exports => ({ ...exports, late: true })) +strictEqual(require(commonJsFilename).late, true) +lateHook.unhook() +commonJsHook.unhook() + +const commonJsTypeScriptFilename = fileURLToPath(commonJsTypeScriptUrl) +const commonJsTypeScriptHook = new Hook([commonJsTypeScriptFilename], exports => { + exports.epsilon++ +}) +strictEqual(require(commonJsTypeScriptFilename).epsilon, 6) +commonJsTypeScriptHook.unhook() + +const esmFilename = fileURLToPath(esmUrl) +let esmFormat +/** + * @param {object} exports The wrapped ESM namespace. + * @param {string} name The module name. + * @param {string|undefined} baseDir The package directory. + * @param {unknown} data Consumer data associated with the module. + * @param {'module'|'commonjs'|undefined} format The module format. + */ +function patchEsm (exports, name, baseDir, data, format) { + exports.foo = 43 + esmFormat = format +} +const esmHook = new Hook([esmFilename], patchEsm) +strictEqual(require(esmFilename).foo, 43) +strictEqual(esmFormat, 'module') +esmHook.unhook() + +const marker = Symbol('iitm-commonjs') +const fsHook = new Hook(['fs'], exports => { + exports[marker] = true +}) +const fs = require('fs') +strictEqual(fs, require('node:fs')) +strictEqual(fs[marker], true) +delete fs[marker] +fsHook.unhook() + +const nodeTestHook = new Hook(['node:test'], exports => { + exports[marker] = true +}) +const nodeTest = require('node:test') +strictEqual(nodeTest, process.getBuiltinModule('node:test')) +strictEqual(nodeTest[marker], true) +delete nodeTest[marker] +nodeTestHook.unhook() diff --git a/test/typescript/bundler.test.mts b/test/typescript/bundler.test.mts new file mode 100644 index 00000000..584b531c --- /dev/null +++ b/test/typescript/bundler.test.mts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' + +import { getNodeModuleFormat, getPackageDetails as getCommonJSPackageDetails } from '../../bundler.js' +import { createWrapperModule, getPackageDetails } from '../../bundler.mjs' + +const moduleUrl = new URL('../fixtures/something.mjs', import.meta.url).href + +interface WrapperData { + version: string + values: readonly ['value'] +} + +const data: WrapperData = { version: '1.0.0', values: ['value'] as const } +const wrapper = await createWrapperModule({ + module: { + url: moduleUrl, + format: 'module', + source: 'export const value = 42', + specifier: './something.mjs', + data, + passthroughExports: exports => exports.map(({ name }) => name) + }, + resolve () { + throw new Error('Unexpected resolve') + }, + load () { + throw new Error('Unexpected load') + } +}) + +assert.equal(wrapper.sideEffects, true) +assert.equal(wrapper.sourceLineOffset, undefined) +assert.equal(wrapper.imports[0].kind, 'runtime') +assert.equal(getNodeModuleFormat(moduleUrl), 'module') +assert.equal(getPackageDetails(moduleUrl)?.path, 'something.mjs') +assert.equal(getCommonJSPackageDetails(moduleUrl)?.name, 'test-fixtures') + +await createWrapperModule({ + module: { + url: moduleUrl, + format: 'commonjs', + source: 'module.exports = 42', + specifier: './something.js' + } +}) + +if (import.meta.url === '') { + await createWrapperModule({ + module: { + url: moduleUrl, + format: 'commonjs', + source: '' as string | undefined, + specifier: './something.js' + }, + load: () => ({ source: 'module.exports = 42' }) + }) + + // @ts-expect-error ESM wrappers require a load adapter. + await createWrapperModule({ + module: { + url: moduleUrl, + format: 'module', + source: 'export const value = 42', + specifier: './something.mjs' + }, + resolve: specifier => ({ url: specifier }) + }) + + // @ts-expect-error ESM wrappers require a resolve adapter. + await createWrapperModule({ + module: { + url: moduleUrl, + format: 'module', + source: 'export const value = 42', + specifier: './something.mjs' + }, + load: () => ({ source: 'export const value = 42' }) + }) + + // @ts-expect-error CommonJS wrappers without inline source require a load adapter. + await createWrapperModule({ + module: { + url: moduleUrl, + format: 'commonjs', + specifier: './something.js' + } + }) +} diff --git a/test/typescript/ts-node.test.mts b/test/typescript/ts-node.test.mts index 9789bcce..12e2c45d 100644 --- a/test/typescript/ts-node.test.mts +++ b/test/typescript/ts-node.test.mts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import defaultHook, { Hook, addHook } from '../../index.js' +import defaultHook, { Hook, addHook, removeHook } from '../../index.js' import { sayHi } from '../fixtures/say-hi.mjs' addHook((url, exported) => { @@ -8,12 +8,18 @@ addHook((url, exported) => { } }) -new defaultHook((exported: any, name: string, baseDir: string|void) => { +new defaultHook(() => {}) +new Hook(() => {}) -}); +function checkHookExportTypes () { + const callableHook = new Hook((exported: (value: string) => string) => exported('test')) + callableHook.unhook() -new Hook((exported: any, name: string, baseDir: string|void) => { + const primitiveHook = (url: string, exported: number) => exported + url.length + addHook(primitiveHook) + removeHook(primitiveHook) +} -}); +void checkHookExportTypes assert.equal(sayHi('test'), 'Hooked')