rnxsim 0.1.520 → 0.1.522

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 (58) hide show
  1. package/dist-lib/agent-daemon-client.cjs +1 -1
  2. package/dist-lib/agent-events.cjs +1 -1
  3. package/dist-lib/agent-identity.cjs +1 -1
  4. package/dist-lib/agent-sessions.cjs +1 -1
  5. package/dist-lib/attached-projects.cjs +1 -1
  6. package/dist-lib/auth/shared-session.cjs +1 -1
  7. package/dist-lib/backend-origin.cjs +1 -1
  8. package/dist-lib/beta.cjs +1 -1
  9. package/dist-lib/beta.mjs +1 -1
  10. package/dist-lib/bridge-constants.cjs +1 -1
  11. package/dist-lib/bridge-contract-input.cjs +7 -2
  12. package/dist-lib/bridge-contract-input.mjs +7 -2
  13. package/dist-lib/bridge-contract.cjs +1 -1
  14. package/dist-lib/bridge-contract.mjs +1 -1
  15. package/dist-lib/capture-contract.cjs +1 -1
  16. package/dist-lib/capture-contract.mjs +1 -1
  17. package/dist-lib/cli-constants.cjs +1 -1
  18. package/dist-lib/cloud-contract.cjs +1 -1
  19. package/dist-lib/cloud-contract.mjs +1 -1
  20. package/dist-lib/cloud.cjs +18 -2
  21. package/dist-lib/cloud.mjs +18 -2
  22. package/dist-lib/config.cjs +1 -1
  23. package/dist-lib/detox/index.cjs +1 -1
  24. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  25. package/dist-lib/home-paths.cjs +1 -1
  26. package/dist-lib/host/bridge-host.cjs +7 -3
  27. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  28. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  29. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  30. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  31. package/dist-lib/host/websocket-proxy.cjs +1 -1
  32. package/dist-lib/index.cjs +3 -2
  33. package/dist-lib/jump-to-source-babel.cjs +1 -1
  34. package/dist-lib/jump-to-source-native.cjs +1 -1
  35. package/dist-lib/menu.cjs +1 -1
  36. package/dist-lib/menu.mjs +1 -1
  37. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  38. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  39. package/dist-lib/metro-production-bundle.cjs +1 -1
  40. package/dist-lib/metro-production-bundle.mjs +1 -1
  41. package/dist-lib/metro.cjs +1 -1
  42. package/dist-lib/profiles.cjs +1 -1
  43. package/dist-lib/public-brand.cjs +1 -1
  44. package/dist-lib/react-native-host-modules.cjs +1 -1
  45. package/dist-lib/react-native-host-modules.mjs +1 -1
  46. package/dist-lib/render-mode.cjs +1 -1
  47. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  48. package/dist-lib/sdk.cjs +1 -1
  49. package/dist-lib/sdk.mjs +1 -1
  50. package/dist-lib/skills.cjs +7 -3
  51. package/dist-lib/swift.cjs +119 -14
  52. package/dist-lib/vite.cjs +95 -34
  53. package/package.json +1 -1
  54. package/src/bridge-contract-input.ts +10 -0
  55. package/src/bridge-contract.ts +5 -0
  56. package/src/cloud.ts +34 -2
  57. package/src/swift.ts +2 -0
  58. package/src/vite-plugin-swift.ts +164 -38
