Skip to content

Commit 5e06d97

Browse files
committed
feat: support react-native/browser mappings on barebone engines
1 parent a27abf8 commit 5e06d97

1 file changed

Lines changed: 100 additions & 12 deletions

File tree

bundler/bundle.js

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,58 @@ import fsPromises, { readFile, writeFile, readdir } from 'node:fs/promises'
33
import { existsSync } from 'node:fs'
44
import { fileURLToPath, pathToFileURL } from 'node:url'
55
import { basename, dirname, extname, resolve, join, relative } from 'node:path'
6-
import { createRequire } from 'node:module'
6+
import nodeModule, { createRequire } from 'node:module'
77
import { randomUUID as uuid, randomBytes } from 'node:crypto'
88
import * as esbuild from 'esbuild'
99

1010
const require = createRequire(import.meta.url)
1111
const resolveRequire = (query) => require.resolve(query)
12-
const cjsMockRegex = /\.exodus-test-mock\.cjs$/u
12+
const cjsMockRegex = /\.exodus-test-mock\.cjs$/
1313
const cjsMockFallback = `throw new Error('Mocking loaded ESM modules in not possible in bundles')`
1414
let 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 = [(rn ?? browser) || null, dirname(pkg)]
54+
reactNativeMaps.set(pkg, res)
55+
return res
56+
}
57+
1658
const emptyToUndefined = (x) => (x.length > 0 ? x : undefined) // optimize out define if there are none
1759
const readSnapshots = async (files, resolvers) => {
1860
const snapshots = []
@@ -435,14 +477,19 @@ export const build = async (...files) => {
435477
: JSON.stringify(x, null, 1).replaceAll(/^ *(".+")(,?)$/gmu, (_, s, c) => `${wrap(s)}${c}`)
436478
}
437479

438-
const conditions = []
480+
const conditions = [] // 'require' and 'import' are built-in
481+
const mainFields = ['module', 'main']
439482
if (process.env.EXODUS_TEST_PLATFORM === 'workerd') {
440483
conditions.push('workerd')
441484
} else if (process.env.EXODUS_TEST_IS_BROWSER) {
442485
// browsers, electron renderer, servo
443486
conditions.push('browser')
487+
mainFields.unshift('browser')
444488
} else if (process.env.EXODUS_TEST_IS_BAREBONE) {
445-
conditions.push('react-native')
489+
conditions.push('react-native') // does not set browser, see https://metrobundler.dev/docs/configuration/#unstable_conditionnames-experimental
490+
// FIXME: mainFields = react-native is unsupported by esbuild: https://github.com/evanw/esbuild/issues/4427
491+
// To not follow just browser here, we resolve that manually in onResolve plugin
492+
// mainFields.unshift('react-native', 'browser') // https://metrobundler.dev/docs/configuration/#resolvermainfields
446493
}
447494

448495
const config = {
@@ -456,7 +503,7 @@ export const build = async (...files) => {
456503
entryNames: filename,
457504
platform: process.env.EXODUS_TEST_IS_BROWSER ? 'browser' : 'neutral',
458505
conditions,
459-
mainFields: ['browser', 'module', 'main'], // FIXME: Removing 'browser' breaks some pkgs
506+
mainFields,
460507
define: {
461508
'process.browser': stringify(true),
462509
'process.emitWarning': 'undefined',
@@ -515,16 +562,57 @@ export const build = async (...files) => {
515562
plugins: [
516563
{
517564
name: 'exodus-test.bundle',
518-
setup({ onResolve, onLoad }) {
519-
onResolve({ filter: /\.[cm]?[jt]sx?$/ }, (args) => {
520-
if (shouldInstallMocks && cjsMockRegex.test(args.path)) {
521-
return { path: args.path, namespace: 'file' }
565+
setup({ onResolve, onLoad, resolve: esbuildResolve }) {
566+
onResolve(
567+
{ filter: process.env.EXODUS_TEST_IS_BAREBONE ? /./ : cjsMockRegex, namespace: 'file' },
568+
async (args) => {
569+
let { path, ...opts } = args
570+
if (shouldInstallMocks && cjsMockRegex.test(path)) return { path, namespace: 'file' }
571+
572+
// This whole hack is needed because of https://github.com/evanw/esbuild/issues/4427
573+
if (process.env.EXODUS_TEST_IS_BAREBONE) {
574+
// Modules are mapped pre-resolve against importer
575+
if (!/^[./]/u.test(path)) {
576+
const [map0] = await mapReactNative(args.importer)
577+
if (map0 && typeof map0 !== 'string' && Object.hasOwn(map0, path)) {
578+
if (map0[path] === false) {
579+
// Unsupported, see https://github.com/evanw/esbuild/issues/4426
580+
// TODO
581+
} else if (typeof map0[path] === 'string') {
582+
path = map0[path]
583+
}
584+
}
585+
}
586+
587+
const r = await esbuildResolve(path, { ...opts, namespace: 'exodus-test.bundle' })
588+
589+
// Errors can only default to usual resolution to support e.g. optional dynamic require()
590+
if (!r.path || r.errors.length > 0 || r.warnings.length > 0 || r.external) return
591+
592+
// Resolved files are mapped post-resolve against their package
593+
const [map, dir] = await mapReactNative(r.path)
594+
if (map) {
595+
if (typeof map === 'string') {
596+
// Only maps the entry point, check if we are using the entry point
597+
// TODO: also check for dir import to a package.json?
598+
if (!path.includes('/')) r.path = resolve(dir, map)
599+
} else {
600+
for (const [key, value] of Object.entries(map)) {
601+
if (!value || typeof value !== 'string') continue // e.g. false
602+
if (r.path !== resolve(dir, key)) continue
603+
r.path = resolve(dir, value)
604+
break
605+
}
606+
}
607+
}
608+
609+
return r
610+
}
522611
}
523-
})
612+
)
524613
onLoad({ filter: /\.[cm]?[jt]sx?$/, namespace: 'file' }, async (args) => {
525614
let filepath = args.path
526-
// Resolve .native versions
527-
// TODO: maybe follow package.json for this
615+
// Load .native versions where available (past onResolve)
528616
if (process.env.EXODUS_TEST_IS_BAREBONE) {
529617
const maybeNative = filepath.replace(/(\.[cm]?[jt]sx?)$/u, '.native$1')
530618
if (existsSync(maybeNative)) filepath = maybeNative

0 commit comments

Comments
 (0)