import { relative } from 'path' ; import type webpack from 'webpack' ; import type { Compiler , Module } from 'webpack' ; import { parse } from 'semver' ; import chalk from 'chalk' ; const cwd = process . cwd ( ) ; declare module 'webpack' { export interface Module { resource : string ; } } export interface NMFResult { resourceResolveData ? : { descriptionFileData ? : { name ? : string ; version ? : string ; } ; relativePath ? : string ; } ; } export type DeduplicateStrategy = 'equality' | 'semver' ; const PLUGIN_NAME = 'DedupePlugin' ; export class DedupePlugin { private readonly strategy : DeduplicateStrategy ; private readonly ignorePackages ? : RegExp [ ] ; private cache : Map < string , Set < string > > = new Map ( ) ; private versions : Map < string , string > = new Map ( ) ; private logs : Map < string , { name : string ; toVersion : string ; toPath : string ; deduped : Array < { fromVersion : string ; fromPath : string ; } > ; } > = new Map ( ) ; constructor ( strategy , ignorePackages ? ) { this . strategy = strategy ; this . ignorePackages = ignorePackages ; } isIgnoredModule ( result ) { if ( ! this . ignorePackages ) { return false ; } const name = getResourceName ( result ) ; if ( ! name ) { return false ; } for ( const regexp of this . ignorePackages ) { if ( regexp . test ( name ) ) { return true ; } } return false ; } addToCache ( key , module ) { const id = module . identifier ( ) ; if ( this . cache . has ( key ) ) { this . cache . get ( key ) . add ( id ) ; } else { this . cache . set ( key , new Set ( [ id ] ) ) ; } } setVersion ( module , version ) { this . versions . set ( module . identifier ( ) , version ) ; } compareModuleVersions ( module1 , module2 ) { const version1 = this . versions . get ( module1 . identifier ( ) ) ; const version2 = this . versions . get ( module2 . identifier ( ) ) ; return parse ( version1 ) . compare ( version2 ) ; } pickBetterModule ( module1 , module2 ) { const versionCompare = this . compareModuleVersions ( module1 , module2 ) ; if ( versionCompare > 0 ) { return module1 ; } if ( versionCompare < 0 ) { return module2 ; } const pathLengthDiff = module1 . resource . length - module2 . resource . length || module1 . resource . localeCompare ( module2 . resource ) ; if ( pathLengthDiff > 0 ) { return module2 ; } return module1 ; } deduplicateCache ( compilation ) { const dedupLinks = new WeakMap < Module , Module > ( ) ; for ( const [ k , v ] of this . cache ) { if ( v . size <= 1 ) { continue ; } let bestModule ; for ( const moduleId of v ) { const module = compilation . findModule ( moduleId ) ; bestModule = bestModule ?? module ; bestModule = this . pickBetterModule ( bestModule , module ) ; } for ( const moduleId of v ) { const module = compilation . findModule ( moduleId ) ; if ( module !== bestModule ) { dedupLinks . set ( module , bestModule ) ; this . logDeduplicationInfo ( k , module , bestModule ) ; } } } return dedupLinks ; } optimizeModules ( compilation ) { const { modules , moduleGraph } = compilation ; const dedupLinks = this . deduplicateCache ( compilation ) ; for ( const module of modules ) { if ( dedupLinks . has ( module ) ) { const deduped = dedupLinks . get ( module as Module ) ; modules . delete ( module ) ; moduleGraph . moveModuleConnections ( module , deduped , ( connection ) => { return connection . originModule !== module ; } ) ; } } } apply ( compiler ) { let called = false ; compiler . hooks . normalModuleFactory . tap ( PLUGIN_NAME , ( nmf ) => { if ( called ) { return ; } called = true ; nmf . hooks . module . tap ( PLUGIN_NAME , ( module , result ) => { if ( ! module . resource . includes ( 'node_modules' ) || this . isIgnoredModule ( result ) ) { return ; } const cacheKey = createCacheKeyFromNMFResult ( result , this . strategy === 'semver' ) ; if ( cacheKey ) { this . addToCache ( cacheKey , module ) ; this . setVersion ( module , getResourceVersion ( result ) ) ; } return module ; } ) ; } ) ; compiler . hooks . thisCompilation . tap ( PLUGIN_NAME , ( compilation ) => { compilation . hooks . optimizeDependencies . tap ( PLUGIN_NAME , ( ) => { this . optimizeModules ( compilation ) ; } ) ; } ) ; compiler . hooks . done . tap ( PLUGIN_NAME , ( ) => { this . flushLogs ( ) ; } ) ; } logDeduplicationInfo ( cacheKey , fromModule , toModule ) { const splitted = cacheKey . split ( '@' ) ; const fromVersion = this . versions . get ( fromModule . identifier ( ) ) ; const toVersion = this . versions . get ( toModule . identifier ( ) ) ; const fromPath = getRelativePathFromCwd ( fromModule . resource ) ; const toPath = getRelativePathFromCwd ( toModule . resource ) ; const name = splitted [ 0 ] || `@${ splitted [ 1 ] }` ; const key = `${ name }@${ toVersion }:${ toPath }` ; let { deduped } = this . logs . get ( key ) ?? { } ; if ( ! deduped ) { deduped = [ ] ; this . logs . set ( key , { name , toVersion , toPath , deduped , } ) ; } deduped . push ( { fromVersion , fromPath , } ) ; } flushLogs ( ) { const keys = [ ... this . logs . keys ( ) ] . sort ( ) ; let log = `\n${ chalk . blue ( 'Deduplicated modules:' ) }\n` ; for ( const key of keys ) { const { name , toVersion , toPath , deduped } = this . logs . get ( key ) ; log += `\tDeduped to ${ chalk . cyanBright ( name ) }@${ chalk . green ( toVersion ) }:${ chalk . bgGray ( toPath ) }\n` ; for ( const { fromPath , fromVersion } of deduped ) { log += `\t\tfrom ${ chalk . red ( fromVersion ) }:${ chalk . bgGray ( fromPath ) }\n` ; } log += '\n' ; } if ( keys . length === 0 ) { log += '   no duplicates found' ; } console . warn ( log ) ; } } export function createDedupePlugin ( strategy , ignorePackages ? ) { return new DedupePlugin ( strategy , ignorePackages ) ; } export function createCacheKeyFromNMFResult ( nmfResult , useSemver = false ) { const name = getResourceName ( nmfResult ) ; const version = getResourceVersion ( nmfResult ) ; if ( ! name || ! version ) { return null ; } const versionForCache = useSemver ? buildCacheVersionFromNMFResult ( nmfResult ) : version ; return `${ name }@${ versionForCache }:${ nmfResult . resourceResolveData . relativePath }` ; } /**
 * Получаем основную версию для зависимости по правилу semver `^version`
 *
 * Когда major версия равна 0, считаем что minor версия является мажорной.
 * Если и minor версия равна 0, считаем что patch версия является мажорной.
 */ function buildCacheVersionFromNMFResult ( nmfResult ) { const version = getResourceVersion ( nmfResult ) ; const semver = parse ( version ) ; const majorVersion = String ( semver . major ) ; const minorVersion = `0.${ semver . minor }` ; const patchVersion = `0.0.${ semver . patch }` ; const minor = semver . minor ? minorVersion : patchVersion ; return semver . major ? majorVersion : minor ; } function getResourceName ( nmfResult ) { return nmfResult ?. resourceResolveData ?. descriptionFileData ?. name ; } function getResourceVersion ( nmfResult ) { return nmfResult ?. resourceResolveData ?. descriptionFileData ?. version ; } function getRelativePathFromCwd ( path ) { return relative ( cwd , path ) ; }