peachsim 0.1.1625 → 0.1.1640

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 (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
@@ -9,6 +9,7 @@ import path from 'node:path'
9
9
  import { promisify } from 'node:util'
10
10
  import { RNX_FORKED_SCRIPT_ENV } from '../cli/internal-child'
11
11
  import { isLoopbackHost } from './backend-origin'
12
+ import { collectBridgeModuleSources } from './react-native-bridge-modules'
12
13
  import { swiftBinaryModules } from './swift-binary-modules'
13
14
  import {
14
15
  swiftAvailableModules,
@@ -24,7 +25,7 @@ import {
24
25
  icuUrl,
25
26
  projectFor,
26
27
  serveSwiftPackageAsset,
27
- sourceStamp,
28
+ sourceContentStamp,
28
29
  standinLibraries,
29
30
  SwiftMissingModulesError,
30
31
  } from './swift-host'
@@ -48,6 +49,37 @@ const run = promisify(execFile)
48
49
  const scriptEnv = { ...process.env, [RNX_FORKED_SCRIPT_ENV]: '1' }
49
50
  export const EXPO_SWIFT_ROUTE = '/__peach/expo-swift'
50
51
  export const EXPO_SWIFT_ENTRY = 'Sources/PeachExpoApp/PeachExpoEntry.swift'
52
+ // a connected caller (the conformance waiter) waits this long. the old 10
53
+ // minute cap aborted a compile that caller was still holding.
54
+ const EXPO_SWIFT_REQUEST_TIMEOUT_MS = 30 * 60_000
55
+ // bun drops an unanswered fetch at six minutes and the waiter retries. aborting
56
+ // in that gap restarts the compile. a reconnect inside this window rejoins it.
57
+ const EXPO_SWIFT_CLIENT_GAP_MS = 5_000
58
+
59
+ // the autolinker's command line changed across Expo releases: before 1.8.0 it
60
+ // takes the project from its working directory, before 1.10.3 the Apple
61
+ // platform is named `ios`.
62
+ function autolinkerAppleFlags(autolinkerManifest: string, appRoot: string) {
63
+ const manifest: unknown = JSON.parse(fs.readFileSync(autolinkerManifest, 'utf8'))
64
+ const version =
65
+ manifest && typeof manifest === 'object' && Reflect.get(manifest, 'version')
66
+ const parts =
67
+ typeof version === 'string'
68
+ ? version.split('.').map((part) => Number.parseInt(part, 10))
69
+ : []
70
+ const [major, minor, patch] = parts
71
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch))
72
+ throw new Error(
73
+ `unreadable expo-modules-autolinking version in ${autolinkerManifest}`,
74
+ )
75
+ const atLeast = (m: number, n: number, p: number) =>
76
+ major !== m ? major > m : minor !== n ? minor > n : patch >= p
77
+ if (atLeast(1, 10, 3))
78
+ return { args: ['--platform', 'apple', '--project-root', appRoot], cwd: undefined }
79
+ if (atLeast(1, 8, 0))
80
+ return { args: ['--platform', 'ios', '--project-root', appRoot], cwd: undefined }
81
+ return { args: ['--platform', 'ios'], cwd: appRoot }
82
+ }
51
83
 
