1+ import { readFile as readFileFromFs } from "node:fs/promises" ;
12import type { Plugin } from "vite" ;
23
4+ type ReadPackageJson = ( path : string ) => Promise < string > ;
5+
6+ type ClientReferenceDedupOptions = {
7+ readFile ?: ReadPackageJson ;
8+ } ;
9+
10+ type PackageImportSpecifier = {
11+ packageName : string ;
12+ specifier : string ;
13+ } ;
14+
15+ type PackagePath = {
16+ packageName : string ;
17+ packageRoot : string ;
18+ relativePath : string ;
19+ } ;
20+
21+ function isRecord ( value : unknown ) : value is Record < string , unknown > {
22+ return typeof value === "object" && value !== null && ! Array . isArray ( value ) ;
23+ }
24+
25+ function defaultReadPackageJson ( path : string ) : Promise < string > {
26+ return readFileFromFs ( path , "utf8" ) ;
27+ }
28+
29+ function parsePackagePath ( absolutePath : string ) : PackagePath | null {
30+ const marker = "/node_modules/" ;
31+ const lastIdx = absolutePath . lastIndexOf ( marker ) ;
32+ if ( lastIdx === - 1 ) return null ;
33+
34+ const rest = absolutePath . slice ( lastIdx + marker . length ) ;
35+ const parts = rest . split ( "/" ) ;
36+ const packagePartCount = rest . startsWith ( "@" ) ? 2 : 1 ;
37+ const packageParts = parts . slice ( 0 , packagePartCount ) ;
38+
39+ if ( packageParts . length < packagePartCount || packageParts . some ( ( part ) => part === "" ) ) {
40+ return null ;
41+ }
42+
43+ const packageName = packageParts . join ( "/" ) ;
44+ const relativeParts = parts . slice ( packagePartCount ) ;
45+
46+ return {
47+ packageName,
48+ packageRoot : absolutePath . slice ( 0 , lastIdx + marker . length + packageName . length ) ,
49+ relativePath : relativeParts . join ( "/" ) ,
50+ } ;
51+ }
52+
353/**
454 * Extract the bare package name from an absolute file path containing node_modules.
555 *
656 * Handles scoped packages (`@org/name`) and nested node_modules.
757 * Returns `null` if the path doesn't contain `/node_modules/`.
858 */
959export function extractPackageName ( absolutePath : string ) : string | null {
10- const marker = "/node_modules/" ;
11- const lastIdx = absolutePath . lastIndexOf ( marker ) ;
12- if ( lastIdx === - 1 ) return null ;
60+ return parsePackagePath ( absolutePath ) ?. packageName ?? null ;
61+ }
1362
14- const rest = absolutePath . slice ( lastIdx + marker . length ) ;
15- if ( rest . startsWith ( "@" ) ) {
16- // Scoped package: @org /name
17- const parts = rest . split ( "/" ) ;
18- if ( parts . length < 2 ) return null ;
19- return `${ parts [ 0 ] } /${ parts [ 1 ] } ` ;
63+ function normalizeExportTarget ( target : string ) : string {
64+ return target . startsWith ( "./" ) ? target . slice ( 2 ) : target ;
65+ }
66+
67+ function matchExportTarget ( target : string , relativePath : string ) : string | null {
68+ const normalizedTarget = normalizeExportTarget ( target ) ;
69+ const wildcardIndex = normalizedTarget . indexOf ( "*" ) ;
70+
71+ if ( wildcardIndex === - 1 ) {
72+ return normalizedTarget === relativePath ? "" : null ;
73+ }
74+
75+ const beforeWildcard = normalizedTarget . slice ( 0 , wildcardIndex ) ;
76+ const afterWildcard = normalizedTarget . slice ( wildcardIndex + 1 ) ;
77+
78+ if ( ! relativePath . startsWith ( beforeWildcard ) || ! relativePath . endsWith ( afterWildcard ) ) {
79+ return null ;
80+ }
81+
82+ return relativePath . slice ( beforeWildcard . length , relativePath . length - afterWildcard . length ) ;
83+ }
84+
85+ function exportKeyToSpecifier (
86+ packageName : string ,
87+ exportKey : string ,
88+ wildcardMatch : string ,
89+ ) : string {
90+ if ( exportKey === "." ) return packageName ;
91+
92+ const subpath = exportKey . startsWith ( "./" ) ? exportKey . slice ( 2 ) : exportKey ;
93+ const resolvedSubpath = subpath . includes ( "*" ) ? subpath . replace ( "*" , wildcardMatch ) : subpath ;
94+
95+ return `${ packageName } /${ resolvedSubpath } ` ;
96+ }
97+
98+ function collectExportTargets ( value : unknown ) : string [ ] {
99+ if ( typeof value === "string" ) return [ value ] ;
100+ if ( Array . isArray ( value ) ) return value . flatMap ( ( entry ) => collectExportTargets ( entry ) ) ;
101+ if ( ! isRecord ( value ) ) return [ ] ;
102+
103+ return Object . values ( value ) . flatMap ( ( entry ) => collectExportTargets ( entry ) ) ;
104+ }
105+
106+ function findExportSpecifier (
107+ packageName : string ,
108+ exportsValue : unknown ,
109+ relativePath : string ,
110+ ) : string | null {
111+ if ( ! isRecord ( exportsValue ) ) {
112+ for ( const target of collectExportTargets ( exportsValue ) ) {
113+ const wildcardMatch = matchExportTarget ( target , relativePath ) ;
114+ if ( wildcardMatch !== null ) {
115+ return exportKeyToSpecifier ( packageName , "." , wildcardMatch ) ;
116+ }
117+ }
118+ return null ;
119+ }
120+
121+ const entries = Object . entries ( exportsValue ) ;
122+ const hasSubpathKeys = entries . some ( ( [ key ] ) => key === "." || key . startsWith ( "./" ) ) ;
123+ if ( ! hasSubpathKeys ) {
124+ for ( const target of collectExportTargets ( exportsValue ) ) {
125+ const wildcardMatch = matchExportTarget ( target , relativePath ) ;
126+ if ( wildcardMatch !== null ) {
127+ return exportKeyToSpecifier ( packageName , "." , wildcardMatch ) ;
128+ }
129+ }
130+ return null ;
20131 }
21- // Regular package: name
22- const slashIdx = rest . indexOf ( "/" ) ;
23- return slashIdx === - 1 ? rest : rest . slice ( 0 , slashIdx ) ;
132+
133+ let bestMatch : { key : string ; wildcard : string } | null = null ;
134+
135+ for ( const [ key , value ] of entries ) {
136+ if ( key !== "." && ! key . startsWith ( "./" ) ) continue ;
137+
138+ for ( const target of collectExportTargets ( value ) ) {
139+ const wildcardMatch = matchExportTarget ( target , relativePath ) ;
140+ if ( wildcardMatch === null ) continue ;
141+
142+ if ( ! bestMatch || key . length > bestMatch . key . length ) {
143+ bestMatch = { key, wildcard : wildcardMatch } ;
144+ }
145+ }
146+ }
147+
148+ return bestMatch ? exportKeyToSpecifier ( packageName , bestMatch . key , bestMatch . wildcard ) : null ;
149+ }
150+
151+ function getLegacyEntry ( packageJson : Record < string , unknown > ) : string {
152+ const browser = packageJson . browser ;
153+ if ( typeof browser === "string" ) return browser ;
154+
155+ const module = packageJson . module ;
156+ if ( typeof module === "string" ) return module ;
157+
158+ const main = packageJson . main ;
159+ return typeof main === "string" ? main : "index.js" ;
160+ }
161+
162+ function matchesLegacyEntry ( legacyEntry : string , relativePath : string ) : boolean {
163+ const normalizedEntry = normalizeExportTarget ( legacyEntry ) ;
164+ return normalizedEntry === relativePath || `${ normalizedEntry } .js` === relativePath ;
165+ }
166+
167+ /**
168+ * Convert an absolute package file path into the least lossy bare import
169+ * specifier that can be handed back to Vite's dependency optimizer.
170+ */
171+ export async function extractPackageImportSpecifier (
172+ absolutePath : string ,
173+ readPackageJson : ReadPackageJson = defaultReadPackageJson ,
174+ ) : Promise < PackageImportSpecifier | null > {
175+ const packagePath = parsePackagePath ( absolutePath ) ;
176+ if ( ! packagePath ) return null ;
177+
178+ const { packageName, packageRoot, relativePath } = packagePath ;
179+ if ( relativePath === "" ) {
180+ return { packageName, specifier : packageName } ;
181+ }
182+
183+ let packageJson : Record < string , unknown > | null = null ;
184+ try {
185+ const rawPackageJson = await readPackageJson ( `${ packageRoot } /package.json` ) ;
186+ const parsedPackageJson : unknown = JSON . parse ( rawPackageJson ) ;
187+ packageJson = isRecord ( parsedPackageJson ) ? parsedPackageJson : null ;
188+ } catch {
189+ packageJson = null ;
190+ }
191+
192+ if ( ! packageJson ) {
193+ return { packageName, specifier : packageName } ;
194+ }
195+
196+ if ( "exports" in packageJson ) {
197+ const exportedSpecifier = findExportSpecifier ( packageName , packageJson . exports , relativePath ) ;
198+ return { packageName, specifier : exportedSpecifier ?? packageName } ;
199+ }
200+
201+ const specifier = matchesLegacyEntry ( getLegacyEntry ( packageJson ) , relativePath )
202+ ? packageName
203+ : `${ packageName } /${ relativePath } ` ;
204+
205+ return { packageName, specifier } ;
24206}
25207
26208const DEDUP_PREFIX = "\0vinext:dedup/" ;
@@ -37,8 +219,10 @@ const PROXY_MARKER = "virtual:vite-rsc/client-in-server-package-proxy/";
37219 *
38220 * Dev-only — production builds use the SSR manifest which handles this correctly.
39221 */
40- export function clientReferenceDedupPlugin ( ) : Plugin {
222+ export function clientReferenceDedupPlugin ( options : ClientReferenceDedupOptions = { } ) : Plugin {
41223 let excludeSet = new Set < string > ( ) ;
224+ const readPackageJson = options . readFile ?? defaultReadPackageJson ;
225+ const packageImportCache = new Map < string , Promise < PackageImportSpecifier | null > > ( ) ;
42226
43227 return {
44228 name : "vinext:client-reference-dedup" ,
@@ -55,7 +239,7 @@ export function clientReferenceDedupPlugin(): Plugin {
55239
56240 resolveId : {
57241 filter : { id : / n o d e _ m o d u l e s / } ,
58- handler ( id , importer ) {
242+ async handler ( id , importer ) {
59243 // Only operate in the client environment
60244 if ( this . environment ?. name !== "client" ) return ;
61245
@@ -65,19 +249,23 @@ export function clientReferenceDedupPlugin(): Plugin {
65249 // Only handle absolute paths through node_modules
66250 if ( ! id . startsWith ( "/" ) || ! id . includes ( "/node_modules/" ) ) return ;
67251
68- const pkgName = extractPackageName ( id ) ;
69- if ( ! pkgName ) return ;
252+ const packageName = extractPackageName ( id ) ;
253+ if ( ! packageName ) return ;
70254
71255 // Respect user's optimizeDeps.exclude
72- if ( excludeSet . has ( pkgName ) ) return ;
73-
74- // Lossy mapping: we collapse submodule paths (e.g. `pkg/dist/Button.js`)
75- // to the bare package name (`pkg`), assuming the package entry barrel-exports
76- // the same symbols. This holds for well-designed component libraries — the
77- // primary target of this plugin. A more precise approach would resolve through
78- // the package's `exports` map to find an exact subpath, but the barrel-export
79- // assumption is sufficient for the common case.
80- return `${ DEDUP_PREFIX } ${ pkgName } ` ;
256+ if ( excludeSet . has ( packageName ) ) return ;
257+
258+ let packageImportPromise = packageImportCache . get ( id ) ;
259+ if ( ! packageImportPromise ) {
260+ packageImportPromise = extractPackageImportSpecifier ( id , readPackageJson ) ;
261+ packageImportCache . set ( id , packageImportPromise ) ;
262+ }
263+
264+ const packageImport = await packageImportPromise ;
265+ if ( ! packageImport ) return ;
266+ if ( excludeSet . has ( packageImport . specifier ) ) return ;
267+
268+ return `${ DEDUP_PREFIX } ${ packageImport . specifier } ` ;
81269 } ,
82270 } ,
83271
0 commit comments