sootsim 0.1.83 → 0.1.84

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 (207) hide show
  1. package/README.md +0 -1
  2. package/detox/colors.ts +54 -0
  3. package/detox/config-loader.ts +135 -0
  4. package/detox/expectations.ts +477 -0
  5. package/detox/gestures.ts +442 -0
  6. package/detox/index.ts +1436 -0
  7. package/detox/jest-environment.ts +86 -0
  8. package/detox/jest-preset.cjs +50 -0
  9. package/detox/matchers.ts +29 -0
  10. package/detox/navigation.ts +43 -0
  11. package/detox/run-test.ts +113 -0
  12. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  13. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  14. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  15. package/detox/screenshots/theme-blue-toggle.png +0 -0
  16. package/detox/screenshots/theme-blue.png +0 -0
  17. package/detox/screenshots/theme-red-toggle.png +0 -0
  18. package/detox/screenshots/theme-red.png +0 -0
  19. package/dist-cli/bin.js +3 -3
  20. package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-2CWD6W6P.js} +2 -2
  21. package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-5W3LOX6S.js} +2 -2
  22. package/dist-cli/chunks/{assert-TV46GUNU.js → assert-ZOMAMKRT.js} +2 -2
  23. package/dist-cli/chunks/auto-bootstrap-NYYSMTIM.js +2 -0
  24. package/dist-cli/chunks/beta-4K2SQACK.js +2 -0
  25. package/dist-cli/chunks/chunk-3HXQ7MJK.js +79 -0
  26. package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-4K7BH2D4.js} +3 -3
  27. package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-4OPRODFA.js} +2 -2
  28. package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-4OWVPRZV.js} +2 -2
  29. package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-5XCXOLG2.js} +2 -2
  30. package/dist-cli/chunks/chunk-67ZZ2CM5.js +1 -0
  31. package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-73UZXB4B.js} +2 -2
  32. package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-7NWNTUJF.js} +1 -1
  33. package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-7YHDJLO2.js} +1 -1
  34. package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-AJVTY6KY.js} +1 -1
  35. package/dist-cli/chunks/chunk-AWSQUOAS.js +67 -0
  36. package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-BCBNVJVG.js} +1 -1
  37. package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-BKBL6K2G.js} +1 -1
  38. package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-C3DPQZ4J.js} +2 -2
  39. package/dist-cli/chunks/chunk-D3ZSBIIY.js +2 -0
  40. package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-D4HUVLZR.js} +1 -1
  41. package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-DUUSJDES.js} +1 -1
  42. package/dist-cli/chunks/{chunk-277XAALA.js → chunk-ELJLF4SG.js} +3 -3
  43. package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-EQ7TFQ2F.js} +1 -1
  44. package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-EQCKGC4B.js} +1 -1
  45. package/dist-cli/chunks/chunk-FUCGLWNN.js +1 -0
  46. package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-HYPJW65U.js} +2 -2
  47. package/dist-cli/chunks/chunk-IILJQCZA.js +2 -0
  48. package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-KU6MSPAH.js} +2 -2
  49. package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-OOOR7NT2.js} +1 -1
  50. package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-P7WDNKOS.js} +3 -3
  51. package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-PPKKA5VW.js} +2 -2
  52. package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PS2G44GT.js} +2 -2
  53. package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-QMSJR5R2.js} +2 -2
  54. package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-RF4R2U46.js} +2 -2
  55. package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-RIXUH3NK.js} +2 -2
  56. package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-SFGUPL2X.js} +2 -2
  57. package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-SQX5CAYG.js} +1 -1
  58. package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-SQZAC7C4.js} +1 -1
  59. package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-SV7FOGJ3.js} +2 -2
  60. package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-TK3OJSEO.js} +2 -2
  61. package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-TL7SIZ7S.js} +1 -1
  62. package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-V2GQ4WXJ.js} +2 -2
  63. package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-VH7F45CN.js} +1 -1
  64. package/dist-cli/chunks/chunk-WNVNU2OW.js +4 -0
  65. package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-XQ2OBHBE.js} +2 -2
  66. package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-YCIA4BHJ.js} +2 -2
  67. package/dist-cli/chunks/chunk-ZSMMJMPA.js +1 -0
  68. package/dist-cli/chunks/cli-version-QB4VH24H.js +2 -0
  69. package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-FWSEEGEH.js} +3 -3
  70. package/dist-cli/chunks/{config-2DSLDCXV.js → config-CYI2WAGP.js} +2 -2
  71. package/dist-cli/chunks/control-UXY7YQVX.js +2 -0
  72. package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-IKAE3KTY.js} +2 -2
  73. package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-ZUMF53YB.js} +2 -2
  74. package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-P6KULKKS.js} +3 -3
  75. package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-SPWAZCYG.js} +2 -2
  76. package/dist-cli/chunks/{device-AYKXKVIQ.js → device-JWEPK6I2.js} +2 -2
  77. package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-IZODTXV2.js} +2 -2
  78. package/dist-cli/chunks/drivers-MK6WJKBC.js +2 -0
  79. package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-R5GP6RVB.js} +3 -3
  80. package/dist-cli/chunks/flow-6O4GEOPJ.js +2 -0
  81. package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-DYDNYX7N.js} +2 -2
  82. package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-GLMX5OKL.js} +2 -2
  83. package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-FJOPCTY2.js} +3 -3
  84. package/dist-cli/chunks/install-A3TUGGHN.js +2 -0
  85. package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-YPJZMZM5.js} +3 -3
  86. package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-GSYPHWNY.js} +2 -2
  87. package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-4G2PKW5X.js} +3 -3
  88. package/dist-cli/chunks/{login-EACQXE24.js → login-KJQGHA64.js} +4 -4
  89. package/dist-cli/chunks/{logout-IBQLMUML.js → logout-XM2SYH5C.js} +2 -2
  90. package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-EOWGI7DG.js} +2 -2
  91. package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-F73TKK37.js} +2 -2
  92. package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-22FDKBUO.js} +2 -2
  93. package/dist-cli/chunks/{react-QDHLMVYL.js → react-5L6VPFUP.js} +2 -2
  94. package/dist-cli/chunks/{record-BUEUWPDI.js → record-JZXCQ4IN.js} +2 -2
  95. package/dist-cli/chunks/runtime-EEBX7CFV.js +2 -0
  96. package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-LXUM3R4A.js} +2 -2
  97. package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-HDRRG33Q.js} +2 -2
  98. package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-WY63LZIX.js} +2 -2
  99. package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-MPV2ENL5.js} +2 -2
  100. package/dist-cli/chunks/{server-MFFVYUGG.js → server-5LBMCJ3G.js} +2 -2
  101. package/dist-cli/chunks/setup-repo-SZSYNKNI.js +2 -0
  102. package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-BQ73YOBF.js} +2 -2
  103. package/dist-cli/chunks/{start-E3DRYY7W.js → start-2WU4W6ZU.js} +4 -4
  104. package/dist-cli/chunks/store-RE45SUBF.js +2 -0
  105. package/dist-cli/chunks/telemetry-DG6GJLCP.js +2 -0
  106. package/dist-cli/chunks/{test-ZY3EF62K.js → test-OVO4CQTG.js} +3 -3
  107. package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-BKM3KFM7.js} +2 -2
  108. package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-MDXGEDQL.js} +2 -2
  109. package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-JGQABWVF.js} +2 -2
  110. package/dist-cli/chunks/upload-UJNUA4ZV.js +2 -0
  111. package/dist-cli/chunks/{web-D2AOZY44.js → web-WYFAYQ72.js} +2 -2
  112. package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PZW2KW6A.js} +2 -2
  113. package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-7ATWJQS6.js} +2 -2
  114. package/dist-lib/agent-daemon-client.cjs +1 -1
  115. package/dist-lib/agent-events.cjs +1 -1
  116. package/dist-lib/agent-sessions.cjs +1 -1
  117. package/dist-lib/attached-projects.cjs +1 -1
  118. package/dist-lib/auth/shared-session.cjs +1 -1
  119. package/dist-lib/backend-origin.cjs +1 -1
  120. package/dist-lib/beta.cjs +44 -0
  121. package/dist-lib/bridge-constants.cjs +1 -1
  122. package/dist-lib/cli-constants.cjs +1 -1
  123. package/dist-lib/config.cjs +1 -1
  124. package/dist-lib/detox/index.cjs +1770 -0
  125. package/dist-lib/detox/jest-preset.cjs +50 -0
  126. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  127. package/dist-lib/home-paths.cjs +1 -1
  128. package/dist-lib/host/bridge-host.cjs +1 -1
  129. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  130. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  131. package/dist-lib/index.cjs +1 -1
  132. package/dist-lib/metro.cjs +1 -1
  133. package/dist-lib/profiles.cjs +1 -1
  134. package/dist-lib/render-mode.cjs +1 -1
  135. package/dist-lib/scripts/demo-app-registry.cjs +809 -0
  136. package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
  137. package/dist-lib/skills.cjs +8322 -0
  138. package/dist-lib/vite-base.cjs +3 -3
  139. package/dist-lib/vite.cjs +1 -1
  140. package/package.json +39 -10
  141. package/scripts/demo-app-registry.ts +989 -0
  142. package/scripts/dev-server-scanner.ts +674 -0
  143. package/src/agent-daemon-client.ts +390 -0
  144. package/src/agent-events.ts +71 -0
  145. package/src/agent-prompt.ts +71 -0
  146. package/src/agent-sessions.ts +572 -0
  147. package/src/attached-projects.ts +536 -0
  148. package/src/auth/shared-session.ts +199 -0
  149. package/src/backend-origin.ts +49 -0
  150. package/src/beta.ts +21 -0
  151. package/src/bridge-constants.ts +10 -0
  152. package/src/cli-constants.ts +1 -0
  153. package/src/cli-version.ts +30 -0
  154. package/src/codex-client.ts +215 -0
  155. package/src/config.ts +110 -0
  156. package/src/dev-bundle-resolution.ts +180 -0
  157. package/src/home-paths.ts +382 -0
  158. package/src/host/agent-host.ts +576 -0
  159. package/src/host/bridge-host.ts +2293 -0
  160. package/src/host/fetch-proxy-handler.ts +288 -0
  161. package/src/host/fetch-proxy-overrides.ts +39 -0
  162. package/src/host/open-url.ts +234 -0
  163. package/src/index.ts +9 -0
  164. package/src/metro-plugin.ts +207 -0
  165. package/src/native-dev-bundle-url.ts +62 -0
  166. package/src/native-seam-manifest.ts +313 -0
  167. package/src/profiles.ts +179 -0
  168. package/src/render-mode.ts +27 -0
  169. package/src/runtime-delivery.ts +334 -0
  170. package/src/screenshots/compose.ts +422 -0
  171. package/src/screenshots/frame-compose.ts +438 -0
  172. package/src/screenshots/orchestrate.ts +244 -0
  173. package/src/screenshots/registry.ts +58 -0
  174. package/src/screenshots/schema.ts +364 -0
  175. package/src/skills/builtin/a11y-review.ts +126 -0
  176. package/src/skills/builtin/compat-check.ts +104 -0
  177. package/src/skills/builtin/perf-profile.ts +84 -0
  178. package/src/skills/builtin/screenshot-all.ts +46 -0
  179. package/src/skills/builtin/test-flow.ts +118 -0
  180. package/src/skills/builtin/visual-diff.ts +94 -0
  181. package/src/skills/registry.ts +107 -0
  182. package/src/skills/types.ts +41 -0
  183. package/src/vite-plugin-one.ts +187 -0
  184. package/src/vite-plugin.ts +1381 -0
  185. package/src/worklets-babel.ts +132 -0
  186. package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
  187. package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
  188. package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
  189. package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
  190. package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
  191. package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
  192. package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
  193. package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
  194. package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
  195. package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
  196. package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
  197. package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
  198. package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
  199. package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
  200. package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
  201. package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
  202. package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
  203. package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
  204. package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
  205. package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
  206. package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
  207. package/dist-cli/chunks/upload-YLJ4RA73.js +0 -2
