rnxsim 0.0.0 → 0.1.312

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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/detox/colors.ts +54 -0
  4. package/detox/config-loader.ts +135 -0
  5. package/detox/element-types.ts +39 -0
  6. package/detox/expectations.ts +537 -0
  7. package/detox/gestures.ts +480 -0
  8. package/detox/index.ts +2118 -0
  9. package/detox/jest-preset.cjs +51 -0
  10. package/detox/jest-setup-after-env.cjs +18 -0
  11. package/detox/matchers.ts +30 -0
  12. package/detox/motion-change.cjs +109 -0
  13. package/detox/motion-change.d.cts +35 -0
  14. package/detox/navigation.ts +43 -0
  15. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  17. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  18. package/detox/screenshots/theme-blue-toggle.png +0 -0
  19. package/detox/screenshots/theme-blue.png +0 -0
  20. package/detox/screenshots/theme-red-toggle.png +0 -0
  21. package/detox/screenshots/theme-red.png +0 -0
  22. package/dist-cli/bin.js +15 -0
  23. package/dist-cli/chunks/agent-NXVDDCFG.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-DXJCFKL3.js +17 -0
  25. package/dist-cli/chunks/app-fonts-W5AJZQJF.js +4 -0
  26. package/dist-cli/chunks/assert-3LOJEUDT.js +49 -0
  27. package/dist-cli/chunks/auth-MFTYYPNX.js +12 -0
  28. package/dist-cli/chunks/beta-GT3NN67B.js +4 -0
  29. package/dist-cli/chunks/camera-6EAK7CUL.js +22 -0
  30. package/dist-cli/chunks/chunk-274UD5FD.js +4 -0
  31. package/dist-cli/chunks/chunk-2SA2VTGP.js +5 -0
  32. package/dist-cli/chunks/chunk-33FKQH54.js +5 -0
  33. package/dist-cli/chunks/chunk-37I5AIS7.js +3 -0
  34. package/dist-cli/chunks/chunk-3D57IBPC.js +4 -0
  35. package/dist-cli/chunks/chunk-4BXCZHAU.js +4 -0
  36. package/dist-cli/chunks/chunk-5OX7ANRA.js +5 -0
  37. package/dist-cli/chunks/chunk-66NF2KNG.js +9 -0
  38. package/dist-cli/chunks/chunk-6UKJB2VC.js +125 -0
  39. package/dist-cli/chunks/chunk-7INBNA3U.js +26 -0
  40. package/dist-cli/chunks/chunk-7VWOSTD5.js +5 -0
  41. package/dist-cli/chunks/chunk-AY47WKS5.js +23 -0
  42. package/dist-cli/chunks/chunk-B7KMNHX7.js +4 -0
  43. package/dist-cli/chunks/chunk-BKNOLP3J.js +4 -0
  44. package/dist-cli/chunks/chunk-BRIZOR4K.js +28 -0
  45. package/dist-cli/chunks/chunk-DM6KUAOZ.js +4 -0
  46. package/dist-cli/chunks/chunk-EUAVBTKU.js +4 -0
  47. package/dist-cli/chunks/chunk-FTC4M26Q.js +4 -0
  48. package/dist-cli/chunks/chunk-FWYYCUAZ.js +4 -0
  49. package/dist-cli/chunks/chunk-G74YIEVS.js +7 -0
  50. package/dist-cli/chunks/chunk-GZDHEONW.js +507 -0
  51. package/dist-cli/chunks/chunk-HG7ORJMT.js +5 -0
  52. package/dist-cli/chunks/chunk-HMDOLCKG.js +15 -0
  53. package/dist-cli/chunks/chunk-JG6S4RSH.js +4 -0
  54. package/dist-cli/chunks/chunk-KR5BGMBZ.js +4 -0
  55. package/dist-cli/chunks/chunk-KZW5WBGU.js +983 -0
  56. package/dist-cli/chunks/chunk-L24Z3CRS.js +30 -0
  57. package/dist-cli/chunks/chunk-LDQ7HVCW.js +4 -0
  58. package/dist-cli/chunks/chunk-MENV7VLD.js +4 -0
  59. package/dist-cli/chunks/chunk-MNGQ42QE.js +568 -0
  60. package/dist-cli/chunks/chunk-N6A3SXMR.js +4 -0
  61. package/dist-cli/chunks/chunk-NLEMQ4PY.js +6 -0
  62. package/dist-cli/chunks/chunk-NPVGMH2N.js +101 -0
  63. package/dist-cli/chunks/chunk-OR3XTXEB.js +107 -0
  64. package/dist-cli/chunks/chunk-PEPUGJVI.js +4 -0
  65. package/dist-cli/chunks/chunk-Q3RGLERJ.js +142 -0
  66. package/dist-cli/chunks/chunk-SI3BUTQR.js +4 -0
  67. package/dist-cli/chunks/chunk-T4FT6CM7.js +4 -0
  68. package/dist-cli/chunks/chunk-TBMVBI26.js +7 -0
  69. package/dist-cli/chunks/chunk-TCWQJILS.js +5 -0
  70. package/dist-cli/chunks/chunk-TGQNQ6XT.js +766 -0
  71. package/dist-cli/chunks/chunk-TYE3WBSR.js +5 -0
  72. package/dist-cli/chunks/chunk-U4PN6JOT.js +6 -0
  73. package/dist-cli/chunks/chunk-VUFRB347.js +4 -0
  74. package/dist-cli/chunks/chunk-VZBLFMYC.js +10 -0
  75. package/dist-cli/chunks/chunk-WBT32R6N.js +25 -0
  76. package/dist-cli/chunks/chunk-XRGEEPJ5.js +38 -0
  77. package/dist-cli/chunks/chunk-Y2FSZSO7.js +71 -0
  78. package/dist-cli/chunks/chunk-YRV7HR3P.js +74 -0
  79. package/dist-cli/chunks/chunk-YZRFF4YA.js +14 -0
  80. package/dist-cli/chunks/chunk-ZMZTHU7V.js +4 -0
  81. package/dist-cli/chunks/cleanup-H3GWQB63.js +25 -0
  82. package/dist-cli/chunks/cli-version-3QPAJ3LC.js +4 -0
  83. package/dist-cli/chunks/compat-5WG2XJV7.js +20 -0
  84. package/dist-cli/chunks/config-ANMBVVLL.js +13 -0
  85. package/dist-cli/chunks/control-NSGNQFJG.js +4 -0
  86. package/dist-cli/chunks/daemon-JHF4BJ3U.js +4 -0
  87. package/dist-cli/chunks/debug-CHV2O2KF.js +200 -0
  88. package/dist-cli/chunks/desktop-FIPIK4CH.js +31 -0
  89. package/dist-cli/chunks/detox-CVUJP7K6.js +52 -0
  90. package/dist-cli/chunks/device-6WCUM4W6.js +18 -0
  91. package/dist-cli/chunks/diagnose-I6EMWEII.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-P27CYFTN.js +4 -0
  93. package/dist-cli/chunks/drivers-HQ6AKZT6.js +4 -0
  94. package/dist-cli/chunks/film-UOQFFYNF.js +74 -0
  95. package/dist-cli/chunks/flow-N27MRRNL.js +4 -0
  96. package/dist-cli/chunks/help-SF6FEGEE.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-NJYFSJKZ.js +4 -0
  98. package/dist-cli/chunks/home-paths-XOV44JEF.js +4 -0
  99. package/dist-cli/chunks/inspect-CWFRFQTY.js +4 -0
  100. package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +4 -0
  101. package/dist-cli/chunks/login-BPFQENCX.js +28 -0
  102. package/dist-cli/chunks/logout-5A45ZGY7.js +4 -0
  103. package/dist-cli/chunks/maestro-E4CSDMLM.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-CKMOJQX2.js +36 -0
  105. package/dist-cli/chunks/mode-SZYNMIQF.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-FRH4SFVG.js +4 -0
  107. package/dist-cli/chunks/perf-VXU2XJAX.js +48 -0
  108. package/dist-cli/chunks/permissions-IRIAOVND.js +13 -0
  109. package/dist-cli/chunks/record-RY7FPLLH.js +72 -0
  110. package/dist-cli/chunks/report-issue-KNWXOE53.js +29 -0
  111. package/dist-cli/chunks/runtime-AR3GJTGK.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-QLALR6GY.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-OQFMFVRL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-6TSRJFGB.js +42 -0
  115. package/dist-cli/chunks/serve-F54J2NQO.js +44 -0
  116. package/dist-cli/chunks/setup-7V6UKX4U.js +32 -0
  117. package/dist-cli/chunks/skills-QQBOSETH.js +50 -0
  118. package/dist-cli/chunks/storage-TAPMKK5O.js +37 -0
  119. package/dist-cli/chunks/store-7NX3S43H.js +4 -0
  120. package/dist-cli/chunks/telemetry-E7BUK6RA.js +4 -0
  121. package/dist-cli/chunks/timeline-IAM564NA.js +24 -0
  122. package/dist-cli/chunks/upgrade-CD55QXIX.js +7 -0
  123. package/dist-cli/chunks/upload-GZ6LIMYM.js +4 -0
  124. package/dist-cli/chunks/version-3DYJ5AS3.js +6 -0
  125. package/dist-cli/chunks/web-6NW6YLPI.js +4 -0
  126. package/dist-cli/chunks/what-happened-CZZDOOVG.js +25 -0
  127. package/dist-lib/agent-daemon-client.cjs +468 -0
  128. package/dist-lib/agent-events.cjs +48 -0
  129. package/dist-lib/agent-identity.cjs +53 -0
  130. package/dist-lib/agent-sessions.cjs +725 -0
  131. package/dist-lib/attached-projects.cjs +526 -0
  132. package/dist-lib/auth/shared-session.cjs +195 -0
  133. package/dist-lib/backend-origin.cjs +70 -0
  134. package/dist-lib/beta.cjs +41 -0
  135. package/dist-lib/beta.mjs +13 -0
  136. package/dist-lib/bridge-constants.cjs +161 -0
  137. package/dist-lib/cli-constants.cjs +32 -0
  138. package/dist-lib/config.cjs +96 -0
  139. package/dist-lib/detox/index.cjs +2863 -0
  140. package/dist-lib/detox/jest-preset.cjs +51 -0
  141. package/dist-lib/dev-bundle-resolution.cjs +223 -0
  142. package/dist-lib/home-paths.cjs +421 -0
  143. package/dist-lib/host/bridge-host.cjs +5600 -0
  144. package/dist-lib/host/fetch-proxy-handler.cjs +373 -0
  145. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  146. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  147. package/dist-lib/host/websocket-proxy.cjs +214 -0
  148. package/dist-lib/index.cjs +513 -0
  149. package/dist-lib/jump-to-source-babel.cjs +93 -0
  150. package/dist-lib/menu.cjs +620 -0
  151. package/dist-lib/menu.mjs +581 -0
  152. package/dist-lib/metro.cjs +297 -0
  153. package/dist-lib/profiles.cjs +246 -0
  154. package/dist-lib/public-brand.cjs +39 -0
  155. package/dist-lib/render-mode.cjs +54 -0
  156. package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
  157. package/dist-lib/sdk.cjs +2333 -0
  158. package/dist-lib/sdk.mjs +2240 -0
  159. package/dist-lib/skills.cjs +23911 -0
  160. package/dist-lib/vite.cjs +358 -0
  161. package/package.json +189 -18
  162. package/scripts/dev-server-scanner.ts +941 -0
  163. package/scripts/optional-demo-registry.ts +113 -0
  164. package/scripts/postinstall.cjs +3 -0
  165. package/scripts/preuninstall.cjs +118 -0
  166. package/skills/contrast/SKILL.md +171 -0
  167. package/skills/rnx-debug/SKILL.md +425 -0
  168. package/skills/rnx-perf/SKILL.md +295 -0
  169. package/skills/rnx-setup/SKILL.md +160 -0
  170. package/skills/rnx-test/SKILL.md +292 -0
  171. package/skills/rnx-visual/SKILL.md +122 -0
  172. package/src/agent-daemon-client.ts +410 -0
  173. package/src/agent-events.ts +71 -0
  174. package/src/agent-identity.ts +23 -0
  175. package/src/agent-prompt.ts +71 -0
  176. package/src/agent-sessions.ts +573 -0
  177. package/src/attached-projects.ts +539 -0
  178. package/src/auth/shared-session.ts +204 -0
  179. package/src/backend-origin.ts +49 -0
  180. package/src/beta.ts +19 -0
  181. package/src/bridge-constants.ts +171 -0
  182. package/src/browser-cache.ts +16 -0
  183. package/src/cli-constants.ts +1 -0
  184. package/src/cli-version.ts +32 -0
  185. package/src/codex-client.ts +215 -0
  186. package/src/config.ts +134 -0
  187. package/src/dev-bundle-resolution.ts +236 -0
  188. package/src/disk-cleanup.ts +466 -0
  189. package/src/home-paths.ts +552 -0
  190. package/src/host/agent-host.ts +666 -0
  191. package/src/host/bridge-host.ts +2626 -0
  192. package/src/host/fetch-proxy-handler.ts +397 -0
  193. package/src/host/fetch-proxy-overrides.ts +51 -0
  194. package/src/host/open-url.ts +89 -0
  195. package/src/host/websocket-proxy.ts +210 -0
  196. package/src/index.ts +12 -705
  197. package/src/jump-to-source-babel.ts +135 -0
  198. package/src/menu.ts +734 -0
  199. package/src/metro-plugin.ts +139 -0
  200. package/src/native-dev-bundle-url.ts +96 -0
  201. package/src/native-seam-manifest.ts +341 -0
  202. package/src/plist.ts +27 -0
  203. package/src/poll-until.ts +92 -0
  204. package/src/profiles.ts +179 -0
  205. package/src/public-brand.ts +9 -0
  206. package/src/render-mode.ts +27 -0
  207. package/src/runtime-assets.ts +228 -0
  208. package/src/runtime-delivery.ts +54 -0
  209. package/src/screenshots/compose.ts +422 -0
  210. package/src/screenshots/frame-compose.ts +454 -0
  211. package/src/screenshots/orchestrate.ts +256 -0
  212. package/src/screenshots/org-deck.ts +91 -0
  213. package/src/screenshots/registry.ts +58 -0
  214. package/src/screenshots/schema.ts +364 -0
  215. package/src/sdk.ts +7 -0
  216. package/src/settings-bundle.ts +418 -0
  217. package/src/skills/builtin/a11y-review.ts +132 -0
  218. package/src/skills/builtin/compat-check.ts +71 -0
  219. package/src/skills/builtin/maestro-test.ts +121 -0
  220. package/src/skills/builtin/perf-profile.ts +90 -0
  221. package/src/skills/builtin/screenshot-all.ts +52 -0
  222. package/src/skills/builtin/visual-diff.ts +100 -0
  223. package/src/skills/registry.ts +107 -0
  224. package/src/skills/types.ts +41 -0
  225. package/src/team-machine-prompt.ts +73 -0
  226. package/src/vite-plugin-one.ts +190 -0
  227. package/src/vite-plugin.ts +1750 -0
  228. package/src/worklets-babel.ts +134 -0
