vxrn 2.8.1 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/exports/clean.mjs +128 -19
  2. package/dist/exports/clean.mjs.map +1 -1
  3. package/dist/exports/clean.native.js +147 -21
  4. package/dist/exports/clean.native.js.map +1 -1
  5. package/dist/exports/prebuild.mjs +2 -1
  6. package/dist/exports/prebuild.mjs.map +1 -1
  7. package/dist/exports/prebuild.native.js +2 -1
  8. package/dist/exports/prebuild.native.js.map +1 -1
  9. package/dist/exports/prebuildWithoutExpo.mjs +196 -7
  10. package/dist/exports/prebuildWithoutExpo.mjs.map +1 -1
  11. package/dist/exports/prebuildWithoutExpo.native.js +280 -42
  12. package/dist/exports/prebuildWithoutExpo.native.js.map +1 -1
  13. package/dist/plugins/reactNativeDevServer.mjs +43 -8
  14. package/dist/plugins/reactNativeDevServer.mjs.map +1 -1
  15. package/dist/plugins/reactNativeDevServer.native.js +45 -8
  16. package/dist/plugins/reactNativeDevServer.native.js.map +1 -1
  17. package/dist/user-interface/index.mjs +31 -24
  18. package/dist/user-interface/index.mjs.map +1 -1
  19. package/dist/user-interface/index.native.js +32 -25
  20. package/dist/user-interface/index.native.js.map +1 -1
  21. package/dist/utils/createNativeDevEngine.mjs +111 -14
  22. package/dist/utils/createNativeDevEngine.mjs.map +1 -1
  23. package/dist/utils/createNativeDevEngine.native.js +143 -14
  24. package/dist/utils/createNativeDevEngine.native.js.map +1 -1
  25. package/dist/utils/scanDepsToOptimize.mjs +10 -6
  26. package/dist/utils/scanDepsToOptimize.mjs.map +1 -1
  27. package/dist/utils/scanDepsToOptimize.native.js +12 -6
  28. package/dist/utils/scanDepsToOptimize.native.js.map +1 -1
  29. package/expo-plugin.cjs +71 -8
  30. package/package.json +14 -14
  31. package/src/exports/clean.test.ts +502 -5
  32. package/src/exports/clean.ts +176 -19
  33. package/src/exports/expoPlugin.test.mjs +44 -1
  34. package/src/exports/prebuild.ts +6 -0
  35. package/src/exports/prebuildWithoutExpo.test.ts +134 -2
  36. package/src/exports/prebuildWithoutExpo.ts +235 -10
  37. package/src/plugins/reactNativeDevServer.test.ts +75 -1
  38. package/src/plugins/reactNativeDevServer.ts +45 -8
  39. package/src/user-interface/index.ts +50 -42
  40. package/src/utils/createNativeDevEngine.test.ts +480 -1
  41. package/src/utils/createNativeDevEngine.ts +220 -17
  42. package/src/utils/imageSizePatch.test.ts +3 -3
  43. package/src/utils/scanDepsToOptimize.test.ts +35 -2
  44. package/src/utils/scanDepsToOptimize.ts +25 -8
  45. package/types/exports/clean.d.ts.map +1 -1
  46. package/types/exports/prebuild.d.ts +2 -1
  47. package/types/exports/prebuild.d.ts.map +1 -1
  48. package/types/exports/prebuildWithoutExpo.d.ts.map +1 -1
  49. package/types/plugins/reactNativeDevServer.d.ts.map +1 -1
  50. package/types/user-interface/index.d.ts.map +1 -1
  51. package/types/utils/createNativeDevEngine.d.ts +1 -1
  52. package/types/utils/createNativeDevEngine.d.ts.map +1 -1
  53. package/types/utils/scanDepsToOptimize.d.ts +25 -0
  54. package/types/utils/scanDepsToOptimize.d.ts.map +1 -1
@@ -4,7 +4,10 @@ import module from 'node:module'
4
4
  import path from 'node:path'
5
5
  import { pathToFileURL } from 'node:url'
6
6
  import {
7
+ androidIconSuffix,
8
+ appIconPreviewResources,
7
9
  validateNativeApp,
10
+ type NativeAppIcon,
8
11
  type NativeAppManifest,
9
12
  type PlistValue,
10
13
  } from '@vxrn/utils/nativeAppManifest'