@@ -0,0 +1,1381 @@
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 {
17
+ SOOTSIM_COMPAT_WRAPPER_REAL_PACKAGES,
18
+ SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SOURCE,
19
+ SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SPECIFIER,
20
+ compatStubsForBuildResolver,
21
+ reactNativeDeepStubsForBuildResolver,
22
+ } from '../../compat/src/stub-manifest.ts'
23
+ import { DEFAULT_SOOTSIM_BRIDGE_PORT } from './bridge-constants.ts'
24
+ import { shouldApplyWorkletsPlugin, transformWorkletsCode } from './worklets-babel.ts'
25
+
26
+ const sootsimPluginRequire = createRequire(import.meta.url)
27
+
28
+ export interface SootOptions {
29
+ // directory of an external RN app to load
30
+ app?: string
31
+ // additional source directories to treat as app sources (metro transforms apply)
32
+ sources?: string[]
33
+ // additional packages that must resolve from the host project, not the external app
34
+ ownedPackages?: string[]
35
+ }
36
+
37
+ // derive sootsim's root from this file's location (src/vite-plugin.ts → ..)
38
+ const sootsimRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
39
+ const workspaceRoot = path.resolve(sootsimRoot, '..', '..')
40
+ const workspaceNodeModules = path.resolve(workspaceRoot, 'node_modules')
41
+ const workspaceTamaguiDir = path.resolve(workspaceNodeModules, '@tamagui')
42
+ // stubs and the mutable React bridge live in @soot/compat.
43
+ const compatRoot = path.resolve(sootsimRoot, '..', 'compat')
44
+
45
+ // the rendering engine and its shims live in the private sootsim-engine
46
+ // workspace package. resolve via workspace root rather than relative hops so
47
+ // renames of either package don't break things.
48
+ const engineRoot = path.resolve(workspaceRoot, 'packages/sootsim-engine')
49
+ const rnShimPath = path.resolve(engineRoot, 'src/react-native/index.ts')
50
+ const reactBridgePath = path.resolve(compatRoot, 'src/react-bridge.ts')
51
+ const sootsimBrowserSourceAliases = [
52
+ {
53
+ find: 'sootsim/backend-origin',
54
+ replacement: path.resolve(sootsimRoot, 'src/backend-origin.ts'),
55
+ },
56
+ {
57
+ find: 'sootsim/bridge-constants',
58
+ replacement: path.resolve(sootsimRoot, 'src/bridge-constants.ts'),
59
+ },
60
+ {
61
+ find: 'sootsim/dev-bundle-resolution',
62
+ replacement: path.resolve(sootsimRoot, 'src/dev-bundle-resolution.ts'),
63
+ },
64
+ ]
65
+
66
+ const compatStubsDir = path.resolve(compatRoot, 'src/stubs')
67
+ const nativeAutoStubPath = path.resolve(compatStubsDir, 'native-auto-stub.ts')
68
+ const rnDeepStubDefault = path.resolve(compatStubsDir, 'react-native-internals.ts')
69
+
70
+ function getInternalTamaguiPackages(): string[] {
71
+ try {
72
+ const scoped = fs
73
+ .readdirSync(workspaceTamaguiDir, { withFileTypes: true })
74
+ .filter((entry) => entry.isDirectory())
75
+ .map((entry) => `@tamagui/${entry.name}`)
76
+
77
+ return ['tamagui', ...scoped].sort()
78
+ } catch {
79
+ return ['tamagui']
80
+ }
81
+ }
82
+
83
+ const internalTamaguiPackages = getInternalTamaguiPackages()
84
+
85
+ const vitePackageStubs = compatStubsForBuildResolver('vite')
86
+ const viteReactNativeDeepStubs = reactNativeDeepStubsForBuildResolver('vite')
87
+ const builtinStubs = Object.fromEntries(
88
+ vitePackageStubs.map((entry) => [entry.specifier, entry.stubFile]),
89
+ )
90
+ const rnLibraryStubs = Object.fromEntries(
91
+ viteReactNativeDeepStubs.map((entry) => [
92
+ entry.specifier,
93
+ path.resolve(compatStubsDir, entry.stubFile),
94
+ ]),
95
+ )
96
+
97
+ // resolve the real @react-navigation/native location at config time so the
98
+ // wrapper at compat/src/stubs/react-navigation-native.tsx can import it
99
+ // without bouncing back through the alias above. the wrapper imports from
100
+ // the synthetic specifier `@sootsim-internal/react-navigation-native-real`
101
+ // which is aliased to this absolute path; rolldown follows the path
102
+ // terminally so its `export *` static analysis sees the real export
103
+ // surface.
104
+ const reactNavigationNativeRealPath = (() => {
105
+ try {
106
+ return sootsimPluginRequire.resolve('@react-navigation/native')
107
+ } catch {
108
+ return null
109
+ }
110
+ })()
111
+
112
+ const reactNativeHapticFeedbackTouchablePath = (() => {
113
+ try {
114
+ return path.join(
115
+ path.dirname(
116
+ sootsimPluginRequire.resolve('react-native-haptic-feedback/package.json'),
117
+ ),
118
+ SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SOURCE,
119
+ )
120
+ } catch {
121
+ return null
122
+ }
123
+ })()
124
+
125
+ const compatWrapperRealPackageAliases: Array<{
126
+ find: string
127
+ replacement: string
128
+ }> = []
129
+ for (const { specifier, packageName } of Object.values(
130
+ SOOTSIM_COMPAT_WRAPPER_REAL_PACKAGES,
131
+ )) {
132
+ try {
133
+ compatWrapperRealPackageAliases.push({
134
+ find: specifier,
135
+ replacement: sootsimPluginRequire.resolve(packageName),
136
+ })
137
+ } catch {}
138
+ }
139
+
140
+ // packages that must always resolve from sootsim to ensure single instances
141
+ // across the renderer/runtime itself. keep this list minimal for external apps.
142
+ const coreOwnedPackages = [
143
+ 'react',
144
+ 'react-dom',
145
+ 'react-reconciler',
146
+ 'react/jsx-runtime',
147
+ 'react/jsx-dev-runtime',
148
+ 'canvaskit-wasm',
149
+ 'canvaskit-wasm/full',
150
+ 'yoga-layout',
151
+ ]
152
+
153
+ const nativePackagePatterns = [
154
+ /^expo-/,
155
+ /^react-native-/,
156
+ /^@react-native\//,
157
+ /^@react-native-community\//,
158
+ /^@expo\//,
159
+ /^expo$/,
160
+ ]
161
+
162
+ function getPackageName(source: string): string {
163
+ return source.startsWith('@')
164
+ ? source.split('/').slice(0, 2).join('/')
165
+ : source.split('/')[0]
166
+ }
167
+
168
+ function isNativePackage(source: string): boolean {
169
+ return nativePackagePatterns.some((p) => p.test(source))
170
+ }
171
+
172
+ function moduleExistsIn(pkgName: string, dir: string): boolean {
173
+ try {
174
+ return fs.existsSync(path.join(dir, 'node_modules', pkgName))
175
+ } catch {
176
+ return false
177
+ }
178
+ }
179
+
180
+ export function sootsim(options: SootOptions = {}): Plugin[] {
181
+ const appDir = options.app ? path.resolve(options.app) : ''
182
+ const extraSources = (options.sources || []).map((s) => path.resolve(s))
183
+ const ownedPackages = new Set([...coreOwnedPackages, ...(options.ownedPackages || [])])
184
+
185
+ function isAppSource(filePath: string): boolean {
186
+ // exclude node_modules — only actual app source files
187
+ if (filePath.includes('/node_modules/')) return false
188
+ if (appDir && filePath.startsWith(appDir)) return true
189
+ return extraSources.some((s) => filePath.startsWith(s))
190
+ }
191
+
192
+ return [
193
+ wsBridgePlugin(),
194
+ reactBridgePlugin(),
195
+ sootsimConfigPlugin(appDir, extraSources),
196
+ fixJsxRuntimeExports(),
197
+ flowStripTransform(),
198
+ nodeModulesJsxTransform(),
199
+ // must run before metroNativeResolve — that plugin resolves relative
200
+ // imports like `./bindings` to absolute file paths via the platform-
201
+ // extension lookup, and once it returns a resolved id, downstream
202
+ // resolveId hooks don't see the source string anymore.
203
+ keyboardControllerBindingsRedirect(),
204
+ reanimatedNativeSeamRedirect(),
205
+ manifestNativeSeamRedirect(),
206
+ workletsBabelTransform(isAppSource),
207
+ metroNativeResolve(isAppSource),
208
+ reactNativeRequirePlugin(isAppSource),
209
+ externalAppResolvePlugin(appDir, isAppSource, ownedPackages),
210
+ externalAppTransformPlugin(isAppSource),
211
+ stubMissingNativeDeps(appDir),
212
+ stubMissingImages(isAppSource),
213
+ ]
214
+ }
215
+
216
+ // shared worker plugin chain for the engine's shell-host build (used by
217
+ // both `vite.config.ts` for dev/watch and `vite.build.config.ts` for prod).
218
+ // keep these here — duplicating the list inline drifts (which is exactly how
219
+ // the prod shell worker shipped without `reanimatedNativeSeamRedirect` and
220
+ // threw "Native part of Reanimated doesn't seem to be initialized" until
221
+ // 2e6a8450).
222
+ //
223
+ // shell-host.ts spawns shell-worker.ts via Vite's declarative
224
+ // `new Worker(new URL(...))` pattern, so shell-worker is a worker chunk of
225
+ // the engine build. apply react-bridge / jsx-runtime fixes plus the
226
+ // reanimated/keyboard-controller native-seam redirects so upstream's
227
+ // reanimated wires up our turbomodule + globals inside the worker too.
228
+ // without these, worker bundles see upstream's original
229
+ // `NativeReanimatedModule.ts`, `TurboModuleRegistry.get('ReanimatedModule')`
230
+ // returns null on web/sootsim, `__reanimatedModuleProxy` never gets set,
231
+ // and the NativeReanimated constructor throws on shell-worker mount.
232
+ //
233
+ // workletsBabelTransform populates `__closure` on user-code worklets so
234
+ // useAnimatedStyle/useDerivedValue/etc. subscribe correctly inside the
235
+ // worker. matching is content-driven today, so the predicate is `() => true`.
236
+ export function engineShellWorkerPlugins(): Plugin[] {
237
+ return [
238
+ workerReactBridgePlugin(),
239
+ fixJsxRuntimeExports(),
240
+ reanimatedNativeSeamRedirect(),
241
+ manifestNativeSeamRedirect(),
242
+ keyboardControllerBindingsRedirect(),
243
+ workletsBabelTransform(() => true),
244
+ ]
245
+ }
246
+
247
+ // react-native-keyboard-controller is pure-JS for everything except its
248
+ // native-event seam (`bindings.ts` / `bindings.native.ts`) and RN platform
249
+ // `findNodeHandle` resolution. let upstream's components, hooks, animated
250
+ // module, and KeyboardAvoidingView resolve from node_modules unchanged, and
251
+ // redirect only those native/platform seams.
252
+ //
253
+ // the redirect runs `enforce: 'pre'` so it sees `./bindings` / `../bindings`
254
+ // / `../../bindings` strings before vite's native resolver collapses them to
255
+ // absolute paths. matches by importer path (must be inside upstream's
256
+ // node_modules root) and source basename.
257
+ export function keyboardControllerBindingsRedirect(): Plugin {
258
+ const target = path.resolve(compatStubsDir, 'react-native-keyboard-controller.ts')
259
+ const findNodeHandleTarget = '\0sootsim:rnkc-find-node-handle-native'
260
+ const nativePlatformBasenames = new Set(['findNodeHandle', 'reanimated'])
261
+ const nativeExts = [
262
+ '.ios.tsx',
263
+ '.ios.ts',
264
+ '.ios.jsx',
265
+ '.ios.js',
266
+ '.native.tsx',
267
+ '.native.ts',
268
+ '.native.jsx',
269
+ '.native.js',
270
+ '.native.mjs',
271
+ ]
272
+ const resolveNativePlatformFile = (source: string, importer: string): string | null => {
273
+ const base = path.resolve(path.dirname(importer), source)
274
+ for (const ext of nativeExts) {
275
+ const candidate = base + ext
276
+ try {
277
+ if (fs.existsSync(candidate)) return candidate
278
+ } catch {}
279
+ }
280
+ for (const ext of nativeExts) {
281
+ const candidate = path.join(base, 'index' + ext)
282
+ try {
283
+ if (fs.existsSync(candidate)) return candidate
284
+ } catch {}
285
+ }
286
+ return null
287
+ }
288
+ return {
289
+ name: 'sootsim-keyboard-controller-bindings-redirect',
290
+ enforce: 'pre',
291
+ resolveId(source, importer) {
292
+ if (!importer) return null
293
+ if (!importer.includes('/react-native-keyboard-controller/')) return null
294
+ // keep bindings imports — strip optional .native and any leading
295
+ // `./`, `../`, `../../` so the depth doesn't matter.
296
+ const basename = source.split('/').pop() ?? ''
297
+ if (nativePlatformBasenames.has(basename)) {
298
+ return (
299
+ resolveNativePlatformFile(source, importer) ??
300
+ (basename === 'findNodeHandle' ? findNodeHandleTarget : null)
301
+ )
302
+ }
303
+ if (basename !== 'bindings' && basename !== 'bindings.native') return null
304
+ // don't redirect bindings imports that already resolved to our file
305
+ if (importer === target) return null
306
+ return target
307
+ },
308
+ load(id) {
309
+ if (id === findNodeHandleTarget) {
310
+ return `export { findNodeHandle } from ${JSON.stringify(rnShimPath)};`
311
+ }
312
+ return null
313
+ },
314
+ }
315
+ }
316
+
317
+ // react-native-reanimated is mostly pure-JS — useSharedValue, useAnimatedStyle,
318
+ // useDerivedValue, withTiming/withSpring/etc., createAnimatedComponent, hooks,
319
+ // layout animations, easing all sit on top of a thin native seam:
320
+ //
321
+ // - src/specs/NativeReanimatedModule (turbomodule that installs __reanimatedModuleProxy)
322
+ // - src/platformFunctions/scrollTo
323
+ // - src/platformFunctions/measure
324
+ // - src/platformFunctions/setNativeProps
325
+ // - src/platformFunctions/dispatchCommand
326
+ // - src/platformFunctions/setGestureState
327
+ //
328
+ // per the project policy of not shimming pure-JS libs, we let upstream resolve
329
+ // from node_modules and redirect ONLY the native seam to our compat stub
330
+ // (one file at packages/compat/src/stubs/react-native-reanimated.ts that
331
+ // exports the right shape — default = turbomodule, named = platform fns).
332
+ //
333
+ // upstream's `assertWorkletsVersion` (DEV-only) imports a build-time script
334
+ // that does `require('react-native-worklets/package.json')`. our flat-file
335
+ // stub for react-native-worklets has no such subpath, so this throws under
336
+ // vite's worker build. redirect the validate script to a no-op stub.
337
+ const reanimatedNativeSeamFiles = new Set([
338
+ 'NativeReanimatedModule',
339
+ 'scrollTo',
340
+ 'measure',
341
+ 'setNativeProps',
342
+ 'dispatchCommand',
343
+ 'setGestureState',
344
+ ])
345
+ const reanimatedFabricUtilsRedirectPath = path.resolve(
346
+ compatStubsDir,
347
+ 'react-native-reanimated-fabric-utils.ts',
348
+ )
349
+ const reanimatedValidateWorkletsVersionPath = path.resolve(
350
+ compatStubsDir,
351
+ 'react-native-reanimated-validate-worklets-version.ts',
352
+ )
353
+ export function reanimatedNativeSeamRedirect(): Plugin {
354
+ const target = path.resolve(compatStubsDir, 'react-native-reanimated.ts')
355
+ return {
356
+ name: 'sootsim-reanimated-native-seam-redirect',
357
+ enforce: 'pre',
358
+ resolveId(source, importer) {
359
+ // upstream's `assertWorkletsVersion` (DEV-only) imports a build-time
360
+ // script that does `require('react-native-worklets/package.json')`.
361
+ // our flat-file stub for react-native-worklets has no such subpath,
362
+ // so this throws under vite's worker build. resolve the package.json
363
+ // import to a synthetic virtual id and load synthetic JSON below.
364
+ if (source === 'react-native-worklets/package.json') {
365
+ return '\0sootsim:react-native-worklets-package-json'
366
+ }
367
+ if (!importer) return null
368
+ // no-op the assertWorkletsVersion validator — same reason. matches both
369
+ // the bare import and the .js-suffixed resolved form some bundlers see.
370
+ if (
371
+ source === 'react-native-reanimated/scripts/validate-worklets-version' ||
372
+ source === 'react-native-reanimated/scripts/validate-worklets-version.js'
373
+ ) {
374
+ return reanimatedValidateWorkletsVersionPath
375
+ }
376
+ if (!importer.includes('/react-native-reanimated/')) return null
377
+ if (importer === target) return null
378
+ // strip optional `.web` / `.native` extension and leading `./`s — the
379
+ // redirect should match regardless of relative depth or platform suffix.
380
+ const basename = (source.split('/').pop() ?? '').replace(
381
+ /\.(native|web|ios|android)$/,
382
+ '',
383
+ )
384
+ // upstream's `fabricUtils.{ts,js}` `getShadowNodeWrapperFromRef` requires
385
+ // `ref.__internalInstanceHandle` (Fabric's react-shadow-tree handle) and
386
+ // throws otherwise. on sootsim refs come through as raw SootSimNodes
387
+ // without that handle. redirect to a sootsim-aware version that
388
+ // lazy-attaches the handle before reading it. see
389
+ // `react-native-reanimated-fabric-utils.ts`.
390
+ if (basename === 'fabricUtils' && importer !== reanimatedFabricUtilsRedirectPath) {
391
+ return reanimatedFabricUtilsRedirectPath
392
+ }
393
+ if (!reanimatedNativeSeamFiles.has(basename)) return null
394
+ if (process.env.SOOTSIM_REANIMATED_REDIRECT_DEBUG) {
395
+ console.log(
396
+ '[reanimatedRedirect]',
397
+ basename,
398
+ '<-',
399
+ source,
400
+ 'from',
401
+ importer.slice(-80),
402
+ )
403
+ }
404
+ return target
405
+ },
406
+ load(id) {
407
+ // synthetic package.json for react-native-worklets — see resolveId
408
+ // above. just enough for upstream's version-validator to read `version`.
409
+ if (id === '\0sootsim:react-native-worklets-package-json') {
410
+ return `export default ${JSON.stringify({ version: '0.0.0-sootsim', name: 'react-native-worklets' })}`
411
+ }
412
+ return null
413
+ },
414
+ }
415
+ }
416
+
417
+ // generalised native-seam redirect driven by NATIVE_SEAM_MANIFEST. each entry
418
+ // names an upstream package + a list of basenames to redirect to a single
419
+ // stub. agents migrating from wholesale-rewrite stubs to the native-seam
420
+ // pattern append entries here without touching the plugin code.
421
+ //
422
+ // the reanimated plugin above stays separate because it has package-specific
423
+ // edge cases (fabricUtils, validate-worklets-version). new migrations should
424
+ // use the manifest path unless they need similar special handling.
425
+ import { NATIVE_SEAM_MANIFEST } from './native-seam-manifest'
426
+ export function manifestNativeSeamRedirect(): Plugin {
427
+ // build a fast lookup: pkg → { basenames, target }
428
+ const byPkg = new Map<
429
+ string,
430
+ { basenames: Set<string>; target: string; notes?: string }
431
+ >()
432
+ for (const entry of NATIVE_SEAM_MANIFEST) {
433
+ const existing = byPkg.get(entry.pkg)
434
+ if (existing) {
435
+ for (const b of entry.seamBasenames) existing.basenames.add(b)
436
+ } else {
437
+ byPkg.set(entry.pkg, {
438
+ basenames: new Set(entry.seamBasenames),
439
+ target: entry.target,
440
+ notes: entry.notes,
441
+ })
442
+ }
443
+ }
444
+ return {
445
+ name: 'sootsim-manifest-native-seam-redirect',
446
+ enforce: 'pre',
447
+ resolveId(source, importer) {
448
+ if (!importer || byPkg.size === 0) return null
449
+ // find the package whose path appears in the importer. matches both
450
+ // `/<pkg>/` (unscoped) and `/@scope/<pkg>/` (scoped — the leading `@`
451
+ // is part of the pkg name in the manifest, e.g. `@notifee/react-native`).
452
+ let match: { basenames: Set<string>; target: string; notes?: string } | undefined
453
+ let matchedPkg: string | undefined
454
+ for (const [pkg, entry] of byPkg) {
455
+ if (importer.includes(`/${pkg}/`)) {
456
+ match = entry
457
+ matchedPkg = pkg
458
+ break
459
+ }
460
+ }
461
+ if (!match) return null
462
+ if (importer === match.target) return null
463
+ const basename = (source.split('/').pop() ?? '').replace(
464
+ /\.(native|web|ios|android)$/,
465
+ '',
466
+ )
467
+ if (!match.basenames.has(basename)) return null
468
+ if (process.env.SOOTSIM_NATIVE_SEAM_DEBUG) {
469
+ console.log(
470
+ '[nativeSeamRedirect]',
471
+ `${matchedPkg}:${basename}`,
472
+ '<-',
473
+ source,
474
+ 'from',
475
+ importer.slice(-80),
476
+ match.notes ? `(${match.notes})` : '',
477
+ )
478
+ }
479
+ return match.target
480
+ },
481
+ }
482
+ }
483
+
484
+ // start WS bridge server for debug CLI connectivity. exported so shell
485
+ // vite can install it standalone (engine builds via `vite build --watch`,
486
+ // which doesn't fire configureServer — the bridge has to run on shell).
487
+ export function wsBridgePlugin(): Plugin {
488
+ let bridgeHost: {
489
+ start?: (options?: { silent?: boolean }) => void | Promise<void>
490
+ close?: () => Promise<void> | void
491
+ } | null = null
492
+ return {
493
+ name: 'sootsim-ws-bridge',
494
+ configureServer(server) {
495
+ if (bridgeHost) return
496
+ // lazy import to avoid pulling ws into the main bundle.
497
+ // note: the .ts extension is required on vite 8 / node ESM — without
498
+ // it, import() throws "Cannot find module" and the bridge silently
499
+ // never starts.
500
+ void import('./host/bridge-host.ts')
501
+ .then(({ SootSimBridgeHost }) => {
502
+ bridgeHost = new SootSimBridgeHost({ port: DEFAULT_SOOTSIM_BRIDGE_PORT })
503
+ bridgeHost.start?.({ silent: true })
504
+ })
505
+ .catch((err: unknown) => {
506
+ // log but don't crash — some environments (e.g. production builds)
507
+ // don't have ws available. silently swallowing has caused debugging
508
+ // pain; print the message instead.
509
+ const message = err instanceof Error ? err.message : String(err)
510
+ console.warn('[sootsim] ws bridge failed to start:', message)
511
+ })
512
+
513
+ // cleanup on server close (HMR restart)
514
+ server.httpServer?.on('close', () => {
515
+ if (bridgeHost) {
516
+ void bridgeHost.close?.()
517
+ bridgeHost = null
518
+ }
519
+ })
520
+ },
521
+ }
522
+ }
523
+
524
+ // applies resolve, define, optimizeDeps config
525
+ function sootsimConfigPlugin(appDir: string, extraSources: string[]): Plugin {
526
+ return {
527
+ name: 'sootsim-config',
528
+ config(_, { mode }) {
529
+ const fsAllow = ['.']
530
+ for (const src of extraSources) fsAllow.push(src)
531
+ if (appDir) {
532
+ fsAllow.push(appDir)
533
+ const appNodeModules = path.join(appDir, 'node_modules')
534
+ if (fs.existsSync(appNodeModules)) fsAllow.push(appNodeModules)
535
+ }
536
+
537
+ // build alias list from builtin stubs
538
+ const stubAliases = Object.entries(builtinStubs)
539
+ // sort longest keys first so subpath aliases (e.g. foo/Bar) match before
540
+ // their parent package (e.g. foo) — vite processes aliases in order
541
+ .sort((a, b) => b[0].length - a[0].length)
542
+ .map(([find, file]) => ({
543
+ find,
544
+ replacement: path.resolve(compatStubsDir, file),
545
+ }))
546
+
547
+ const dedupe = ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime']
548
+
549
+ // internal sootsim builds must keep the entire Tamagui package family on a
550
+ // single module graph. mixing raw @fs modules with prebundled .vite/deps
551
+ // versions breaks context identity (Adapt/Portal/ZIndex/etc).
552
+ if (!appDir) {
553
+ dedupe.push(...internalTamaguiPackages)
554
+ }
555
+
556
+ const optimizeDepsExclude = [
557
+ '@tamagui/config',
558
+ '@tamagui/demos',
559
+ 'react-native',
560
+ // tamagui native variants reference RN internals that the metro transform
561
+ // handles — must go through sootsim plugin, not rolldown pre-bundling
562
+ ...(!appDir ? internalTamaguiPackages : []),
563
+ // ships .js with JSX that rolldown can't parse
564
+ 'react-native-actions-sheet',
565
+ // these packages import `react-native-reanimated` and `react-native-gesture-handler`,
566
+ // both of which we alias to local stubs. pre-bundling would resolve those imports
567
+ // against the REAL packages (the alias only applies to main-plugin resolveId), so
568
+ // the resulting dep has a different reanimated instance than the app graph. keep
569
+ // them served live so our plugin aliases kick in.
570
+ '@gorhom/bottom-sheet',
571
+ '@gorhom/portal',
572
+ ]
573
+
574
+ return {
575
+ define: {
576
+ 'process.env.NODE_ENV': JSON.stringify(mode),
577
+ 'process.env.TEST_NATIVE_PLATFORM': JSON.stringify(''),
578
+ 'process.env.TAMAGUI_TARGET': JSON.stringify('native'),
579
+ global: 'globalThis',
580
+ },
581
+ optimizeDeps: {
582
+ include: [
583
+ 'react',
584
+ 'react-dom',
585
+ 'react-dom/client',
586
+ 'react-reconciler',
587
+ 'react-reconciler/constants',
588
+ 'react/jsx-runtime',
589
+ 'react/jsx-dev-runtime',
590
+ '@react-native/normalize-color',
591
+ // CJS deps transitively imported by @gorhom/bottom-sheet and
592
+ // @gorhom/portal (both excluded above). vite's CJS→ESM interop
593
+ // only kicks in via pre-bundle, so these must be pre-bundled even
594
+ // though their importers aren't.
595
+ 'invariant',
596
+ 'nanoid/non-secure',
597
+ ],
598
+ exclude: optimizeDepsExclude,
599
+ // vite 8: rolldown replaces esbuild for dep optimization
600
+ rolldownOptions: {
601
+ resolve: {
602
+ conditionNames: ['react-native', 'import', 'require'],
603
+ mainFields: ['react-native', 'module', 'jsnext:main', 'jsnext'],
604
+ extensions: [
605
+ '.ios.tsx',
606
+ '.ios.ts',
607
+ '.ios.jsx',
608
+ '.ios.js',
609
+ '.native.tsx',
610
+ '.native.ts',
611
+ '.native.jsx',
612
+ '.native.js',
613
+ '.native.mjs',
614
+ '.mjs',
615
+ '.js',
616
+ '.mts',
617
+ '.ts',
618
+ '.jsx',
619
+ '.tsx',
620
+ '.json',
621
+ ],
622
+ },
623
+ plugins: [
624
+ {
625
+ name: 'sootsim-stub-images',
626
+ resolveId(source: string, importer: string | undefined) {
627
+ if (
628
+ /\.(jpg|png|gif)$/.test(source) &&
629
+ importer?.includes('node_modules')
630
+ ) {
631
+ return { id: '\0stub-image' }
632
+ }
633
+ },
634
+ load(id: string) {
635
+ if (id === '\0stub-image') return 'export default ""'
636
+ },
637
+ },
638
+ ],
639
+ },
640
+ },
641
+ build: { target: 'esnext' },
642
+ oxc: { target: 'esnext' },
643
+ server: {
644
+ fs: { allow: fsAllow },
645
+ },
646
+ resolve: {
647
+ dedupe,
648
+ conditions: ['react-native', 'import', 'require'],
649
+ mainFields: ['react-native', 'module', 'jsnext:main', 'jsnext'],
650
+ extensions: [
651
+ '.ios.tsx',
652
+ '.ios.ts',
653
+ '.ios.jsx',
654
+ '.ios.js',
655
+ '.native.tsx',
656
+ '.native.ts',
657
+ '.native.jsx',
658
+ '.native.js',
659
+ '.native.mjs',
660
+ '.mjs',
661
+ '.js',
662
+ '.mts',
663
+ '.ts',
664
+ '.jsx',
665
+ '.tsx',
666
+ '.json',
667
+ ],
668
+ alias: [
669
+ ...sootsimBrowserSourceAliases,
670
+ ...Object.entries(rnLibraryStubs).map(([find, replacement]) => ({
671
+ find,
672
+ replacement,
673
+ })),
674
+ { find: /^react-native\/Libraries\/.*/, replacement: rnDeepStubDefault },
675
+ { find: 'react-native', replacement: rnShimPath },
676
+ { find: 'react-native-web', replacement: rnShimPath },
677
+ // resolved-path alias for the synthetic specifier the
678
+ // @react-navigation/native compat wrapper imports from. terminates
679
+ // the wrapper's own real-package import without re-entering the
680
+ // alias above. omit the entry if the package isn't installed —
681
+ // the wrapper file simply won't be loaded in that case.
682
+ ...(reactNavigationNativeRealPath
683
+ ? [
684
+ {
685
+ find: '@sootsim-internal/react-navigation-native-real',
686
+ replacement: reactNavigationNativeRealPath,
687
+ },
688
+ ]
689
+ : []),
690
+ ...(reactNativeHapticFeedbackTouchablePath
691
+ ? [
692
+ {
693
+ find: SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SPECIFIER,
694
+ replacement: reactNativeHapticFeedbackTouchablePath,
695
+ },
696
+ ]
697
+ : []),
698
+ ...compatWrapperRealPackageAliases,
699
+ ...stubAliases,
700
+ ],
701
+ },
702
+ }
703
+ },
704
+ }
705
+ }
706
+
707
+ // redirect `import ... from 'react'` to react-bridge for sootsim, compat, and
708
+ // react-reconciler code. this enables runtime React version switching for
709
+ // external app bundles — bindReact(appReact) swaps which React all code uses.
710
+ function reactBridgePlugin(): Plugin {
711
+ let isBuild = false
712
+ let redirectCount = 0
713
+ let skipCount = 0
714
+ return {
715
+ name: 'sootsim-react-bridge',
716
+ enforce: 'pre',
717
+ configResolved(config) {
718
+ isBuild = config.command === 'build'
719
+ console.log('[react-bridge-plugin] isBuild:', isBuild)
720
+ },
721
+ async resolveId(source, importer, options) {
722
+ if (source !== 'react' || !importer) return null
723
+
724
+ // log all react imports to see what's being processed
725
+ if (
726
+ importer.includes('/compat/') ||
727
+ importer.includes('/sootsim/src/') ||
728
+ importer.includes('/sootsim-engine/src/')
729
+ ) {
730
+ skipCount++
731
+ if (skipCount <= 5) {
732
+ const skip = (options as { scan?: boolean })?.scan || options?.ssr || isBuild
733
+ if (!skip) {
734
+ console.log(
735
+ '[react-bridge-plugin] react import from:',
736
+ importer.split('/').slice(-3).join('/'),
737
+ 'skip:',
738
+ skip,
739
+ )
740
+ }
741
+ }
742
+ }
743
+
744
+ // skip during dep scanning / SSR — only active in browser serve mode
745
+ if ((options as { scan?: boolean })?.scan || options?.ssr || isBuild) return null
746
+
747
+ // only redirect imports from sootsim, sootsim-engine, compat, and react-reconciler
748
+ const shouldRedirect =
749
+ importer.includes('/sootsim/src/') ||
750
+ importer.includes('/sootsim-engine/src/') ||
751
+ importer.includes('/compat/src/') ||
752
+ importer.includes('react-reconciler')
753
+
754
+ if (!shouldRedirect) return null
755
+
756
+ // don't redirect react-bridge's own import of react (avoid infinite loop)
757
+ if (importer.includes('react-bridge')) return null
758
+
759
+ // stub-registry needs the real React (with __CLIENT_INTERNALS) for bundle stubs
760
+ if (importer.includes('stub-registry')) return null
761
+
762
+ redirectCount++
763
+ if (redirectCount <= 10) {
764
+ console.log(
765
+ '[react-bridge-plugin] REDIRECTING:',
766
+ importer.split('/').slice(-3).join('/'),
767
+ )
768
+ } else if (redirectCount === 11) {
769
+ console.log('[react-bridge-plugin] ... more redirects')
770
+ }
771
+ return { id: reactBridgePath, external: false }
772
+ },
773
+ }
774
+ }
775
+
776
+ // worker-compatible react-bridge redirect. unlike the main plugin, this one
777
+ // also applies during builds since workers are built, not served. exported for
778
+ // use in worker.plugins configuration.
779
+ export function workerReactBridgePlugin(): Plugin {
780
+ return {
781
+ name: 'sootsim-worker-react-bridge',
782
+ enforce: 'pre',
783
+ async resolveId(source, importer, options) {
784
+ if (source !== 'react' || !importer) return null
785
+
786
+ // skip during dep scanning / SSR
787
+ if ((options as { scan?: boolean })?.scan || options?.ssr) return null
788
+
789
+ // don't redirect react-bridge's own import of react (avoid infinite loop)
790
+ if (importer.includes('react-bridge')) return null
791
+
792
+ // stub-registry needs the real React (with __CLIENT_INTERNALS) for bundle stubs
793
+ if (importer.includes('stub-registry')) return null
794
+
795
+ // redirect react imports from sootsim, sootsim-engine, compat, react-reconciler,
796
+ // and any pure-JS RN-ecosystem package the bundle pulls in. without this
797
+ // redirect, packages like react-native-drawer-layout / use-latest-callback
798
+ // call into a separate React instance than the renderer/bridge does, so
799
+ // their useRef/useEffect refs collide with the engine's hooks state and
800
+ // we hit "Maximum update depth exceeded" infinite-loop renders.
801
+ const shouldRedirect =
802
+ importer.includes('/sootsim/src/') ||
803
+ importer.includes('/sootsim-engine/src/') ||
804
+ importer.includes('/compat/src/') ||
805
+ importer.includes('react-reconciler') ||
806
+ importer.includes('/node_modules/')
807
+
808
+ if (!shouldRedirect) return null
809
+
810
+ return { id: reactBridgePath, external: false }
811
+ },
812
+ }
813
+ }
814
+
815
+ // react 19's jsx-runtime.development.js wraps exports.jsx inside a conditional
816
+ // IIFE, so esbuild's static CJS analysis can't detect the named exports during
817
+ // pre-bundling. this plugin wraps the real pre-bundled module with explicit exports.
818
+ // exported for use in worker.plugins configuration.
819
+ export function fixJsxRuntimeExports(): Plugin {
820
+ return {
821
+ name: 'sootsim-fix-jsx-runtime',
822
+ enforce: 'pre',
823
+ async resolveId(source, importer, options) {
824
+ if (source === 'react/jsx-runtime' || source === 'react/jsx-dev-runtime') {
825
+ const resolved = await this.resolve(source, importer, {
826
+ ...options,
827
+ skipSelf: true,
828
+ })
829
+ if (!resolved) return null
830
+ const prefix =
831
+ source === 'react/jsx-runtime'
832
+ ? '\0sootsim:jsx-runtime:'
833
+ : '\0sootsim:jsx-dev-runtime:'
834
+ return prefix + resolved.id
835
+ }
836
+ return null
837
+ },
838
+ load(id) {
839
+ if (id.startsWith('\0sootsim:jsx-runtime:')) {
840
+ const realId = id.slice('\0sootsim:jsx-runtime:'.length)
841
+ return [
842
+ `import * as _mod from ${JSON.stringify(realId)};`,
843
+ `const _m = _mod.default || _mod;`,
844
+ `export const jsx = _m.jsx || _mod.jsx;`,
845
+ `export const jsxs = _m.jsxs || _mod.jsxs;`,
846
+ `export const Fragment = _m.Fragment || _mod.Fragment;`,
847
+ ].join('\n')
848
+ }
849
+ if (id.startsWith('\0sootsim:jsx-dev-runtime:')) {
850
+ const realId = id.slice('\0sootsim:jsx-dev-runtime:'.length)
851
+ // react/jsx-runtime is already wrapped by this plugin (above) to expose
852
+ // { jsx, jsxs, Fragment } as named exports, so read them directly from
853
+ // the namespace — no `_runtime.default || _runtime` fallback needed.
854
+ // rolldown statically knows the wrapped module has no default export
855
+ // and would emit IMPORT_IS_UNDEFINED warnings on every build.
856
+ return [
857
+ `import * as _mod from ${JSON.stringify(realId)};`,
858
+ `import * as _runtime from "react/jsx-runtime";`,
859
+ `const _m = _mod.default || _mod;`,
860
+ // production React builds may not expose jsxDEV from react/jsx-dev-runtime.
861
+ // fall back to jsx so transformed modules still execute.
862
+ `export const jsxDEV = _m.jsxDEV || _mod.jsxDEV || _m.jsx || _mod.jsx || _runtime.jsx;`,
863
+ `export const Fragment = _m.Fragment || _mod.Fragment || _runtime.Fragment;`,
864
+ ].join('\n')
865
+ }
866
+ return null
867
+ },
868
+ }
869
+ }
870
+
871
+ // esbuild plugin for optimizeDeps pre-bundling
872
+ function sootsimEsbuildPlugin(appDir: string) {
873
+ const platformExts = [
874
+ '.ios.tsx',
875
+ '.ios.ts',
876
+ '.ios.jsx',
877
+ '.ios.js',
878
+ '.native.tsx',
879
+ '.native.ts',
880
+ '.native.jsx',
881
+ '.native.js',
882
+ '.native.mjs',
883
+ ]
884
+
885
+ function isAppSourceEsbuild(filePath: string): boolean {
886
+ return !!(appDir && filePath.startsWith(appDir))
887
+ }
888
+
889
+ return {
890
+ name: 'sootsim-esbuild',
891
+ setup(build: any) {
892
+ // metro-style .ios > .native resolution
893
+ build.onResolve({ filter: /^\./ }, (args: any) => {
894
+ if (!args.importer.includes('node_modules') && !isAppSourceEsbuild(args.importer))
895
+ return null
896
+ const dir = path.dirname(args.importer)
897
+ const resolved = path.resolve(dir, args.path)
898
+ for (const ext of platformExts) {
899
+ const candidate = resolved + ext
900
+ try {
901
+ if (fs.existsSync(candidate)) return { path: candidate }
902
+ } catch {}
903
+ }
904
+ for (const ext of platformExts) {
905
+ const candidate = path.join(resolved, 'index' + ext)
906
+ try {
907
+ if (fs.existsSync(candidate)) return { path: candidate }
908
+ } catch {}
909
+ }
910
+ const allExts = [...platformExts, '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']
911
+ for (const ext of allExts) {
912
+ if (args.path.endsWith(ext)) {
913
+ const base = resolved.slice(0, -ext.length)
914
+ for (const pext of platformExts) {
915
+ const candidate = base + pext
916
+ try {
917
+ if (fs.existsSync(candidate)) return { path: candidate }
918
+ } catch {}
919
+ }
920
+ break
921
+ }
922
+ }
923
+ return null
924
+ })
925
+
926
+ // react-native deep path stubs
927
+ for (const [importPath, stubPath] of Object.entries(rnLibraryStubs)) {
928
+ build.onResolve(
929
+ { filter: new RegExp(`^${importPath.replace(/\//g, '\\/')}$`) },
930
+ () => ({
931
+ path: stubPath,
932
+ }),
933
+ )
934
+ }
935
+ build.onResolve({ filter: /^react-native\/Libraries\// }, (args: any) => {
936
+ return { path: rnLibraryStubs[args.path] || rnDeepStubDefault }
937
+ })
938
+
939
+ // resolve builtin stubs (react-native-safe-area-context, expo-*, etc.)
940
+ for (const [pkgName, stubFile] of Object.entries(builtinStubs)) {
941
+ const stubPath = path.resolve(compatStubsDir, stubFile)
942
+ const escaped = pkgName.replace(/[.*+?^${}()|[\]\\/@-]/g, '\\$&')
943
+ build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
944
+ path: stubPath,
945
+ }))
946
+ }
947
+
948
+ // external app node_modules resolution — use esbuild's own resolve
949
+ // so exports conditions are respected (not CJS require.resolve which ignores them)
950
+ if (appDir) {
951
+ const owned = new Set(coreOwnedPackages)
952
+ build.onResolve({ filter: /^[^.]/ }, async (args: any) => {
953
+ if (args.pluginData?.fromAppResolve) return null
954
+ if (
955
+ !isAppSourceEsbuild(args.importer) &&
956
+ !args.importer.includes(path.join(appDir, 'node_modules'))
957
+ )
958
+ return null
959
+ const src = args.path
960
+ if (src.startsWith('\0')) return null
961
+ if (owned.has(src) || owned.has(getPackageName(src))) return null
962
+ if (src === 'react-native' || src.startsWith('react-native/')) return null
963
+ try {
964
+ const result = await build.resolve(src, {
965
+ resolveDir: appDir,
966
+ pluginData: { fromAppResolve: true },
967
+ })
968
+ if (result.errors.length) return null
969
+ return { path: result.path }
970
+ } catch {
971
+ return null
972
+ }
973
+ })
974
+ }
975
+
976
+ // auto-stub missing native packages
977
+ build.onResolve(
978
+ {
979
+ filter:
980
+ /^(expo-|react-native-|@react-native\/|@react-native-community\/|@expo\/|expo$)/,
981
+ },
982
+ (args: any) => {
983
+ const pkgName = getPackageName(args.path)
984
+ if (moduleExistsIn(pkgName, sootsimRoot)) return null
985
+ if (appDir && moduleExistsIn(pkgName, appDir)) return null
986
+ return { path: nativeAutoStubPath }
987
+ },
988
+ )
989
+
990
+ // stub missing image imports
991
+ build.onResolve({ filter: /\.(jpg|png|gif)$/ }, (args: any) => {
992
+ if (args.importer.includes('node_modules')) {
993
+ return { path: args.path, namespace: 'stub-image' }
994
+ }
995
+ return null
996
+ })
997
+ build.onLoad({ filter: /.*/, namespace: 'stub-image' }, () => ({
998
+ contents: 'export default ""',
999
+ loader: 'js',
1000
+ }))
1001
+ },
1002
+ }
1003
+ }
1004
+
1005
+ // handle .js files containing JSX from node_modules during production builds.
1006
+ // upstream `react-native-reanimated` and downstream consumers (
1007
+ // `react-native-keyboard-controller`, `react-native-gesture-handler`,
1008
+ // `react-native-screens`, sootsim's own fixtures + RN compat source) ship
1009
+ // raw source containing `'worklet'` directives and inline updaters to
1010
+ // auto-workletizable hooks. the `react-native-worklets/plugin` babel
1011
+ // transform lifts those into wrapper functions carrying `__closure`,
1012
+ // `__workletHash`, and `__initData`. without that transform,
1013
+ // `useAnimatedStyle`'s `let inputs = Object.values(updater.__closure ?? {})`
1014
+ // returns `[]` and the mapper never subscribes — animations don't run.
1015
+ //
1016
+ // metro builds (apps loaded inside sootsim — 3pc, bluesky, expensify,
1017
+ // eigen, …) run the plugin in their own pipeline, so the bundle that
1018
+ // reaches us already has `__closure` baked in. our transform only needs
1019
+ // to act on:
1020
+ //
1021
+ // - sootsim's own src + test fixtures + external-app source, and
1022
+ // - upstream library source loaded from node_modules where the redirect
1023
+ // plugin let pure-JS resolve naturally.
1024
+ //
1025
+ // the keyword list + ignored-path list is ported from
1026
+ // `~/one/packages/compiler/src/transformBabel.ts` (which is itself ported
1027
+ // from reanimated 3.15.1's autoworkletization keywords). keep the two in
1028
+ // sync if upstream adds new auto-workletized hooks. detection is
1029
+ // content-driven, not path-driven, so we don't need a per-package
1030
+ // allow-list — anything that mentions a workletizable identifier and
1031
+ // isn't on the ignored-path list goes through.
1032
+
1033
+ export function workletsBabelTransform(
1034
+ // accepted for future per-target overrides; matching is content-driven
1035
+ // today so we don't actually consult it. kept for plugin-list parity.
1036
+ _isAppSource: (p: string) => boolean = () => false,
1037
+ ): Plugin {
1038
+ return {
1039
+ name: 'sootsim-worklets-babel-transform',
1040
+ enforce: 'pre',
1041
+ async transform(code, id) {
1042
+ if (!shouldApplyWorkletsPlugin(id, code)) return null
1043
+ try {
1044
+ if (process.env.SOOTSIM_WORKLETS_BABEL_DEBUG) {
1045
+ console.log('[worklets-babel] transforming', id.slice(-80))
1046
+ }
1047
+ return await transformWorkletsCode(code, id, true)
1048
+ } catch (err) {
1049
+ // surface but don't kill the build — fall back to raw code so the
1050
+ // rest of the pipeline can run. these failures are loud in the
1051
+ // console and easy to chase.
1052
+ const message = err instanceof Error ? err.message : String(err)
1053
+ console.warn(`[sootsim-worklets-babel] transform failed for ${id}: ${message}`)
1054
+ return null
1055
+ }
1056
+ },
1057
+ }
1058
+ }
1059
+
1060
+ // rolldown can't parse JSX in .js files — transform them first.
1061
+ // needed because some RN packages (e.g. react-native-actions-sheet) ship JSX in .js.
1062
+ // some upstream RN libraries (e.g. @react-native-segmented-control/segmented-control)
1063
+ // publish raw Flow source as their runtime entry. Metro strips Flow in its babel
1064
+ // pipeline; vite/rolldown/oxc do not. detect the `@flow` pragma in node_modules and
1065
+ // run @babel/plugin-transform-flow-strip-types so the wrapper-real seam can pull
1066
+ // in the real upstream JS without re-implementing it locally. mirrored in
1067
+ // scripts/build-bundler-deps.ts so esbuild-based dep prebundles stay in sync.
1068
+ let flowBabel: typeof import('@babel/core') | null = null
1069
+ let flowStripPlugin: import('@babel/core').PluginItem | null = null
1070
+ let jsxSyntaxPlugin: import('@babel/core').PluginItem | null = null
1071
+ function loadFlowBabel(): void {
1072
+ if (flowBabel) return
1073
+ flowBabel = sootsimPluginRequire('@babel/core')
1074
+ flowStripPlugin = sootsimPluginRequire(
1075
+ '@babel/plugin-transform-flow-strip-types',
1076
+ ) as import('@babel/core').PluginItem
1077
+ jsxSyntaxPlugin = sootsimPluginRequire(
1078
+ '@babel/plugin-syntax-jsx',
1079
+ ) as import('@babel/core').PluginItem
1080
+ }
1081
+ function flowStripTransform(): Plugin {
1082
+ return {
1083
+ name: 'sootsim-flow-strip',
1084
+ enforce: 'pre',
1085
+ transform(code, id) {
1086
+ if (!id.includes('/node_modules/')) return null
1087
+ if (!/\.[cm]?jsx?$/.test(id)) return null
1088
+ if (!code.includes('@flow')) return null
1089
+ loadFlowBabel()
1090
+ const result = flowBabel!.transformSync(code, {
1091
+ filename: id,
1092
+ babelrc: false,
1093
+ configFile: false,
1094
+ plugins: [flowStripPlugin!, jsxSyntaxPlugin!],
1095
+ sourceMaps: true,
1096
+ compact: false,
1097
+ })
1098
+ if (!result?.code) return null
1099
+ return { code: result.code, map: result.map ?? null }
1100
+ },
1101
+ }
1102
+ }
1103
+
1104
+ function nodeModulesJsxTransform(): Plugin {
1105
+ return {
1106
+ name: 'sootsim-node-modules-jsx',
1107
+ enforce: 'pre',
1108
+ async transform(code, id) {
1109
+ if (!id.includes('/node_modules/')) return null
1110
+ if (!id.endsWith('.js')) return null
1111
+ if (!code.includes('<')) return null
1112
+ // quick check: does it look like JSX? (angle bracket followed by uppercase or known RN component)
1113
+ if (!/[=(\s,]<[A-Z]/.test(code) && !/<\/[A-Z]/.test(code)) return null
1114
+ const needsReact =
1115
+ !code.includes('import React') && !code.includes('import * as React')
1116
+ const withReact = needsReact ? `import React from 'react';\n${code}` : code
1117
+ const transformed = await transformWithOxc(withReact, id, {
1118
+ lang: 'jsx',
1119
+ jsx: { runtime: 'classic' },
1120
+ })
1121
+ return { code: transformed.code, map: transformed.map }
1122
+ },
1123
+ }
1124
+ }
1125
+
1126
+ // metro-style platform resolution: .ios > .native > base
1127
+ function metroNativeResolve(isAppSource: (p: string) => boolean): Plugin {
1128
+ const platformExts = [
1129
+ '.ios.tsx',
1130
+ '.ios.ts',
1131
+ '.ios.jsx',
1132
+ '.ios.js',
1133
+ '.native.tsx',
1134
+ '.native.ts',
1135
+ '.native.jsx',
1136
+ '.native.js',
1137
+ '.native.mjs',
1138
+ ]
1139
+ const allExts = [...platformExts, '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']
1140
+
1141
+ function tryResolve(base: string): string | null {
1142
+ for (const ext of platformExts) {
1143
+ const candidate = base + ext
1144
+ try {
1145
+ if (fs.existsSync(candidate)) return candidate
1146
+ } catch {}
1147
+ }
1148
+ for (const ext of platformExts) {
1149
+ const candidate = path.join(base, 'index' + ext)
1150
+ try {
1151
+ if (fs.existsSync(candidate)) return candidate
1152
+ } catch {}
1153
+ }
1154
+ return null
1155
+ }
1156
+
1157
+ return {
1158
+ name: 'sootsim-metro-native-resolve',
1159
+ enforce: 'pre',
1160
+ resolveId(source, importer) {
1161
+ if (!importer) return null
1162
+ if (!importer.includes('node_modules') && !isAppSource(importer)) return null
1163
+ if (!source.startsWith('.')) return null
1164
+ if (platformExts.some((ext) => source.endsWith(ext))) return null
1165
+
1166
+ const hasBaseExt = allExts.some((ext) => source.endsWith(ext))
1167
+ const dir = path.dirname(importer)
1168
+
1169
+ if (hasBaseExt) {
1170
+ const resolved = path.resolve(dir, source)
1171
+ for (const ext of allExts) {
1172
+ if (source.endsWith(ext)) {
1173
+ const found = tryResolve(resolved.slice(0, -ext.length))
1174
+ if (found) return found
1175
+ break
1176
+ }
1177
+ }
1178
+ } else {
1179
+ const found = tryResolve(path.resolve(dir, source))
1180
+ if (found) return found
1181
+ }
1182
+ return null
1183
+ },
1184
+ }
1185
+ }
1186
+
1187
+ // convert require("react-native") and deep library requires to ESM imports
1188
+ function reactNativeRequirePlugin(isAppSource: (p: string) => boolean): Plugin {
1189
+ return {
1190
+ name: 'sootsim-react-native-require',
1191
+ enforce: 'pre',
1192
+ transform(code, id) {
1193
+ if (!id.includes('node_modules') && !isAppSource(id)) return null
1194
+ if (!code.includes('react-native')) return null
1195
+
1196
+ let hasChanges = false
1197
+ let imports = ''
1198
+ let result = code
1199
+ let importCounter = 0
1200
+
1201
+ if (
1202
+ result.includes('require("react-native")') ||
1203
+ result.includes("require('react-native')")
1204
+ ) {
1205
+ imports += `import * as __soot_rn_shim__ from ${JSON.stringify(rnShimPath)};\n`
1206
+ result = result.replace(/require\(["']react-native["']\)/g, '__soot_rn_shim__')
1207
+ hasChanges = true
1208
+ }
1209
+
1210
+ const deepRequireRegex = /require\(["'](react-native\/Libraries\/[^"']+)["']\)/g
1211
+ const replacements: Array<{ full: string; path: string; varName: string }> = []
1212
+ const seenPaths = new Map<string, string>()
1213
+ let match
1214
+
1215
+ deepRequireRegex.lastIndex = 0
1216
+ while ((match = deepRequireRegex.exec(result)) !== null) {
1217
+ const importPath = match[1]
1218
+ if (!seenPaths.has(importPath)) {
1219
+ const varName = `__soot_rn_lib_${importCounter++}__`
1220
+ const stubPath = rnLibraryStubs[importPath] || rnDeepStubDefault
1221
+ imports += `import * as ${varName} from ${JSON.stringify(stubPath)};\n`
1222
+ seenPaths.set(importPath, varName)
1223
+ }
1224
+ replacements.push({
1225
+ full: match[0],
1226
+ path: importPath,
1227
+ varName: seenPaths.get(importPath)!,
1228
+ })
1229
+ }
1230
+
1231
+ if (replacements.length > 0) {
1232
+ for (const rep of replacements) {
1233
+ result = result.replace(rep.full, rep.varName)
1234
+ }
1235
+ hasChanges = true
1236
+ }
1237
+
1238
+ if (!hasChanges) return null
1239
+ return { code: imports + result, map: null }
1240
+ },
1241
+ }
1242
+ }
1243
+
1244
+ // resolve bare imports from external app's node_modules
1245
+ function externalAppResolvePlugin(
1246
+ appDir: string,
1247
+ isAppSource: (p: string) => boolean,
1248
+ ownedPackages: Set<string>,
1249
+ ): Plugin {
1250
+ if (!appDir) return { name: 'sootsim-external-app-resolve' }
1251
+
1252
+ // synthetic importer inside the app dir so vite searches app's node_modules
1253
+ // using its conditions-aware exports resolution (not CJS require.resolve)
1254
+ const appVirtualImporter = path.join(appDir, '_virtual_.js')
1255
+
1256
+ return {
1257
+ name: 'sootsim-external-app-resolve',
1258
+ enforce: 'pre',
1259
+ async resolveId(source, importer, options) {
1260
+ if (!importer) return null
1261
+ if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0'))
1262
+ return null
1263
+ if (ownedPackages.has(source) || ownedPackages.has(getPackageName(source)))
1264
+ return null
1265
+ if (!isAppSource(importer) && !importer.includes(path.join(appDir, 'node_modules')))
1266
+ return null
1267
+ if (source === 'react-native' || source.startsWith('react-native/')) return null
1268
+
1269
+ // re-resolve through vite's pipeline so exports conditions (react-native, import) are respected
1270
+ const resolved = await this.resolve(source, appVirtualImporter, {
1271
+ ...options,
1272
+ skipSelf: true,
1273
+ })
1274
+ return resolved || null
1275
+ },
1276
+ }
1277
+ }
1278
+
1279
+ // convert require() calls and handle JSX in .js files from external app sources
1280
+ function externalAppTransformPlugin(isAppSource: (p: string) => boolean): Plugin {
1281
+ return {
1282
+ name: 'sootsim-external-app-transform',
1283
+ enforce: 'pre',
1284
+ async transform(code, id) {
1285
+ if (!isAppSource(id)) return null
1286
+
1287
+ let result = code
1288
+ let imports = ''
1289
+ let importCounter = 0
1290
+ let hasChanges = false
1291
+
1292
+ if (code.includes('require(')) {
1293
+ const requireRegex = /require\(["']([^"']+)["']\)/g
1294
+ const replacements: Array<{ full: string; varName: string }> = []
1295
+ const seenPaths = new Map<string, string>()
1296
+ let match
1297
+
1298
+ requireRegex.lastIndex = 0
1299
+ while ((match = requireRegex.exec(result)) !== null) {
1300
+ const reqPath = match[1]
1301
+ if (reqPath === 'react-native' || reqPath.startsWith('react-native/')) continue
1302
+
1303
+ if (!seenPaths.has(reqPath)) {
1304
+ const varName = `__soot_cjs_${importCounter++}__`
1305
+ imports += `import * as ${varName} from ${JSON.stringify(reqPath)};\n`
1306
+ seenPaths.set(reqPath, varName)
1307
+ }
1308
+ replacements.push({ full: match[0], varName: seenPaths.get(reqPath)! })
1309
+ }
1310
+
1311
+ if (replacements.length > 0) {
1312
+ for (const rep of replacements) {
1313
+ result = result.replace(rep.full, rep.varName)
1314
+ }
1315
+ hasChanges = true
1316
+ }
1317
+ }
1318
+
1319
+ if (imports) result = imports + result
1320
+
1321
+ if (id.endsWith('.js') && result.includes('<')) {
1322
+ const needsReact =
1323
+ !result.includes('import React') && !result.includes('import * as React')
1324
+ const withReact = needsReact ? `import React from 'react';\n${result}` : result
1325
+ const transformed = await transformWithOxc(withReact, id, {
1326
+ lang: 'jsx',
1327
+ jsx: { runtime: 'classic' },
1328
+ })
1329
+ return { code: transformed.code, map: transformed.map }
1330
+ }
1331
+
1332
+ if (!hasChanges) return null
1333
+ return { code: result, map: null }
1334
+ },
1335
+ }
1336
+ }
1337
+
1338
+ // auto-stub native packages that don't exist in any node_modules
1339
+ function stubMissingNativeDeps(appDir: string): Plugin {
1340
+ const checked = new Map<string, boolean>()
1341
+
1342
+ function packageExists(pkgName: string): boolean {
1343
+ const cached = checked.get(pkgName)
1344
+ if (cached !== undefined) return cached
1345
+ let exists = moduleExistsIn(pkgName, sootsimRoot)
1346
+ if (!exists && appDir) exists = moduleExistsIn(pkgName, appDir)
1347
+ checked.set(pkgName, exists)
1348
+ return exists
1349
+ }
1350
+
1351
+ return {
1352
+ name: 'sootsim-stub-missing-native-deps',
1353
+ resolveId(source) {
1354
+ if (!isNativePackage(source)) return null
1355
+ const pkgName = getPackageName(source)
1356
+ if (packageExists(pkgName)) return null
1357
+ console.log(`[sootsim] auto-stubbing missing native dep: ${source}`)
1358
+ return nativeAutoStubPath
1359
+ },
1360
+ }
1361
+ }
1362
+
1363
+ // stub missing image imports from node_modules
1364
+ function stubMissingImages(isAppSource: (p: string) => boolean): Plugin {
1365
+ return {
1366
+ name: 'sootsim-stub-images',
1367
+ enforce: 'pre',
1368
+ resolveId(id, importer) {
1369
+ if (id.endsWith('.jpg') || id.endsWith('.png') || id.endsWith('.gif')) {
1370
+ if (importer?.includes('node_modules') || (importer && isAppSource(importer))) {
1371
+ return '\0stub-image'
1372
+ }
1373
+ }
1374
+ return null
1375
+ },
1376
+ load(id) {
1377
+ if (id === '\0stub-image') return 'export default ""'
1378
+ return null
1379
+ },
1380
+ }
1381
+ }