@@ -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,21 @@ export const build = async (...files) => {
435488 : JSON . stringify ( x , null , 1 ) . replaceAll ( / ^ * ( " .+ " ) ( , ? ) $ / gmu, ( _ , s , c ) => `${ wrap ( s ) } ${ c } ` )
436489 }
437490
438- const conditions = [ ]
439- if ( process . env . EXODUS_TEST_PLATFORM === 'workerd' ) {
440- conditions . push ( 'workerd' )
441- } else if ( process . env . EXODUS_TEST_IS_BROWSER ) {
491+ const conditions = [ ] // 'require' and 'import' are built-in
492+ const mainFields = [ 'module' , 'main' ]
493+ if ( process . env . EXODUS_TEST_IS_BROWSER ) {
442494 // browsers, electron renderer, servo
443495 conditions . push ( 'browser' )
496+ mainFields . unshift ( 'browser' )
444497 } else if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
445- conditions . push ( 'react-native' )
498+ conditions . push ( 'react-native' ) // does not set browser, see https://metrobundler.dev/docs/configuration/#unstable_conditionnames-experimental
499+ // FIXME: mainFields = react-native is unsupported by esbuild: https://github.com/evanw/esbuild/issues/4427
500+ // To not follow just browser here, we resolve that manually in onResolve plugin
501+ // mainFields.unshift('react-native', 'browser') // https://metrobundler.dev/docs/configuration/#resolvermainfields
502+ } else {
503+ // TODO: sort out deno:bundle, node:bundle, workerd:bundle etc
504+ mainFields . unshift ( 'browser' ) // FIXME: Removing 'browser' breaks some pkgs
505+ if ( process . env . EXODUS_TEST_PLATFORM === 'workerd' ) conditions . push ( 'workerd' )
446506 }
447507
448508 const config = {
@@ -456,7 +516,7 @@ export const build = async (...files) => {
456516 entryNames : filename ,
457517 platform : process . env . EXODUS_TEST_IS_BROWSER ? 'browser' : 'neutral' ,
458518 conditions,
459- mainFields : [ 'browser' , 'module' , 'main' ] , // FIXME: Removing 'browser' breaks some pkgs
519+ mainFields,
460520 define : {
461521 'process.browser' : stringify ( true ) ,
462522 'process.emitWarning' : 'undefined' ,
@@ -515,16 +575,57 @@ export const build = async (...files) => {
515575 plugins : [
516576 {
517577 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' }
578+ setup ( { onResolve, onLoad, resolve : esbuildResolve } ) {
579+ onResolve (
580+ { filter : process . env . EXODUS_TEST_IS_BAREBONE ? / ./ : cjsMockRegex , namespace : 'file' } ,
581+ async ( args ) => {
582+ let { path, ...opts } = args
583+ if ( shouldInstallMocks && cjsMockRegex . test ( path ) ) return { path, namespace : 'file' }
584+
585+ // This whole hack is needed because of https://github.com/evanw/esbuild/issues/4427
586+ if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
587+ // Modules are mapped pre-resolve against importer
588+ if ( ! / ^ [ . / ] / u. test ( path ) ) {
589+ const { map } = await mapReactNative ( args . importer )
590+ if ( map && Object . hasOwn ( map , path ) ) {
591+ if ( map [ path ] === false ) {
592+ // Unsupported, see https://github.com/evanw/esbuild/issues/4426
593+ // TODO
594+ } else if ( typeof map [ path ] === 'string' ) {
595+ path = map [ path ]
596+ }
597+ }
598+ }
599+
600+ const r = await esbuildResolve ( path , { ...opts , namespace : 'exodus-test.bundle' } )
601+
602+ // Errors can only default to usual resolution to support e.g. optional dynamic require()
603+ if ( ! r . path || r . errors . length > 0 || r . warnings . length > 0 || r . external ) return
604+
605+ // Resolved files are mapped post-resolve against their package
606+ const { map, main, dir } = await mapReactNative ( r . path )
607+
608+ // Only maps the entry point, check if we are using the entry point
609+ // TODO: also check for dir import to a package.json?
610+ if ( main && ! path . includes ( '/' ) ) r . path = resolve ( dir , main )
611+
612+ // TODO: check if this can conflict with main
613+ if ( map ) {
614+ for ( const [ key , value ] of Object . entries ( map ) ) {
615+ if ( ! value || typeof value !== 'string' ) continue // e.g. false
616+ if ( r . path !== resolve ( dir , key ) ) continue
617+ r . path = resolve ( dir , value )
618+ break
619+ }
620+ }
621+
622+ return r
623+ }
522624 }
523- } )
625+ )
524626 onLoad ( { filter : / \. [ c m ] ? [ j t ] s x ? $ / , namespace : 'file' } , async ( args ) => {
525627 let filepath = args . path
526- // Resolve .native versions
527- // TODO: maybe follow package.json for this
628+ // Load .native versions where available (past onResolve)
528629 if ( process . env . EXODUS_TEST_IS_BAREBONE ) {
529630 const maybeNative = filepath . replace ( / ( \. [ c m ] ? [ j t ] s x ? ) $ / u, '.native$1' )
530631 if ( existsSync ( maybeNative ) ) filepath = maybeNative
0 commit comments