Skip to content

Commit 3a8efc7

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

1 file changed

Lines changed: 116 additions & 15 deletions

File tree

bundler/bundle.js

Lines changed: 116 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,69 @@ 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 = { 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+
1669
const emptyToUndefined = (x) => (x.length > 0 ? x : undefined) // optimize out define if there are none
1770
const 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: /\.[cm]?[jt]sx?$/ }, (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: /\.[cm]?[jt]sx?$/, 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(/(\.[cm]?[jt]sx?)$/u, '.native$1')
530631
if (existsSync(maybeNative)) filepath = maybeNative

0 commit comments

Comments
 (0)