@@ -3,16 +3,69 @@ import fsPromises, { readFile, writeFile, readdir } from 'node:fs/promises'
33import { existsSync } from 'node:fs'
44import { fileURLToPath , pathToFileURL } from 'node:url'
55import { basename , dirname , extname , resolve , join , relative } from 'node:path'
6- import { createRequire } from 'node:module'
6+ import nodeModule , { createRequire } from 'node:module'
77import { randomUUID as uuid , randomBytes } from 'node:crypto'
88import * as esbuild from 'esbuild'
99
1010const require = createRequire ( import . meta. url )
1111const resolveRequire = ( query ) => require . resolve ( query )
12- const cjsMockRegex = / \. e x o d u s - t e s t - m o c k \. c j s $ / u
12+ const cjsMockRegex = / \. e x o d u s - t e s t - m o c k \. c j s $ /
1313const cjsMockFallback = `throw new Error('Mocking loaded ESM modules in not possible in bundles')`
1414let resolveSrc , globLib
1515
16+ const packageJSONs = new Map ( )
17+
18+ function findPackageJSON ( file ) {
19+ if ( packageJSONs . has ( file ) ) return packageJSONs . get ( file )
20+
21+ assert . equal ( resolve ( file ) , file ) // must be absolute and not end with a '/'
22+
23+ if ( nodeModule . findPackageJSON ) {
24+ const res = nodeModule . findPackageJSON ( pathToFileURL ( file ) ) ?? null
25+ packageJSONs . set ( file , res )
26+ return res
27+ }
28+
29+ // does not go into /package.json if file is a dir
30+ for ( let dir = dirname ( file ) ; dir ; ) {
31+ const res = join ( dir , 'package.json' )
32+ if ( existsSync ( res ) ) {
33+ packageJSONs . set ( file , res )
34+ return res
35+ }
36+
37+ const parent = dirname ( dir )
38+ if ( ! parent || parent === dir ) break
39+ dir = parent
40+ }
41+
42+ packageJSONs . set ( file , null )
43+ return null
44+ }
45+
46+ const reactNativeMaps = new Map ( )
47+
48+ async function mapReactNative ( context ) {
49+ const pkg = findPackageJSON ( context )
50+ if ( ! pkg ) return [ null ]
51+ if ( reactNativeMaps . has ( pkg ) ) return reactNativeMaps . get ( pkg )
52+ const { browser, 'react-native' : rn } = JSON . parse ( await readFile ( pkg , 'utf8' ) )
53+ const res = { map : null , main : null , dir : dirname ( pkg ) }
54+
55+ // Do not return pkg["main"] string as it's already resolved by esbuild, no need to overwrite, also pkg["main"] shouldn't be a map
56+ for ( const prop of [ browser , rn ] ) {
57+ if ( ! prop ) continue
58+ if ( typeof prop === 'string' ) {
59+ res . main = prop
60+ } else {
61+ res . map = { ...res . map , ...prop }
62+ }
63+ }
64+
65+ reactNativeMaps . set ( pkg , res )
66+ return res
67+ }
68+
1669const emptyToUndefined = ( x ) => ( x . length > 0 ? x : undefined ) // optimize out define if there are none
1770const readSnapshots = async ( files , resolvers ) => {
1871 const snapshots = [ ]
@@ -435,14 +488,19 @@ export const build = async (...files) => {
435488 : JSON . stringify ( x , null , 1 ) . replaceAll ( / ^ * ( " .+ " ) ( , ? ) $ / gmu, ( _ , s , c ) => `${ wrap ( s ) } ${ c } ` )
436489 }
437490
438- const conditions = [ ]
491+ const conditions = [ ] // 'require' and 'import' are built-in
492+ const mainFields = [ 'module' , 'main' ]
439493 if ( process . env . EXODUS_TEST_PLATFORM === 'workerd' ) {
440494 conditions . push ( 'workerd' )
441495 } else if ( process . env . EXODUS_TEST_IS_BROWSER ) {
442496 // browsers, electron renderer, servo
443497 conditions . push ( 'browser' )
498+ mainFields . unshift ( 'browser' )
444499 } else if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
445- conditions . push ( 'react-native' )
500+ conditions . push ( 'react-native' ) // does not set browser, see https://metrobundler.dev/docs/configuration/#unstable_conditionnames-experimental
501+ // FIXME: mainFields = react-native is unsupported by esbuild: https://github.com/evanw/esbuild/issues/4427
502+ // To not follow just browser here, we resolve that manually in onResolve plugin
503+ // mainFields.unshift('react-native', 'browser') // https://metrobundler.dev/docs/configuration/#resolvermainfields
446504 }
447505
448506 const config = {
@@ -456,7 +514,7 @@ export const build = async (...files) => {
456514 entryNames : filename ,
457515 platform : process . env . EXODUS_TEST_IS_BROWSER ? 'browser' : 'neutral' ,
458516 conditions,
459- mainFields : [ 'browser' , 'module' , 'main' ] , // FIXME: Removing 'browser' breaks some pkgs
517+ mainFields,
460518 define : {
461519 'process.browser' : stringify ( true ) ,
462520 'process.emitWarning' : 'undefined' ,
@@ -515,16 +573,57 @@ export const build = async (...files) => {
515573 plugins : [
516574 {
517575 name : 'exodus-test.bundle' ,
518- setup ( { onResolve, onLoad } ) {
519- onResolve ( { filter : / \. [ c m ] ? [ j t ] s x ? $ / } , ( args ) => {
520- if ( shouldInstallMocks && cjsMockRegex . test ( args . path ) ) {
521- return { path : args . path , namespace : 'file' }
576+ setup ( { onResolve, onLoad, resolve : esbuildResolve } ) {
577+ onResolve (
578+ { filter : process . env . EXODUS_TEST_IS_BAREBONE ? / ./ : cjsMockRegex , namespace : 'file' } ,
579+ async ( args ) => {
580+ let { path, ...opts } = args
581+ if ( shouldInstallMocks && cjsMockRegex . test ( path ) ) return { path, namespace : 'file' }
582+
583+ // This whole hack is needed because of https://github.com/evanw/esbuild/issues/4427
584+ if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
585+ // Modules are mapped pre-resolve against importer
586+ if ( ! / ^ [ . / ] / u. test ( path ) ) {
587+ const { map } = await mapReactNative ( args . importer )
588+ if ( map && Object . hasOwn ( map , path ) ) {
589+ if ( map [ path ] === false ) {
590+ // Unsupported, see https://github.com/evanw/esbuild/issues/4426
591+ // TODO
592+ } else if ( typeof map [ path ] === 'string' ) {
593+ path = map [ path ]
594+ }
595+ }
596+ }
597+
598+ const r = await esbuildResolve ( path , { ...opts , namespace : 'exodus-test.bundle' } )
599+
600+ // Errors can only default to usual resolution to support e.g. optional dynamic require()
601+ if ( ! r . path || r . errors . length > 0 || r . warnings . length > 0 || r . external ) return
602+
603+ // Resolved files are mapped post-resolve against their package
604+ const { map, main, dir } = await mapReactNative ( r . path )
605+
606+ // Only maps the entry point, check if we are using the entry point
607+ // TODO: also check for dir import to a package.json?
608+ if ( main && ! path . includes ( '/' ) ) r . path = resolve ( dir , main )
609+
610+ // TODO: check if this can conflict with main
611+ if ( map ) {
612+ for ( const [ key , value ] of Object . entries ( map ) ) {
613+ if ( ! value || typeof value !== 'string' ) continue // e.g. false
614+ if ( r . path !== resolve ( dir , key ) ) continue
615+ r . path = resolve ( dir , value )
616+ break
617+ }
618+ }
619+
620+ return r
621+ }
522622 }
523- } )
623+ )
524624 onLoad ( { filter : / \. [ c m ] ? [ j t ] s x ? $ / , namespace : 'file' } , async ( args ) => {
525625 let filepath = args . path
526- // Resolve .native versions
527- // TODO: maybe follow package.json for this
626+ // Load .native versions where available (past onResolve)
528627 if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
529628 const maybeNative = filepath . replace ( / ( \. [ c m ] ? [ j t ] s x ? ) $ / u, '.native$1' )
530629 if ( existsSync ( maybeNative ) ) filepath = maybeNative
0 commit comments