peachsim 0.1.626 → 0.1.628

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 (106) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +3 -2
  3. package/cli/bin.ts +6 -2
  4. package/cli/bridge-flow-runner.ts +79 -72
  5. package/cli/commands/box/bundle-plane.ts +7 -2
  6. package/cli/commands/box.ts +9 -2
  7. package/cli/commands/cleanup.ts +6 -6
  8. package/cli/commands/control.ts +36 -6
  9. package/cli/commands/detox.ts +25 -11
  10. package/cli/commands/flow.ts +20 -8
  11. package/cli/commands/inspect.ts +22 -37
  12. package/cli/commands/maestro-generate.ts +5 -12
  13. package/cli/commands/maestro.ts +16 -6
  14. package/cli/commands/mode.ts +11 -7
  15. package/cli/commands/platform.ts +1 -0
  16. package/cli/commands/report-issue.ts +1 -1
  17. package/cli/commands/setup.ts +16 -11
  18. package/cli/commands/show-touches-mode.ts +33 -0
  19. package/cli/commands/skills.ts +16 -0
  20. package/cli/commands/test.ts +22 -1
  21. package/cli/commands/upload.ts +18 -8
  22. package/cli/commands/welcome.ts +3 -3
  23. package/cli/drivers/playwright-sim-host.ts +16 -8
  24. package/cli/drivers/playwright.ts +8 -5
  25. package/cli/help-core.ts +9 -4
  26. package/cli/internal-child.ts +14 -2
  27. package/cli/levenshtein.ts +27 -0
  28. package/cli/main.ts +28 -18
  29. package/cli/run-rnx.ts +8 -4
  30. package/cli/ws-bridge.ts +3 -2
  31. package/detox/index.ts +234 -7
  32. package/dist-lib/agent-daemon-client.cjs +2 -1
  33. package/dist-lib/agent-events.cjs +1 -1
  34. package/dist-lib/agent-identity.cjs +1 -1
  35. package/dist-lib/agent-sessions.cjs +1 -1
  36. package/dist-lib/attached-projects.cjs +1 -1
  37. package/dist-lib/auth/shared-session.cjs +1 -1
  38. package/dist-lib/backend-origin.cjs +1 -1
  39. package/dist-lib/beta.cjs +1 -1
  40. package/dist-lib/beta.mjs +1 -1
  41. package/dist-lib/bridge-constants.cjs +4 -1
  42. package/dist-lib/bridge-contract-input.cjs +1 -1
  43. package/dist-lib/bridge-contract-input.mjs +1 -1
  44. package/dist-lib/bridge-contract.cjs +1 -1
  45. package/dist-lib/bridge-contract.mjs +1 -1
  46. package/dist-lib/capture-contract.cjs +18 -8
  47. package/dist-lib/capture-contract.mjs +18 -8
  48. package/dist-lib/cli-constants.cjs +1 -1
  49. package/dist-lib/cloud-contract.cjs +1 -1
  50. package/dist-lib/cloud-contract.mjs +1 -1
  51. package/dist-lib/cloud-test-args.cjs +1 -1
  52. package/dist-lib/cloud-test-args.mjs +1 -1
  53. package/dist-lib/cloud.cjs +1 -1
  54. package/dist-lib/cloud.mjs +1 -1
  55. package/dist-lib/config.cjs +1 -1
  56. package/dist-lib/detox/index.cjs +170 -13
  57. package/dist-lib/dev-bundle-resolution.cjs +2 -2
  58. package/dist-lib/home-paths.cjs +1 -1
  59. package/dist-lib/host/bridge-host.cjs +280 -138
  60. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  61. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  62. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  63. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  64. package/dist-lib/host/websocket-proxy.cjs +1 -1
  65. package/dist-lib/index.cjs +62 -12
  66. package/dist-lib/jump-to-source-babel.cjs +1 -1
  67. package/dist-lib/jump-to-source-native.cjs +1 -1
  68. package/dist-lib/menu.cjs +7 -1
  69. package/dist-lib/menu.mjs +7 -1
  70. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  71. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  72. package/dist-lib/metro-production-bundle.cjs +1 -1
  73. package/dist-lib/metro-production-bundle.mjs +1 -1
  74. package/dist-lib/metro.cjs +43 -4
  75. package/dist-lib/profiles.cjs +1 -1
  76. package/dist-lib/public-brand.cjs +1 -1
  77. package/dist-lib/react-native-host-modules.cjs +1 -1
  78. package/dist-lib/react-native-host-modules.mjs +1 -1
  79. package/dist-lib/render-mode.cjs +1 -1
  80. package/dist-lib/scripts/dev-server-scanner.cjs +215 -28
  81. package/dist-lib/sdk.cjs +1 -1
  82. package/dist-lib/sdk.mjs +1 -1
  83. package/dist-lib/skills.cjs +431 -276
  84. package/dist-lib/swift-compile.cjs +1 -1
  85. package/dist-lib/swift-compile.mjs +1 -1
  86. package/dist-lib/swift-package.cjs +1 -1
  87. package/dist-lib/swift-package.mjs +1 -1
  88. package/dist-lib/swift-submit.cjs +1 -1
  89. package/dist-lib/swift-submit.mjs +1 -1
  90. package/dist-lib/swift.cjs +14512 -14208
  91. package/dist-lib/vite-kotlin.cjs +1 -1
  92. package/dist-lib/vite-swift.cjs +1 -1
  93. package/dist-lib/vite.cjs +44 -4
  94. package/install.mjs +16 -0
  95. package/install.sh +199 -0
  96. package/package.json +6 -1
  97. package/scripts/dev-server-scanner.ts +77 -46
  98. package/skills/contrast/SKILL.md +1 -1
  99. package/src/bridge-constants.ts +11 -0
  100. package/src/capture-contract.ts +40 -7
  101. package/src/dev-bundle-resolution.ts +1 -1
  102. package/src/host/bridge-host.ts +26 -137
  103. package/src/menu.ts +7 -0
  104. package/src/native-seam-manifest.ts +4 -16
  105. package/src/runtime-assets.ts +59 -2
  106. package/src/vite-plugin.ts +723 -25