52
84
  export async function collectExpoSwiftSources(
53
85
  appRoot: string,
@@ -68,23 +100,25 @@ export async function collectExpoSwiftSources(
68
100
  return null
69
101
  throw error
70
102
  }
103
+ // autolinkers before 1.0 predate app-owned Swift modules and `--project-root`.
104
+ const autolinkerVersion: unknown = JSON.parse(
105
+ fs.readFileSync(autolinker, 'utf8'),
106
+ ).version
107
+ if (typeof autolinkerVersion === 'string' && Number.parseInt(autolinkerVersion, 10) < 1)
108
+ return null
109
+ const cli = path.join(path.dirname(autolinker), 'bin/expo-modules-autolinking.js')
110
+ const flags = autolinkerAppleFlags(autolinker, appRoot)
71
111
  // both upstream commands discover their inputs independently. settle both
72
112
  // before propagating errors so a failed request leaves no child running.
73
113
  const [searched, resolvedModules] = await Promise.allSettled(
74
114
  ['search', 'resolve'].map((command) =>
75
- run(
76
- process.execPath,
77
- [
78
- path.join(path.dirname(autolinker), 'bin/expo-modules-autolinking.js'),
79
- command,
80
- '--platform',
81
- 'apple',
82
- '--project-root',
83
- appRoot,
84
- '--json',
85
- ],
86
- { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, env: scriptEnv, signal },
87
- ),
115
+ run(process.execPath, [cli, command, ...flags.args, '--json'], {
116
+ timeout: 120_000,
117
+ maxBuffer: 8 * 1024 * 1024,
118
+ env: scriptEnv,
119
+ cwd: flags.cwd,
120
+ signal,
121
+ }),
88
122
  ),
89
123
  )
90
124
  if (searched.status === 'rejected') throw searched.reason
@@ -102,7 +136,10 @@ export async function collectExpoSwiftSources(
102
136
  let bytes = 0
103
137
  let fileCount = 0
104
138
  // native Expo resolves packages by name before generating its Apple provider.
105
- for (const [, entry] of Object.entries(search).sort(([a], [b]) => a.localeCompare(b))) {
139
+ // the search result is keyed by that name; older autolinkers add no name field.
140
+ for (const [packageName, entry] of Object.entries(search).sort(([a], [b]) =>
141
+ a.localeCompare(b),
142
+ )) {
106
143
  if (!entry || typeof entry !== 'object')
107
144
  throw new Error('invalid Expo autolinking entry')
108
145
  const sourceRoot = Reflect.get(entry, 'path')
@@ -115,7 +152,7 @@ export async function collectExpoSwiftSources(
115
152
  (candidate) =>
116
153
  candidate &&
117
154
  typeof candidate === 'object' &&
118
- Reflect.get(candidate, 'packageName') === Reflect.get(entry, 'name'),
155
+ Reflect.get(candidate, 'packageName') === packageName,
119
156
  )
120
157
  const namespaces =
121
158
  resolution && typeof resolution === 'object'
@@ -226,10 +263,11 @@ export async function collectExpoSwiftSources(
226
263
  }
227
264
  walk(ios)
228
265
  for (const module of classes) {
229
- const unit = {
266
+ const unit: ExpoSwiftSourceUnit = {
230
267
  namespace,
231
268
  moduleId: `${namespace}.${module.name}`,
232
269
  classes: [module.registration],
270
+ bridgeModules: [],
233
271
  files: swiftSourceClosure(files, [module.name]),
234
272
  frameworks: [...frameworks],
235
273
  }
@@ -282,7 +320,12 @@ export async function collectExpoSwiftSources(
282
320
  return units
283
321
  }
284
322
 
285
- function expoManifest(files: Record<string, string>, generation = ''): string {
323
+ // an app target's Swift compiles in its own language mode; Expo packages in the default
324
+ function expoManifest(
325
+ files: Record<string, string>,
326
+ generation = '',
327
+ modes: Map<string, number> = new Map(),
328
+ ): string {
286
329
  const targets = new Map<string, string>()
287
330
  for (const file of Object.keys(files)) {
288
331
  const match = /^(Sources|__standins__)\/([^/]+)\//.exec(file)
@@ -292,11 +335,29 @@ function expoManifest(files: Record<string, string>, generation = ''): string {
292
335
  throw new Error(`duplicate Expo Swift compile target ${match[2]}`)
293
336
  targets.set(match[2]!, dir)
294
337
  }
295
- return `// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: "PeachExpo", targets: [${[...targets].map(([name, dir]) => `.target(name: ${JSON.stringify(name)}, path: ${JSON.stringify(dir)}${dir.startsWith(`${SWIFT_STANDINS_DIR}/`) ? ', swiftSettings: [.swiftLanguageMode(.v5)]' : ''})`).join(', ')}, .executableTarget(name: "PeachExpoApp", path: "Sources/PeachExpoApp")])\n// stand-in inputs: ${generation}\n`
338
+ return `// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: "PeachExpo", targets: [${[...targets].map(([name, dir]) => `.target(name: ${JSON.stringify(name)}, path: ${JSON.stringify(dir)}${dir.startsWith(`${SWIFT_STANDINS_DIR}/`) ? ', swiftSettings: [.swiftLanguageMode(.v5)]' : modes.has(name) ? `, swiftSettings: [.swiftLanguageMode(.v${modes.get(name)})]` : ''})`).join(', ')}, .executableTarget(name: "PeachExpoApp", path: "Sources/PeachExpoApp")])\n// stand-in inputs: ${generation}\n`
339
+ }
340
+
341
+ function languageModes(units: ExpoSwiftSourceUnit[]): Map<string, number> {
342
+ return new Map(
343
+ units.flatMap((unit) =>
344
+ unit.swiftLanguageMode === undefined
345
+ ? []
346
+ : [[unit.namespace, unit.swiftLanguageMode]],
347
+ ),
348
+ )
296
349
  }
297
350
 
298
351
  function expoEntry(units: ExpoSwiftSourceUnit[]): string {
299
- return `import SwiftUI\nimport ExpoModulesCore\n${[...new Set(units.map((unit) => unit.namespace))].map((namespace) => `import ${namespace}`).join('\n')}\n\n@main struct PeachExpoApp: PeachPackage {\n init() { MainActor.assumeIsolated { PeachExpoRuntime.register([${units.flatMap((unit) => unit.classes).join(', ')}]) } }\n func view(props: JSON) -> PeachExpoNativeView { PeachExpoNativeView(props: props) }\n}\n\n@_expose(wasm, "peach_expo_call")\n@_cdecl("peach_expo_call")\n@MainActor public func peachExpoCall(_ payload: UnsafePointer<UInt8>?, _ length: Int32) -> UnsafeMutablePointer<UInt8>? {\n PeachExpoRuntime.dispatch(payload, length)\n}\n`
352
+ // an Objective-C bridge module registers with no Swift of its own
353
+ const namespaces = [...new Set(units.map((unit) => unit.namespace))].filter(
354
+ (namespace) =>
355
+ units.some((unit) =>
356
+ Object.keys(unit.files).some((file) => file.startsWith(`Sources/${namespace}/`)),
357
+ ),
358
+ )
359
+ const bridgeModules = units.flatMap((unit) => unit.bridgeModules)
360
+ return `import SwiftUI\nimport ExpoModulesCore\n${bridgeModules.length ? 'import React\n' : ''}${namespaces.map((namespace) => `import ${namespace}`).join('\n')}\n\n@main struct PeachExpoApp: PeachPackage {\n init() { MainActor.assumeIsolated { PeachExpoRuntime.register([${units.flatMap((unit) => unit.classes).join(', ')}])${bridgeModules.length ? `; RCTModuleHost.register([${bridgeModules.join(', ')}])` : ''} } }\n func view(props: JSON) -> PeachExpoNativeView { PeachExpoNativeView(props: props) }\n}\n\n@_expose(wasm, "peach_expo_call")\n@_cdecl("peach_expo_call")\n@MainActor public func peachExpoCall(_ payload: UnsafePointer<UInt8>?, _ length: Int32) -> UnsafeMutablePointer<UInt8>? {\n PeachExpoRuntime.dispatch(payload, length)\n}\n`
300
361
  }
301
362
 
302
363
  const compiling = new Map<
@@ -307,12 +368,41 @@ const compiling = new Map<
307
368
  clients: number
308
369
  }
309
370
  >()
371
+ // identical sources already produced a wasm that is still registered on its
372
+ // project. a second caller otherwise relinks namespaces whose failure records
373
+ // did not stick, and that link blows the flow's ready bound.
374
+ const preparedExpoSwift = new Map<string, ExpoSwiftCompilationResult>()
375
+
376
+ function expoSwiftPreparedKey(base: string, units: ExpoSwiftSourceUnit[]): string {
377
+ const hash = createHash('sha256')
378
+ hash.update(base)
379
+ for (const unit of units) {
380
+ hash.update('\0')
381
+ hash.update(unit.moduleId)
382
+ for (const name of Object.keys(unit.files).sort()) {
383
+ hash.update('\0')
384
+ hash.update(name)
385
+ hash.update('\0')
386
+ hash.update(unit.files[name] ?? '')
387
+ }
388
+ }
389
+ return hash.digest('hex')
390
+ }
391
+
392
+ // this process's copy of the app's expo sources. the build key covers the
393
+ // same directory, so another server compiling the app keeps its own tree.
394
+ function expoSwiftPackage(appKey: string, name: string): string {
395
+ return path.join(os.tmpdir(), 'peach-expo-swift', appKey, `p${process.pid}`, name)
396
+ }
397
+
310
398
  class ExpoSwiftLinkRefusal extends Error {}
311
399
  // a failed compiler result is valid only for the exact app and framework inputs.
312
- // it is kept beside the namespace's build tree, so a restarted dev server skips
313
- // the namespace instead of relinking every namespace and isolating each again.
400
+ // it is kept at the app level, above each process's build copy, so a restarted
401
+ // dev server skips the namespace instead of relinking every namespace and
402
+ // isolating each again.
314
403
  function failureRecordPath(packagePath: string) {
315
- return `${packagePath}.failed.json`
404
+ const app = path.dirname(path.dirname(packagePath))
405
+ return path.join(app, `${path.basename(packagePath)}.failed.json`)
316
406
  }
317
407
 
318
408
  function readFailedCompilation(packagePath: string) {
@@ -350,7 +440,9 @@ export async function compileExpoSwiftModules(
350
440
  ) => Promise<void>,
351
441
  options: ExpoSwiftPreparationOptions = {},
352
442
  ): Promise<ExpoSwiftCompilationResult | null> {
353
- const timeoutMs = options.timeoutMs ?? 600_000
443
+ // a cold isolation compiles the sdk once per module. one module ran past
444
+ // six minutes, and the readiness wait allows thirty, so the compile does too.
445
+ const timeoutMs = options.timeoutMs ?? 30 * 60_000
354
446
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
355
447
  throw new Error('Expo Swift preparation timeout must be between 1 and 2147483647ms')
356
448
  const controller = new AbortController()
@@ -367,9 +459,20 @@ export async function compileExpoSwiftModules(
367
459
  try {
368
460
  signal.throwIfAborted()
369
461
  report(`discovering app-owned modules (budget ${timeoutMs}ms): ${root}`)
370
- const units = await collectExpoSwiftSources(root, signal)
462
+ const expoUnits = await collectExpoSwiftSources(root, signal)
371
463
  signal.throwIfAborted()
372
- if (!units) return null
464
+ const bridge = collectBridgeModuleSources(fs.realpathSync(root), signal)
465
+ for (const unit of bridge?.units ?? []) {
466
+ if (expoUnits?.some((expo) => expo.namespace === unit.namespace))
467
+ throw new Error(
468
+ `app target ${unit.namespace} shares its Swift module name with a local Expo module`,
469
+ )
470
+ unit.files[EXPO_SWIFT_ENTRY] = expoEntry([unit])
471
+ unit.files['Package.swift'] = expoManifest(unit.files, '', languageModes([unit]))
472
+ }
473
+ const units = [...(expoUnits ?? []), ...(bridge?.units ?? [])]
474
+ const bridgeFailures = bridge?.failures ?? []
475
+ if (units.length === 0 && bridgeFailures.length === 0) return null
373
476
  report(
374
477
  `preparing ${units.length} modules: ${units.map((unit) => unit.moduleId).join(', ')}`,
375
478
  )
@@ -380,9 +483,16 @@ export async function compileExpoSwiftModules(
380
483
  if (!module && publishArtifact)
381
484
  throw new Error('No local Swift toolchain or distribution is available')
382
485
  const result: ExpoSwiftCompilationResult = {
383
- namespaces: units.map((unit) => unit.moduleId),
486
+ namespaces: [
487
+ ...units.map((unit) => unit.moduleId),
488
+ ...bridgeFailures.map((failure) => failure.namespace),
489
+ ],
490
+ bridgeModules: [
491
+ ...(bridge?.units ?? []).map((unit) => unit.moduleId),
492
+ ...bridgeFailures.map((failure) => failure.namespace),
493
+ ],
384
494
  modules: [],
385
- failures: [],
495
+ failures: [...bridgeFailures],
386
496
  }
387
497
  if (!module) {
388
498
  for (const unit of units)
@@ -475,10 +585,14 @@ export async function compileExpoSwiftModules(
475
585
  unit,
476
586
  [...built.gaps].map(([name, reason]) => `${name}: no stand-in: ${reason}`),
477
587
  )
478
- unit.files['Package.swift'] = expoManifest(unit.files, generation)
588
+ unit.files['Package.swift'] = expoManifest(
589
+ unit.files,
590
+ generation,
591
+ languageModes([unit]),
592
+ )
479
593
  }
480
594
  }
481
- const packagePath = path.join(os.tmpdir(), 'peach-expo-swift', key, unit.moduleId)
595
+ const packagePath = expoSwiftPackage(key, unit.moduleId)
482
596
  // preserve the compiler's input stamp across unchanged reloads.
483
597
  // only this adapter's build copy is synchronized; app sources are read only.
484
598
  syncSwiftTree(
@@ -491,7 +605,7 @@ export async function compileExpoSwiftModules(
491
605
  ),
492
606
  )
493
607
  projectFor(packagePath).libraries = libraries.get(unit)
494
- const stamp = sourceStamp(packagePath, module.root)
608
+ const stamp = sourceContentStamp(packagePath, module.root)
495
609
  const failed = readFailedCompilation(packagePath)
496
610
  if (failed?.stamp === stamp) {
497
611
  failures.set(unit.moduleId, failed.diagnostic)
@@ -501,6 +615,14 @@ export async function compileExpoSwiftModules(
501
615
  stamps.set(unit, stamp)
502
616
  candidates.push(unit)
503
617
  }
618
+ const preparedKey = expoSwiftPreparedKey(base, units)
619
+ if (!publishArtifact) {
620
+ const prepared = preparedExpoSwift.get(preparedKey)
621
+ if (prepared) {
622
+ report('reusing prepared expo swift artifact')
623
+ return prepared
624
+ }
625
+ }
504
626
  const link = async (linked: ExpoSwiftSourceUnit[]) => {
505
627
  const files: Record<string, string> = {}
506
628
  for (const unit of linked)
@@ -517,8 +639,9 @@ export async function compileExpoSwiftModules(
517
639
  files['Package.swift'] = expoManifest(
518
640
  files,
519
641
  linked.map((unit) => generations.get(unit) ?? '').join(','),
642
+ languageModes(linked),
520
643
  )
521
- const packagePath = path.join(os.tmpdir(), 'peach-expo-swift', key, 'linked-app')
644
+ const packagePath = expoSwiftPackage(key, 'linked-app')
522
645
  syncSwiftTree(
523
646
  packagePath,
524
647
  new Map(
@@ -538,7 +661,7 @@ export async function compileExpoSwiftModules(
538
661
  projectFor(packagePath).libraries =
539
662
  linkedLibraries.size > 0 ? Object.fromEntries(linkedLibraries) : undefined
540
663
  signal.throwIfAborted()
541
- const stamp = sourceStamp(packagePath, module.root)
664
+ const stamp = sourceContentStamp(packagePath, module.root)
542
665
  const refused = readFailedCompilation(packagePath)
543
666
  if (refused?.stamp === stamp) {
544
667
  report('reusing recent link refusal')
@@ -563,7 +686,7 @@ export async function compileExpoSwiftModules(
563
686
  throw error
564
687
  // admission and interrupted builds say nothing about individual modules.
565
688
  // share their result briefly, including across dev server restarts.
566
- if (sourceStamp(packagePath, module.root) === stamp)
689
+ if (sourceContentStamp(packagePath, module.root) === stamp)
567
690
  failureRecords.set(packagePath, {
568
691
  stamp,
569
692
  diagnostic,
@@ -587,12 +710,7 @@ export async function compileExpoSwiftModules(
587
710
  report('linked build failed; isolating module diagnostics')
588
711
  linked = []
589
712
  for (const unit of candidates) {
590
- const packagePath = path.join(
591
- os.tmpdir(),
592
- 'peach-expo-swift',
593
- key,
594
- unit.moduleId,
595
- )
713
+ const packagePath = expoSwiftPackage(key, unit.moduleId)
596
714
  try {
597
715
  signal.throwIfAborted()
598
716
  report(`checking ${unit.moduleId}`)
@@ -630,13 +748,14 @@ export async function compileExpoSwiftModules(
630
748
  )
631
749
  throw error
632
750
  // a source error or a module peach does not provide holds until the
633
- // stamp changes; retrying either relinks every namespace and rewrites
634
- // the linked package, so no reload would ever reuse its artifact.
751
+ // stamp changes. the compiler also reports the same error at the
752
+ // build copy, so the app lines are enough to hold it. retrying
753
+ // either relinks every namespace and rewrites the linked package,
754
+ // so no reload would ever reuse its artifact.
635
755
  const missing = error instanceof SwiftMissingModulesError
636
756
  if (
637
- ((sourceErrors.length && sourceErrors.length === errors.length) ||
638
- missing) &&
639
- sourceStamp(packagePath, module.root) === stamps.get(unit)
757
+ (missing || sourceErrors.length > 0) &&
758
+ sourceContentStamp(packagePath, module.root) === stamps.get(unit)
640
759
  ) {
641
760
  failureRecords.set(packagePath, {
642
761
  stamp: stamps.get(unit)!,
@@ -651,7 +770,29 @@ export async function compileExpoSwiftModules(
651
770
  }
652
771
  // every namespace compiles alone, so the link itself is what failed.
653
772
  if (linked.length === candidates.length) throw linkError
654
- if (linked.length) artifact = await link(linked)
773
+ if (linked.length) {
774
+ try {
775
+ artifact = await link(linked)
776
+ } catch (error) {
777
+ signal.throwIfAborted()
778
+ if (!(error instanceof SwiftMissingModulesError)) throw error
779
+ // the merged sources import a module none of the pieces needed
780
+ // alone. drop the pieces that import it and link the rest; a
781
+ // miss that none of them import is still a failed link.
782
+ const missing = new Set(
783
+ [...error.message.matchAll(/^ (\S+)/gm)].map((match) => match[1]),
784
+ )
785
+ const dropped = linked.filter((unit) =>
786
+ Object.values(unit.files).some((source) =>
787
+ swiftImportNames(source).some((name) => missing.has(name)),
788
+ ),
789
+ )
790
+ if (dropped.length === 0) throw error
791
+ for (const unit of dropped) failures.set(unit.moduleId, error.message)
792
+ linked = linked.filter((unit) => !dropped.includes(unit))
793
+ if (linked.length) artifact = await link(linked)
794
+ }
795
+ }
655
796
  }
656
797
  }
657
798
  }
@@ -677,6 +818,7 @@ export async function compileExpoSwiftModules(
677
818
  report(
678
819
  `ready: ${result.modules.length} artifact, ${result.failures.length} unavailable modules`,
679
820
  )
821
+ if (!publishArtifact) preparedExpoSwift.set(preparedKey, result)
680
822
  return result
681
823
  } catch (error) {
682
824
  const failure: unknown = signal.aborted ? signal.reason : error
@@ -746,6 +888,7 @@ export async function handleExpoSwiftRequest(
746
888
  const controller = new AbortController()
747
889
  const promise = compileExpoSwiftModules(key, `http://${host}`, undefined, {
748
890
  signal: controller.signal,
891
+ timeoutMs: EXPO_SWIFT_REQUEST_TIMEOUT_MS,
749
892
  }).finally(() => {
750
893
  if (compiling.get(flightKey)?.controller === controller) compiling.delete(flightKey)
751
894
  })
@@ -758,9 +901,13 @@ export async function handleExpoSwiftRequest(
758
901
  const release = () => {
759
902
  if (released) return
760
903
  released = true
761
- if (--current.clients === 0) {
762
- current.controller.abort(new Error('Expo Swift preparation client disconnected'))
763
- }
904
+ if (--current.clients > 0) return
905
+ const flight = current
906
+ setTimeout(() => {
907
+ if (flight.clients > 0 || flight.controller.signal.aborted) return
908
+ if (compiling.get(flightKey)?.controller !== flight.controller) return
909
+ flight.controller.abort(new Error('Expo Swift preparation client disconnected'))
910
+ }, EXPO_SWIFT_CLIENT_GAP_MS)
764
911
  }
765
912
  res.once('close', release)
766
913
  try {
@@ -0,0 +1,78 @@
1
+ // an app with local Expo Swift modules only renders once the bridge compiles
2
+ // them, and a cold /__peach/expo-swift artifact compiles for minutes (a linked
3
+ // build plus per-namespace isolation builds in sequence) while a flow waits
4
+ // 120s for its first tree. so `peach open` GETs the same endpoint the engine
5
+ // will fetch and returns only once it answers; the bridge shares one compile
6
+ // per root, so the engine's own fetch then finds it done. a warm artifact
7
+ // answers in ~1s and an app without local modules answers null fast.
8
+ export const EXPO_SWIFT_READY_TIMEOUT_MS = 30 * 60_000
9
+ const EXPO_SWIFT_READY_PROGRESS_MS = 30_000
10
+
11
+ export type ExpoSwiftReadyOptions = {
12
+ timeoutMs?: number
13
+ progressMs?: number
14
+ signal?: AbortSignal
15
+ onProgress?: (elapsedMs: number) => void
16
+ }
17
+
18
+ export async function awaitExpoSwiftReady(
19
+ endpointUrl: string,
20
+ options: ExpoSwiftReadyOptions = {},
21
+ ): Promise<void> {
22
+ const timeoutMs = options.timeoutMs ?? EXPO_SWIFT_READY_TIMEOUT_MS
23
+ const progressMs = options.progressMs ?? EXPO_SWIFT_READY_PROGRESS_MS
24
+ const start = Date.now()
25
+ const report =
26
+ options.onProgress ??
27
+ ((elapsedMs: number) => {
28
+ console.log(
29
+ ` still waiting on ${endpointUrl} after ${Math.round(elapsedMs / 1000)}s — expo swift compile in progress`,
30
+ )
31
+ })
32
+ const progress = setInterval(() => report(Date.now() - start), progressMs)
33
+ try {
34
+ options.signal?.throwIfAborted()
35
+ const timeout = AbortSignal.timeout(timeoutMs)
36
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout
37
+ let res: Response | undefined
38
+ while (!res) {
39
+ try {
40
+ // bun ends an unanswered fetch at six minutes. that close is the last
41
+ // client, so the bridge aborts the shared compile and the retry starts
42
+ // over. hold the socket for the caller's bound instead.
43
+ const init: RequestInit = { signal }
44
+ Object.assign(init, { timeout: false })
45
+ res = await fetch(endpointUrl, init)
46
+ } catch (error) {
47
+ if (options.signal?.aborted) throw error
48
+ if (timeout.aborted) {
49
+ throw new Error(
50
+ `timed out after ${timeoutMs}ms waiting for ${endpointUrl} — the expo swift compile did not answer within the bound`,
51
+ )
52
+ }
53
+ // a dropped socket still rejoins the same compile when the bridge has
54
+ // another client. retry only that case.
55
+ if (error instanceof Error && error.name === 'TimeoutError') continue
56
+ throw new Error(`could not reach ${endpointUrl}`, { cause: error })
57
+ }
58
+ }
59
+ if (!res.ok) {
60
+ const body = (await res.text()).trim().slice(0, 500)
61
+ throw new Error(`${endpointUrl} answered ${res.status}${body ? `: ${body}` : ''}`)
62
+ }
63
+ await res.text()
64
+ console.log(
65
+ ` expo swift modules ready in ${((Date.now() - start) / 1000).toFixed(1)}s`,
66
+ )
67
+ } finally {
68
+ clearInterval(progress)
69
+ }
70
+ }
71
+
72
+ // the expoSwift endpoint a built shell url carries, or null for an app with no
73
+ // project root
74
+ export async function awaitStampedExpoSwiftReady(shellUrl: string): Promise<void> {
75
+ const endpoint = new URL(shellUrl).searchParams.get('expoSwift')
76
+ if (!endpoint) return
77
+ await awaitExpoSwiftReady(endpoint)
78
+ }
@@ -1,4 +1,5 @@
1
1
  import { insertBundleGlobalFooter, readBundleGlobalFooter } from './bundleGlobalFooter'
2
+ import { isRecord } from './is-record'
2
3
 
3
4
  // what the dev server answers for an app's local Expo Kotlin modules on an
4
5
  // Android device: one linked Kotlin/Wasm process, or each module's diagnostic.
@@ -10,14 +11,10 @@ export interface ExpoKotlinCompilationResult {
10
11
  failures: { namespace: string; diagnostic: string }[]
11
12
  }
12
13
 
13
- function record(value: unknown): value is Record<string, unknown> {
14
- return value !== null && typeof value === 'object' && !Array.isArray(value)
15
- }
16
-
17
14
  export function readExpoKotlinMetadata(value: unknown): ExpoKotlinCompilationResult {
18
15
  const invalid = () => new Error('invalid Expo Kotlin compilation metadata')
19
16
  if (
20
- !record(value) ||
17
+ !isRecord(value) ||
21
18
  !Array.isArray(value.namespaces) ||
22
19
  !Array.isArray(value.failures)
23
20
  )
@@ -30,7 +27,7 @@ export function readExpoKotlinMetadata(value: unknown): ExpoKotlinCompilationRes
30
27
  const failures: ExpoKotlinCompilationResult['failures'] = []
31
28
  for (const failure of value.failures) {
32
29
  if (
33
- !record(failure) ||
30
+ !isRecord(failure) ||
34
31
  typeof failure.namespace !== 'string' ||
35
32
  typeof failure.diagnostic !== 'string'
36
33
  )
@@ -40,7 +37,7 @@ export function readExpoKotlinMetadata(value: unknown): ExpoKotlinCompilationRes
40
37
  const module = value.module
41
38
  if (module === null) return { namespaces, module: null, failures }
42
39
  if (
43
- !record(module) ||
40
+ !isRecord(module) ||
44
41
  typeof module.url !== 'string' ||
45
42
  typeof module.hash !== 'string' ||
46
43
  typeof module.compose !== 'boolean'
@@ -1,4 +1,5 @@
1
1
  import { insertBundleGlobalFooter, readBundleGlobalFooter } from './bundleGlobalFooter'
2
+ import { isRecord } from './is-record'
2
3
 
3
4
  export interface ExpoSwiftPreparationOptions {
4
5
  signal?: AbortSignal
@@ -9,8 +10,12 @@ export interface ExpoSwiftSourceUnit {
9
10
  namespace: string
10
11
  moduleId: string
11
12
  classes: string[]
13
+ // legacy bridge module registrations (react-native-bridge-modules.ts)
14
+ bridgeModules: string[]
12
15
  files: Record<string, string>
13
16
  frameworks: string[]
17
+ // the app target's Swift language mode; Expo packages compile in the default
18
+ swiftLanguageMode?: number
14
19
  }
15
20
 
16
21
  export interface ExpoSwiftCompiledModule {
@@ -23,13 +28,12 @@ export interface ExpoSwiftCompiledModule {
23
28
 
24
29
  export interface ExpoSwiftCompilationResult {
25
30
  namespaces: string[]
31
+ // the namespaces that are legacy bridge modules rather than Expo modules
32
+ bridgeModules?: string[]
26
33
  modules: ExpoSwiftCompiledModule[]
27
34
  failures: { namespace: string; diagnostic: string }[]
28
35
  }
29
36
 
30
- function record(value: unknown): value is Record<string, unknown> {
31
- return value !== null && typeof value === 'object' && !Array.isArray(value)
32
- }
33
37
  function strings(value: unknown): value is string[] {
34
38
  return Array.isArray(value) && value.every((item) => typeof item === 'string')
35
39
  }
@@ -38,7 +42,7 @@ export function isExpoSwiftCompilationResult(
38
42
  value: unknown,
39
43
  ): value is ExpoSwiftCompilationResult {
40
44
  if (
41
- !record(value) ||
45
+ !isRecord(value) ||
42
46
  !strings(value.namespaces) ||
43
47
  !Array.isArray(value.modules) ||
44
48
  value.modules.length > 1 ||
@@ -47,9 +51,16 @@ export function isExpoSwiftCompilationResult(
47
51
  return false
48
52
  const remaining = new Set(value.namespaces)
49
53
  if (remaining.size !== value.namespaces.length) return false
54
+ // bundles published before legacy bridge modules carry no list
55
+ if (
56
+ value.bridgeModules !== undefined &&
57
+ (!strings(value.bridgeModules) ||
58
+ !value.bridgeModules.every((name) => remaining.has(name)))
59
+ )
60
+ return false
50
61
  for (const artifact of value.modules) {
51
62
  if (
52
- !record(artifact) ||
63
+ !isRecord(artifact) ||
53
64
  !strings(artifact.namespaces) ||
54
65
  artifact.namespaces.length === 0 ||
55
66
  !artifact.namespaces.every((namespace) => remaining.delete(namespace)) ||
@@ -62,7 +73,7 @@ export function isExpoSwiftCompilationResult(
62
73
  }
63
74
  for (const failure of value.failures) {
64
75
  if (
65
- !record(failure) ||
76
+ !isRecord(failure) ||
66
77
  typeof failure.namespace !== 'string' ||
67
78
  !remaining.delete(failure.namespace) ||
68
79
  typeof failure.diagnostic !== 'string'