@@ -1353,6 +1356,59 @@ const IPAD_APP_ICON_SIZES: Array<[string, string[]]> = [
1353
1356
  ['83.5x83.5', ['2x']],
1354
1357
  ]
1355
1358
 
1359
+ // ios 18 home screen appearances use the single 1024 universal entry, which
1360
+ // the asset compiler scales to every size, with a dark and a tinted variant
1361
+ // beside the light one.
1362
+ async function writeAppearanceIcon(args: {
1363
+ root: string
1364
+ iconDir: string
1365
+ icon: NativeAppIcon
1366
+ source: string
1367
+ }): Promise<void> {
1368
+ const { root, iconDir, icon, source } = args
1369
+ const variantSource = (variant: { source?: string } | undefined) => {
1370
+ if (!variant?.source) return source
1371
+ const resolved = path.resolve(root, variant.source)
1372
+ if (!FSExtra.existsSync(resolved)) {
1373
+ throw new Error(`[vxrn] native.app icon appearance source does not exist: ${resolved}`)
1374
+ }
1375
+ return resolved
1376
+ }
1377
+ const render = async (file: string, from: string, background?: string, gray = false) => {
1378
+ let image = sharp(from).rotate().resize(1024, 1024, { fit: 'cover' })
1379
+ if (background) image = image.flatten({ background })
1380
+ if (gray) image = image.greyscale()
1381
+ await image.png().toFile(path.join(iconDir, file))
1382
+ }
1383
+ const images: Array<Record<string, unknown>> = []
1384
+ const entry = (filename: string, value?: string) => ({
1385
+ ...(value ? { appearances: [{ appearance: 'luminosity', value }] } : {}),
1386
+ filename,
1387
+ idiom: 'universal',
1388
+ platform: 'ios',
1389
+ size: '1024x1024',
1390
+ })
1391
+ await render('icon-1024.png', source, icon.backgroundColor)
1392
+ images.push(entry('icon-1024.png'))
1393
+ if (icon.dark) {
1394
+ await render('icon-1024-dark.png', variantSource(icon.dark), icon.dark.backgroundColor)
1395
+ images.push(entry('icon-1024-dark.png', 'dark'))
1396
+ }
1397
+ if (icon.tinted) {
1398
+ await render(
1399
+ 'icon-1024-tinted.png',
1400
+ variantSource(icon.tinted),
1401
+ icon.tinted.backgroundColor ?? '#000000',
1402
+ true
1403
+ )
1404
+ images.push(entry('icon-1024-tinted.png', 'tinted'))
1405
+ }
1406
+ FSExtra.writeFileSync(
1407
+ path.join(iconDir, 'Contents.json'),
1408
+ `${JSON.stringify({ images, info: { author: 'xcode', version: 1 } }, null, 2)}\n`
1409
+ )
1410
+ }
1411
+
1356
1412
  async function generateAppIcons(args: {
1357
1413
  root: string
1358
1414
  dest: string
@@ -1368,9 +1424,9 @@ async function generateAppIcons(args: {
1368
1424
  images: Array<{ idiom: string; scale: string; size: string; filename?: string }>
1369
1425
  info: { author: string; version: number }
1370
1426
  } = JSON.parse(FSExtra.readFileSync(path.join(assetsDir, 'AppIcon.appiconset', 'Contents.json'), 'utf8'))
1371
- const icons: Array<[string, { source: string; backgroundColor: string }]> = [
1427
+ const icons: Array<[string, NativeAppIcon]> = [
1372
1428
  ['AppIcon', app.icon],
1373
- ...Object.entries(app.ios?.alternateIcons ?? {}),
1429
+ ...Object.entries(app.alternateIcons ?? {}),
1374
1430
  ]
1375
1431
  for (const [name, icon] of icons) {
1376
1432
  const source = path.resolve(root, icon.source)
@@ -1383,6 +1439,10 @@ async function generateAppIcons(args: {
1383
1439
  }
1384
1440
  const iconDir = path.join(assetsDir, `${name}.appiconset`)
1385
1441
  FSExtra.mkdirSync(iconDir, { recursive: true })
1442
+ if (icon.dark || icon.tinted) {
1443
+ await writeAppearanceIcon({ root, iconDir, icon, source })
1444
+ continue
1445
+ }
1386
1446
  const contents = structuredClone(template)
1387
1447
  // the template is iPhone-only. App Store upload rejects a tablet build
1388
1448
  // without the iPad sizes (ITMS-90023, 152 and 167 pixels among them).
@@ -1418,17 +1478,172 @@ async function generateAppIcons(args: {
1418
1478
  if (metadata.width !== metadata.height || metadata.width === undefined || metadata.width < 1024) {
1419
1479
  throw new Error('[vxrn] native.app.icon source must be a square image at least 1024px wide')
1420
1480
  }
1481
+ // the primary icon and each alternate's launcher icon, one legacy png per
1482
+ // density. an alternate's alias points at its own ic_launcher_<name> pair.
1483
+ const launchers: Array<[string, NativeAppIcon, string]> = [
1484
+ ['ic_launcher', app.icon, source],
1485
+ ...(await Promise.all(
1486
+ Object.entries(app.alternateIcons ?? {}).map(
1487
+ async ([name, icon]): Promise<[string, NativeAppIcon, string]> => {
1488
+ const alternateSource = path.resolve(root, icon.source)
1489
+ if (!FSExtra.existsSync(alternateSource)) {
1490
+ throw new Error(`[vxrn] native.app icon source does not exist: ${alternateSource}`)
1491
+ }
1492
+ const alternate = await sharp(alternateSource).metadata()
1493
+ if (alternate.width !== alternate.height || alternate.width === undefined || alternate.width < 1024) {
1494
+ throw new Error(`[vxrn] native.app icon source must be a square image at least 1024px wide: ${alternateSource}`)
1495
+ }
1496
+ return [androidAliasIconName(name), icon, alternateSource]
1497
+ }
1498
+ )
1499
+ )),
1500
+ ]
1421
1501
  for (const [density, multiplier] of Object.entries(ANDROID_DENSITIES)) {
1422
1502
  const pixels = 48 * multiplier
1423
1503
  const iconDir = path.join(dest, 'app', 'src', 'main', 'res', `mipmap-${density}`)
1424
- for (const filename of ['ic_launcher.png', 'ic_launcher_round.png']) {
1425
- await sharp(source)
1504
+ for (const [resource, icon, iconSource] of launchers) {
1505
+ for (const filename of [`${resource}.png`, `${resource}_round.png`]) {
1506
+ await sharp(iconSource)
1507
+ .rotate()
1508
+ .resize(pixels, pixels, { fit: 'cover' })
1509
+ .flatten({ background: icon.backgroundColor })
1510
+ .png()
1511
+ .toFile(path.join(iconDir, filename))
1512
+ }
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ function androidAliasIconName(name: string): string {
1518
+ return `ic_launcher_${androidIconSuffix(name)}`
1519
+ }
1520
+
1521
+ const LAUNCHER_FILTER = /[ \t]*<intent-filter>(?:(?!<\/intent-filter>)[\s\S])*android\.intent\.category\.LAUNCHER[\s\S]*?<\/intent-filter>\n?/
1522
+
1523
+ // launcher aliases for One.AppIcon: the real MainActivity stays enabled and
1524
+ // loses its launcher filter to a Primary alias, each alternate is a disabled
1525
+ // alias, and every alias targets a small activity that forwards into the
1526
+ // host. android finishes activities launched through an alias when that alias
1527
+ // is disabled, so an alias must never target the react native host itself.
1528
+ function stampAndroidIconAliases(manifest: string, app: NativeAppManifest): string {
1529
+ const alternates = Object.keys(app.alternateIcons ?? {})
1530
+ const host = manifest.match(/<activity\b(?:(?!<\/activity>)[\s\S])*android:name="\.MainActivity"[\s\S]*?<\/activity>/)?.[0]
1531
+ if (!host || !LAUNCHER_FILTER.test(host)) {
1532
+ throw new Error(
1533
+ '[vxrn] cannot stamp alternate icons: expected the launcher filter on .MainActivity in app/src/main/AndroidManifest.xml'
1534
+ )
1535
+ }
1536
+ const alias = (name: string, icon: string, enabled: boolean) => ` <activity-alias
1537
+ android:name=".${name}"
1538
+ android:targetActivity=".AppIconLauncherActivity"
1539
+ android:enabled="${enabled}"
1540
+ android:exported="true"
1541
+ android:icon="@mipmap/${icon}"
1542
+ android:roundIcon="@mipmap/${icon}_round"
1543
+ android:label="@string/app_name">
1544
+ <intent-filter>
1545
+ <action android:name="android.intent.action.MAIN" />
1546
+ <category android:name="android.intent.category.LAUNCHER" />
1547
+ </intent-filter>
1548
+ </activity-alias>`
1549
+ const stamps = [
1550
+ ` <activity
1551
+ android:name=".AppIconLauncherActivity"
1552
+ android:exported="false"
1553
+ android:taskAffinity=""
1554
+ android:excludeFromRecents="true"
1555
+ android:theme="@android:style/Theme.NoDisplay" />`,
1556
+ alias('Primary', 'ic_launcher', true),
1557
+ ...alternates.map((name) => alias(name, androidAliasIconName(name), false)),
1558
+ ].join('\n')
1559
+ const anchor = ' </application>'
1560
+ if (!manifest.includes(anchor)) {
1561
+ throw new Error(
1562
+ '[vxrn] cannot stamp alternate icons: expected </application> in app/src/main/AndroidManifest.xml'
1563
+ )
1564
+ }
1565
+ return manifest
1566
+ .replace(host, host.replace(LAUNCHER_FILTER, ''))
1567
+ .replace(anchor, `${stamps}\n${anchor}`)
1568
+ }
1569
+
1570
+ function generateAndroidIconForwarder(dest: string, app: NativeAppManifest): void {
1571
+ if (!app.alternateIcons || !app.android) return
1572
+ const packageName = app.android.applicationId
1573
+ const file = path.join(
1574
+ dest,
1575
+ 'app',
1576
+ 'src',
1577
+ 'main',
1578
+ 'java',
1579
+ ...packageName.split('.'),
1580
+ 'AppIconLauncherActivity.kt'
1581
+ )
1582
+ FSExtra.mkdirSync(path.dirname(file), { recursive: true })
1583
+ FSExtra.writeFileSync(
1584
+ file,
1585
+ `package ${packageName}
1586
+
1587
+ import android.app.Activity
1588
+ import android.content.Intent
1589
+ import android.os.Bundle
1590
+
1591
+ // launcher aliases finish before react native mounts in the permanent host.
1592
+ class AppIconLauncherActivity : Activity() {
1593
+ override fun onCreate(savedInstanceState: Bundle?) {
1594
+ super.onCreate(savedInstanceState)
1595
+ startActivity(Intent(intent).setClass(this, MainActivity::class.java)
1596
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or
1597
+ Intent.FLAG_ACTIVITY_SINGLE_TOP))
1598
+ finish()
1599
+ }
1600
+ }
1601
+ `
1602
+ )
1603
+ }
1604
+
1605
+ // a 64 point preview of the primary icon and each alternate, bundled as plain
1606
+ // images so an in-app picker (One.AppIcon.list) needs no second copy of the
1607
+ // art: an imageset on ios, which cannot load an app icon set by name, and a
1608
+ // density-independent drawable on android.
1609
+ async function generateAppIconPreviews(args: {
1610
+ root: string
1611
+ dest: string
1612
+ platform: 'ios' | 'android'
1613
+ app: NativeAppManifest
1614
+ }): Promise<void> {
1615
+ const { root, dest, platform, app } = args
1616
+ if (!app.icon || !app.alternateIcons) return
1617
+ const icons: Array<[string | undefined, NativeAppIcon]> = [
1618
+ [undefined, app.icon],
1619
+ ...Object.entries(app.alternateIcons),
1620
+ ]
1621
+ for (const [name, icon] of icons) {
1622
+ const preview = (scale: number) =>
1623
+ sharp(path.resolve(root, icon.source))
1426
1624
  .rotate()
1427
- .resize(pixels, pixels, { fit: 'cover' })
1428
- .flatten({ background: app.icon.backgroundColor })
1625
+ .resize(64 * scale, 64 * scale, { fit: 'cover' })
1626
+ .flatten({ background: icon.backgroundColor })
1429
1627
  .png()
1430
- .toFile(path.join(iconDir, filename))
1628
+ const resources = appIconPreviewResources(name)
1629
+ if (platform === 'android') {
1630
+ const dir = path.join(dest, 'app', 'src', 'main', 'res', 'drawable-nodpi')
1631
+ FSExtra.mkdirSync(dir, { recursive: true })
1632
+ await preview(3).toFile(path.join(dir, `${resources.android}.png`))
1633
+ continue
1431
1634
  }
1635
+ const dir = path.join(dest, app.name, 'Images.xcassets', `${resources.ios}.imageset`)
1636
+ FSExtra.mkdirSync(dir, { recursive: true })
1637
+ const images: Array<Record<string, string>> = []
1638
+ for (const scale of [1, 2, 3]) {
1639
+ const filename = `preview@${scale}x.png`
1640
+ await preview(scale).toFile(path.join(dir, filename))
1641
+ images.push({ filename, idiom: 'universal', scale: `${scale}x` })
1642
+ }
1643
+ FSExtra.writeFileSync(
1644
+ path.join(dir, 'Contents.json'),
1645
+ `${JSON.stringify({ images, info: { author: 'xcode', version: 1 } }, null, 2)}\n`
1646
+ )
1432
1647
  }
1433
1648
  }
1434
1649
 
@@ -1855,7 +2070,8 @@ export function renderPrebuildFile(args: {
1855
2070
  app: NativeAppManifest
1856
2071
  nitroWebImage?: boolean
1857
2072
  }): RenderedPrebuildFile {
1858
- const { relativePath, content, platform, app, nitroWebImage } = args
2073
+ const { content, platform, app, nitroWebImage } = args
2074
+ const relativePath = args.relativePath.replace(/\\/g, '/')
1859
2075
  const appName = app.name
1860
2076
  const schemes =
1861
2077
  app.scheme === undefined ? [] : Array.isArray(app.scheme) ? app.scheme : [app.scheme]
@@ -2493,6 +2709,13 @@ ${schemes.map((scheme) => ` <data android:scheme="${scheme}" />`).joi
2493
2709
  </activity>`
2494
2710
  )
2495
2711
  }
2712
+ if (
2713
+ platform === 'android' &&
2714
+ relativePath === 'app/src/main/AndroidManifest.xml' &&
2715
+ app.alternateIcons !== undefined
2716
+ ) {
2717
+ rendered = stampAndroidIconAliases(rendered, app)
2718
+ }
2496
2719
  if (platform === 'ios' && relativePath.endsWith('.xcodeproj/project.pbxproj')) {
2497
2720
  rendered = rendered.replace(
2498
2721
  /TARGETED_DEVICE_FAMILY = "1,2";/g,
@@ -2518,12 +2741,12 @@ ${schemes.map((scheme) => ` <data android:scheme="${scheme}" />`).joi
2518
2741
  }
2519
2742
  }
2520
2743
  if (platform === 'ios' && relativePath.endsWith('.xcodeproj/project.pbxproj') &&
2521
- app.ios?.alternateIcons !== undefined) {
2744
+ app.alternateIcons !== undefined) {
2522
2745
  const anchor = 'ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;'
2523
2746
  if (!rendered.includes(anchor)) {
2524
2747
  throw new Error('[vxrn] prebuild template lost its primary app icon build setting')
2525
2748
  }
2526
- const names = Object.keys(app.ios.alternateIcons).sort().join(' ')
2749
+ const names = Object.keys(app.alternateIcons).sort().join(' ')
2527
2750
  rendered = rendered.replaceAll(anchor,
2528
2751
  `${anchor}\n\t\t\t\tASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "${names}";`)
2529
2752
  }
@@ -2879,6 +3102,7 @@ export const generateForPlatform = async (
2879
3102
  }
2880
3103
 
2881
3104
  await generateAppIcons({ root, dest, platform, app })
3105
+ await generateAppIconPreviews({ root, dest, platform, app })
2882
3106
  if (platform === 'android') await generateAdaptiveIcon({ root, dest, app })
2883
3107
  await generateSplashScreen({ root, dest, platform, app })
2884
3108
  if (platform === 'ios') generateAccentColor({ dest, app })
@@ -2888,6 +3112,7 @@ export const generateForPlatform = async (
2888
3112
  if (platform === 'ios') generateOneBridgingHeader(dest, app)
2889
3113
  if (platform === 'ios') generateIosWidgets(dest, app)
2890
3114
  if (platform === 'ios') await generateSwiftPackages({ root, dest })
3115
+ if (platform === 'android') generateAndroidIconForwarder(dest, app)
2891
3116
  if (platform === 'android') await generateKotlinSources({ root, dest })
2892
3117
  }
2893
3118
 
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { createServer } from 'vite'
5
- import { expect, it } from 'vitest'
5
+ import { expect, it, vi } from 'vitest'
6
6
  import { createReactNativeDevServerPlugin } from './reactNativeDevServer'
7
7
 
8
8
  it('serves native media with byte ranges and preview content types', async () => {
@@ -72,3 +72,77 @@ it('serves native media with byte ranges and preview content types', async () =>
72
72
  await rm(root, { recursive: true, force: true })
73
73
  }
74
74
  })
75
+
76
+ it('starts the native multipart response before bundling finishes', async () => {
77
+ const root = await mkdtemp(join(tmpdir(), 'native-bundle-http-'))
78
+ let finishBuild!: () => void
79
+ const building = new Promise<void>((resolve) => {
80
+ finishBuild = resolve
81
+ })
82
+ vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
83
+ let failBuild = false
84
+ const previousPlugins = globalThis.__vxrnAddNativePlugins
85
+ globalThis.__vxrnAddNativePlugins = () => [
86
+ {
87
+ name: 'blocked-native-bundle',
88
+ transform(_code, id) {
89
+ if (id.endsWith('/__virtual-native-entry.tsx'))
90
+ return 'globalThis.nativeBundleReady = true'
91
+ },
92
+ async renderChunk() {
93
+ await building
94
+ if (failBuild) throw new Error('native fixture failed')
95
+ },
96
+ },
97
+ ]
98
+ const server = await createServer({
99
+ root,
100
+ configFile: false,
101
+ plugins: [createReactNativeDevServerPlugin()],
102
+ server: { host: '127.0.0.1', port: 0 },
103
+ })
104
+ try {
105
+ await server.listen()
106
+ const { port } = server.httpServer!.address() as { port: number }
107
+ const response = await fetch(`http://127.0.0.1:${port}/index.bundle?platform=ios`, {
108
+ headers: { accept: 'multipart/mixed' },
109
+ signal: AbortSignal.timeout(1_000),
110
+ })
111
+ expect(response.headers.get('content-type')).toMatch(
112
+ /multipart\/mixed; boundary="[^"]+"/
113
+ )
114
+ const reader = response.body!.getReader()
115
+ const preamble = await reader.read()
116
+ await vi.advanceTimersByTimeAsync(10_000)
117
+ const progress = await reader.read()
118
+ expect(new TextDecoder().decode(progress.value)).toContain('"status":"Bundling"')
119
+ finishBuild()
120
+ reader.releaseLock()
121
+ let body =
122
+ new TextDecoder().decode(preamble.value) + new TextDecoder().decode(progress.value)
123
+ for await (const chunk of response.body!) {
124
+ body += new TextDecoder().decode(chunk)
125
+ }
126
+ expect(body).toContain('X-Http-Status: 200')
127
+ expect(body).toContain('nativeBundleReady')
128
+ const boundary = response.headers.get('content-type')!.match(/boundary="([^"]+)"/)![1]
129
+ expect(body.endsWith(`\r\n--${boundary}--\r\n`)).toBe(true)
130
+ const plain = await fetch(`http://127.0.0.1:${port}/index.bundle?platform=ios`)
131
+ expect(plain.headers.get('content-type')).toBe('text/javascript')
132
+ expect(await plain.text()).toContain('nativeBundleReady')
133
+
134
+ failBuild = true
135
+ const failed = await fetch(`http://127.0.0.1:${port}/index.bundle?platform=android`, {
136
+ headers: { accept: 'multipart/mixed' },
137
+ })
138
+ const error = await failed.text()
139
+ expect(error).toContain('X-Http-Status: 500')
140
+ expect(error).toContain('native fixture failed')
141
+ } finally {
142
+ finishBuild()
143
+ await server.close()
144
+ globalThis.__vxrnAddNativePlugins = previousPlugins
145
+ vi.useRealTimers()
146
+ await rm(root, { recursive: true, force: true })
147
+ }
148
+ })
@@ -116,7 +116,7 @@ export function createReactNativeDevServerPlugin(
116
116
  let compileBytecode: ReturnType<typeof nativeDevBytecodeCompiler> | undefined
117
117
 
118
118
  const devToolsSocketEndpoints = ['/inspector/device', '/inspector/debug']
119
- const reactNativeDevToolsUrl = `http://${host}:${getBoundPort(server)}`
119
+ const reactNativeDevToolsUrl = `http://${typeof host === 'string' && host !== '0.0.0.0' ? host : 'localhost'}:${getBoundPort(server)}`
120
120
  const { middleware, websocketEndpoints } = createDevMiddleware({
121
121
  serverBaseUrl: reactNativeDevToolsUrl,
122
122
  logger: console,
@@ -573,6 +573,42 @@ export function createReactNativeDevServerPlugin(
573
573
  )
574
574
  }
575
575
 
576
+ // react native's default URLSession times out while waiting for a
577
+ // silent rebuild. start its multipart stream before building, as metro
578
+ // does, so the native loader can wait for the bundle on that stream.
579
+ const boundary = String(req.headers.accept || '').includes('multipart/mixed')
580
+ ? `vxrn-${randomUUID()}`
581
+ : undefined
582
+ let progressTimer: ReturnType<typeof setInterval> | undefined
583
+ if (boundary) {
584
+ res.writeHead(200, {
585
+ 'Cache-Control': 'no-store',
586
+ 'Content-Type': `multipart/mixed; boundary="${boundary}"`,
587
+ })
588
+ res.write('Bundling native code')
589
+ // URLSession also enforces an idle timeout after receiving headers.
590
+ // keep reporting the ongoing build until its final part is ready.
591
+ progressTimer = setInterval(() => {
592
+ res.write(
593
+ `\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${JSON.stringify({ status: 'Bundling' })}`
594
+ )
595
+ }, 10_000)
596
+ progressTimer.unref()
597
+ res.once('close', () => clearInterval(progressTimer))
598
+ }
599
+ const sendBundle = (status: number, type: string, body: string | Buffer) => {
600
+ if (boundary) {
601
+ res.write(
602
+ `\r\n--${boundary}\r\nContent-Type: ${type}\r\nX-Http-Status: ${status}\r\n\r\n`
603
+ )
604
+ res.write(body)
605
+ res.end(`\r\n--${boundary}--\r\n`)
606
+ } else {
607
+ res.writeHead(status, { 'Cache-Control': 'no-store', 'Content-Type': type })
608
+ res.end(body)
609
+ }
610
+ }
611
+
576
612
  try {
577
613
  const bundle = await (await getDevEngine(platform)).getBundle()
578
614
  let body: string | Buffer = bundle.code
@@ -591,19 +627,20 @@ export function createReactNativeDevServerPlugin(
591
627
  // a DevSettings reload requests this exact URL again. native URL
592
628
  // loading may otherwise reuse the first response and restart the app
593
629
  // on a route map from before a file was added or removed.
594
- res.writeHead(200, {
595
- 'Cache-Control': 'no-store',
596
- 'Content-Type': 'text/javascript',
597
- })
598
- res.end(body)
630
+ sendBundle(200, 'text/javascript', body)
599
631
  } catch (err) {
600
632
  console.error(` Error building React Native bundle`)
601
633
  console.error(err)
602
634
  console.error(
603
635
  `\n\n Note, some errors may happen due to a stale Vite cache, you may want to try re-running with the "--clean" flag`
604
636
  )
605
- res.writeHead(500, { 'Content-Type': 'text/plain' })
606
- res.end(err instanceof Error ? err.stack || err.message : String(err))
637
+ sendBundle(
638
+ 500,
639
+ 'text/plain',
640
+ err instanceof Error ? err.stack || err.message : String(err)
641
+ )
642
+ } finally {
643
+ clearInterval(progressTimer)
607
644
  }
608
645
  }
609
646
 
@@ -60,57 +60,65 @@ const COMMANDS = [
60
60
  keys: 'dt',
61
61
  label: 'open React Native DevTools',
62
62
  terminalLabel: 'open React Native \x1b[1mD\x1b[0mev\x1b[1mT\x1b[0mools',
63
- action: (ctx) => {
64
- const { host, port } = ctx.server.config.server
65
- const serverUrl = `http://${typeof host === 'string' && !!host ? host : 'localhost'}:${port}`
66
- const url = new URL('/open-debugger', serverUrl)
67
-
68
- // TODO: Seems to need these if multiple devices are connected, but haven't figured out how to pass these yet
69
- // Currently will just launch DevTools for most recently connected device
70
- // url.searchParams.set('appId', );
71
- // url.searchParams.set('device', );
72
- // url.searchParams.set('target', );
73
-
74
- // The `/open-debugger` endpoint may not respond, so we don't wait for it and will ignore timeout errors
75
- ;(async () => {
76
- const response = await fetch(url, {
77
- method: 'POST',
78
- signal: AbortSignal.timeout(3000),
79
- }).catch((error) => {
80
- if (error.name === 'TimeoutError') {
81
- return null
82
- }
83
-
84
- throw error
85
- })
86
-
87
- if (!response) {
88
- // This is common for now, so don't log it
89
- // console.info(`No response received from the React Native DevTools.`)
90
- } else if (response.ok === false) {
91
- const responseText = await response.text()
92
-
93
- if (responseText.includes('Unable to find debugger target')) {
94
- // Will already print "No compatible apps connected. React Native DevTools can only be used with the Hermes engine.", so no need to warn again
95
- return
96
- }
97
-
98
- console.warn(
99
- `Failed to open React Native DevTools, ${url} returns ${response.status}: ${responseText}.`
100
- )
101
- }
102
- })()
103
- },
63
+ action: openReactNativeDevTools,
64
+ },
65
+ {
66
+ keys: 'j',
67
+ label: 'open React Native DevTools',
68
+ terminalLabel: 'open React Native DevTools',
69
+ action: openReactNativeDevTools,
104
70
  },
105
71
 
106
72
  {
107
73
  keys: '?',
108
- label: 'open editor',
74
+ label: 'show commands',
109
75
  terminalLabel: 'show this menu',
110
76
  action: async (ctx) => {},
111
77
  },
112
78
  ] satisfies Command[]
113
79
 
80
+ function openReactNativeDevTools(ctx: Context) {
81
+ const { host } = ctx.server.config.server
82
+ const serverUrl = `http://${typeof host === 'string' && !!host ? host : 'localhost'}:${getBoundPort(ctx.server)}`
83
+ const url = new URL('/open-debugger', serverUrl)
84
+
85
+ // TODO: Seems to need these if multiple devices are connected, but haven't figured out how to pass these yet
86
+ // Currently will just launch DevTools for most recently connected device
87
+ // url.searchParams.set('appId', );
88
+ // url.searchParams.set('device', );
89
+ // url.searchParams.set('target', );
90
+
91
+ // The `/open-debugger` endpoint may not respond, so we don't wait for it and will ignore timeout errors
92
+ ;(async () => {
93
+ const response = await fetch(url, {
94
+ method: 'POST',
95
+ signal: AbortSignal.timeout(3000),
96
+ }).catch((error) => {
97
+ if (error.name === 'TimeoutError') {
98
+ return null
99
+ }
100
+
101
+ throw error
102
+ })
103
+
104
+ if (!response) {
105
+ // This is common for now, so don't log it
106
+ // console.info(`No response received from the React Native DevTools.`)
107
+ } else if (response.ok === false) {
108
+ const responseText = await response.text()
109
+
110
+ if (responseText.includes('Unable to find debugger target')) {
111
+ // Will already print "No compatible apps connected. React Native DevTools can only be used with the Hermes engine.", so no need to warn again
112
+ return
113
+ }
114
+
115
+ console.warn(
116
+ `Failed to open React Native DevTools, ${url} returns ${response.status}: ${responseText}.`
117
+ )
118
+ }
119
+ })()
120
+ }
121
+
114
122
  export async function startUserInterface(context: Context) {
115
123
  if (!supportsKeyboardCommands()) {
116
124
  return