@@ -47,7 +47,8 @@ interface SwiftArtifact {
47
47
  interface SwiftProject {
48
48
  packagePath: string
49
49
  artifact: SwiftArtifact | null
50
- building: Promise<SwiftArtifact> | null
50
+ // the build in flight and the sources it was started from
51
+ building: { stamp: string; promise: Promise<SwiftArtifact> } | null
51
52
  }
52
53
 
53
54
  // a project is its swiftpm package: the build, the artifact, and the dev
@@ -63,35 +64,71 @@ function projectFor(packagePath: string): SwiftProject {
63
64
  return created
64
65
  }
65
66
 
66
- function rootFor(file: string): string {
67
- return devServer?.config.root ?? path.dirname(file)
68
- }
69
-
70
67
  // the compile unit is the whole package: `swift build` compiles every source
71
- // in it, so one file's edit can change the artifact for all of them.
72
- function sourceFiles(packagePath: string): string[] {
68
+ // in it, so one file's edit can change the artifact for all of them. also
69
+ // used for a linked module below, whose own nested packages are separate
70
+ // units by the same rule.
71
+ function sourceFiles(root: string): string[] {
73
72
  const found: string[] = []
74
73
  const walk = (dir: string): void => {
75
74
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
76
75
  if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
77
76
  const full = path.join(dir, entry.name)
78
- if (entry.isDirectory()) walk(full)
79
- else if (entry.name.endsWith('.swift')) found.push(full)
77
+ // a nested Package.swift is a separate package with its own compile:
78
+ // its sources belong to that unit's stamp, not this one's
79
+ if (entry.isDirectory()) {
80
+ if (full !== root && fs.existsSync(path.join(full, 'Package.swift'))) continue
81
+ walk(full)
82
+ } else if (entry.name.endsWith('.swift')) found.push(full)
80
83
  }
81
84
  }
82
- walk(packagePath)
85
+ walk(root)
83
86
  return found
84
87
  }
85
88
 
86
- function sourceStamp(packagePath: string): string {
89
+ // the SwiftUI module the project links: its manifest names it as a path
90
+ // dependency, and `swift build` compiles it with the project, so an edit
91
+ // there changes the artifact without moving any of the project's own mtimes.
92
+ export function linkedModuleRoots(packagePath: string): string[] {
93
+ let manifest: string
94
+ try {
95
+ manifest = fs.readFileSync(path.join(packagePath, 'Package.swift'), 'utf8')
96
+ } catch {
97
+ return []
98
+ }
99
+ const roots = new Set<string>()
100
+ const dependency = /\.package\(\s*path:\s*"([^"]+)"\s*\)/g
101
+ for (const match of manifest.replace(/^\s*\/\/.*$/gm, '').matchAll(dependency)) {
102
+ const root = path.resolve(packagePath, match[1])
103
+ if (fs.existsSync(path.join(root, 'Package.swift'))) roots.add(root)
104
+ }
105
+ return [...roots].sort()
106
+ }
107
+
108
+ // the stamp covers the project plus the modules it links: a running dev
109
+ // server rebuilds only when this moves, so leaving the linked sources out
110
+ // would keep serving the artifact built before a SwiftUI module edit for as
111
+ // long as the project's own sources stayed the same. mirrors the compile
112
+ // service's artifact hash, which names the same closure.
113
+ export function sourceStamp(packagePath: string): string {
87
114
  const hash = createHash('sha256')
88
- for (const file of sourceFiles(packagePath).sort()) {
89
- hash.update(path.relative(packagePath, file))
90
- hash.update(String(fs.statSync(file).mtimeMs))
115
+ for (const root of [packagePath, ...linkedModuleRoots(packagePath)]) {
116
+ hash.update(root)
117
+ for (const file of sourceFiles(root).sort()) {
118
+ hash.update(path.relative(root, file))
119
+ hash.update(String(fs.statSync(file).mtimeMs))
120
+ }
91
121
  }
92
122
  return hash.digest('hex').slice(0, 16)
93
123
  }
94
124
 
125
+ // the registry slot for a view: the owning package plus the source within
126
+ // it. two packages in one app can hold the same filename, and a bare
127
+ // relative path would share one slot, one instance, and its state.
128
+ export function viewRootId(packagePath: string, file: string): string {
129
+ return path.join(packagePath, path.relative(packagePath, file))
130
+ }
131
+
95
132
  // the app fetches the artifact from a worker whose origin is the shell, so the
96
133
  // url has to name this dev server rather than resolve relative to the shell.
97
134
  function artifactUrl(project: SwiftProject, artifact: SwiftArtifact): string {
@@ -120,6 +157,12 @@ function builtArtifact(packagePath: string): string {
120
157
  return path.join(binDir, artifacts[0])
121
158
  }
122
159
 
160
+ function streamText(value: unknown): string {
161
+ if (typeof value === 'string') return value.trim()
162
+ if (Buffer.isBuffer(value)) return value.toString('utf8').trim()
163
+ return ''
164
+ }
165
+
123
166
  async function compile(packagePath: string, stamp: string): Promise<SwiftArtifact> {
124
167
  const swift = path.join(TOOLCHAIN_BIN, 'swift')
125
168
  if (!fs.existsSync(swift)) {
@@ -144,9 +187,24 @@ async function compile(packagePath: string, stamp: string): Promise<SwiftArtifac
144
187
  { maxBuffer: 64 * 1024 * 1024 },
145
188
  )
146
189
  } catch (error) {
147
- throw new Error(
148
- `swift build failed for ${packagePath}\n${error instanceof Error ? error.message : String(error)}`,
149
- )
190
+ // `swift build` prints the compiler's diagnostics on stdout and swiftpm's
191
+ // own on stderr; the failure's message holds only the command line, so
192
+ // both streams are what names the line that broke. stdout leads because
193
+ // compiler diagnostics are what the editor needs to show.
194
+ const streams =
195
+ error && typeof error === 'object'
196
+ ? [
197
+ streamText(Reflect.get(error, 'stdout')),
198
+ streamText(Reflect.get(error, 'stderr')),
199
+ ].filter(Boolean)
200
+ : []
201
+ const detail =
202
+ streams.length > 0
203
+ ? streams.join('\n')
204
+ : error instanceof Error
205
+ ? error.message
206
+ : String(error)
207
+ throw new Error(`swift build failed for ${packagePath}\n${detail}`)
150
208
  }
151
209
  const bytes = fs.readFileSync(builtArtifact(packagePath))
152
210
  return {
@@ -159,7 +217,17 @@ async function compile(packagePath: string, stamp: string): Promise<SwiftArtifac
159
217
  function artifactFor(project: SwiftProject): Promise<SwiftArtifact> {
160
218
  const stamp = sourceStamp(project.packagePath)
161
219
  if (project.artifact?.stamp === stamp) return Promise.resolve(project.artifact)
162
- project.building ??= compile(project.packagePath, stamp).then(
220
+ if (project.building) {
221
+ // a second edit during a build: the build in flight was started from
222
+ // older sources, so its artifact is not the answer for these. wait it out
223
+ // and ask again, which starts the build these sources need; joining it
224
+ // would emit the older artifact and nothing would trigger another load.
225
+ const running = project.building
226
+ if (running.stamp === stamp) return running.promise
227
+ const again = () => artifactFor(project)
228
+ return running.promise.then(again, again)
229
+ }
230
+ const promise = compile(project.packagePath, stamp).then(
163
231
  (artifact) => {
164
232
  project.artifact = artifact
165
233
  project.building = null
@@ -170,18 +238,51 @@ function artifactFor(project: SwiftProject): Promise<SwiftArtifact> {
170
238
  throw error
171
239
  },
172
240
  )
173
- return project.building
241
+ project.building = { stamp, promise }
242
+ return promise
243
+ }
244
+
245
+ // the swiftpm package that owns an imported file: the nearest directory at
246
+ // or above the file that holds a Package.swift, stopping at the vite root
247
+ // once the dev server has named it. a swift-only project keeps its sources at
248
+ // the root so that is its package; a React Native app keeps its in a
249
+ // subdirectory (a `native/` package beside the js), and each such package
250
+ // compiles on its own.
251
+ function packageFor(file: string): string {
252
+ let dir = path.dirname(file)
253
+ const stop = devServer?.config.root
254
+ for (;;) {
255
+ if (fs.existsSync(path.join(dir, 'Package.swift'))) return dir
256
+ if ((stop !== undefined && dir === stop) || path.dirname(dir) === dir) break
257
+ dir = path.dirname(dir)
258
+ }
259
+ throw new Error(
260
+ `no Package.swift above ${file}${stop === undefined ? '' : ` (searched up to ${stop})`}; a .swift import needs a swiftpm package like the one in packages/sootsim-swift/example`,
261
+ )
262
+ }
263
+
264
+ // whether the package mounts as a view: its @main type conforms to
265
+ // RNXPackage instead of to App. the symbol graph is the exact read once the
266
+ // transform emits one; until then the conformance on the @main declaration is
267
+ // unambiguous, comments aside.
268
+ function isViewPackage(packagePath: string): boolean {
269
+ const main =
270
+ /@main\s+(?:\w+\s+)*(?:struct|class|enum|actor)\s+\w+[^{]*:\s*[^{]*\bRNXPackage\b/
271
+ for (const file of sourceFiles(packagePath)) {
272
+ const text = fs.readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, '')
273
+ if (main.test(text)) return true
274
+ }
275
+ return false
174
276
  }
175
277
 
176
278
  export interface SwiftPluginOptions {
177
279
  // swiftpm package that owns the project's .swift sources. defaults to the
178
- // vite root, which is the layout a swift-only project uses.
280
+ // nearest Package.swift above each imported file, which is the vite root
281
+ // for the layout a swift-only project uses.
179
282
  packagePath?: string
180
283
  }
181
284
 
182
285
  export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
183
- const packagePathFor = (file: string): string => options.packagePath ?? rootFor(file)
184
-
185
286
  return {
186
287
  name: 'rnx-swift',
187
288
  enforce: 'pre',
@@ -205,13 +306,25 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
205
306
 
206
307
  async load(id) {
207
308
  if (!id.startsWith(VIRTUAL_PREFIX)) return null
208
- const packagePath = packagePathFor(id.slice(VIRTUAL_PREFIX.length))
309
+ const file = id.slice(VIRTUAL_PREFIX.length)
310
+ // the module is the whole package's artifact, so an import of a file
311
+ // that was deleted would keep answering with it. a deleted .tsx fails
312
+ // its importer; so does a deleted .swift.
313
+ if (!fs.existsSync(file)) throw new Error(`swift source ${file} no longer exists`)
314
+ const packagePath = options.packagePath ?? packageFor(file)
209
315
  const project = projectFor(packagePath)
210
316
  // every source in the package is a dependency of this module, so a
211
317
  // bundler watching this module learns about a `.swift` edit and re-runs
212
318
  // load. that is the whole hot reload: the new hash in the emitted module
213
319
  // is what tells the mount it is looking at a different app.
214
320
  for (const source of sourceFiles(packagePath)) this.addWatchFile(source)
321
+ // an edit to the linked SwiftUI module rebuilds the artifact without
322
+ // touching the project's own sources, so those files gate the reload
323
+ // too: the new hash in the emitted module is what tells the mount it
324
+ // is looking at a different app.
325
+ for (const root of linkedModuleRoots(packagePath)) {
326
+ for (const source of sourceFiles(root)) this.addWatchFile(source)
327
+ }
215
328
  let artifact: SwiftArtifact
216
329
  try {
217
330
  artifact = await artifactFor(project)
@@ -225,6 +338,18 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
225
338
  this.warn(error instanceof Error ? error.message : String(error))
226
339
  artifact = project.artifact
227
340
  }
341
+ // a view package mounts into its importer: the default export is the
342
+ // view component, rooted by its package and source path so a hot reload
343
+ // finds the same instance. an app package keeps the root component it
344
+ // always had.
345
+ if (isViewPackage(packagePath)) {
346
+ const rootId = viewRootId(packagePath, file)
347
+ return [
348
+ `import { createSwiftView } from 'rnxsim/swift'`,
349
+ `export default createSwiftView({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)}, rootId: ${JSON.stringify(rootId)} })`,
350
+ '',
351
+ ].join('\n')
352
+ }
228
353
  return [
229
354
  `import { createSwiftModule } from 'rnxsim/swift'`,
230
355
  `export default createSwiftModule({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)} })`,
@@ -234,7 +359,6 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
234
359
 
235
360
  configureServer(server) {
236
361
  devServer = server
237
- const packagePath = packagePathFor('')
238
362
 
239
363
  server.middlewares.use((req, res, next) => {
240
364
  const url = (req.url ?? '').split('?')[0]
@@ -242,24 +366,26 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
242
366
  next()
243
367
  return
244
368
  }
245
- const project = projectFor(packagePath)
246
- void artifactFor(project).then(
247
- (artifact) => {
248
- if (url !== `${ARTIFACT_PATH}${artifact.hash}.wasm`) {
249
- res.statusCode = 404
250
- res.end('swift artifact is stale')
251
- return
252
- }
369
+ // the url names a content hash, not a package: the request is answered
370
+ // by whichever known project built it, so a React Native app whose
371
+ // swift package sits below its root serves the same path its imports
372
+ // compiled. served from what is already built, never rebuilt here: the
373
+ // page only knows a hash that `load` emitted, and `load` under a compile
374
+ // error emits the last good hash, whose bytes are still the project's
375
+ // artifact. rebuilding would fail that fetch with the compile error and
376
+ // fail every other project's artifact with it.
377
+ for (const project of projects.values()) {
378
+ const artifact = project.artifact
379
+ if (artifact && url === `${ARTIFACT_PATH}${artifact.hash}.wasm`) {
253
380
  // the tenant worker's origin is the shell, never this dev server
254
381
  res.setHeader('access-control-allow-origin', '*')
255
382
  res.setHeader('content-type', 'application/wasm')
256
383
  res.end(artifact.bytes)
257
- },
258
- (error: unknown) => {
259
- res.statusCode = 500
260
- res.end(error instanceof Error ? error.message : String(error))
261
- },
262
- )
384
+ return
385
+ }
386
+ }
387
+ res.statusCode = 404
388
+ res.end('swift artifact is stale')
263
389
  })
264
390
  },
265
391
  }