@@ -8,11 +8,20 @@
8
8
  // // with external app:
9
9
  // export default defineConfig({ plugins: [sootsim({ app: '~/my-rn-app' })] })
10
10
 
11
+ import crypto from 'crypto'
11
12
  import fs from 'fs'
12
13
  import { createRequire } from 'module'
13
14
  import path from 'path'
14
- import { fileURLToPath } from 'url'
15
+ import { fileURLToPath, pathToFileURL } from 'url'
15
16
  import { type Plugin, transformWithOxc } from 'vite'
17
+
18
+ declare module '@babel/core' {
19
+ interface TransformCaller {
20
+ platform?: string
21
+ isDev?: boolean
22
+ projectRoot?: string
23
+ }
24
+ }
16
25
  import { resolveExportsHiddenSubpath } from '../../peach-compat/src/resolve-hidden-subpath.ts'
17
26
  import {
18
27
  SOOTSIM_COMPAT_PUBLISHED_BROWSER_ENTRIES,
@@ -352,6 +361,10 @@ export function sootsim(options: SootSimOptions = {}): Plugin[] {
352
361
  keyboardControllerBindingsRedirect(),
353
362
  reanimatedNativeSeamRedirect(),
354
363
  manifestNativeSeamRedirect(),
364
+ expoDomComponentsPlugin({
365
+ appDir,
366
+ projectRoot: appDir || workspaceRoot,
367
+ }),
355
368
  workletsBabelTransform(isAppSource),
356
369
  metroNativeResolve(isAppSource),
357
370
  reactNativeRequirePlugin(isAppSource),
@@ -439,6 +452,7 @@ export function engineShellWorkerPlugins(): Plugin[] {
439
452
  nodeModulesJsxTransform(),
440
453
  recordingPluginWorkerRedirect(),
441
454
  reanimatedNativeSeamRedirect(),
455
+ gestureHandlerReanimatedRequire(),
442
456
  manifestNativeSeamRedirect(),
443
457
  keyboardControllerBindingsRedirect(),
444
458
  workletsBabelTransform(() => true),
@@ -540,6 +554,9 @@ export function workerBootChunkPlugin(closures: WorkerBootClosure[]): Plugin {
540
554
  const chunk = chunks[0]
541
555
  const pkgs = new Map<string, Set<string>>()
542
556
  for (const id of chunk.moduleIds) {
557
+ // a virtual `\0` id (the jsx-runtime re-export shim) names a
558
+ // node_modules path without being a second copy of that package
559
+ if (id.startsWith('\0')) continue
543
560
  const nmIndex = id.lastIndexOf('/node_modules/')
544
561
  if (nmIndex !== -1) {
545
562
  const rest = id.slice(nmIndex + '/node_modules/'.length)
@@ -562,14 +579,81 @@ export function workerBootChunkPlugin(closures: WorkerBootClosure[]): Plugin {
562
579
  duplicates.length > 0
563
580
  ? `\ndetected duplicate packages bundled from multiple roots (likely a symlinked or cross-tree node_modules):\n ${duplicates.join('\n ')}`
564
581
  : ''
582
+ // the largest modules are where a cut pays, so the error names them
583
+ const largest = Object.entries(chunk.modules)
584
+ .sort(([, a], [, b]) => b.renderedLength - a.renderedLength)
585
+ .slice(0, 40)
586
+ .map(
587
+ ([id, module]) =>
588
+ `${module.renderedLength} ${path.relative(process.cwd(), id)}`,
589
+ )
565
590
  this.error(
566
- `worker ${closure.entry}: ${chunks[0].fileName} is ${bytes} bytes, over the ${closure.maxBytes} byte cap; eliminate excess boot dependencies rather than raising the cap${dupDetail}`,
591
+ `worker ${closure.entry}: ${chunks[0].fileName} is ${bytes} bytes, over the ${closure.maxBytes} byte cap; eliminate excess boot dependencies rather than raising the cap${dupDetail}\nlargest modules (rendered bytes):\n ${largest.join('\n ')}`,
567
592
  )
568
593
  }
569
594
  },
570
595
  }
571
596
  }
572
597
 
598
+ // react-native-gesture-handler reaches reanimated through an optional
599
+ // `require('react-native-reanimated')` in reanimatedWrapper. a require of an
600
+ // ES module binds its whole namespace, so every reanimated export (the
601
+ // layout-animation presets alone were ~230kB) stayed in each engine worker
602
+ // file although gesture-handler reads a handful of members. resolve that one
603
+ // require to a module re-exporting exactly the members gesture-handler's
604
+ // sources read off its `Reanimated` binding, scanned from the installed
605
+ // package so an upgrade that reads another member keeps it.
606
+ export function gestureHandlerReanimatedRequire(): Plugin {
607
+ const virtualId = '\0sootsim:gesture-handler-reanimated'
608
+ const packageMarker = '/node_modules/react-native-gesture-handler/'
609
+ let sourceDir: string | null = null
610
+ const readMembers = (dir: string, members: Set<string>) => {
611
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
612
+ const entryPath = path.join(dir, entry.name)
613
+ if (entry.isDirectory()) {
614
+ readMembers(entryPath, members)
615
+ } else if (/\.[jt]sx?$/.test(entry.name)) {
616
+ for (const match of fs
617
+ .readFileSync(entryPath, 'utf8')
618
+ .matchAll(/\bReanimated\??\.([A-Za-z_$][\w$]*)/g)) {
619
+ members.add(match[1])
620
+ }
621
+ }
622
+ }
623
+ }
624
+ return {
625
+ name: 'sootsim-gesture-handler-reanimated-require',
626
+ apply: 'build',
627
+ enforce: 'pre',
628
+ resolveId: {
629
+ filter: { id: /^react-native-reanimated$/ },
630
+ handler(_source, importer) {
631
+ if (!importer || !/\/reanimatedWrapper\.[jt]s$/.test(importer)) return null
632
+ const markerIndex = importer.indexOf(packageMarker)
633
+ if (markerIndex === -1) return null
634
+ const packageRoot = importer.slice(0, markerIndex + packageMarker.length)
635
+ // the tree the importer resolved from (src or lib), not every build
636
+ sourceDir = path.join(
637
+ packageRoot,
638
+ importer.slice(packageRoot.length).split('/')[0],
639
+ )
640
+ return virtualId
641
+ },
642
+ },
643
+ load(id) {
644
+ if (id !== virtualId || !sourceDir) return null
645
+ const members = new Set<string>()
646
+ readMembers(sourceDir, members)
647
+ if (members.size === 0) {
648
+ this.error(
649
+ `gesture-handler reanimated require: no \`Reanimated.<member>\` reads under ${sourceDir}; its reanimatedWrapper changed shape, so update this plugin`,
650
+ )
651
+ }
652
+ return `export { ${[...members].sort().join(', ')} } from 'react-native-reanimated'\n`
653
+ },
654
+ }
655
+ }
656
+
573
657
  // react-native-keyboard-controller is pure-JS for everything except its
574
658
  // native-event seam (`bindings.ts` / `bindings.native.ts`) and RN platform
575
659
  // `findNodeHandle` resolution. let upstream's components, hooks, animated
@@ -768,26 +852,20 @@ export function reanimatedNativeSeamRedirect(): Plugin {
768
852
  // use the manifest path unless they need similar special handling.
769
853
  import { NATIVE_SEAM_MANIFEST } from './native-seam-manifest.ts'
770
854
  export function manifestNativeSeamRedirect(): Plugin {
771
- // build a fast lookup: pkg → { basenames, target }
772
- const byPkg = new Map<
773
- string,
774
- { basenames: Set<string>; target: string; notes?: string }
775
- >()
855
+ // pkg → basename → seam. one package can split its native surface across
856
+ // several upstream files, each redirected to its own seam.
857
+ const byPkg = new Map<string, Map<string, { target: string; notes?: string }>>()
776
858
  for (const entry of NATIVE_SEAM_MANIFEST) {
777
- const existing = byPkg.get(entry.pkg)
778
- if (existing) {
779
- for (const b of entry.seamBasenames) existing.basenames.add(b)
780
- } else {
781
- byPkg.set(entry.pkg, {
782
- basenames: new Set(entry.seamBasenames),
783
- target: entry.target,
784
- notes: entry.notes,
785
- })
859
+ const seams = byPkg.get(entry.pkg) ?? new Map()
860
+ byPkg.set(entry.pkg, seams)
861
+ for (const basename of entry.seamBasenames) {
862
+ seams.set(basename, { target: entry.target, notes: entry.notes })
786
863
  }
787
864
  }
865
+ const targets = new Set(NATIVE_SEAM_MANIFEST.map((entry) => entry.target))
788
866
  const sourceFilter = new RegExp(
789
867
  `(?:^|/)(?:${[...byPkg.values()]
790
- .flatMap(({ basenames }) => [...basenames])
868
+ .flatMap((seams) => [...seams.keys()])
791
869
  .map((basename) => basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
792
870
  .join('|')})(?:\\.(?:native|web|ios|android))?$`,
793
871
  )
@@ -797,26 +875,26 @@ export function manifestNativeSeamRedirect(): Plugin {
797
875
  resolveId: {
798
876
  filter: { id: sourceFilter },
799
877
  handler(source, importer) {
800
- if (!importer || byPkg.size === 0) return null
878
+ if (!importer || byPkg.size === 0 || targets.has(importer)) return null
801
879
  // find the package whose path appears in the importer. matches both
802
880
  // `/<pkg>/` (unscoped) and `/@scope/<pkg>/` (scoped — the leading `@`
803
881
  // is part of the pkg name in the manifest, e.g. `@notifee/react-native`).
804
- let match: { basenames: Set<string>; target: string; notes?: string } | undefined
882
+ let seams: Map<string, { target: string; notes?: string }> | undefined
805
883
  let matchedPkg: string | undefined
806
884
  for (const [pkg, entry] of byPkg) {
807
885
  if (importer.includes(`/${pkg}/`)) {
808
- match = entry
886
+ seams = entry
809
887
  matchedPkg = pkg
810
888
  break
811
889
  }
812
890
  }
813
- if (!match) return null
814
- if (importer === match.target) return null
891
+ if (!seams) return null
815
892
  const basename = (source.split('/').pop() ?? '').replace(
816
893
  /\.(native|web|ios|android)$/,
817
894
  '',
818
895
  )
819
- if (!match.basenames.has(basename)) return null
896
+ const seam = seams.get(basename)
897
+ if (!seam) return null
820
898
  if (process.env.SOOTSIM_NATIVE_SEAM_DEBUG) {
821
899
  console.log(
822
900
  '[nativeSeamRedirect]',
@@ -825,10 +903,10 @@ export function manifestNativeSeamRedirect(): Plugin {
825
903
  source,
826
904
  'from',
827
905
  importer.slice(-80),
828
- match.notes ? `(${match.notes})` : '',
906
+ seam.notes ? `(${seam.notes})` : '',
829
907
  )
830
908
  }
831
- return match.target
909
+ return seam.target
832
910
  },
833
911
  },
834
912
  }
@@ -1931,3 +2009,623 @@ function stubMissingImages(isAppSource: (p: string) => boolean): Plugin {
1931
2009
  },
1932
2010
  }
1933
2011
  }
2012
+
2013
+ let domBabelCorePromise: Promise<typeof import('@babel/core')> | null = null
2014
+ async function getDomBabelCore() {
2015
+ if (!domBabelCorePromise) {
2016
+ domBabelCorePromise = import('@babel/core') as Promise<typeof import('@babel/core')>
2017
+ }
2018
+ return domBabelCorePromise
2019
+ }
2020
+
2021
+ export function expoDomComponentsPlugin(
2022
+ options: {
2023
+ appDir?: string
2024
+ projectRoot?: string
2025
+ } = {},
2026
+ ): Plugin {
2027
+ let isProduction = false
2028
+ const appDir = options.appDir ? path.resolve(options.appDir) : ''
2029
+ const projectRoot = options.projectRoot ?? appDir ?? workspaceRoot
2030
+ const domHashMap = new Map<string, string>()
2031
+ const domWebModuleIds = new Set<string>()
2032
+
2033
+ // specifiers that must stay runtime requires served by the page's
2034
+ // window.require shim instead of becoming static imports:
2035
+ // - expo's log-box is native-only; importing it would pull react-native
2036
+ // into the web closure (mirrors the @expo/log-box virtual stub).
2037
+ // - the react-native-web stylesheets are an optional integration whose
2038
+ // callers fall back when require returns an empty object.
2039
+ const domRuntimeRequireSpecifiers = new Set([
2040
+ '@expo/log-box',
2041
+ '@expo/log-box/lib',
2042
+ 'react-native-web/dist/exports/StyleSheet',
2043
+ 'react-native-web/dist/exports/StyleSheet/dom',
2044
+ // metro fast-refresh plumbing cannot work under vite (no metro server,
2045
+ // no hmr client socket); the page gets hmr from vite's own client.
2046
+ // importing the real module would also fork the engine's refresh
2047
+ // identity, so serve a no-op shape instead.
2048
+ 'react-refresh/runtime',
2049
+ ])
2050
+
2051
+ // expo's dom web closure is written for metro, where require() exists in
2052
+ // every module. vite serves ESM, so a bare require is a ReferenceError
2053
+ // (and the entry module that defines window.require evaluates after its
2054
+ // static imports). rewrite static requires in closure modules to ESM
2055
+ // imports at serve time; this only changes module format, never behavior.
2056
+ function rewriteDomWebRequires(code: string): string | null {
2057
+ const pattern = /(^|[^.\w$])require\(\s*('[^']*'|"[^"]*")\s*\)/gm
2058
+ const names = new Map<string, string>()
2059
+ let found = false
2060
+ for (const match of code.matchAll(pattern)) {
2061
+ const spec = match[2].slice(1, -1)
2062
+ if (domRuntimeRequireSpecifiers.has(spec)) continue
2063
+ found = true
2064
+ if (!names.has(spec)) names.set(spec, `__domRequire_${names.size}`)
2065
+ }
2066
+ if (!found) return null
2067
+ const body = code.replace(pattern, (full, prefix, quoted: string) => {
2068
+ const spec = quoted.slice(1, -1)
2069
+ if (domRuntimeRequireSpecifiers.has(spec)) return full
2070
+ const name = names.get(spec)!
2071
+ return `${prefix}(${name}.default ?? ${name})`
2072
+ })
2073
+ const imports = [...names]
2074
+ .map(([spec, name]) => `import * as ${name} from ${JSON.stringify(spec)};`)
2075
+ .join('\n')
2076
+ return `${imports}\n${body}`
2077
+ }
2078
+
2079
+ function isDomEntryId(id: string): boolean {
2080
+ const clean = id.replace(/\?.*$/, '')
2081
+ return (
2082
+ clean === `${virtualIdPrefix}_expo_dom_entry` ||
2083
+ clean === '\\0_expo_dom_entry' ||
2084
+ clean === '_expo_dom_entry'
2085
+ )
2086
+ }
2087
+
2088
+ function isDomWebModule(id: string, importer?: string): boolean {
2089
+ // the web closure is rooted at the dom entry: only modules reachable
2090
+ // from it take web resolution. never classify by path substring: native
2091
+ // modules such as @expo/dom-webview sources contain 'dom-web' but must
2092
+ // keep native resolution (their react-native import must not rewrite to
2093
+ // react-native-web, which unmounts the native webview).
2094
+ if (isDomEntryId(id)) return true
2095
+ if (!importer) return false
2096
+ const cleanImporter = importer.replace(/\?.*$/, '')
2097
+ return domWebModuleIds.has(cleanImporter) || domWebModuleIds.has(importer)
2098
+ }
2099
+
2100
+ return {
2101
+ name: 'sootsim-expo-dom-components',
2102
+ enforce: 'pre',
2103
+ config() {
2104
+ // the dom web closure imports CJS-only packages (react, expo's
2105
+ // metro-hmr devtooling). vite's CJS→ESM interop only kicks in via
2106
+ // pre-bundle, and lazily reached page graphs cannot rely on
2107
+ // first-request optimization, so force them here. nothing outside
2108
+ // this closure imports pretty-format or log-box-utils.
2109
+ // react-refresh/runtime is deliberately absent: the engine imports
2110
+ // it directly and its module identity is load-bearing for fast
2111
+ // refresh, so the closure shims it instead (see below).
2112
+ return {
2113
+ optimizeDeps: {
2114
+ include: [
2115
+ 'react',
2116
+ 'react-dom',
2117
+ 'react-dom/client',
2118
+ 'react/jsx-runtime',
2119
+ 'react/jsx-dev-runtime',
2120
+ 'pretty-format',
2121
+ '@expo/log-box-utils',
2122
+ ],
2123
+ },
2124
+ }
2125
+ },
2126
+ configResolved(config) {
2127
+ isProduction =
2128
+ (config.mode === 'production' || config.isProduction) &&
2129
+ process.env.SOOTSIM_TENANT_BUILD_MODE !== 'development'
2130
+ },
2131
+ configureServer(server) {
2132
+ server.middlewares.use((req, res, next) => {
2133
+ if (!req.url) return next()
2134
+ const rawUrl = req.url
2135
+ const isDomRoute =
2136
+ rawUrl.startsWith('/_expo/@dom') ||
2137
+ rawUrl.startsWith('/www.bundle') ||
2138
+ rawUrl.startsWith('www.bundle')
2139
+ if (!isDomRoute) return next()
2140
+
2141
+ const url = new URL(
2142
+ rawUrl.startsWith('/') ? rawUrl : '/' + rawUrl,
2143
+ 'http://localhost',
2144
+ )
2145
+ if (url.pathname === '/_expo/@dom/entry' || url.pathname.endsWith('/entry')) {
2146
+ return next()
2147
+ }
2148
+
2149
+ let fileUrl = url.searchParams.get('file')
2150
+ if (
2151
+ !fileUrl &&
2152
+ (url.pathname.includes('/www.bundle/') ||
2153
+ url.pathname.startsWith('/www.bundle'))
2154
+ ) {
2155
+ const match = url.pathname.match(/([a-f0-9]{32})\.html/i)
2156
+ if (match && domHashMap.has(match[1])) {
2157
+ fileUrl = domHashMap.get(match[1])!
2158
+ }
2159
+ }
2160
+ if (!fileUrl) return next()
2161
+
2162
+ let decodedFilePath: string
2163
+ try {
2164
+ decodedFilePath = fileUrl.startsWith('file:') ? fileURLToPath(fileUrl) : fileUrl
2165
+ } catch {
2166
+ decodedFilePath = fileUrl
2167
+ }
2168
+ const title = path.basename(decodedFilePath)
2169
+ const entryScriptSrc = `/_expo/@dom/entry?file=${encodeURIComponent(fileUrl)}`
2170
+
2171
+ const html = `<!DOCTYPE html>
2172
+ <html>
2173
+ <head>
2174
+ <meta charset="utf-8" />
2175
+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
2176
+ <title>${title.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}</title>
2177
+ <style id="expo-dom-component-style">
2178
+ html, body {
2179
+ -webkit-overflow-scrolling: touch;
2180
+ margin: 0;
2181
+ padding: 0;
2182
+ width: 100%;
2183
+ height: 100%;
2184
+ }
2185
+ #root {
2186
+ display: flex;
2187
+ flex: 1;
2188
+ width: 100%;
2189
+ height: 100%;
2190
+ }
2191
+ </style>
2192
+ <script>
2193
+ window.__DEV__ = true;
2194
+ window.process = window.process || { env: { NODE_ENV: 'development' } };
2195
+ // expo's dom closure branches on EXPO_OS (hmr setup takes the web
2196
+ // form only when it is 'web'); metro sets this at bundle time.
2197
+ window.process.env = window.process.env || {};
2198
+ window.process.env.NODE_ENV = window.process.env.NODE_ENV || 'development';
2199
+ window.process.env.EXPO_OS = 'web';
2200
+ (function () {
2201
+ var documentUrl = location.href;
2202
+ function frameUrl() { return String(location.href); }
2203
+ window.ReactNativeWebView = window.ReactNativeWebView || {
2204
+ postMessage: function (data) {
2205
+ window.parent.postMessage({
2206
+ __sootsimWebViewKind: 'reactNativeWebViewMessage',
2207
+ data: String(data),
2208
+ url: frameUrl(),
2209
+ loading: document.readyState !== 'complete',
2210
+ title: String(document.title || '')
2211
+ }, '*');
2212
+ },
2213
+ injectedObjectJson: function () {
2214
+ try {
2215
+ if (window.parent && window.parent.__sootsimGetInjectedObjectJson) {
2216
+ var res = window.parent.__sootsimGetInjectedObjectJson(frameUrl());
2217
+ if (res) return typeof res === 'string' ? res : JSON.stringify(res);
2218
+ }
2219
+ } catch (e) {}
2220
+ return "{}";
2221
+ }
2222
+ };
2223
+ window.addEventListener('message', function (event) {
2224
+ if (event.source !== window.parent) return;
2225
+ var payload = event.data;
2226
+ if (!payload || typeof payload !== 'object') return;
2227
+ if (payload.__sootsimWebViewKind !== 'injectJavaScript') return;
2228
+ event.stopImmediatePropagation();
2229
+ try {
2230
+ (0, eval)(String(payload.js || ''));
2231
+ } catch (err) {
2232
+ setTimeout(function () { throw err; }, 0);
2233
+ }
2234
+ }, true);
2235
+ // metro-style require shim for expo's dom closure: classic scripts
2236
+ // run before module imports evaluate, so this covers eval-time
2237
+ // requires. static requires are rewritten to imports at serve time;
2238
+ // only denylisted specifiers (native-only log-box, optional
2239
+ // stylesheets) reach this. the entry module installs the fuller
2240
+ // version below once react is loaded.
2241
+ window.require = window.require || function (mod) {
2242
+ if (mod === '@expo/log-box' || mod === '@expo/log-box/lib') {
2243
+ return { setupLogBox: function () {} };
2244
+ }
2245
+ if (mod === 'react-refresh/runtime') {
2246
+ return {
2247
+ injectIntoGlobalHook: function () {},
2248
+ createSignatureFunctionForTransform: function () { return function (t) { return t; }; },
2249
+ isLikelyComponentType: function () { return false; },
2250
+ getFamilyByType: function () { return undefined; },
2251
+ register: function () {},
2252
+ performReactRefresh: function () {},
2253
+ hasUnrecoverableErrors: function () { return false; }
2254
+ };
2255
+ }
2256
+ return {};
2257
+ };
2258
+ })();
2259
+ </script>
2260
+ <script>
2261
+ var injectedObject = {};
2262
+ try {
2263
+ injectedObject = JSON.parse(window.ReactNativeWebView.injectedObjectJson());
2264
+ } catch (e) {
2265
+ console.warn('Failed to parse injectedObjectJson:', e);
2266
+ }
2267
+ window.$$EXPO_DOM_HOST_OS = injectedObject.EXPO_DOM_HOST_OS || 'ios';
2268
+ window.$$EXPO_INITIAL_PROPS = injectedObject.initialProps || { names: [], props: {} };
2269
+ </script>
2270
+ </head>
2271
+ <body>
2272
+ <noscript>DOM Components require <code>javaScriptEnabled</code></noscript>
2273
+ <div id="root"></div>
2274
+ <script type="module" src="/@vite/client"></script>
2275
+ <script type="module" src="${entryScriptSrc}"></script>
2276
+ </body>
2277
+ </html>`
2278
+ res.statusCode = 200
2279
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
2280
+ res.setHeader('Cache-Control', 'no-cache')
2281
+ // the shell page is cross-origin isolated (COOP same-origin plus
2282
+ // COEP require-corp): a same-origin iframe document without matching
2283
+ // response headers is blocked before any script runs. mirror the
2284
+ // engine-tenant static handler.
2285
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
2286
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
2287
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
2288
+ res.end(html)
2289
+ })
2290
+ },
2291
+ async resolveId(id, importer) {
2292
+ // expo's ExpoDOMWebView and RNWebView wrappers require their package in
2293
+ // a try/catch and export undefined when it is not installed, so the
2294
+ // webview wrapper can report the missing package it chose. esm cannot
2295
+ // catch a static import, so resolve the package here and answer with
2296
+ // the same undefined when it is absent.
2297
+ const optionalWebViewReexport = async (pkg: string, from: string | undefined) => {
2298
+ const resolved = await this.resolve(pkg, from, { skipSelf: true })
2299
+ return `${virtualIdPrefix}optional-webview:${pkg}${resolved ? '' : '?missing'}`
2300
+ }
2301
+ const inDomWeb = importer == null || isDomWebModule(id, importer)
2302
+ if (
2303
+ (id === '@expo/metro-runtime' ||
2304
+ id.startsWith('@expo/metro-runtime/') ||
2305
+ id.startsWith('@expo/metro/')) &&
2306
+ inDomWeb
2307
+ ) {
2308
+ return `${virtualIdPrefix}@expo/metro-runtime`
2309
+ }
2310
+ if ((id === '@expo/log-box' || id === '@expo/log-box/lib') && inDomWeb) {
2311
+ return `${virtualIdPrefix}@expo/log-box`
2312
+ }
2313
+ if (
2314
+ (id === 'expo/build/dom/webview/ExpoDOMWebView' ||
2315
+ id.endsWith('/expo/build/dom/webview/ExpoDOMWebView.js') ||
2316
+ id.endsWith('/expo/build/dom/webview/ExpoDOMWebView')) &&
2317
+ inDomWeb
2318
+ ) {
2319
+ return optionalWebViewReexport('@expo/dom-webview', importer)
2320
+ }
2321
+ if (
2322
+ (id === 'expo/build/dom/webview/RNWebView' ||
2323
+ id.endsWith('/expo/build/dom/webview/RNWebView.js') ||
2324
+ id.endsWith('/expo/build/dom/webview/RNWebView')) &&
2325
+ inDomWeb
2326
+ ) {
2327
+ return optionalWebViewReexport('react-native-webview', importer)
2328
+ }
2329
+ if (
2330
+ id.startsWith('/_expo/@dom/entry') ||
2331
+ id.startsWith('@dom-entry:') ||
2332
+ id.includes('/_expo/@dom/entry')
2333
+ ) {
2334
+ const query = id.includes('?') ? id.slice(id.indexOf('?')) : ''
2335
+ const resolved = `${virtualIdPrefix}_expo_dom_entry${query}`
2336
+ domWebModuleIds.add(virtualIdPrefix + '_expo_dom_entry')
2337
+ domWebModuleIds.add('\0_expo_dom_entry')
2338
+ domWebModuleIds.add('_expo_dom_entry')
2339
+ return resolved
2340
+ }
2341
+
2342
+ if (isDomWebModule(id, importer)) {
2343
+ if (id === 'expo/dom/internal') {
2344
+ const resolved = path.resolve(
2345
+ workspaceNodeModules,
2346
+ 'expo/build/dom/internal/internal.web.js',
2347
+ )
2348
+ domWebModuleIds.add(resolved)
2349
+ return resolved
2350
+ }
2351
+ if (id === 'expo/dom') {
2352
+ const resolved = path.resolve(
2353
+ workspaceNodeModules,
2354
+ 'expo/build/dom/dom/dom.web.js',
2355
+ )
2356
+ domWebModuleIds.add(resolved)
2357
+ return resolved
2358
+ }
2359
+ if (id === 'react-native') {
2360
+ const resolved = path.resolve(
2361
+ workspaceNodeModules,
2362
+ 'react-native-web/dist/cjs/index.js',
2363
+ )
2364
+ domWebModuleIds.add(resolved)
2365
+ return resolved
2366
+ }
2367
+ if (
2368
+ id.endsWith('/launch/registerRootComponent') ||
2369
+ id === '../launch/registerRootComponent'
2370
+ ) {
2371
+ const resolved = path.resolve(
2372
+ workspaceNodeModules,
2373
+ 'expo/build/launch/registerRootComponent.web.js',
2374
+ )
2375
+ domWebModuleIds.add(resolved)
2376
+ return resolved
2377
+ }
2378
+ if (id.startsWith('.') && importer) {
2379
+ const dir = path.dirname(importer.replace(/\?.*$/, ''))
2380
+ const resolved = path.resolve(dir, id)
2381
+ if (fs.existsSync(resolved)) {
2382
+ try {
2383
+ const stat = fs.statSync(resolved)
2384
+ if (stat.isFile()) {
2385
+ domWebModuleIds.add(resolved)
2386
+ return resolved
2387
+ }
2388
+ if (stat.isDirectory()) {
2389
+ const indexCandidates = [
2390
+ path.join(resolved, 'index.web.tsx'),
2391
+ path.join(resolved, 'index.web.ts'),
2392
+ path.join(resolved, 'index.web.jsx'),
2393
+ path.join(resolved, 'index.web.js'),
2394
+ path.join(resolved, 'index.tsx'),
2395
+ path.join(resolved, 'index.ts'),
2396
+ path.join(resolved, 'index.jsx'),
2397
+ path.join(resolved, 'index.js'),
2398
+ ]
2399
+ for (const candidate of indexCandidates) {
2400
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
2401
+ domWebModuleIds.add(candidate)
2402
+ return candidate
2403
+ }
2404
+ }
2405
+ }
2406
+ } catch {}
2407
+ }
2408
+ const webExtensions = [
2409
+ '.web.tsx',
2410
+ '.web.ts',
2411
+ '.web.jsx',
2412
+ '.web.js',
2413
+ '.tsx',
2414
+ '.ts',
2415
+ '.jsx',
2416
+ '.js',
2417
+ ]
2418
+ for (const ext of webExtensions) {
2419
+ const candidate =
2420
+ resolved.endsWith('.js') && ext.startsWith('.web.')
2421
+ ? resolved.slice(0, -3) + ext
2422
+ : resolved + ext
2423
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
2424
+ domWebModuleIds.add(candidate)
2425
+ return candidate
2426
+ }
2427
+ }
2428
+ const indexCandidates = [
2429
+ path.join(resolved, 'index.web.tsx'),
2430
+ path.join(resolved, 'index.web.ts'),
2431
+ path.join(resolved, 'index.web.jsx'),
2432
+ path.join(resolved, 'index.web.js'),
2433
+ path.join(resolved, 'index.tsx'),
2434
+ path.join(resolved, 'index.ts'),
2435
+ path.join(resolved, 'index.jsx'),
2436
+ path.join(resolved, 'index.js'),
2437
+ ]
2438
+ for (const candidate of indexCandidates) {
2439
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
2440
+ domWebModuleIds.add(candidate)
2441
+ return candidate
2442
+ }
2443
+ }
2444
+ }
2445
+ // bare specifiers must keep vite core resolution: resolving them
2446
+ // here would pin an absolute /@fs id that bypasses dep pre-bundling,
2447
+ // serving raw CJS with no named/default exports in the browser.
2448
+ // (relative ids above keep our metro-style .web preference.)
2449
+ const isBare =
2450
+ !id.startsWith('.') &&
2451
+ !id.startsWith('/') &&
2452
+ !id.startsWith('\0') &&
2453
+ !id.includes('://') &&
2454
+ !path.isAbsolute(id)
2455
+ if (isBare) return null
2456
+ const fallback = await this.resolve(id, importer, { skipSelf: true })
2457
+ if (fallback) {
2458
+ domWebModuleIds.add(fallback.id.replace(/\?.*$/, ''))
2459
+ return fallback
2460
+ }
2461
+ }
2462
+ return null
2463
+ },
2464
+ load(id) {
2465
+ if (id === `${virtualIdPrefix}@expo/metro-runtime`) {
2466
+ // metro's hmr client cannot work under vite: there is no metro
2467
+ // server or hot socket, and vite's own client provides reload.
2468
+ // expo's hmr module constructs one at startup and calls
2469
+ // on/send/isEnabled/enable/disable/hasPendingUpdates/close, so
2470
+ // serve a no-op with that surface instead of connecting anywhere.
2471
+ return `class NoopMetroHMRClient {
2472
+ on() { return () => {}; }
2473
+ send() {}
2474
+ isEnabled() { return false; }
2475
+ hasPendingUpdates() { return false; }
2476
+ enable() {}
2477
+ disable() {}
2478
+ close() {}
2479
+ }
2480
+ export default NoopMetroHMRClient;
2481
+ `
2482
+ }
2483
+ if (id === `${virtualIdPrefix}@expo/log-box`) {
2484
+ return 'export function setupLogBox() {}; export default { setupLogBox() {} };'
2485
+ }
2486
+ if (id.startsWith(`${virtualIdPrefix}optional-webview:`)) {
2487
+ const pkg = id
2488
+ .slice(`${virtualIdPrefix}optional-webview:`.length)
2489
+ .replace(/\?.*$/, '')
2490
+ return id.endsWith('?missing')
2491
+ ? 'export default undefined;\n'
2492
+ : `import { WebView } from ${JSON.stringify(pkg)};\nexport default WebView;\n`
2493
+ }
2494
+ if (id.startsWith(`${virtualIdPrefix}_expo_dom_entry`)) {
2495
+ const query = id.slice(`${virtualIdPrefix}_expo_dom_entry`.length)
2496
+ const params = new URLSearchParams(query)
2497
+ const fileUrl = params.get('file')
2498
+ if (!fileUrl) {
2499
+ throw new Error(`Missing file query param in DOM component entry: ${id}`)
2500
+ }
2501
+ let targetPath: string
2502
+ try {
2503
+ targetPath = fileUrl.startsWith('file:') ? fileURLToPath(fileUrl) : fileUrl
2504
+ } catch {
2505
+ targetPath = fileUrl
2506
+ }
2507
+ const internalWebPath = path.resolve(
2508
+ workspaceNodeModules,
2509
+ 'expo/build/dom/internal/internal.web.js',
2510
+ )
2511
+ // bare specifiers on purpose: the workspace react/react-dom package
2512
+ // roots are CJS, and serving them by absolute path bypasses vite's
2513
+ // pre-bundling, so the browser gets raw CJS with no default export.
2514
+ // bare imports resolve through vite to the pre-bundled ESM build.
2515
+ return `import React from 'react';
2516
+ import * as ReactDOM from 'react-dom';
2517
+ import * as ReactDOMClient from 'react-dom/client';
2518
+ if (typeof window !== 'undefined') {
2519
+ window.React = React;
2520
+ window.ReactDOM = ReactDOM;
2521
+ window.ReactDOMClient = ReactDOMClient;
2522
+ window.require = function (mod) {
2523
+ if (mod === 'react') return React;
2524
+ if (mod === 'react-dom') return ReactDOM;
2525
+ if (mod === 'react-dom/client') return ReactDOMClient;
2526
+ if (mod === './withDevTools' || (typeof mod === 'string' && mod.endsWith('withDevTools'))) {
2527
+ return { withDevTools: function (c) { return c; } };
2528
+ }
2529
+ // expo's dom-entry calls require('@expo/log-box/lib').setupLogBox() in
2530
+ // dev; mirror the plugin's @expo/log-box virtual stub.
2531
+ if (mod === '@expo/log-box' || mod === '@expo/log-box/lib') {
2532
+ return { setupLogBox: function () {} };
2533
+ }
2534
+ // metro fast-refresh plumbing cannot run under vite; mirror the head
2535
+ // script's no-op shape for requires that execute after module eval.
2536
+ if (mod === 'react-refresh/runtime') {
2537
+ return {
2538
+ injectIntoGlobalHook: function () {},
2539
+ createSignatureFunctionForTransform: function () { return function (t) { return t; }; },
2540
+ isLikelyComponentType: function () { return false; },
2541
+ getFamilyByType: function () { return undefined; },
2542
+ register: function () {},
2543
+ performReactRefresh: function () {},
2544
+ hasUnrecoverableErrors: function () { return false; }
2545
+ };
2546
+ }
2547
+ return {};
2548
+ };
2549
+ }
2550
+ import { registerDOMComponent } from ${JSON.stringify(internalWebPath)};
2551
+ import Component from ${JSON.stringify(targetPath + '?dom-web')};
2552
+ registerDOMComponent(Component);
2553
+ `
2554
+ }
2555
+ return null
2556
+ },
2557
+ async transform(code, id) {
2558
+ const query = id.includes('?') ? id.slice(id.indexOf('?') + 1) : ''
2559
+ if (new URLSearchParams(query).has('dom-web')) {
2560
+ const cleanId = id.replace(/\?.*$/, '')
2561
+ const transformed = await transformWithOxc(code, cleanId, {
2562
+ lang: cleanId.endsWith('.tsx')
2563
+ ? 'tsx'
2564
+ : cleanId.endsWith('.ts')
2565
+ ? 'ts'
2566
+ : cleanId.endsWith('.jsx')
2567
+ ? 'jsx'
2568
+ : 'js',
2569
+ jsx: { runtime: 'automatic' },
2570
+ })
2571
+ return { code: transformed.code, map: transformed.map ?? null }
2572
+ }
2573
+
2574
+ const cleanId = id.replace(/\?.*$/, '')
2575
+ if (domWebModuleIds.has(cleanId) || domWebModuleIds.has(id)) {
2576
+ const rewritten = rewriteDomWebRequires(code)
2577
+ if (rewritten) return { code: rewritten, map: null }
2578
+ }
2579
+ // like expo's use-dom babel plugin, any module whose program opens with
2580
+ // the directive is a dom component, wherever it lives.
2581
+ if (!/\.(tsx?|jsx?)$/.test(cleanId)) return null
2582
+ if (!code.includes('use dom')) return null
2583
+ if (!/(?:^|\n)\s*['"]use dom['"](?:\s*;|\s*\n)/.test(code)) return null
2584
+
2585
+ const isTS = /\.(tsx?|mts|cts)$/.test(cleanId)
2586
+ const isTSX = cleanId.endsWith('.tsx')
2587
+
2588
+ const { transformAsync } = await getDomBabelCore()
2589
+ const { expoUseDomDirectivePlugin } = sootsimPluginRequire(
2590
+ 'babel-preset-expo/build/plugins/use-dom-directive-plugin',
2591
+ )
2592
+
2593
+ const fileUrl = pathToFileURL(cleanId).href
2594
+ const hash = crypto.createHash('md5').update(fileUrl).digest('hex')
2595
+ domHashMap.set(hash, fileUrl)
2596
+
2597
+ const result = await transformAsync(code, {
2598
+ filename: cleanId,
2599
+ babelrc: false,
2600
+ configFile: false,
2601
+ sourceMaps: true,
2602
+ caller: {
2603
+ name: 'peachsim',
2604
+ platform: 'ios',
2605
+ isDev: !isProduction,
2606
+ projectRoot,
2607
+ },
2608
+ presets: isTS
2609
+ ? [
2610
+ [
2611
+ sootsimPluginRequire.resolve('@babel/preset-typescript'),
2612
+ { isTSX, allExtensions: isTSX, allowDeclareFields: true },
2613
+ ],
2614
+ ]
2615
+ : [],
2616
+ plugins: [
2617
+ [sootsimPluginRequire.resolve('@babel/plugin-syntax-jsx')],
2618
+ [expoUseDomDirectivePlugin],
2619
+ ],
2620
+ })
2621
+
2622
+ if (result?.code) {
2623
+ return {
2624
+ code: result.code,
2625
+ map: result.map ?? null,
2626
+ }
2627
+ }
2628
+ return null
2629
+ },
2630
+ }
2631
+ }