diff --git a/e2e/handles-node_modules.spec.ts b/e2e/handles-node_modules.spec.ts index ad44725..3129445 100644 --- a/e2e/handles-node_modules.spec.ts +++ b/e2e/handles-node_modules.spec.ts @@ -113,8 +113,8 @@ console.log(upperCase('Hi, there!')); ); it( - 'always resolves files in node_modules by CommonJS exports ' + - 'regardless of type of import statement', + 'with preferResolveByDependencyAsCjs as true, ' + + 'resolves files in node_modules by CommonJS exports ignoring type of import statement', () => { setupWebpackProject({ 'webpack.config.js': ` @@ -122,7 +122,11 @@ const Plugin = require('${rootPath}'); module.exports = { ${webpackConfigReusable} entry: './src/index.js', - plugins: [new Plugin()], + plugins: [ + new Plugin({ + preferResolveByDependencyAsCjs: true, + }), + ], }; `, 'src/withEsmImport.js': ` @@ -161,6 +165,83 @@ console.log(green('Hi, there!')); } } ); + + it( + 'with preferResolveByDependencyAsCjs as false, ' + + 'resolves files in node_modules according to type of import statement', + () => { + setupWebpackProject({ + 'webpack.config.js': ` +const Plugin = require('${rootPath}'); +module.exports = { + ${webpackConfigReusable} + entry: './src/index.js', + plugins: [ + new Plugin({ + preferResolveByDependencyAsCjs: false, + }), + ], +}; +`, + 'src/withEsmImport.js': ` +import { green } from 'colorette'; +console.log(green('Hi, there!')); +`, + 'src/withCjsImport.js': ` +const { green } = require('colorette'); +console.log(green('Hi, there!')); +`, + 'package.json': evaluateMustHavePackageJsonText({ + ['dependencies']: { + ['colorette']: '^2.0.19', + }, + }), + }); + + subCaseWithEsmImport(); + subCaseWithCjsImport(); + + function subCaseWithEsmImport() { + try { + fs.rmSync('dist', { recursive: true }); + } catch {} + useSubEntry('withEsmImport'); + expect(execWebpack().status).toBe(0); + expectCommonDirToIncludeSameFilesAnd({ + 'dist/index.js': noop, + [`dist/withEsmImport.js`]: (t) => + expect(t).toIncludeMultiple([ + 'require("./node_modules/colorette/index.js")', + 'Hi, there!', + ]), + 'dist/node_modules/colorette/index.js': noop, + }); + const { status, stdout } = execNode('dist/index.js'); + expect(status).toBe(0); + expect(stdout).toInclude('Hi, there!'); + } + + function subCaseWithCjsImport() { + try { + fs.rmSync('dist', { recursive: true }); + } catch {} + useSubEntry('withCjsImport'); + expect(execWebpack().status).toBe(0); + expectCommonDirToIncludeSameFilesAnd({ + 'dist/index.js': noop, + [`dist/withCjsImport.js`]: (t) => + expect(t).toIncludeMultiple([ + 'require("./node_modules/colorette/index.cjs")', + 'Hi, there!', + ]), + 'dist/node_modules/colorette/index.cjs': noop, + }); + const { status, stdout } = execNode('dist/index.js'); + expect(status).toBe(0); + expect(stdout).toInclude('Hi, there!'); + } + } + ); }); describe('with loader helpers indirectly included from node_modules', () => { diff --git a/e2e/validates-inputs.spec.ts b/e2e/validates-inputs.spec.ts index 2ce1950..0a5b4b0 100644 --- a/e2e/validates-inputs.spec.ts +++ b/e2e/validates-inputs.spec.ts @@ -36,6 +36,7 @@ describe('validates options', () => { hoistNodeModules?: boolean; longestCommonDir?: boolean | string; extentionMapping?: boolean; + preferResolveByDependencyAsCjs?: boolean; }) { setupWebpackProject({ 'webpack.config.js': ` @@ -52,6 +53,11 @@ module.exports = { (b) => boolToText(b, 'longestCommonDir: __dirname,', 'longestCommonDir: 0,') )} ${boolToText(validOpts.extentionMapping, 'extentionMapping: {},', 'extentionMapping: 0,')} + ${ + (boolToText(validOpts.preferResolveByDependencyAsCjs), + 'preferResolveByDependencyAsCjs: true,', + 'preferResolveByDependencyAsCjs: 0,') + } }), ], }; @@ -88,12 +94,19 @@ module.exports = { expect(stderr).toIncludeMultiple(['Error', 'longestCommonDir', './src/some/where']); }); - it(`throws error if extentionMapping not valid in format`, () => { + it('throws error if extentionMapping not valid in format', () => { setup({ extentionMapping: false }); const { status, stderr } = execWebpack(); expect(status).toBeGreaterThan(0); expect(stderr).toIncludeMultiple(['Invalid', 'extentionMapping']); }); + + it('throws error if preferResolveByDependencyAsCjs not valid in format', () => { + setup({ preferResolveByDependencyAsCjs: false }); + const { status, stderr } = execWebpack(); + expect(status).toBeGreaterThan(0); + expect(stderr).toIncludeMultiple(['Invalid', 'preferResolveByDependencyAsCjs']); + }); }); describe('validates entries', () => { @@ -112,20 +125,25 @@ module.exports = { expect(stderr).toIncludeMultiple(['Error', 'No entry', `outside 'node_modules'`]); }); - it(`throws error if any '.mjs' file found`, () => { + it(`prints warning if any '.mjs' file found with target 'node'`, () => { setupWebpackProject({ 'webpack.config.js': ` const Plugin = require('${rootPath}'); module.exports = { + mode: 'production', + target: 'node', + output: { + path: __dirname + '/dist', + }, entry: './src/index.mjs', plugins: [new Plugin()], }; `, 'src/index.mjs': '', }); - const { status, stderr } = execWebpack(); - expect(status).toBeGreaterThan(0); - expect(stderr).toIncludeMultiple(['Error', 'Outputting ES modules', `'.mjs' files`]); + const { status, stdout } = execWebpack(); + expect(status).toBe(0); + expect(stdout).toIncludeMultiple(['WARNING', `'.mjs' files`, './src/index.mjs']); }); it(`throws error if any '.json' file is not type of JSON`, () => { diff --git a/src/Plugin.ts b/src/Plugin.ts index 6cdc5cc..0b1b66a 100644 --- a/src/Plugin.ts +++ b/src/Plugin.ts @@ -7,24 +7,27 @@ import { validate } from 'schema-utils'; import { commonDirSync } from './commonDir'; import { - enableBuiltinNodeGlobalsByDefaultIfTargetNodeCompatible, - forceDisableOutputtingEsm, + alignResolveByDependency, + enableBuiltinNodeGlobalsByDefault, + forceDisableOutputModule, forceDisableSplitChunks, forceSetLibraryType, + isTargetNodeCompatible, throwErrIfHotModuleReplacementEnabled, throwErrIfOutputPathNotSpecified, - unifyDependencyResolving, } from './compilerOptions'; import { Condition, createConditionTest } from './conditionTest'; import { baseNodeModules, + externalModuleTypeCjs, extJson, - moduleType, + hookStageVeryEarly, + outputLibraryTypeCjs, pluginName, - reEsmFile, + reMjsFile, reNodeModules, + resolveByDependencyTypeCjs, sourceTypeAsset, - stageVeryEarly, } from './constants'; import optionsSchema from './optionsSchema.json'; import { @@ -34,6 +37,7 @@ import { Module, NormalModule, sources, + WebpackError, } from './peers/webpack'; import ModuleProfile from './peers/webpack/lib/ModuleProfile'; import { SourceMapDevToolPluginController } from './SourceMapDevToolPluginController'; @@ -49,6 +53,7 @@ export interface TranspileWebpackPluginInternalOptions { hoistNodeModules: boolean; longestCommonDir?: string; extentionMapping: Record; + preferResolveByDependencyAsCjs: boolean; } export class TranspileWebpackPlugin { @@ -67,30 +72,43 @@ export class TranspileWebpackPlugin { exclude: options.exclude ?? [], hoistNodeModules: options.hoistNodeModules ?? true, extentionMapping: options.extentionMapping ?? {}, + preferResolveByDependencyAsCjs: options.preferResolveByDependencyAsCjs ?? true, }; this.sourceMapDevToolPluginController = new SourceMapDevToolPluginController(); this.terserWebpackPluginController = new TerserWebpackPluginController(); } apply(compiler: Compiler) { - const { exclude, hoistNodeModules, longestCommonDir, extentionMapping } = this.options; + const { + exclude, + hoistNodeModules, + longestCommonDir, + extentionMapping, + preferResolveByDependencyAsCjs, + } = this.options; forceDisableSplitChunks(compiler.options); - forceSetLibraryType(compiler.options, moduleType); - forceDisableOutputtingEsm(compiler.options); + forceSetLibraryType(compiler.options, outputLibraryTypeCjs); + forceDisableOutputModule(compiler.options); const isPathExcluded = createConditionTest(exclude); const isPathInNodeModules = createConditionTest(reNodeModules); - const isPathEsmFile = createConditionTest(reEsmFile); + const isPathMjsFile = createConditionTest(reMjsFile); this.sourceMapDevToolPluginController.apply(compiler); this.terserWebpackPluginController.apply(compiler); - compiler.hooks.environment.tap({ name: pluginName, stage: stageVeryEarly }, () => { + compiler.hooks.environment.tap({ name: pluginName, stage: hookStageVeryEarly }, () => { throwErrIfOutputPathNotSpecified(compiler.options); throwErrIfHotModuleReplacementEnabled(compiler.options); - enableBuiltinNodeGlobalsByDefaultIfTargetNodeCompatible(compiler.options); - unifyDependencyResolving(compiler.options, moduleType.split('-')[0]); + + if (isTargetNodeCompatible(compiler.options.target)) { + enableBuiltinNodeGlobalsByDefault(compiler.options); + } + + if (preferResolveByDependencyAsCjs) { + alignResolveByDependency(compiler.options, resolveByDependencyTypeCjs); + } }); compiler.hooks.finishMake.tapPromise(pluginName, async (compilation) => { @@ -116,18 +134,20 @@ export class TranspileWebpackPlugin { ); if (entryResourcePathsWoNodeModules.length === 0) { - throw new Error(`No entry is found outside 'node_modules'`); + throw new Error(`${pluginName}${os.EOL}No entry is found outside 'node_modules'`); } - const entryResourcePathsOutputtingEsm = entryResourcePaths.filter(isPathEsmFile); - if (entryResourcePathsOutputtingEsm.length > 0) { - throw new Error( - `Outputting ES modules is not supported yet. Found '.mjs' files:${os.EOL}` + - entryResourcePathsOutputtingEsm - .map((p) => ' ' + path.relative(context, p)) - .join(os.EOL) + - `${os.EOL}----` - ); + if (isTargetNodeCompatible(compiler.options.target)) { + const entryResourceMjsFiles = entryResourcePaths.filter(isPathMjsFile); + if (entryResourceMjsFiles.length > 0) { + const warning = new WebpackError( + `${pluginName}${os.EOL}Might be problematic to run '.mjs' files with target 'node'. Found '.mjs' files:${os.EOL}` + + entryResourceMjsFiles + .map((p) => ` .${path.sep}${path.relative(context, p)}`) + .join(os.EOL) + ); + compilation.warnings.push(warning); + } } const commonDir = commonDirSync(entryResourcePaths, { @@ -239,7 +259,7 @@ export class TranspileWebpackPlugin { request = `.${path.sep}${request}`; } - const extModCandidate = new ExternalModule(request, moduleType, request); + const extModCandidate = new ExternalModule(request, externalModuleTypeCjs, request); let extMod = compilation.getModule(extModCandidate); let doesExtModNeedBuild = false; if (!(extMod instanceof ExternalModule)) { @@ -342,7 +362,10 @@ export class TranspileWebpackPlugin { const { jsonData } = entryMod.buildInfo; if (!jsonData) { throw new Error( - `File '${path.relative(context, entryResourcePath)}' is not type of JSON` + `${pluginName}${os.EOL}File '${path.relative( + context, + entryResourcePath + )}' is not type of JSON` ); } entryMod.buildInfo.assets = { diff --git a/src/SourceMapDevToolPluginController.ts b/src/SourceMapDevToolPluginController.ts index 844bf8f..c3efa0d 100644 --- a/src/SourceMapDevToolPluginController.ts +++ b/src/SourceMapDevToolPluginController.ts @@ -1,4 +1,4 @@ -import { pluginName, stageVeryEarly } from './constants'; +import { pluginName, hookStageVeryEarly } from './constants'; import { Compiler, EvalSourceMapDevToolPlugin, SourceMapDevToolPlugin } from './peers/webpack'; import { CompilerOptions, SourceMapDevToolPluginOptions } from './types'; @@ -7,7 +7,7 @@ export class SourceMapDevToolPluginController { oldDevtool: CompilerOptions['devtool']; apply(compiler: Compiler): void { - compiler.hooks.environment.tap({ name: pluginName, stage: stageVeryEarly }, () => { + compiler.hooks.environment.tap({ name: pluginName, stage: hookStageVeryEarly }, () => { if (compiler.options.devtool) { if (compiler.options.devtool.includes('source-map')) { this.initSourceMapDevToolPlugin(compiler); @@ -18,7 +18,7 @@ export class SourceMapDevToolPluginController { } }); - compiler.hooks.initialize.tap({ name: pluginName, stage: stageVeryEarly }, () => { + compiler.hooks.initialize.tap({ name: pluginName, stage: hookStageVeryEarly }, () => { // Restore devtool after compiler options get processed inside webpack. this.restoreDevtool(compiler.options); }); diff --git a/src/commonDir.ts b/src/commonDir.ts index a66d20a..fb017f7 100644 --- a/src/commonDir.ts +++ b/src/commonDir.ts @@ -1,6 +1,9 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; +import { pluginName } from './constants'; + function longestCommonPrefix(strs: string[]): string { if (!strs.length) return ''; @@ -38,7 +41,7 @@ export function commonDirSync( if (!isDir(prefix)) { prefix = path.dirname(prefix); if (!isDir(prefix)) { - throw new Error('No valid common dir is figured out'); + throw new Error(`${pluginName}${os.EOL}No valid common dir is figured out`); } } @@ -46,7 +49,9 @@ export function commonDirSync( const finalLongestCommonDir = normalizePath(opts.longestCommonDir, opts); if (!isDir(finalLongestCommonDir)) { - throw new Error(`The longestCommonDir '${opts.longestCommonDir}' doesn't exist`); + throw new Error( + `${pluginName}${os.EOL}The longestCommonDir '${opts.longestCommonDir}' doesn't exist` + ); } if (prefix.startsWith(finalLongestCommonDir)) { diff --git a/src/compilerOptions.ts b/src/compilerOptions.ts index 637fbd0..9989b65 100644 --- a/src/compilerOptions.ts +++ b/src/compilerOptions.ts @@ -1,3 +1,5 @@ +import os from 'node:os'; + import { flatten, pick, set } from 'lodash'; import { pluginName } from './constants'; @@ -13,13 +15,14 @@ export function forceSetLibraryType(compilerOptions: CompilerOptions, libraryTyp set(compilerOptions, 'output.library.type', libraryType); } -export function forceDisableOutputtingEsm(compilerOptions: CompilerOptions): void { +export function forceDisableOutputModule(compilerOptions: CompilerOptions): void { set(compilerOptions, 'experiments.outputModule', false); } export function throwErrIfOutputPathNotSpecified(compilerOptions: CompilerOptions): void { const { output } = compilerOptions; - if (!output.path) throw new Error(`The output.path in webpack config is not specified`); + if (!output.path) + throw new Error(`${pluginName}${os.EOL}The output.path in webpack config is not specified`); } export function throwErrIfHotModuleReplacementEnabled(compilerOptions: CompilerOptions): void { @@ -29,36 +32,32 @@ export function throwErrIfHotModuleReplacementEnabled(compilerOptions: CompilerO p instanceof HotModuleReplacementPlugin || p.constructor.name === 'HotModuleReplacementPlugin' ) { - throw new Error(`Hot module replacement is not supported when using plugin '${pluginName}'`); + throw new Error( + `${pluginName}${os.EOL}Hot module replacement is not supported when using plugin '${pluginName}'` + ); } } } -export function enableBuiltinNodeGlobalsByDefaultIfTargetNodeCompatible( - compilerOptions: CompilerOptions -): void { - const { target } = compilerOptions; - - const isTargetNodeCompatible = flatten([target]).some( - (t) => typeof t === 'string' && t.includes('node') - ); - - if (isTargetNodeCompatible) { - if (compilerOptions.node) { - D(compilerOptions.node, '__dirname', false); - D(compilerOptions.node, '__filename', false); - } +export function enableBuiltinNodeGlobalsByDefault(compilerOptions: CompilerOptions): void { + if (compilerOptions.node) { + D(compilerOptions.node, '__dirname', false); + D(compilerOptions.node, '__filename', false); } } -export function unifyDependencyResolving(compilerOptions: CompilerOptions, wantedType: string) { +export function isTargetNodeCompatible(target: CompilerOptions['target']): boolean { + return flatten([target]).some((t) => typeof t === 'string' && t.includes('node')); +} + +export function alignResolveByDependency(compilerOptions: CompilerOptions, preferredType: string) { const { byDependency } = compilerOptions.resolve; if (!byDependency) return; - if (!Object.prototype.hasOwnProperty.call(byDependency, wantedType)) wantedType = 'unknown'; - const wantedOpts = byDependency[wantedType]; + if (!Object.prototype.hasOwnProperty.call(byDependency, preferredType)) preferredType = 'unknown'; + const preferredOpts = byDependency[preferredType]; for (const [type, opts] of Object.entries(byDependency)) { - if (type !== wantedType) { - Object.assign(opts, pick(wantedOpts, Object.keys(opts))); + if (type !== preferredType) { + Object.assign(opts, pick(preferredOpts, Object.keys(opts))); } } } diff --git a/src/constants.ts b/src/constants.ts index 696c6c8..50610b7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,14 +9,16 @@ export const { name: packageName } = readJsonSync<{ name: string }>( export const pluginName = startCase(packageName); export const reNodeModules = /[\\/]node_modules[\\/]/; -export const reEsmFile = /\.mjs$/; +export const reMjsFile = /\.mjs$/; export const baseNodeModules = 'node_modules'; -export const moduleType = 'commonjs-module'; +export const resolveByDependencyTypeCjs = 'commonjs'; +export const outputLibraryTypeCjs = 'commonjs-module'; +export const externalModuleTypeCjs = 'commonjs-module'; export const extJson = '.json'; export const sourceTypeAsset = 'asset'; -export const stageVeryEarly = -1000000; +export const hookStageVeryEarly = -1000000; diff --git a/src/optionsSchema.json b/src/optionsSchema.json index ad621c9..7e17161 100644 --- a/src/optionsSchema.json +++ b/src/optionsSchema.json @@ -12,6 +12,9 @@ }, "extentionMapping": { "type": "object" + }, + "preferResolveByDependencyAsCjs": { + "type": "boolean" } }, "additionalProperties": false,