@@ -0,0 +1,1750 @@
1
+ // sootsim vite plugin — use in any vite project to get react-native resolution,
2
+ // native dep stubbing, and external app support.
3
+ //
4
+ // usage:
5
+ // import { sootsim } from 'sootsim/vite'
6
+ // export default defineConfig({ plugins: [sootsim()] })
7
+ //
8
+ // // with external app:
9
+ // export default defineConfig({ plugins: [sootsim({ app: '~/my-rn-app' })] })
10
+
11
+ import fs from 'fs'
12
+ import { createRequire } from 'module'
13
+ import path from 'path'
14
+ import { fileURLToPath } from 'url'
15
+ import { type Plugin, transformWithOxc } from 'vite'
16
+ import { resolveExportsHiddenSubpath } from '../../compat/src/resolve-hidden-subpath.ts'
17
+ import {
18
+ SOOTSIM_COMPAT_PUBLISHED_BROWSER_ENTRIES,
19
+ SOOTSIM_COMPAT_WRAPPER_REAL_PACKAGES,
20
+ SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SOURCE,
21
+ SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SPECIFIER,
22
+ compatStubsForBuildResolver,
23
+ reactNativeDeepStubsForBuildResolver,
24
+ } from '../../compat/src/stub-manifest.ts'
25
+ import { resolveSootsimBridgePort } from './bridge-constants.ts'
26
+ import {
27
+ REANIMATED_WORKLETIZATION_REGEX,
28
+ shouldApplyWorkletsPlugin,
29
+ transformWorkletsCode,
30
+ } from './worklets-babel.ts'
31
+
32
+ const sootsimPluginRequire = createRequire(import.meta.url)
33
+ const virtualIdPrefix = String.fromCharCode(0)
34
+
35
+ export interface SootSimOptions {
36
+ // directory of an external RN app to load
37
+ app?: string
38
+ // additional source directories to treat as app sources (metro transforms apply)
39
+ sources?: string[]
40
+ // additional packages that must resolve from the host project, not the external app
41
+ ownedPackages?: string[]
42
+ }
43
+
44
+ // derive sootsim's root from this file's location (src/vite-plugin.ts → ..)
45
+ const sootsimRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
46
+ const workspaceRoot = path.resolve(sootsimRoot, '..', '..')
47
+ const workspaceNodeModules = path.resolve(workspaceRoot, 'node_modules')
48
+ const workspaceTamaguiDir = path.resolve(workspaceNodeModules, '@tamagui')
49
+ // stubs and the mutable React bridge live in @sootsim/compat.
50
+ const compatRoot = path.resolve(sootsimRoot, '..', 'compat')
51
+
52
+ // the rendering engine and its shims live in the private sootsim-engine
53
+ // workspace package. resolve via workspace root rather than relative hops so
54
+ // renames of either package don't break things.
55
+ const engineRoot = path.resolve(workspaceRoot, 'packages/sootsim-engine')
56
+ const rnShimPath = path.resolve(engineRoot, 'src/react-native/index.ts')
57
+ const reactBridgePath = path.resolve(compatRoot, 'src/react-bridge.ts')
58
+ const sootsimBrowserSourceAliases = [
59
+ {
60
+ find: 'rnxsim/backend-origin',
61
+ replacement: path.resolve(sootsimRoot, 'src/backend-origin.ts'),
62
+ },
63
+ {
64
+ find: 'rnxsim/bridge-constants',
65
+ replacement: path.resolve(sootsimRoot, 'src/bridge-constants.ts'),
66
+ },
67
+ {
68
+ find: 'rnxsim/dev-bundle-resolution',
69
+ replacement: path.resolve(sootsimRoot, 'src/dev-bundle-resolution.ts'),
70
+ },
71
+ ]
72
+
73
+ const compatStubsDir = path.resolve(compatRoot, 'src/stubs')
74
+ const nativeAutoStubPath = path.resolve(compatStubsDir, 'native-auto-stub.ts')
75
+ const rnDeepStubDefault = path.resolve(compatStubsDir, 'react-native-internals.ts')
76
+
77
+ function getInternalTamaguiPackages(): string[] {
78
+ try {
79
+ const scoped = fs
80
+ .readdirSync(workspaceTamaguiDir, { withFileTypes: true })
81
+ .filter((entry) => entry.isDirectory())
82
+ .map((entry) => `@tamagui/${entry.name}`)
83
+
84
+ return ['tamagui', ...scoped].sort()
85
+ } catch {
86
+ return ['tamagui']
87
+ }
88
+ }
89
+
90
+ const internalTamaguiPackages = getInternalTamaguiPackages()
91
+
92
+ const vitePackageStubs = compatStubsForBuildResolver('vite')
93
+ const viteReactNativeDeepStubs = reactNativeDeepStubsForBuildResolver('vite')
94
+ const builtinStubs = Object.fromEntries(
95
+ vitePackageStubs.map((entry) => [entry.specifier, entry.stubFile]),
96
+ )
97
+ const rnLibraryStubs = Object.fromEntries(
98
+ viteReactNativeDeepStubs.map((entry) => [
99
+ entry.specifier,
100
+ path.resolve(compatStubsDir, entry.stubFile),
101
+ ]),
102
+ )
103
+
104
+ type PublishedBrowserPackage = {
105
+ specifier: string
106
+ packageDir: string
107
+ browserEntry: string
108
+ }
109
+
110
+ function resolvePublishedBrowserPackages(appDir: string): PublishedBrowserPackage[] {
111
+ const packageRequire = appDir
112
+ ? createRequire(path.join(appDir, 'package.json'))
113
+ : sootsimPluginRequire
114
+ return Object.values(SOOTSIM_COMPAT_PUBLISHED_BROWSER_ENTRIES).flatMap(
115
+ ({ specifier }) => {
116
+ try {
117
+ const packageJson = packageRequire.resolve(`${specifier}/package.json`)
118
+ const packageManifest = JSON.parse(fs.readFileSync(packageJson, 'utf8'))
119
+ const rootExport = Reflect.get(packageManifest.exports, '.')
120
+ const browserFile =
121
+ typeof rootExport === 'object' && rootExport !== null
122
+ ? Reflect.get(rootExport, 'browser')
123
+ : undefined
124
+ if (typeof browserFile !== 'string') return []
125
+ const packageDir = path.dirname(packageJson)
126
+ const browserEntry = path.resolve(packageDir, browserFile)
127
+ if (!fs.existsSync(browserEntry)) return []
128
+ return [{ specifier, packageDir, browserEntry }]
129
+ } catch {
130
+ return []
131
+ }
132
+ },
133
+ )
134
+ }
135
+
136
+ function resolvePublishedBrowserInternal(
137
+ source: string,
138
+ importer: string | undefined,
139
+ packages: PublishedBrowserPackage[],
140
+ ): string | null {
141
+ if (!importer || !source.startsWith('.')) return null
142
+ const cleanImporter = importer.split('?')[0]
143
+ const packageEntry = packages.find(({ packageDir }) => {
144
+ const relative = path.relative(packageDir, cleanImporter)
145
+ return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative)
146
+ })
147
+ if (!packageEntry) return null
148
+
149
+ const cleanSource = source.split('?')[0]
150
+ const resolved = path.resolve(path.dirname(cleanImporter), cleanSource)
151
+ const sourceExtension = path.extname(cleanSource)
152
+ if (sourceExtension) {
153
+ const candidate = `${resolved.slice(0, -sourceExtension.length)}.web${sourceExtension}`
154
+ return fs.existsSync(candidate) ? candidate : null
155
+ }
156
+
157
+ for (const extension of ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']) {
158
+ const fileCandidate = `${resolved}.web${extension}`
159
+ if (fs.existsSync(fileCandidate)) return fileCandidate
160
+ const indexCandidate = path.join(resolved, `index.web${extension}`)
161
+ if (fs.existsSync(indexCandidate)) return indexCandidate
162
+ }
163
+ return null
164
+ }
165
+
166
+ // resolve the real @react-navigation/native location at config time so the
167
+ // wrapper at compat/src/stubs/react-navigation-native.tsx can import it
168
+ // without bouncing back through the alias above. the wrapper imports from
169
+ // the synthetic specifier `@sootsim-internal/react-navigation-native-real`
170
+ // which is aliased to this absolute path; rolldown follows the path
171
+ // terminally so its `export *` static analysis sees the real export
172
+ // surface.
173
+ const reactNavigationNativeRealPath = (() => {
174
+ try {
175
+ return sootsimPluginRequire.resolve('@react-navigation/native')
176
+ } catch {
177
+ return null
178
+ }
179
+ })()
180
+
181
+ const reactNativeHapticFeedbackTouchablePath = (() => {
182
+ try {
183
+ return path.join(
184
+ path.dirname(
185
+ sootsimPluginRequire.resolve('react-native-haptic-feedback/package.json'),
186
+ ),
187
+ SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SOURCE,
188
+ )
189
+ } catch {
190
+ return null
191
+ }
192
+ })()
193
+
194
+ // `@expo/ui/swift-ui/modifiers` is pure JS (every modifier just returns a
195
+ // ModifierConfig via createModifier) and runs from the real bundle unchanged —
196
+ // the runtime stub-registry intentionally does NOT intercept it. but the
197
+ // `@expo/ui/swift-ui` stub alias is a vite PREFIX match, so without an explicit
198
+ // longer alias it captures `.../modifiers` and rewrites it to
199
+ // `<stubs>/expo-ui.ts/modifiers` ("Not a directory"). resolve the real subpath
200
+ // here and alias it first (stubAliases are sorted longest-first, and this is
201
+ // longer than `@expo/ui/swift-ui`, so it wins).
202
+ const expoUiSwiftUiModifiersRealPath = (() => {
203
+ try {
204
+ return sootsimPluginRequire.resolve('@expo/ui/swift-ui/modifiers')
205
+ } catch {
206
+ return null
207
+ }
208
+ })()
209
+
210
+ // the same prefix-alias trap, for the whole Android half of the package.
211
+ // `@expo/ui/jetpack-compose` and its `/modifiers` subpath are deliberately NOT
212
+ // intercepted: every component is a thin `requireNativeView('ExpoUI', …)`
213
+ // wrapper and the Android views come from the compat native seam, so the real
214
+ // upstream JS has to load. Without these two longer aliases the `@expo/ui` stub
215
+ // prefix rewrites them to `<stubs>/expo-ui.ts/jetpack-compose` ("Not a
216
+ // directory") and every Android Expo UI screen fails to build.
217
+ const expoUiJetpackComposeRealAliases = [
218
+ '@expo/ui/jetpack-compose/modifiers',
219
+ '@expo/ui/jetpack-compose',
220
+ ].flatMap((specifier) => {
221
+ try {
222
+ return [{ find: specifier, replacement: sootsimPluginRequire.resolve(specifier) }]
223
+ } catch {
224
+ return []
225
+ }
226
+ })
227
+
228
+ // `react-native-gesture-handler/ReanimatedSwipeable` and `.../DrawerLayout` are
229
+ // upstream pure JS and should load from the installed package. The root RNGH
230
+ // package still aliases to SootSim's native seam, so add exact subpath aliases
231
+ // before the root stub can rewrite them to
232
+ // `<stubs>/react-native-gesture-handler.ts/<subpath>`.
233
+ function rnghSubpathRealPath(subpath: string): string | null {
234
+ try {
235
+ const packageJsonPath = sootsimPluginRequire.resolve(
236
+ `react-native-gesture-handler/${subpath}/package.json`,
237
+ )
238
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
239
+ const moduleEntry = packageJson?.module
240
+ if (typeof moduleEntry !== 'string' || moduleEntry.length === 0) return null
241
+ const modulePath = path.resolve(path.dirname(packageJsonPath), moduleEntry)
242
+ if (fs.existsSync(modulePath) && fs.statSync(modulePath).isFile()) return modulePath
243
+ const jsPath = `${modulePath}.js`
244
+ if (fs.existsSync(jsPath)) return jsPath
245
+ const indexPath = path.join(modulePath, 'index.js')
246
+ return fs.existsSync(indexPath) ? indexPath : null
247
+ } catch {
248
+ return null
249
+ }
250
+ }
251
+
252
+ const rnghReanimatedSwipeableRealPath = rnghSubpathRealPath('ReanimatedSwipeable')
253
+ const rnghDrawerLayoutRealPath = rnghSubpathRealPath('DrawerLayout')
254
+
255
+ const rnghGestureHandlerRootViewContextRealPath = (() => {
256
+ try {
257
+ return path.join(
258
+ path.dirname(
259
+ sootsimPluginRequire.resolve('react-native-gesture-handler/package.json'),
260
+ ),
261
+ 'lib/module/GestureHandlerRootViewContext.js',
262
+ )
263
+ } catch {
264
+ return null
265
+ }
266
+ })()
267
+ const rnghGestureHandlerRootViewContextShimPath = path.resolve(
268
+ compatStubsDir,
269
+ 'rngh-gesture-handler-root-view-context.ts',
270
+ )
271
+ const rnghRootStubPath = path.resolve(compatStubsDir, 'react-native-gesture-handler.ts')
272
+ const rnghLibModuleRoot = rnghGestureHandlerRootViewContextRealPath
273
+ ? path.dirname(rnghGestureHandlerRootViewContextRealPath)
274
+ : null
275
+
276
+ const compatWrapperRealPackageAliases: Array<{
277
+ find: string
278
+ replacement: string
279
+ }> = []
280
+ for (const { specifier, packageName } of Object.values(
281
+ SOOTSIM_COMPAT_WRAPPER_REAL_PACKAGES,
282
+ )) {
283
+ let replacement = resolveExportsHiddenSubpath(packageName, (specifier) =>
284
+ sootsimPluginRequire.resolve(specifier),
285
+ )
286
+ if (!replacement) {
287
+ try {
288
+ replacement = sootsimPluginRequire.resolve(packageName)
289
+ } catch {}
290
+ }
291
+ if (!replacement) {
292
+ // a dropped alias only surfaces much later as an opaque
293
+ // "failed to resolve @sootsim-internal/…", so say it here instead.
294
+ console.warn(
295
+ `[sootsim] compat wrapper alias ${specifier} -> ${packageName} did not resolve`,
296
+ )
297
+ continue
298
+ }
299
+ compatWrapperRealPackageAliases.push({ find: specifier, replacement })
300
+ }
301
+
302
+ // packages that must always resolve from sootsim to ensure single instances
303
+ // across the renderer/runtime itself. keep this list minimal for external apps.
304
+ const coreOwnedPackages = [
305
+ 'react',
306
+ 'react-dom',
307
+ 'react-reconciler',
308
+ 'react/jsx-runtime',
309
+ 'react/jsx-dev-runtime',
310
+ 'canvaskit-wasm',
311
+ 'canvaskit-wasm/full',
312
+ 'yoga-layout',
313
+ ]
314
+
315
+ const nativePackagePatterns = [
316
+ /^expo-/,
317
+ /^react-native-/,
318
+ /^@react-native\//,
319
+ /^@react-native-community\//,
320
+ /^@expo\//,
321
+ /^expo$/,
322
+ ]
323
+
324
+ function getPackageName(source: string): string {
325
+ return source.startsWith('@')
326
+ ? source.split('/').slice(0, 2).join('/')
327
+ : source.split('/')[0]
328
+ }
329
+
330
+ function isNativePackage(source: string): boolean {
331
+ return nativePackagePatterns.some((p) => p.test(source))
332
+ }
333
+
334
+ function moduleExistsIn(pkgName: string, dir: string): boolean {
335
+ try {
336
+ return fs.existsSync(path.join(dir, 'node_modules', pkgName))
337
+ } catch {
338
+ return false
339
+ }
340
+ }
341
+
342
+ export function sootsim(options: SootSimOptions = {}): Plugin[] {
343
+ const appDir = options.app ? path.resolve(options.app) : ''
344
+ const extraSources = (options.sources || []).map((s) => path.resolve(s))
345
+ const ownedPackages = new Set([...coreOwnedPackages, ...(options.ownedPackages || [])])
346
+ const publishedBrowserPackages = resolvePublishedBrowserPackages(appDir)
347
+
348
+ function isAppSource(filePath: string): boolean {
349
+ // exclude node_modules — only actual app source files
350
+ if (filePath.includes('/node_modules/')) return false
351
+ if (appDir && filePath.startsWith(appDir)) return true
352
+ return extraSources.some((s) => filePath.startsWith(s))
353
+ }
354
+
355
+ return [
356
+ wsBridgePlugin(),
357
+ reactBridgePlugin(),
358
+ {
359
+ name: 'sootsim-published-browser-internals',
360
+ enforce: 'pre',
361
+ resolveId: {
362
+ filter: { id: /^\./ },
363
+ handler(source, importer) {
364
+ return resolvePublishedBrowserInternal(
365
+ source,
366
+ importer,
367
+ publishedBrowserPackages,
368
+ )
369
+ },
370
+ },
371
+ },
372
+ sootsimConfigPlugin(appDir, extraSources, publishedBrowserPackages),
373
+ rnghUpstreamInternalResolvePlugin(),
374
+ fixJsxRuntimeExports(),
375
+ flowStripTransform(),
376
+ nodeModulesJsxTransform(),
377
+ // must run before metroNativeResolve — that plugin resolves relative
378
+ // imports like `./bindings` to absolute file paths via the platform-
379
+ // extension lookup, and once it returns a resolved id, downstream
380
+ // resolveId hooks don't see the source string anymore.
381
+ keyboardControllerBindingsRedirect(),
382
+ reanimatedNativeSeamRedirect(),
383
+ manifestNativeSeamRedirect(),
384
+ workletsBabelTransform(isAppSource),
385
+ metroNativeResolve(isAppSource),
386
+ reactNativeRequirePlugin(isAppSource),
387
+ ...(appDir ? [externalAppResolvePlugin(appDir, isAppSource, ownedPackages)] : []),
388
+ ...(appDir || extraSources.length > 0
389
+ ? [externalAppTransformPlugin(isAppSource)]
390
+ : []),
391
+ stubMissingNativeDeps(appDir),
392
+ stubMissingImages(isAppSource),
393
+ ]
394
+ }
395
+
396
+ function stripViteQuery(id: string): string {
397
+ return id.split('?')[0]
398
+ }
399
+
400
+ function isPathInside(child: string, parent: string): boolean {
401
+ const rel = path.relative(parent, child)
402
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel))
403
+ }
404
+
405
+ function rnghUpstreamInternalResolvePlugin(): Plugin {
406
+ return {
407
+ name: 'sootsim-rngh-upstream-internal-resolve',
408
+ enforce: 'pre',
409
+ resolveId: {
410
+ filter: {
411
+ id: /(?:^\.\.\/\.\.(?:\.(?:js|jsx|mjs|ts|tsx))?$|GestureDetector(?:\.(?:js|jsx|mjs|ts|tsx))?$|GestureHandlerRootViewContext(?:\.(?:js|jsx|mjs|ts|tsx))?$|(?:PanGestureHandler|TapGestureHandler|State)(?:\.(?:js|jsx|mjs|ts|tsx))?$)/,
412
+ },
413
+ handler(source, importer) {
414
+ if (!importer || !rnghLibModuleRoot) return null
415
+ const importerPath = stripViteQuery(importer)
416
+ if (!isPathInside(importerPath, rnghLibModuleRoot)) return null
417
+ const sourcePath = source.replace(/\.(js|jsx|mjs|ts|tsx)$/, '')
418
+ if (
419
+ sourcePath === '../..' ||
420
+ sourcePath.endsWith('/handlers/gestures/GestureDetector') ||
421
+ // upstream's DrawerLayout builds on the old-API handlers and the
422
+ // State enum; both come from our seam, not upstream's native modules.
423
+ sourcePath.endsWith('/handlers/PanGestureHandler') ||
424
+ sourcePath.endsWith('/handlers/TapGestureHandler') ||
425
+ sourcePath === '../State'
426
+ ) {
427
+ return rnghRootStubPath
428
+ }
429
+ if (sourcePath.endsWith('GestureHandlerRootViewContext')) {
430
+ return rnghGestureHandlerRootViewContextShimPath
431
+ }
432
+ return null
433
+ },
434
+ },
435
+ }
436
+ }
437
+
438
+ // shared worker plugin chain for the engine's shell-host build (used by
439
+ // both `vite.config.ts` for dev/watch and `vite.build.config.ts` for prod).
440
+ // keep these here — duplicating the list inline drifts (which is exactly how
441
+ // the prod shell worker shipped without `reanimatedNativeSeamRedirect` and
442
+ // threw "Native part of Reanimated doesn't seem to be initialized" until
443
+ // 2e6a8450).
444
+ //
445
+ // shell-host.ts spawns shell-worker.ts via Vite's declarative
446
+ // `new Worker(new URL(...))` pattern, so shell-worker is a worker chunk of
447
+ // the engine build. apply react-bridge / jsx-runtime fixes plus the
448
+ // reanimated/keyboard-controller native-seam redirects so upstream's
449
+ // reanimated wires up our turbomodule + globals inside the worker too.
450
+ // without these, worker bundles see upstream's original
451
+ // `NativeReanimatedModule.ts`, `TurboModuleRegistry.get('ReanimatedModule')`
452
+ // returns null on web/sootsim, `__reanimatedModuleProxy` never gets set,
453
+ // and the NativeReanimated constructor throws on shell-worker mount.
454
+ //
455
+ // workletsBabelTransform populates `__closure` on user-code worklets so
456
+ // useAnimatedStyle/useDerivedValue/etc. subscribe correctly inside the
457
+ // worker. matching is content-driven today, so the predicate is `() => true`.
458
+ export function engineShellWorkerPlugins(): Plugin[] {
459
+ return [
460
+ workerReactBridgePlugin(),
461
+ fixJsxRuntimeExports(),
462
+ flowStripTransform(),
463
+ nodeModulesJsxTransform(),
464
+ reanimatedNativeSeamRedirect(),
465
+ manifestNativeSeamRedirect(),
466
+ keyboardControllerBindingsRedirect(),
467
+ rnghUpstreamInternalResolvePlugin(),
468
+ workletsBabelTransform(() => true),
469
+ ]
470
+ }
471
+
472
+ // react-native-keyboard-controller is pure-JS for everything except its
473
+ // native-event seam (`bindings.ts` / `bindings.native.ts`) and RN platform
474
+ // `findNodeHandle` resolution. let upstream's components, hooks, animated
475
+ // module, and KeyboardAvoidingView resolve from node_modules unchanged, and
476
+ // redirect only those native/platform seams.
477
+ //
478
+ // the redirect runs `enforce: 'pre'` so it sees `./bindings` / `../bindings`
479
+ // / `../../bindings` strings before vite's native resolver collapses them to
480
+ // absolute paths. matches by importer path (must be inside upstream's
481
+ // node_modules root) and source basename.
482
+ export function keyboardControllerBindingsRedirect(): Plugin {
483
+ const target = path.resolve(compatStubsDir, 'react-native-keyboard-controller.ts')
484
+ const findNodeHandleTarget = '\0sootsim:rnkc-find-node-handle-native'
485
+ const nativePlatformBasenames = new Set(['findNodeHandle', 'reanimated'])
486
+ const nativeExts = [
487
+ '.ios.tsx',
488
+ '.ios.ts',
489
+ '.ios.jsx',
490
+ '.ios.js',
491
+ '.native.tsx',
492
+ '.native.ts',
493
+ '.native.jsx',
494
+ '.native.js',
495
+ '.native.mjs',
496
+ ]
497
+ const resolveNativePlatformFile = (source: string, importer: string): string | null => {
498
+ const base = path.resolve(path.dirname(importer), source)
499
+ for (const ext of nativeExts) {
500
+ const candidate = base + ext
501
+ try {
502
+ if (fs.existsSync(candidate)) return candidate
503
+ } catch {}
504
+ }
505
+ for (const ext of nativeExts) {
506
+ const candidate = path.join(base, 'index' + ext)
507
+ try {
508
+ if (fs.existsSync(candidate)) return candidate
509
+ } catch {}
510
+ }
511
+ return null
512
+ }
513
+ return {
514
+ name: 'sootsim-keyboard-controller-bindings-redirect',
515
+ enforce: 'pre',
516
+ resolveId: {
517
+ filter: {
518
+ id: /(?:^|\/)(?:bindings(?:\.native)?|findNodeHandle|reanimated)$/,
519
+ },
520
+ handler(source, importer) {
521
+ if (!importer) return null
522
+ if (!importer.includes('/react-native-keyboard-controller/')) return null
523
+ // keep bindings imports — strip optional .native and any leading
524
+ // `./`, `../`, `../../` so the depth doesn't matter.
525
+ const basename = source.split('/').pop() ?? ''
526
+ if (nativePlatformBasenames.has(basename)) {
527
+ return (
528
+ resolveNativePlatformFile(source, importer) ??
529
+ (basename === 'findNodeHandle' ? findNodeHandleTarget : null)
530
+ )
531
+ }
532
+ if (basename !== 'bindings' && basename !== 'bindings.native') return null
533
+ // don't redirect bindings imports that already resolved to our file
534
+ if (importer === target) return null
535
+ return target
536
+ },
537
+ },
538
+ load: {
539
+ filter: {
540
+ id: new RegExp(`^${virtualIdPrefix}sootsim:rnkc-find-node-handle-native$`),
541
+ },
542
+ handler() {
543
+ return `export { findNodeHandle } from ${JSON.stringify(rnShimPath)};`
544
+ },
545
+ },
546
+ }
547
+ }
548
+
549
+ // react-native-reanimated is mostly pure-JS — useSharedValue, useAnimatedStyle,
550
+ // useDerivedValue, withTiming/withSpring/etc., createAnimatedComponent, hooks,
551
+ // layout animations, easing all sit on top of a thin native seam:
552
+ //
553
+ // - src/specs/NativeReanimatedModule (turbomodule that installs __reanimatedModuleProxy)
554
+ // - src/platformFunctions/scrollTo
555
+ // - src/platformFunctions/measure
556
+ // - src/platformFunctions/setNativeProps
557
+ // - src/platformFunctions/dispatchCommand
558
+ // - src/platformFunctions/setGestureState
559
+ //
560
+ // per the project policy of not shimming pure-JS libs, we let upstream resolve
561
+ // from node_modules and redirect ONLY the native seam to our compat stub
562
+ // (one file at packages/compat/src/stubs/react-native-reanimated.ts that
563
+ // exports the right shape — default = turbomodule, named = platform fns).
564
+ //
565
+ // upstream's `assertWorkletsVersion` (DEV-only) imports a build-time script
566
+ // that does `require('react-native-worklets/package.json')`. our flat-file
567
+ // stub for react-native-worklets has no such subpath, so this throws under
568
+ // vite's worker build. redirect the validate script to a no-op stub.
569
+ const reanimatedNativeSeamFiles = new Set([
570
+ 'NativeReanimatedModule',
571
+ 'scrollTo',
572
+ 'measure',
573
+ 'setNativeProps',
574
+ 'dispatchCommand',
575
+ 'setGestureState',
576
+ ])
577
+ const reanimatedFabricUtilsRedirectPath = path.resolve(
578
+ compatStubsDir,
579
+ 'react-native-reanimated-fabric-utils.ts',
580
+ )
581
+ const reanimatedValidateWorkletsVersionPath = path.resolve(
582
+ compatStubsDir,
583
+ 'react-native-reanimated-validate-worklets-version.ts',
584
+ )
585
+ export function reanimatedNativeSeamRedirect(): Plugin {
586
+ const target = path.resolve(compatStubsDir, 'react-native-reanimated.ts')
587
+ return {
588
+ name: 'sootsim-reanimated-native-seam-redirect',
589
+ enforce: 'pre',
590
+ resolveId: {
591
+ filter: {
592
+ id: /(?:^react-native-worklets\/package\.json$|^react-native-reanimated\/scripts\/validate-worklets-version(?:\.js)?$|(?:^|\/)(?:fabricUtils|NativeReanimatedModule|scrollTo|measure|setNativeProps|dispatchCommand|setGestureState)(?:\.(?:native|web|ios|android))?$)/,
593
+ },
594
+ handler(source, importer) {
595
+ // upstream's `assertWorkletsVersion` (DEV-only) imports a build-time
596
+ // script that does `require('react-native-worklets/package.json')`.
597
+ // our flat-file stub for react-native-worklets has no such subpath,
598
+ // so this throws under vite's worker build. resolve the package.json
599
+ // import to a synthetic virtual id and load synthetic JSON below.
600
+ if (source === 'react-native-worklets/package.json') {
601
+ return '\0sootsim:react-native-worklets-package-json'
602
+ }
603
+ if (!importer) return null
604
+ // no-op the assertWorkletsVersion validator — same reason. matches both
605
+ // the bare import and the .js-suffixed resolved form some bundlers see.
606
+ if (
607
+ source === 'react-native-reanimated/scripts/validate-worklets-version' ||
608
+ source === 'react-native-reanimated/scripts/validate-worklets-version.js'
609
+ ) {
610
+ return reanimatedValidateWorkletsVersionPath
611
+ }
612
+ if (!importer.includes('/react-native-reanimated/')) return null
613
+ if (importer === target) return null
614
+ // strip optional `.web` / `.native` extension and leading `./`s — the
615
+ // redirect should match regardless of relative depth or platform suffix.
616
+ const basename = (source.split('/').pop() ?? '').replace(
617
+ /\.(native|web|ios|android)$/,
618
+ '',
619
+ )
620
+ // keep fabricUtils on the SootSim Fabric public-instance contract. the
621
+ // redirected module reads the constructor-attached handle for native
622
+ // refs and uses ReactFabric's renderer lookup for composite refs.
623
+ if (
624
+ basename === 'fabricUtils' &&
625
+ importer !== reanimatedFabricUtilsRedirectPath
626
+ ) {
627
+ return reanimatedFabricUtilsRedirectPath
628
+ }
629
+ if (!reanimatedNativeSeamFiles.has(basename)) return null
630
+ if (process.env.SOOTSIM_REANIMATED_REDIRECT_DEBUG) {
631
+ console.log(
632
+ '[reanimatedRedirect]',
633
+ basename,
634
+ '<-',
635
+ source,
636
+ 'from',
637
+ importer.slice(-80),
638
+ )
639
+ }
640
+ return target
641
+ },
642
+ },
643
+ load: {
644
+ filter: {
645
+ id: new RegExp(`^${virtualIdPrefix}sootsim:react-native-worklets-package-json$`),
646
+ },
647
+ handler() {
648
+ // synthetic package.json for react-native-worklets — see resolveId
649
+ // above. just enough for upstream's version-validator to read `version`.
650
+ return `export default ${JSON.stringify({ version: '0.0.0-sootsim', name: 'react-native-worklets' })}`
651
+ },
652
+ },
653
+ }
654
+ }
655
+
656
+ // generalised native-seam redirect driven by NATIVE_SEAM_MANIFEST. each entry
657
+ // names an upstream package + a list of basenames to redirect to a single
658
+ // stub. agents migrating from wholesale-rewrite stubs to the native-seam
659
+ // pattern append entries here without touching the plugin code.
660
+ //
661
+ // the reanimated plugin above stays separate because it has package-specific
662
+ // edge cases (fabricUtils, validate-worklets-version). new migrations should
663
+ // use the manifest path unless they need similar special handling.
664
+ import { NATIVE_SEAM_MANIFEST } from './native-seam-manifest.ts'
665
+ export function manifestNativeSeamRedirect(): Plugin {
666
+ // build a fast lookup: pkg → { basenames, target }
667
+ const byPkg = new Map<
668
+ string,
669
+ { basenames: Set<string>; target: string; notes?: string }
670
+ >()
671
+ for (const entry of NATIVE_SEAM_MANIFEST) {
672
+ const existing = byPkg.get(entry.pkg)
673
+ if (existing) {
674
+ for (const b of entry.seamBasenames) existing.basenames.add(b)
675
+ } else {
676
+ byPkg.set(entry.pkg, {
677
+ basenames: new Set(entry.seamBasenames),
678
+ target: entry.target,
679
+ notes: entry.notes,
680
+ })
681
+ }
682
+ }
683
+ const sourceFilter = new RegExp(
684
+ `(?:^|/)(?:${[...byPkg.values()]
685
+ .flatMap(({ basenames }) => [...basenames])
686
+ .map((basename) => basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
687
+ .join('|')})(?:\\.(?:native|web|ios|android))?$`,
688
+ )
689
+ return {
690
+ name: 'sootsim-manifest-native-seam-redirect',
691
+ enforce: 'pre',
692
+ resolveId: {
693
+ filter: { id: sourceFilter },
694
+ handler(source, importer) {
695
+ if (!importer || byPkg.size === 0) return null
696
+ // find the package whose path appears in the importer. matches both
697
+ // `/<pkg>/` (unscoped) and `/@scope/<pkg>/` (scoped — the leading `@`
698
+ // is part of the pkg name in the manifest, e.g. `@notifee/react-native`).
699
+ let match: { basenames: Set<string>; target: string; notes?: string } | undefined
700
+ let matchedPkg: string | undefined
701
+ for (const [pkg, entry] of byPkg) {
702
+ if (importer.includes(`/${pkg}/`)) {
703
+ match = entry
704
+ matchedPkg = pkg
705
+ break
706
+ }
707
+ }
708
+ if (!match) return null
709
+ if (importer === match.target) return null
710
+ const basename = (source.split('/').pop() ?? '').replace(
711
+ /\.(native|web|ios|android)$/,
712
+ '',
713
+ )
714
+ if (!match.basenames.has(basename)) return null
715
+ if (process.env.SOOTSIM_NATIVE_SEAM_DEBUG) {
716
+ console.log(
717
+ '[nativeSeamRedirect]',
718
+ `${matchedPkg}:${basename}`,
719
+ '<-',
720
+ source,
721
+ 'from',
722
+ importer.slice(-80),
723
+ match.notes ? `(${match.notes})` : '',
724
+ )
725
+ }
726
+ return match.target
727
+ },
728
+ },
729
+ }
730
+ }
731
+
732
+ // start WS bridge server for debug CLI connectivity. exported so shell
733
+ // vite can install it standalone (engine builds via `vite build --watch`,
734
+ // which doesn't fire configureServer — the bridge has to run on shell).
735
+ export function wsBridgePlugin(): Plugin {
736
+ let bridgeHost: {
737
+ startAsync?: (options?: { silent?: boolean }) => Promise<number>
738
+ close?: () => Promise<void> | void
739
+ } | null = null
740
+ // the port the bridge ACTUALLY bound, which is not necessarily the one it
741
+ // asked for: the host scans forward from its preferred port on EADDRINUSE.
742
+ // the page has to be told this value. when it derived its own guess from
743
+ // the shell's http port instead, any box where 7668 was already taken sent
744
+ // the page dialing a port nobody listens on, and the only symptom was a
745
+ // websocket console error during boot.
746
+ let boundPort: Promise<number | null> | null = null
747
+ return {
748
+ name: 'sootsim-ws-bridge',
749
+ apply: 'serve',
750
+ configureServer(server) {
751
+ if (boundPort) return
752
+ // lazy import to avoid pulling ws into the main bundle.
753
+ // note: the .ts extension is required on vite 8 / node ESM — without
754
+ // it, import() throws "Cannot find module" and the bridge silently
755
+ // never starts.
756
+ boundPort = import('./host/bridge-host.ts')
757
+ .then(({ SootSimBridgeHost }) => {
758
+ const host = new SootSimBridgeHost({
759
+ port: resolveSootsimBridgePort({
760
+ explicitPort: process.env.SOOTSIM_BRIDGE_PORT,
761
+ portOffset: process.env.PORT_OFFSET,
762
+ }),
763
+ writeDevLockfile: true,
764
+ // resolved on every lockfile heartbeat: the address is null until
765
+ // vite's http server is listening, then the real bound port. lets
766
+ // CLI commands resolve the shell base URL from the same lockfile
767
+ // that names the bridge port (one world, no split-brain).
768
+ getShellPort: () => {
769
+ const addr = server.httpServer?.address()
770
+ if (addr && typeof addr === 'object') return addr.port
771
+ return server.config.server.port || null
772
+ },
773
+ })
774
+ bridgeHost = host
775
+ // startAsync, not start: the whole point is to learn the bound port.
776
+ return host.startAsync({ silent: true })
777
+ })
778
+ .catch((err: unknown) => {
779
+ // log but don't crash — some environments (e.g. production builds)
780
+ // don't have ws available. silently swallowing has caused debugging
781
+ // pain; print the message instead.
782
+ const message = err instanceof Error ? err.message : String(err)
783
+ console.warn('[rnx] ws bridge failed to start:', message)
784
+ return null
785
+ })
786
+
787
+ // cleanup on server close (HMR restart)
788
+ server.httpServer?.on('close', () => {
789
+ if (bridgeHost) {
790
+ void bridgeHost.close?.()
791
+ bridgeHost = null
792
+ }
793
+ boundPort = null
794
+ })
795
+ },
796
+ // publish the bound port to the page. head-prepend so it lands before the
797
+ // module script that reads it. `vite build` never runs configureServer, so
798
+ // boundPort stays null there and nothing is injected.
799
+ //
800
+ // `||` rather than a plain assignment, and do not "simplify" it away: the
801
+ // playwright driver and the electron preload inject the port of the daemon
802
+ // they actually own via addInitScript, which runs before any page script.
803
+ // that value is authoritative and must win, so this dev server only ever
804
+ // fills in a blank — it never overwrites a port someone else published.
805
+ async transformIndexHtml() {
806
+ const port = boundPort ? await boundPort : null
807
+ if (port == null) return
808
+ return [
809
+ {
810
+ tag: 'script',
811
+ injectTo: 'head-prepend' as const,
812
+ children: `window.__sootsimBridgePort=window.__sootsimBridgePort||${port};`,
813
+ },
814
+ ]
815
+ },
816
+ }
817
+ }
818
+
819
+ // applies resolve, define, optimizeDeps config
820
+ function sootsimConfigPlugin(
821
+ appDir: string,
822
+ extraSources: string[],
823
+ publishedBrowserPackages: PublishedBrowserPackage[],
824
+ ): Plugin {
825
+ return {
826
+ name: 'sootsim-config',
827
+ config(_, { mode }) {
828
+ const fsAllow = ['.']
829
+ for (const src of extraSources) fsAllow.push(src)
830
+ if (appDir) {
831
+ fsAllow.push(appDir)
832
+ const appNodeModules = path.join(appDir, 'node_modules')
833
+ if (fs.existsSync(appNodeModules)) fsAllow.push(appNodeModules)
834
+ }
835
+
836
+ // build alias list from builtin stubs
837
+ const stubAliases = Object.entries(builtinStubs)
838
+ // sort longest keys first so subpath aliases (e.g. foo/Bar) match before
839
+ // their parent package (e.g. foo) — vite processes aliases in order
840
+ .sort((a, b) => b[0].length - a[0].length)
841
+ .map(([find, file]) => ({
842
+ find,
843
+ replacement: path.resolve(compatStubsDir, file),
844
+ }))
845
+
846
+ const publishedBrowserEntryAliases = publishedBrowserPackages.map(
847
+ ({ specifier, browserEntry }) => ({
848
+ find: new RegExp(`^${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
849
+ replacement: browserEntry,
850
+ }),
851
+ )
852
+
853
+ const dedupe = ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime']
854
+
855
+ // internal sootsim builds must keep the entire Tamagui package family on a
856
+ // single module graph. mixing raw @fs modules with prebundled .vite/deps
857
+ // versions breaks context identity (Adapt/Portal/ZIndex/etc).
858
+ if (!appDir) {
859
+ dedupe.push(...internalTamaguiPackages)
860
+ }
861
+
862
+ const optimizeDepsExclude = [
863
+ '@tamagui/config',
864
+ '@tamagui/demos',
865
+ 'react-native',
866
+ // tamagui native variants reference RN internals that the metro transform
867
+ // handles — must go through sootsim plugin, not rolldown pre-bundling
868
+ ...(!appDir ? internalTamaguiPackages : []),
869
+ // ships .js with JSX that rolldown can't parse
870
+ 'react-native-actions-sheet',
871
+ // these packages import `react-native-reanimated` and `react-native-gesture-handler`,
872
+ // both of which we alias to local stubs. pre-bundling would resolve those imports
873
+ // against the REAL packages (the alias only applies to main-plugin resolveId), so
874
+ // the resulting dep has a different reanimated instance than the app graph. keep
875
+ // them served live so our plugin aliases kick in.
876
+ '@gorhom/bottom-sheet',
877
+ '@gorhom/portal',
878
+ // these reviewed packages publish a supported browser artifact. keep
879
+ // them on the live graph so the exact alias below wins over their
880
+ // `react-native` export during dependency optimization too.
881
+ ...Object.values(SOOTSIM_COMPAT_PUBLISHED_BROWSER_ENTRIES).map(
882
+ ({ specifier }) => specifier,
883
+ ),
884
+ ]
885
+
886
+ return {
887
+ define: {
888
+ 'process.env.NODE_ENV': JSON.stringify(mode),
889
+ 'process.env.TEST_NATIVE_PLATFORM': JSON.stringify(''),
890
+ 'process.env.TAMAGUI_TARGET': JSON.stringify('native'),
891
+ // expo sdk 57's winter runtime (expo/src/winter/runtime.native.ts)
892
+ // replaces globalThis.fetch with a native implementation whose
893
+ // Response class extends ExpoFetchModule.NativeResponse. that native
894
+ // module does not exist in a browser, so constructing any response
895
+ // throws `this.addListener is not a function` and every request the
896
+ // app makes fails. upstream's own web build keeps the platform fetch
897
+ // (src/winter/fetch/fetch.web.ts is `globalThis.fetch`), and this is
898
+ // upstream's supported switch for that — it removes the native fetch
899
+ // install, leaving the browser's streaming fetch in place.
900
+ 'process.env.EXPO_PUBLIC_USE_RN_FETCH': JSON.stringify('1'),
901
+ global: 'globalThis',
902
+ },
903
+ optimizeDeps: {
904
+ include: [
905
+ 'react',
906
+ 'react-dom',
907
+ 'react-dom/client',
908
+ 'react-reconciler',
909
+ 'react-reconciler/constants',
910
+ 'react/jsx-runtime',
911
+ 'react/jsx-dev-runtime',
912
+ '@react-native/normalize-color',
913
+ // CJS deps transitively imported by @gorhom/bottom-sheet and
914
+ // @gorhom/portal (both excluded above). vite's CJS→ESM interop
915
+ // only kicks in via pre-bundle, so these must be pre-bundled even
916
+ // though their importers aren't.
917
+ 'invariant',
918
+ 'nanoid/non-secure',
919
+ ],
920
+ exclude: optimizeDepsExclude,
921
+ // vite 8: rolldown replaces esbuild for dep optimization
922
+ rolldownOptions: {
923
+ resolve: {
924
+ conditionNames: ['react-native', 'import'],
925
+ mainFields: ['react-native', 'module', 'jsnext:main', 'jsnext'],
926
+ extensions: [
927
+ '.ios.tsx',
928
+ '.ios.ts',
929
+ '.ios.jsx',
930
+ '.ios.js',
931
+ '.native.tsx',
932
+ '.native.ts',
933
+ '.native.jsx',
934
+ '.native.js',
935
+ '.native.mjs',
936
+ '.mjs',
937
+ '.js',
938
+ '.mts',
939
+ '.ts',
940
+ '.jsx',
941
+ '.tsx',
942
+ '.json',
943
+ ],
944
+ },
945
+ plugins: [
946
+ {
947
+ name: 'sootsim-published-browser-internals',
948
+ resolveId(source: string, importer: string | undefined) {
949
+ const replacement = resolvePublishedBrowserInternal(
950
+ source,
951
+ importer,
952
+ publishedBrowserPackages,
953
+ )
954
+ return replacement ? { id: replacement } : null
955
+ },
956
+ },
957
+ {
958
+ name: 'sootsim-stub-images',
959
+ resolveId(source: string, importer: string | undefined) {
960
+ if (
961
+ /\.(jpg|png|gif)$/.test(source) &&
962
+ importer?.includes('node_modules')
963
+ ) {
964
+ return { id: '\0stub-image' }
965
+ }
966
+ },
967
+ load(id: string) {
968
+ if (id === '\0stub-image') return 'export default ""'
969
+ },
970
+ },
971
+ ],
972
+ },
973
+ },
974
+ build: { target: 'esnext' },
975
+ oxc: { target: 'esnext' },
976
+ server: {
977
+ fs: { allow: fsAllow },
978
+ },
979
+ resolve: {
980
+ dedupe,
981
+ conditions: ['react-native'],
982
+ mainFields: ['react-native', 'module', 'jsnext:main', 'jsnext'],
983
+ extensions: [
984
+ '.ios.tsx',
985
+ '.ios.ts',
986
+ '.ios.jsx',
987
+ '.ios.js',
988
+ '.native.tsx',
989
+ '.native.ts',
990
+ '.native.jsx',
991
+ '.native.js',
992
+ '.native.mjs',
993
+ '.mjs',
994
+ '.js',
995
+ '.mts',
996
+ '.ts',
997
+ '.jsx',
998
+ '.tsx',
999
+ '.json',
1000
+ ],
1001
+ alias: [
1002
+ ...sootsimBrowserSourceAliases,
1003
+ ...publishedBrowserEntryAliases,
1004
+ ...Object.entries(rnLibraryStubs).map(([find, replacement]) => ({
1005
+ find,
1006
+ replacement,
1007
+ })),
1008
+ { find: /^react-native\/Libraries\/.*/, replacement: rnDeepStubDefault },
1009
+ { find: 'react-native', replacement: rnShimPath },
1010
+ { find: 'react-native-web', replacement: rnShimPath },
1011
+ // resolved-path alias for the synthetic specifier the
1012
+ // @react-navigation/native compat wrapper imports from. terminates
1013
+ // the wrapper's own real-package import without re-entering the
1014
+ // alias above. omit the entry if the package isn't installed —
1015
+ // the wrapper file simply won't be loaded in that case.
1016
+ ...(reactNavigationNativeRealPath
1017
+ ? [
1018
+ {
1019
+ find: '@sootsim-internal/react-navigation-native-real',
1020
+ replacement: reactNavigationNativeRealPath,
1021
+ },
1022
+ ]
1023
+ : []),
1024
+ ...(reactNativeHapticFeedbackTouchablePath
1025
+ ? [
1026
+ {
1027
+ find: SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SPECIFIER,
1028
+ replacement: reactNativeHapticFeedbackTouchablePath,
1029
+ },
1030
+ ]
1031
+ : []),
1032
+ ...compatWrapperRealPackageAliases,
1033
+ ...(expoUiSwiftUiModifiersRealPath
1034
+ ? [
1035
+ {
1036
+ find: '@expo/ui/swift-ui/modifiers',
1037
+ replacement: expoUiSwiftUiModifiersRealPath,
1038
+ },
1039
+ ]
1040
+ : []),
1041
+ ...expoUiJetpackComposeRealAliases,
1042
+ ...(rnghReanimatedSwipeableRealPath
1043
+ ? [
1044
+ {
1045
+ find: /^react-native-gesture-handler\/ReanimatedSwipeable$/,
1046
+ replacement: rnghReanimatedSwipeableRealPath,
1047
+ },
1048
+ ]
1049
+ : []),
1050
+ ...(rnghDrawerLayoutRealPath
1051
+ ? [
1052
+ {
1053
+ find: /^react-native-gesture-handler\/DrawerLayout$/,
1054
+ replacement: rnghDrawerLayoutRealPath,
1055
+ },
1056
+ ]
1057
+ : []),
1058
+ ...stubAliases,
1059
+ ],
1060
+ },
1061
+ }
1062
+ },
1063
+ }
1064
+ }
1065
+
1066
+ // redirect `import ... from 'react'` to react-bridge for sootsim, compat, and
1067
+ // react-reconciler code. this enables runtime React version switching for
1068
+ // external app bundles — bindReact(appReact) swaps which React all code uses.
1069
+ function reactBridgePlugin(): Plugin {
1070
+ let redirectCount = 0
1071
+ return {
1072
+ name: 'sootsim-react-bridge',
1073
+ apply: 'serve',
1074
+ enforce: 'pre',
1075
+ resolveId: {
1076
+ filter: { id: /^react$/ },
1077
+ async handler(_source, importer, options) {
1078
+ if (!importer) return null
1079
+
1080
+ // skip during dep scanning / SSR — only active in browser serve mode
1081
+ if ((options as { scan?: boolean })?.scan || options?.ssr) return null
1082
+
1083
+ // only redirect imports from sootsim, sootsim-engine, compat, and react-reconciler
1084
+ const shouldRedirect =
1085
+ importer.includes('/sootsim/src/') ||
1086
+ importer.includes('/sootsim-engine/src/') ||
1087
+ importer.includes('/compat/src/') ||
1088
+ importer.includes('react-reconciler')
1089
+
1090
+ if (!shouldRedirect) return null
1091
+
1092
+ // don't redirect react-bridge's own import of react (avoid infinite loop)
1093
+ if (importer.includes('react-bridge')) return null
1094
+
1095
+ // stub-registry needs the real React (with __CLIENT_INTERNALS) for bundle stubs
1096
+ if (importer.includes('stub-registry')) return null
1097
+
1098
+ redirectCount++
1099
+ if (redirectCount <= 10) {
1100
+ console.log(
1101
+ '[react-bridge-plugin] REDIRECTING:',
1102
+ importer.split('/').slice(-3).join('/'),
1103
+ )
1104
+ } else if (redirectCount === 11) {
1105
+ console.log('[react-bridge-plugin] ... more redirects')
1106
+ }
1107
+ return { id: reactBridgePath, external: false }
1108
+ },
1109
+ },
1110
+ }
1111
+ }
1112
+
1113
+ // worker-compatible react-bridge redirect. unlike the main plugin, this one
1114
+ // also applies during builds since workers are built, not served. exported for
1115
+ // use in worker.plugins configuration.
1116
+ export function workerReactBridgePlugin(): Plugin {
1117
+ return {
1118
+ name: 'sootsim-worker-react-bridge',
1119
+ enforce: 'pre',
1120
+ resolveId: {
1121
+ filter: { id: /^react$/ },
1122
+ async handler(_source, importer, options) {
1123
+ if (!importer) return null
1124
+
1125
+ // skip during dep scanning / SSR
1126
+ if ((options as { scan?: boolean })?.scan || options?.ssr) return null
1127
+
1128
+ // don't redirect react-bridge's own import of react (avoid infinite loop)
1129
+ if (importer.includes('react-bridge')) return null
1130
+
1131
+ // stub-registry needs the real React (with __CLIENT_INTERNALS) for bundle stubs
1132
+ if (importer.includes('stub-registry')) return null
1133
+
1134
+ // redirect react imports from sootsim, sootsim-engine, compat, react-reconciler,
1135
+ // and any pure-JS RN-ecosystem package the bundle pulls in. without this
1136
+ // redirect, packages like react-native-drawer-layout / use-latest-callback
1137
+ // call into a separate React instance than the renderer/bridge does, so
1138
+ // their useRef/useEffect refs collide with the engine's hooks state and
1139
+ // we hit "Maximum update depth exceeded" infinite-loop renders.
1140
+ const shouldRedirect =
1141
+ importer.includes('/sootsim/src/') ||
1142
+ importer.includes('/sootsim-engine/src/') ||
1143
+ importer.includes('/compat/src/') ||
1144
+ importer.includes('react-reconciler') ||
1145
+ importer.includes('/node_modules/')
1146
+
1147
+ if (!shouldRedirect) return null
1148
+
1149
+ return { id: reactBridgePath, external: false }
1150
+ },
1151
+ },
1152
+ }
1153
+ }
1154
+
1155
+ // react 19's jsx-runtime.development.js wraps exports.jsx inside a conditional
1156
+ // IIFE, so esbuild's static CJS analysis can't detect the named exports during
1157
+ // pre-bundling. this plugin wraps the real pre-bundled module with explicit exports.
1158
+ // exported for use in worker.plugins configuration.
1159
+ export function fixJsxRuntimeExports(): Plugin {
1160
+ return {
1161
+ name: 'sootsim-fix-jsx-runtime',
1162
+ enforce: 'pre',
1163
+ resolveId: {
1164
+ filter: { id: /^react\/jsx-(?:dev-)?runtime$/ },
1165
+ async handler(source, importer, options) {
1166
+ const resolved = await this.resolve(source, importer, {
1167
+ ...options,
1168
+ skipSelf: true,
1169
+ })
1170
+ if (!resolved) return null
1171
+ const prefix =
1172
+ source === 'react/jsx-runtime'
1173
+ ? '\0sootsim:jsx-runtime:'
1174
+ : '\0sootsim:jsx-dev-runtime:'
1175
+ return prefix + resolved.id
1176
+ },
1177
+ },
1178
+ load: {
1179
+ filter: {
1180
+ id: new RegExp(`^${virtualIdPrefix}sootsim:jsx-(?:dev-)?runtime:`),
1181
+ },
1182
+ handler(id) {
1183
+ if (id.startsWith('\0sootsim:jsx-runtime:')) {
1184
+ const realId = id.slice('\0sootsim:jsx-runtime:'.length)
1185
+ return [
1186
+ `import * as _mod from ${JSON.stringify(realId)};`,
1187
+ `const _m = _mod.default || _mod;`,
1188
+ `export const jsx = _m.jsx || _mod.jsx;`,
1189
+ `export const jsxs = _m.jsxs || _mod.jsxs;`,
1190
+ `export const Fragment = _m.Fragment || _mod.Fragment;`,
1191
+ ].join('\n')
1192
+ }
1193
+ if (id.startsWith('\0sootsim:jsx-dev-runtime:')) {
1194
+ const realId = id.slice('\0sootsim:jsx-dev-runtime:'.length)
1195
+ // react/jsx-runtime is already wrapped by this plugin (above) to expose
1196
+ // { jsx, jsxs, Fragment } as named exports, so read them directly from
1197
+ // the namespace — no `_runtime.default || _runtime` fallback needed.
1198
+ // rolldown statically knows the wrapped module has no default export
1199
+ // and would emit IMPORT_IS_UNDEFINED warnings on every build.
1200
+ return [
1201
+ `import * as _mod from ${JSON.stringify(realId)};`,
1202
+ `import * as _runtime from "react/jsx-runtime";`,
1203
+ `const _m = _mod.default || _mod;`,
1204
+ // production React builds may not expose jsxDEV from react/jsx-dev-runtime.
1205
+ // fall back to jsx so transformed modules still execute.
1206
+ `export const jsxDEV = _m.jsxDEV || _mod.jsxDEV || _m.jsx || _mod.jsx || _runtime.jsx;`,
1207
+ `export const Fragment = _m.Fragment || _mod.Fragment || _runtime.Fragment;`,
1208
+ ].join('\n')
1209
+ }
1210
+ return null
1211
+ },
1212
+ },
1213
+ }
1214
+ }
1215
+
1216
+ // esbuild plugin for optimizeDeps pre-bundling
1217
+ function sootsimEsbuildPlugin(appDir: string) {
1218
+ const platformExts = [
1219
+ '.ios.tsx',
1220
+ '.ios.ts',
1221
+ '.ios.jsx',
1222
+ '.ios.js',
1223
+ '.native.tsx',
1224
+ '.native.ts',
1225
+ '.native.jsx',
1226
+ '.native.js',
1227
+ '.native.mjs',
1228
+ ]
1229
+
1230
+ function isAppSourceEsbuild(filePath: string): boolean {
1231
+ return !!(appDir && filePath.startsWith(appDir))
1232
+ }
1233
+
1234
+ return {
1235
+ name: 'sootsim-esbuild',
1236
+ setup(build: any) {
1237
+ // metro-style .ios > .native resolution
1238
+ build.onResolve({ filter: /^\./ }, (args: any) => {
1239
+ if (!args.importer.includes('node_modules') && !isAppSourceEsbuild(args.importer))
1240
+ return null
1241
+ const dir = path.dirname(args.importer)
1242
+ const resolved = path.resolve(dir, args.path)
1243
+ for (const ext of platformExts) {
1244
+ const candidate = resolved + ext
1245
+ try {
1246
+ if (fs.existsSync(candidate)) return { path: candidate }
1247
+ } catch {}
1248
+ }
1249
+ for (const ext of platformExts) {
1250
+ const candidate = path.join(resolved, 'index' + ext)
1251
+ try {
1252
+ if (fs.existsSync(candidate)) return { path: candidate }
1253
+ } catch {}
1254
+ }
1255
+ const allExts = [...platformExts, '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']
1256
+ for (const ext of allExts) {
1257
+ if (args.path.endsWith(ext)) {
1258
+ const base = resolved.slice(0, -ext.length)
1259
+ for (const pext of platformExts) {
1260
+ const candidate = base + pext
1261
+ try {
1262
+ if (fs.existsSync(candidate)) return { path: candidate }
1263
+ } catch {}
1264
+ }
1265
+ break
1266
+ }
1267
+ }
1268
+ return null
1269
+ })
1270
+
1271
+ // react-native deep path stubs
1272
+ for (const [importPath, stubPath] of Object.entries(rnLibraryStubs)) {
1273
+ build.onResolve(
1274
+ { filter: new RegExp(`^${importPath.replace(/\//g, '\\/')}$`) },
1275
+ () => ({
1276
+ path: stubPath,
1277
+ }),
1278
+ )
1279
+ }
1280
+ build.onResolve({ filter: /^react-native\/Libraries\// }, (args: any) => {
1281
+ return { path: rnLibraryStubs[args.path] || rnDeepStubDefault }
1282
+ })
1283
+
1284
+ // resolve builtin stubs (react-native-safe-area-context, expo-*, etc.)
1285
+ for (const [pkgName, stubFile] of Object.entries(builtinStubs)) {
1286
+ const stubPath = path.resolve(compatStubsDir, stubFile)
1287
+ const escaped = pkgName.replace(/[.*+?^${}()|[\]\\/@-]/g, '\\$&')
1288
+ build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
1289
+ path: stubPath,
1290
+ }))
1291
+ }
1292
+
1293
+ // external app node_modules resolution — use esbuild's own resolve
1294
+ // so exports conditions are respected (not CJS require.resolve which ignores them)
1295
+ if (appDir) {
1296
+ const owned = new Set(coreOwnedPackages)
1297
+ build.onResolve({ filter: /^[^.]/ }, async (args: any) => {
1298
+ if (args.pluginData?.fromAppResolve) return null
1299
+ if (
1300
+ !isAppSourceEsbuild(args.importer) &&
1301
+ !args.importer.includes(path.join(appDir, 'node_modules'))
1302
+ )
1303
+ return null
1304
+ const src = args.path
1305
+ if (src.startsWith('\0')) return null
1306
+ if (owned.has(src) || owned.has(getPackageName(src))) return null
1307
+ if (src === 'react-native' || src.startsWith('react-native/')) return null
1308
+ try {
1309
+ const result = await build.resolve(src, {
1310
+ resolveDir: appDir,
1311
+ pluginData: { fromAppResolve: true },
1312
+ })
1313
+ if (result.errors.length) return null
1314
+ return { path: result.path }
1315
+ } catch {
1316
+ return null
1317
+ }
1318
+ })
1319
+ }
1320
+
1321
+ // auto-stub missing native packages
1322
+ build.onResolve(
1323
+ {
1324
+ filter:
1325
+ /^(expo-|react-native-|@react-native\/|@react-native-community\/|@expo\/|expo$)/,
1326
+ },
1327
+ (args: any) => {
1328
+ const pkgName = getPackageName(args.path)
1329
+ if (moduleExistsIn(pkgName, sootsimRoot)) return null
1330
+ if (appDir && moduleExistsIn(pkgName, appDir)) return null
1331
+ return { path: nativeAutoStubPath }
1332
+ },
1333
+ )
1334
+
1335
+ // stub missing image imports
1336
+ build.onResolve({ filter: /\.(jpg|png|gif)$/ }, (args: any) => {
1337
+ if (args.importer.includes('node_modules')) {
1338
+ return { path: args.path, namespace: 'stub-image' }
1339
+ }
1340
+ return null
1341
+ })
1342
+ build.onLoad({ filter: /.*/, namespace: 'stub-image' }, () => ({
1343
+ contents: 'export default ""',
1344
+ loader: 'js',
1345
+ }))
1346
+ },
1347
+ }
1348
+ }
1349
+
1350
+ // handle .js files containing JSX from node_modules during production builds.
1351
+ // upstream `react-native-reanimated` and downstream consumers (
1352
+ // `react-native-keyboard-controller`, `react-native-gesture-handler`,
1353
+ // `react-native-screens`, sootsim's own fixtures + RN compat source) ship
1354
+ // raw source containing `'worklet'` directives and inline updaters to
1355
+ // auto-workletizable hooks. the `react-native-worklets/plugin` babel
1356
+ // transform lifts those into wrapper functions carrying `__closure`,
1357
+ // `__workletHash`, and `__initData`. without that transform,
1358
+ // `useAnimatedStyle`'s `let inputs = Object.values(updater.__closure ?? {})`
1359
+ // returns `[]` and the mapper never subscribes — animations don't run.
1360
+ //
1361
+ // metro builds (apps loaded inside sootsim — 3pc, bluesky, expensify,
1362
+ // eigen, …) run the plugin in their own pipeline, so the bundle that
1363
+ // reaches us already has `__closure` baked in. our transform only needs
1364
+ // to act on:
1365
+ //
1366
+ // - sootsim's own src + test fixtures + external-app source, and
1367
+ // - upstream library source loaded from node_modules where the redirect
1368
+ // plugin let pure-JS resolve naturally.
1369
+ //
1370
+ // the keyword list + ignored-path list is ported from
1371
+ // `~/one/packages/compiler/src/transformBabel.ts` (which is itself ported
1372
+ // from reanimated 3.15.1's autoworkletization keywords). keep the two in
1373
+ // sync if upstream adds new auto-workletized hooks. detection is
1374
+ // content-driven, not path-driven, so we don't need a per-package
1375
+ // allow-list — anything that mentions a workletizable identifier and
1376
+ // isn't on the ignored-path list goes through.
1377
+
1378
+ export function workletsBabelTransform(
1379
+ // accepted for future per-target overrides; matching is content-driven
1380
+ // today so we don't actually consult it. kept for plugin-list parity.
1381
+ _isAppSource: (p: string) => boolean = () => false,
1382
+ ): Plugin {
1383
+ return {
1384
+ name: 'sootsim-worklets-babel-transform',
1385
+ enforce: 'pre',
1386
+ transform: {
1387
+ filter: {
1388
+ id: {
1389
+ include: /\.(?:tsx?|jsx?|mjs|cjs|mts|cts)$/,
1390
+ exclude: /node_modules\/(?:react|react-dom|react-native|react-native-web)\//,
1391
+ },
1392
+ code: REANIMATED_WORKLETIZATION_REGEX,
1393
+ },
1394
+ async handler(code, id) {
1395
+ if (!shouldApplyWorkletsPlugin(id, code)) return null
1396
+ try {
1397
+ if (process.env.SOOTSIM_WORKLETS_BABEL_DEBUG) {
1398
+ console.log('[worklets-babel] transforming', id.slice(-80))
1399
+ }
1400
+ return await transformWorkletsCode(code, id, true)
1401
+ } catch (err) {
1402
+ // surface but don't kill the build — fall back to raw code so the
1403
+ // rest of the pipeline can run. these failures are loud in the
1404
+ // console and easy to chase.
1405
+ const message = err instanceof Error ? err.message : String(err)
1406
+ console.warn(`[sootsim-worklets-babel] transform failed for ${id}: ${message}`)
1407
+ return null
1408
+ }
1409
+ },
1410
+ },
1411
+ }
1412
+ }
1413
+
1414
+ // rolldown can't parse JSX in .js files — transform them first.
1415
+ // needed because some RN packages (e.g. react-native-actions-sheet) ship JSX in .js.
1416
+ // some upstream RN libraries (e.g. @react-native-segmented-control/segmented-control)
1417
+ // publish raw Flow source as their runtime entry. Metro strips Flow in its babel
1418
+ // pipeline; vite/rolldown/oxc do not. detect the `@flow` pragma in node_modules and
1419
+ // run @babel/plugin-transform-flow-strip-types so the wrapper-real seam can pull
1420
+ // in the real upstream JS without re-implementing it locally. mirrored in
1421
+ // scripts/build-bundler-deps.ts so esbuild-based dep prebundles stay in sync.
1422
+ let flowBabel: typeof import('@babel/core') | null = null
1423
+ let flowStripPlugin: import('@babel/core').PluginItem | null = null
1424
+ let jsxSyntaxPlugin: import('@babel/core').PluginItem | null = null
1425
+ function loadFlowBabel(): void {
1426
+ if (flowBabel) return
1427
+ flowBabel = sootsimPluginRequire('@babel/core')
1428
+ flowStripPlugin = sootsimPluginRequire(
1429
+ '@babel/plugin-transform-flow-strip-types',
1430
+ ) as import('@babel/core').PluginItem
1431
+ jsxSyntaxPlugin = sootsimPluginRequire(
1432
+ '@babel/plugin-syntax-jsx',
1433
+ ) as import('@babel/core').PluginItem
1434
+ }
1435
+ function flowStripTransform(): Plugin {
1436
+ return {
1437
+ name: 'sootsim-flow-strip',
1438
+ enforce: 'pre',
1439
+ transform: {
1440
+ filter: {
1441
+ id: /\/node_modules\/.*\.[cm]?jsx?$/,
1442
+ code: /@flow/,
1443
+ },
1444
+ handler(code, id) {
1445
+ loadFlowBabel()
1446
+ const result = flowBabel!.transformSync(code, {
1447
+ filename: id,
1448
+ babelrc: false,
1449
+ configFile: false,
1450
+ plugins: [flowStripPlugin!, jsxSyntaxPlugin!],
1451
+ sourceMaps: true,
1452
+ compact: false,
1453
+ })
1454
+ if (!result?.code) return null
1455
+ return { code: result.code, map: result.map ?? null }
1456
+ },
1457
+ },
1458
+ }
1459
+ }
1460
+
1461
+ function nodeModulesJsxTransform(): Plugin {
1462
+ return {
1463
+ name: 'sootsim-node-modules-jsx',
1464
+ enforce: 'pre',
1465
+ transform: {
1466
+ filter: {
1467
+ id: /\/node_modules\/.*\.js$/,
1468
+ code: /</,
1469
+ },
1470
+ async handler(code, id) {
1471
+ // quick check: does it look like JSX? (angle bracket followed by uppercase or known RN component)
1472
+ if (!/[=(\s,]<[A-Z]/.test(code) && !/<\/[A-Z]/.test(code)) return null
1473
+ const needsReact =
1474
+ !code.includes('import React') && !code.includes('import * as React')
1475
+ const withReact = needsReact ? `import React from 'react';\n${code}` : code
1476
+ const transformed = await transformWithOxc(withReact, id, {
1477
+ lang: 'jsx',
1478
+ jsx: { runtime: 'classic' },
1479
+ })
1480
+ return { code: transformed.code, map: transformed.map }
1481
+ },
1482
+ },
1483
+ }
1484
+ }
1485
+
1486
+ // metro-style platform resolution: .ios > .native > base
1487
+ function metroNativeResolve(isAppSource: (p: string) => boolean): Plugin {
1488
+ const platformExts = [
1489
+ '.ios.tsx',
1490
+ '.ios.ts',
1491
+ '.ios.jsx',
1492
+ '.ios.js',
1493
+ '.native.tsx',
1494
+ '.native.ts',
1495
+ '.native.jsx',
1496
+ '.native.js',
1497
+ '.native.mjs',
1498
+ ]
1499
+ const allExts = [...platformExts, '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']
1500
+
1501
+ function tryResolve(base: string): string | null {
1502
+ for (const ext of platformExts) {
1503
+ const candidate = base + ext
1504
+ try {
1505
+ if (fs.existsSync(candidate)) return candidate
1506
+ } catch {}
1507
+ }
1508
+ for (const ext of platformExts) {
1509
+ const candidate = path.join(base, 'index' + ext)
1510
+ try {
1511
+ if (fs.existsSync(candidate)) return candidate
1512
+ } catch {}
1513
+ }
1514
+ return null
1515
+ }
1516
+
1517
+ return {
1518
+ name: 'sootsim-metro-native-resolve',
1519
+ enforce: 'pre',
1520
+ resolveId: {
1521
+ filter: { id: /^\./ },
1522
+ handler(source, importer) {
1523
+ if (!importer) return null
1524
+ if (!importer.includes('node_modules') && !isAppSource(importer)) return null
1525
+ if (platformExts.some((ext) => source.endsWith(ext))) return null
1526
+
1527
+ const dir = path.dirname(importer)
1528
+
1529
+ const hasBaseExt = allExts.some((ext) => source.endsWith(ext))
1530
+ if (hasBaseExt) {
1531
+ const resolved = path.resolve(dir, source)
1532
+ for (const ext of allExts) {
1533
+ if (source.endsWith(ext)) {
1534
+ const found = tryResolve(resolved.slice(0, -ext.length))
1535
+ if (found) return found
1536
+ break
1537
+ }
1538
+ }
1539
+ } else {
1540
+ const found = tryResolve(path.resolve(dir, source))
1541
+ if (found) return found
1542
+ }
1543
+ return null
1544
+ },
1545
+ },
1546
+ }
1547
+ }
1548
+
1549
+ // convert require("react-native") and deep library requires to ESM imports
1550
+ function reactNativeRequirePlugin(isAppSource: (p: string) => boolean): Plugin {
1551
+ return {
1552
+ name: 'sootsim-react-native-require',
1553
+ enforce: 'pre',
1554
+ transform: {
1555
+ filter: { code: /react-native/ },
1556
+ handler(code, id) {
1557
+ if (!id.includes('node_modules') && !isAppSource(id)) return null
1558
+
1559
+ let hasChanges = false
1560
+ let imports = ''
1561
+ let result = code
1562
+ let importCounter = 0
1563
+
1564
+ if (
1565
+ result.includes('require("react-native")') ||
1566
+ result.includes("require('react-native')")
1567
+ ) {
1568
+ imports += `import * as __soot_rn_shim__ from ${JSON.stringify(rnShimPath)};\n`
1569
+ result = result.replace(/require\(["']react-native["']\)/g, '__soot_rn_shim__')
1570
+ hasChanges = true
1571
+ }
1572
+
1573
+ const deepRequireRegex = /require\(["'](react-native\/Libraries\/[^"']+)["']\)/g
1574
+ const replacements: Array<{ full: string; path: string; varName: string }> = []
1575
+ const seenPaths = new Map<string, string>()
1576
+ let match
1577
+
1578
+ deepRequireRegex.lastIndex = 0
1579
+ while ((match = deepRequireRegex.exec(result)) !== null) {
1580
+ const importPath = match[1]
1581
+ if (!seenPaths.has(importPath)) {
1582
+ const varName = `__soot_rn_lib_${importCounter++}__`
1583
+ const stubPath = rnLibraryStubs[importPath] || rnDeepStubDefault
1584
+ imports += `import * as ${varName} from ${JSON.stringify(stubPath)};\n`
1585
+ seenPaths.set(importPath, varName)
1586
+ }
1587
+ replacements.push({
1588
+ full: match[0],
1589
+ path: importPath,
1590
+ varName: seenPaths.get(importPath)!,
1591
+ })
1592
+ }
1593
+
1594
+ if (replacements.length > 0) {
1595
+ for (const rep of replacements) {
1596
+ result = result.replace(rep.full, rep.varName)
1597
+ }
1598
+ hasChanges = true
1599
+ }
1600
+
1601
+ if (!hasChanges) return null
1602
+ return { code: imports + result, map: null }
1603
+ },
1604
+ },
1605
+ }
1606
+ }
1607
+
1608
+ // resolve bare imports from external app's node_modules
1609
+ function externalAppResolvePlugin(
1610
+ appDir: string,
1611
+ isAppSource: (p: string) => boolean,
1612
+ ownedPackages: Set<string>,
1613
+ ): Plugin {
1614
+ // synthetic importer inside the app dir so vite searches app's node_modules
1615
+ // using its conditions-aware exports resolution (not CJS require.resolve)
1616
+ const appVirtualImporter = path.join(appDir, '_virtual_.js')
1617
+
1618
+ return {
1619
+ name: 'sootsim-external-app-resolve',
1620
+ enforce: 'pre',
1621
+ async resolveId(source, importer, options) {
1622
+ if (!importer) return null
1623
+ if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0'))
1624
+ return null
1625
+ if (ownedPackages.has(source) || ownedPackages.has(getPackageName(source)))
1626
+ return null
1627
+ if (!isAppSource(importer) && !importer.includes(path.join(appDir, 'node_modules')))
1628
+ return null
1629
+ if (source === 'react-native' || source.startsWith('react-native/')) return null
1630
+
1631
+ // re-resolve through vite's pipeline so exports conditions (react-native, import) are respected
1632
+ const resolved = await this.resolve(source, appVirtualImporter, {
1633
+ ...options,
1634
+ skipSelf: true,
1635
+ })
1636
+ return resolved || null
1637
+ },
1638
+ }
1639
+ }
1640
+
1641
+ // convert require() calls and handle JSX in .js files from external app sources
1642
+ function externalAppTransformPlugin(isAppSource: (p: string) => boolean): Plugin {
1643
+ return {
1644
+ name: 'sootsim-external-app-transform',
1645
+ enforce: 'pre',
1646
+ transform: {
1647
+ filter: { code: /require\(|</ },
1648
+ async handler(code, id) {
1649
+ if (!isAppSource(id)) return null
1650
+
1651
+ let result = code
1652
+ let imports = ''
1653
+ let importCounter = 0
1654
+ let hasChanges = false
1655
+
1656
+ if (code.includes('require(')) {
1657
+ const requireRegex = /require\(["']([^"']+)["']\)/g
1658
+ const replacements: Array<{ full: string; varName: string }> = []
1659
+ const seenPaths = new Map<string, string>()
1660
+ let match
1661
+
1662
+ requireRegex.lastIndex = 0
1663
+ while ((match = requireRegex.exec(result)) !== null) {
1664
+ const reqPath = match[1]
1665
+ if (reqPath === 'react-native' || reqPath.startsWith('react-native/'))
1666
+ continue
1667
+
1668
+ if (!seenPaths.has(reqPath)) {
1669
+ const varName = `__soot_cjs_${importCounter++}__`
1670
+ imports += `import * as ${varName} from ${JSON.stringify(reqPath)};\n`
1671
+ seenPaths.set(reqPath, varName)
1672
+ }
1673
+ replacements.push({ full: match[0], varName: seenPaths.get(reqPath)! })
1674
+ }
1675
+
1676
+ if (replacements.length > 0) {
1677
+ for (const rep of replacements) {
1678
+ result = result.replace(rep.full, rep.varName)
1679
+ }
1680
+ hasChanges = true
1681
+ }
1682
+ }
1683
+
1684
+ if (imports) result = imports + result
1685
+
1686
+ if (id.endsWith('.js') && result.includes('<')) {
1687
+ const needsReact =
1688
+ !result.includes('import React') && !result.includes('import * as React')
1689
+ const withReact = needsReact ? `import React from 'react';\n${result}` : result
1690
+ const transformed = await transformWithOxc(withReact, id, {
1691
+ lang: 'jsx',
1692
+ jsx: { runtime: 'classic' },
1693
+ })
1694
+ return { code: transformed.code, map: transformed.map }
1695
+ }
1696
+
1697
+ if (!hasChanges) return null
1698
+ return { code: result, map: null }
1699
+ },
1700
+ },
1701
+ }
1702
+ }
1703
+
1704
+ // auto-stub native packages that don't exist in any node_modules
1705
+ function stubMissingNativeDeps(appDir: string): Plugin {
1706
+ const checked = new Map<string, boolean>()
1707
+
1708
+ function packageExists(pkgName: string): boolean {
1709
+ const cached = checked.get(pkgName)
1710
+ if (cached !== undefined) return cached
1711
+ let exists = moduleExistsIn(pkgName, sootsimRoot)
1712
+ if (!exists && appDir) exists = moduleExistsIn(pkgName, appDir)
1713
+ checked.set(pkgName, exists)
1714
+ return exists
1715
+ }
1716
+
1717
+ return {
1718
+ name: 'sootsim-stub-missing-native-deps',
1719
+ resolveId(source) {
1720
+ if (!isNativePackage(source)) return null
1721
+ const pkgName = getPackageName(source)
1722
+ if (packageExists(pkgName)) return null
1723
+ console.log(`[rnx] auto-stubbing missing native dep: ${source}`)
1724
+ return nativeAutoStubPath
1725
+ },
1726
+ }
1727
+ }
1728
+
1729
+ // stub missing image imports from node_modules
1730
+ function stubMissingImages(isAppSource: (p: string) => boolean): Plugin {
1731
+ return {
1732
+ name: 'sootsim-stub-images',
1733
+ enforce: 'pre',
1734
+ resolveId: {
1735
+ filter: { id: /\.(?:jpg|png|gif)$/ },
1736
+ handler(id, importer) {
1737
+ if (importer?.includes('node_modules') || (importer && isAppSource(importer))) {
1738
+ return '\0stub-image'
1739
+ }
1740
+ return null
1741
+ },
1742
+ },
1743
+ load: {
1744
+ filter: { id: new RegExp(`^${virtualIdPrefix}stub-image$`) },
1745
+ handler() {
1746
+ return 'export default ""'
1747
+ },
1748
+ },
1749
+ }
1750
+ }