peachsim 0.1.527

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