1
1
import { dirname , join , normalize , posix } from 'path' ;
2
2
import type { Plugin , RollupCache } from 'rollup' ;
3
3
import { rollup } from 'rollup' ;
4
- import { existsSync , promises as fs } from 'fs' ;
4
+ import { promises as fs } from 'fs' ;
5
5
import { resolve , legacy as resolveLegacy } from 'resolve.exports' ;
6
6
import commonjs from '@rollup/plugin-commonjs' ;
7
7
import { processGlobalPlugin } from './process-global-plugin' ;
8
8
import * as esbuild from 'esbuild' ;
9
9
import { parse } from 'cjs-module-lexer' ;
10
- import MagicString from 'magic-string' ;
11
10
import { fileURLToPath } from 'url' ;
11
+ import { createRequire } from 'module' ;
12
12
import { jsExts } from '../middleware/js' ;
13
13
import { changeErrorMessage } from '../../utils' ;
14
14
@@ -78,9 +78,9 @@ export const npmPlugin = ({ root }: { root: string }): Plugin => {
78
78
const cachePath = join ( cacheDir , '@npm' , `${ resolved . idWithVersion } .js` ) ;
79
79
const cached = await getFromCache ( cachePath ) ;
80
80
if ( cached ) return cached ;
81
- const result = await bundleNpmModule ( resolved . path , false ) ;
81
+ const result = await bundleNpmModule ( resolved . path , id , false ) ;
82
82
// Queue up a second-pass optimized/minified build
83
- bundleNpmModule ( resolved . path , true ) . then ( ( optimizedResult ) => {
83
+ bundleNpmModule ( resolved . path , id , true ) . then ( ( optimizedResult ) => {
84
84
setInCache ( cachePath , optimizedResult ) ;
85
85
} ) ;
86
86
setInCache ( cachePath , result ) ;
@@ -89,17 +89,16 @@ export const npmPlugin = ({ root }: { root: string }): Plugin => {
89
89
} ;
90
90
} ;
91
91
92
- const nodeResolve = async ( id : string , root : string ) => {
93
- const pathChunks = id . split ( posix . sep ) ;
94
- const isNpmNamespace = id [ 0 ] === '@' ;
95
- const packageName = pathChunks . slice ( 0 , isNpmNamespace ? 2 : 1 ) ;
96
- // If it is an npm namespace, then get the first two folders, otherwise just one
97
- const pkgDir = join ( root , 'node_modules' , ...packageName ) ;
98
- await fs . stat ( pkgDir ) . catch ( ( ) => {
99
- throw new Error ( `Could not resolve ${ id } from ${ root } ` ) ;
100
- } ) ;
101
- // Path within imported module
102
- const subPath = join ( ...pathChunks . slice ( isNpmNamespace ? 2 : 1 ) ) ;
92
+ interface ResolveResult {
93
+ path : string ;
94
+ idWithVersion : string ;
95
+ }
96
+
97
+ const resolveFromFolder = async (
98
+ pkgDir : string ,
99
+ subPath : string ,
100
+ packageName : string [ ] ,
101
+ ) : Promise < false | ResolveResult > => {
103
102
const pkgJsonPath = join ( pkgDir , 'package.json' ) ;
104
103
let pkgJson ;
105
104
try {
@@ -133,31 +132,63 @@ const nodeResolve = async (id: string, root: string) => {
133
132
if ( ! result && subPath === '.' )
134
133
result = resolveLegacy ( pkgJson , { browser : false , fields : [ 'main' ] } ) ;
135
134
136
- if ( ! result ) {
135
+ if ( ! result && ! ( 'exports' in pkgJson ) ) {
137
136
const extensions = [ '.js' , '/index.js' , '.cjs' , '/index.cjs' ] ;
137
+ // If this was not conditionally included, this would have infinite recursion
138
+ if ( subPath !== '.' ) extensions . unshift ( '' ) ;
138
139
for ( const extension of extensions ) {
139
140
const path = normalize ( join ( pkgDir , subPath ) + extension ) ;
140
- if ( existsSync ( path ) ) return { path, idWithVersion } ;
141
+ const stats = await fs . stat ( path ) . catch ( ( ) => null ) ;
142
+ if ( stats ) {
143
+ if ( stats . isFile ( ) ) return { path, idWithVersion } ;
144
+ if ( stats . isDirectory ( ) ) {
145
+ // If you import some-package/foo and foo is a folder with a package.json in it,
146
+ // resolve main fields from the package.json
147
+ const result = await resolveFromFolder ( path , '.' , packageName ) ;
148
+ if ( result ) return { path : result . path , idWithVersion } ;
149
+ }
150
+ }
141
151
}
142
-
143
- throw new Error ( `Could not resolve ${ id } ` ) ;
144
152
}
145
153
154
+ if ( ! result ) return false ;
146
155
return { path : join ( pkgDir , result ) , idWithVersion } ;
147
156
} ;
148
157
158
+ const resolveCache = new Map < string , ResolveResult > ( ) ;
159
+
160
+ const resolveCacheKey = ( id : string , root : string ) => `${ id } \0\0${ root } ` ;
161
+
162
+ const nodeResolve = async ( id : string , root : string ) => {
163
+ const cacheKey = resolveCacheKey ( id , root ) ;
164
+ const cached = resolveCache . get ( cacheKey ) ;
165
+ if ( cached ) return cached ;
166
+ const pathChunks = id . split ( posix . sep ) ;
167
+ const isNpmNamespace = id [ 0 ] === '@' ;
168
+ const packageName = pathChunks . slice ( 0 , isNpmNamespace ? 2 : 1 ) ;
169
+ // If it is an npm namespace, then get the first two folders, otherwise just one
170
+ const pkgDir = join ( root , 'node_modules' , ...packageName ) ;
171
+ await fs . stat ( pkgDir ) . catch ( ( ) => {
172
+ throw new Error ( `Could not resolve ${ id } from ${ root } ` ) ;
173
+ } ) ;
174
+ // Path within imported module
175
+ const subPath = join ( ...pathChunks . slice ( isNpmNamespace ? 2 : 1 ) ) ;
176
+ const result = await resolveFromFolder ( pkgDir , subPath , packageName ) ;
177
+ if ( result ) {
178
+ resolveCache . set ( cacheKey , result ) ;
179
+ return result ;
180
+ }
181
+
182
+ throw new Error ( `Could not resolve ${ id } ` ) ;
183
+ } ;
184
+
149
185
const pluginNodeResolve = ( ) : Plugin => {
150
186
return {
151
187
name : 'node-resolve' ,
152
188
resolveId ( id ) {
153
189
if ( isBareImport ( id ) ) return { id : prefix + id , external : true } ;
154
- if ( id . startsWith ( prefix ) ) {
155
- return {
156
- // Remove the leading slash, otherwise rollup turns it into a relative path up to disk root
157
- id,
158
- external : true ,
159
- } ;
160
- }
190
+ // If requests already have the npm prefix, mark them as external
191
+ if ( id . startsWith ( prefix ) ) return { id, external : true } ;
161
192
} ,
162
193
} ;
163
194
} ;
@@ -166,58 +197,60 @@ let npmCache: RollupCache | undefined;
166
197
167
198
/**
168
199
* Bundle am npm module entry path into a single file
169
- * @param mod The module to bundle, including subpackage/path
200
+ * @param mod The full path of the module to bundle, including subpackage/path
201
+ * @param id The imported identifier
170
202
* @param optimize Whether the bundle should be a minified/optimized bundle, or the default quick non-optimized bundle
171
203
*/
172
- const bundleNpmModule = async ( mod : string , optimize : boolean ) => {
204
+ const bundleNpmModule = async ( mod : string , id : string , optimize : boolean ) => {
205
+ let namedExports : string [ ] = [ ] ;
206
+ if ( dynamicCJSModules . has ( id ) ) {
207
+ let isValidCJS = true ;
208
+ try {
209
+ const text = await fs . readFile ( mod , 'utf8' ) ;
210
+ // Goal: Determine if it is ESM or CJS.
211
+ // Try to parse it with cjs-module-lexer, if it fails, assume it is ESM
212
+ // eslint-disable-next-line @cloudfour/typescript-eslint/await-thenable
213
+ await parse ( text ) ;
214
+ } catch {
215
+ isValidCJS = false ;
216
+ }
217
+
218
+ if ( isValidCJS ) {
219
+ const require = createRequire ( import . meta. url ) ;
220
+ // eslint-disable-next-line @cloudfour/typescript-eslint/no-var-requires
221
+ const imported = require ( mod ) ;
222
+ if ( typeof imported === 'object' && ! imported . __esModule )
223
+ namedExports = Object . keys ( imported ) ;
224
+ }
225
+ }
226
+
227
+ const virtualEntry = '\0virtualEntry' ;
228
+ const hasSyntheticNamedExports = namedExports . length > 0 ;
173
229
const bundle = await rollup ( {
174
- input : mod ,
230
+ input : hasSyntheticNamedExports ? virtualEntry : mod ,
175
231
cache : npmCache ,
176
232
shimMissingExports : true ,
177
233
treeshake : true ,
178
234
preserveEntrySignatures : 'allow-extension' ,
179
235
plugins : [
180
- {
181
- // This plugin fixes cases of module.exports = require('...')
182
- // By default, the named exports from the required module are not generated
183
- // This plugin detects those exports,
184
- // and makes it so that @rollup /plugin-commonjs can see them and turn them into ES exports (via syntheticNamedExports)
185
- // This edge case happens in React, so it was necessary to fix it.
186
- name : 'cjs-module-lexer' ,
187
- async transform ( code , id ) {
188
- if ( id . startsWith ( '\0' ) ) return ;
189
- const out = new MagicString ( code ) ;
190
- const re =
191
- / ( ^ | [ \s ; ] ) m o d u l e \. e x p o r t s \s * = \s * r e q u i r e \( [ " ' ] ( [ ^ " ' ] * ) [ " ' ] \) ( $ | [ \s ; ] ) / g;
192
- let match ;
193
- while ( ( match = re . exec ( code ) ) ) {
194
- const [ , leadingWhitespace , moduleName , trailingWhitespace ] = match ;
195
-
196
- const resolved = await this . resolve ( moduleName , id ) ;
197
- if ( ! resolved || resolved . external ) return ;
198
-
199
- try {
200
- const text = await fs . readFile ( resolved . id , 'utf8' ) ;
201
- // eslint-disable-next-line @cloudfour/typescript-eslint/await-thenable
202
- const parsed = await parse ( text ) ;
203
- let replacement = '' ;
204
- for ( const exportName of parsed . exports ) {
205
- replacement += `\nmodule.exports.${ exportName } = require("${ moduleName } ").${ exportName } ` ;
206
- }
207
-
208
- out . overwrite (
209
- match . index ,
210
- re . lastIndex ,
211
- leadingWhitespace + replacement + trailingWhitespace ,
212
- ) ;
213
- } catch {
214
- return ;
236
+ hasSyntheticNamedExports &&
237
+ ( {
238
+ // This plugin handles special-case packages whose named exports cannot be found via static analysis
239
+ // For these packages, the package is require()'d, and the named exports are determined that way.
240
+ // A virtual entry exports the named exports from the real entry package
241
+ name : 'cjs-named-exports' ,
242
+ resolveId ( id ) {
243
+ if ( id === virtualEntry ) return virtualEntry ;
244
+ } ,
245
+ load ( id ) {
246
+ if ( id === virtualEntry ) {
247
+ const code = `export * from '${ mod } '
248
+ export {${ namedExports . join ( ', ' ) } } from '${ mod } '
249
+ export { default } from '${ mod } '` ;
250
+ return code ;
215
251
}
216
- }
217
-
218
- return out . toString ( ) ;
219
- } ,
220
- } as Plugin ,
252
+ } ,
253
+ } as Plugin ) ,
221
254
pluginNodeResolve ( ) ,
222
255
processGlobalPlugin ( { NODE_ENV : 'development' } ) ,
223
256
commonjs ( {
@@ -247,3 +280,9 @@ const bundleNpmModule = async (mod: string, optimize: boolean) => {
247
280
248
281
return output [ 0 ] . code ;
249
282
} ;
283
+
284
+ /**
285
+ * Any package names in this set will need to have their named exports detected manually via require()
286
+ * because the export names cannot be statically analyzed
287
+ */
288
+ const dynamicCJSModules = new Set ( [ 'prop-types' , 'react-dom' , 'react' ] ) ;
0 commit comments