rnxsim 0.1.522 → 0.1.524

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 (56) hide show
  1. package/cli/commands/control.ts +31 -15
  2. package/dist-lib/agent-daemon-client.cjs +1 -1
  3. package/dist-lib/agent-events.cjs +1 -1
  4. package/dist-lib/agent-identity.cjs +1 -1
  5. package/dist-lib/agent-sessions.cjs +1 -1
  6. package/dist-lib/attached-projects.cjs +1 -1
  7. package/dist-lib/auth/shared-session.cjs +1 -1
  8. package/dist-lib/backend-origin.cjs +1 -1
  9. package/dist-lib/beta.cjs +1 -1
  10. package/dist-lib/beta.mjs +1 -1
  11. package/dist-lib/bridge-constants.cjs +1 -1
  12. package/dist-lib/bridge-contract-input.cjs +1 -1
  13. package/dist-lib/bridge-contract-input.mjs +1 -1
  14. package/dist-lib/bridge-contract.cjs +1 -1
  15. package/dist-lib/bridge-contract.mjs +1 -1
  16. package/dist-lib/capture-contract.cjs +1 -1
  17. package/dist-lib/capture-contract.mjs +1 -1
  18. package/dist-lib/cli-constants.cjs +1 -1
  19. package/dist-lib/cloud-contract.cjs +1 -1
  20. package/dist-lib/cloud-contract.mjs +1 -1
  21. package/dist-lib/cloud.cjs +1 -1
  22. package/dist-lib/cloud.mjs +1 -1
  23. package/dist-lib/config.cjs +1 -1
  24. package/dist-lib/detox/index.cjs +1 -1
  25. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  26. package/dist-lib/home-paths.cjs +1 -1
  27. package/dist-lib/host/bridge-host.cjs +1 -1
  28. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  29. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  30. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  31. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  32. package/dist-lib/host/websocket-proxy.cjs +1 -1
  33. package/dist-lib/index.cjs +1 -1
  34. package/dist-lib/jump-to-source-babel.cjs +1 -1
  35. package/dist-lib/jump-to-source-native.cjs +1 -1
  36. package/dist-lib/menu.cjs +1 -1
  37. package/dist-lib/menu.mjs +1 -1
  38. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  39. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  40. package/dist-lib/metro-production-bundle.cjs +1 -1
  41. package/dist-lib/metro-production-bundle.mjs +1 -1
  42. package/dist-lib/metro.cjs +1 -1
  43. package/dist-lib/profiles.cjs +1 -1
  44. package/dist-lib/public-brand.cjs +1 -1
  45. package/dist-lib/react-native-host-modules.cjs +1 -1
  46. package/dist-lib/react-native-host-modules.mjs +1 -1
  47. package/dist-lib/render-mode.cjs +1 -1
  48. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  49. package/dist-lib/sdk.cjs +1 -1
  50. package/dist-lib/sdk.mjs +1 -1
  51. package/dist-lib/skills.cjs +26 -12
  52. package/dist-lib/swift.cjs +151 -72
  53. package/dist-lib/vite.cjs +189 -24
  54. package/package.json +1 -1
  55. package/src/swift.ts +2 -3
  56. package/src/vite-plugin-swift.ts +185 -41
package/src/swift.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  // the runtime the `.swift` vite transform emits its module against. the
2
2
  // implementation lives beside the toolchain it drives, in sootsim-swift.
3
3
  export {
4
- createSwiftModule,
5
- createSwiftView,
4
+ createSwiftEntry,
5
+ type SwiftEntryOptions,
6
6
  type SwiftModuleOptions,
7
- type SwiftViewOptions,
8
7
  } from '../../sootsim-swift/src/mount.tsx'
@@ -7,7 +7,7 @@
7
7
  // artifact. both share the build below, so the artifact is compiled once.
8
8
  //
9
9
  // the hash is the hash of the built wasm, so a new hash means a genuinely
10
- // different app: createSwiftModule moves the @State store across it instead of
10
+ // different app: createSwiftEntry moves the @State store across it instead of
11
11
  // remounting.
12
12
 
13
13
  import { execFile } from 'node:child_process'
@@ -16,6 +16,7 @@ import fs from 'node:fs'
16
16
  import os from 'node:os'
17
17
  import path from 'node:path'
18
18
  import { promisify } from 'node:util'
19
+ import { enumerateBundleImages } from '../../sootsim-swift/src/bundleAssets'
19
20
  import toolchain from '../../sootsim-swift/toolchain.json'
20
21
  import type { Plugin, ViteDevServer } from 'vite'
21
22
 
@@ -33,6 +34,7 @@ const WASM_SDK = `${toolchain.release}_wasm`
33
34
  const WASM_TRIPLE = 'wasm32-unknown-wasip1'
34
35
 
35
36
  const ARTIFACT_PATH = '/__rnx/swift/'
37
+ const RESOURCE_PATH = '/__rnx/swift-res/'
36
38
  const VIRTUAL_PREFIX = '\0rnx-swift:'
37
39
 
38
40
  interface SwiftArtifact {
@@ -49,6 +51,9 @@ interface SwiftProject {
49
51
  artifact: SwiftArtifact | null
50
52
  // the build in flight and the sources it was started from
51
53
  building: { stamp: string; promise: Promise<SwiftArtifact> } | null
54
+ // content hash to absolute path for this load's bundle images: the served
55
+ // urls name a hash, never a path, so there is nothing to traverse.
56
+ resourceFiles: Map<string, string>
52
57
  }
53
58
 
54
59
  // a project is its swiftpm package: the build, the artifact, and the dev
@@ -59,7 +64,12 @@ let devServer: ViteDevServer | undefined
59
64
  function projectFor(packagePath: string): SwiftProject {
60
65
  const existing = projects.get(packagePath)
61
66
  if (existing) return existing
62
- const created: SwiftProject = { packagePath, artifact: null, building: null }
67
+ const created: SwiftProject = {
68
+ packagePath,
69
+ artifact: null,
70
+ building: null,
71
+ resourceFiles: new Map(),
72
+ }
63
73
  projects.set(packagePath, created)
64
74
  return created
65
75
  }
@@ -86,6 +96,119 @@ function sourceFiles(root: string): string[] {
86
96
  return found
87
97
  }
88
98
 
99
+ // every file that can carry bundle content: catalogs and flat Resources
100
+ // rasters anywhere under the package. `.build` is skipped where sourceFiles
101
+ // does not need to: a release tree holds tens of thousands of files and no
102
+ // bundle content, and walking it per import would tax every hot reload.
103
+ function resourceCandidateFiles(root: string): string[] {
104
+ const found: string[] = []
105
+ const walk = (dir: string): void => {
106
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
107
+ if (
108
+ entry.name.startsWith('.') ||
109
+ entry.name === 'node_modules' ||
110
+ entry.name === '.build'
111
+ )
112
+ continue
113
+ const full = path.join(dir, entry.name)
114
+ if (entry.isDirectory()) {
115
+ if (full !== root && fs.existsSync(path.join(full, 'Package.swift'))) continue
116
+ walk(full)
117
+ } else {
118
+ found.push(full)
119
+ }
120
+ }
121
+ }
122
+ walk(root)
123
+ return found
124
+ }
125
+
126
+ const RESOURCE_CONTENT_TYPES: Record<string, string> = {
127
+ '.avif': 'image/avif',
128
+ '.bmp': 'image/bmp',
129
+ '.gif': 'image/gif',
130
+ '.ico': 'image/x-icon',
131
+ '.jpeg': 'image/jpeg',
132
+ '.jpg': 'image/jpeg',
133
+ '.png': 'image/png',
134
+ '.webp': 'image/webp',
135
+ }
136
+
137
+ export interface SwiftResourceTable {
138
+ /** bundle asset name to absolute serving url. */
139
+ table: Record<string, string>
140
+ /** project-relative image files this load's table covers (for watching). */
141
+ watched: string[]
142
+ }
143
+
144
+ // the producer for the local vehicle: enumerate the package's bundle images
145
+ // through the shared rule both hosts use, hash each raster's bytes into its
146
+ // url (an edited photo moves its url, so no stale paint survives a reload),
147
+ // and register hash to path for the serving middleware below.
148
+ export function buildSwiftResourceTable(
149
+ project: SwiftProject,
150
+ baseUrl: string,
151
+ ): SwiftResourceTable {
152
+ const absolute = resourceCandidateFiles(project.packagePath)
153
+ const relative = absolute.map((file) =>
154
+ path.relative(project.packagePath, file).split(path.sep).join(path.posix.sep),
155
+ )
156
+ const byRelative = new Map(absolute.map((file, index) => [relative[index], file]))
157
+ const found = enumerateBundleImages(relative, (candidate) => {
158
+ const file = byRelative.get(candidate)
159
+ if (!file || path.basename(file) !== 'Contents.json') return null
160
+ try {
161
+ return fs.readFileSync(file, 'utf8')
162
+ } catch {
163
+ return null
164
+ }
165
+ })
166
+ const table: Record<string, string> = {}
167
+ const watched: string[] = []
168
+ const registered = new Map<string, string>()
169
+ for (const { name, file } of found) {
170
+ const disk = byRelative.get(file)
171
+ if (!disk) continue
172
+ let bytes: Buffer
173
+ try {
174
+ bytes = fs.readFileSync(disk)
175
+ } catch {
176
+ continue
177
+ }
178
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
179
+ const ext = path.posix.extname(file).toLowerCase()
180
+ table[name] = `${baseUrl}${RESOURCE_PATH}${hash}${ext}`
181
+ watched.push(file)
182
+ // a Contents.json edit (scale swap, file rename) re-resolves the same
183
+ // way a raster edit does, so the catalog rides the watcher too.
184
+ const contents = file.replace(/\/[^/]+$/, '/Contents.json')
185
+ if (byRelative.has(contents)) watched.push(contents)
186
+ registered.set(`${hash}${ext}`, disk)
187
+ }
188
+ project.resourceFiles = registered
189
+ return { table, watched }
190
+ }
191
+
192
+ // the middleware's lookup, exported for the producer's own proof: a hit is
193
+ // known bytes for a known hash, anything else is null, never a file.
194
+ export function serveSwiftResource(
195
+ project: SwiftProject,
196
+ key: string,
197
+ ): { bytes: Buffer; contentType: string } | null {
198
+ const disk = project.resourceFiles.get(key)
199
+ if (!disk) return null
200
+ try {
201
+ return {
202
+ bytes: fs.readFileSync(disk),
203
+ contentType:
204
+ RESOURCE_CONTENT_TYPES[path.extname(disk).toLowerCase()] ??
205
+ 'application/octet-stream',
206
+ }
207
+ } catch {
208
+ return null
209
+ }
210
+ }
211
+
89
212
  // the SwiftUI module the project links: its manifest names it as a path
90
213
  // dependency, and `swift build` compiles it with the project, so an edit
91
214
  // there changes the artifact without moving any of the project's own mtimes.
@@ -108,8 +231,12 @@ export function linkedModuleRoots(packagePath: string): string[] {
108
231
  // the stamp covers the project plus the modules it links: a running dev
109
232
  // server rebuilds only when this moves, so leaving the linked sources out
110
233
  // 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.
234
+ // long as the project's own sources stayed the same. it names the same
235
+ // closure the compile service's artifact hash names, but it is not the same
236
+ // function: the stamp hashes mtimes, which is the cheap rebuild signal for a
237
+ // watcher, while the artifact address hashes content, which is what stays
238
+ // stable when a checkout restores byte-identical files. a restore rebuilds
239
+ // here and reuses the stored artifact there, by design.
113
240
  export function sourceStamp(packagePath: string): string {
114
241
  const hash = createHash('sha256')
115
242
  for (const root of [packagePath, ...linkedModuleRoots(packagePath)]) {
@@ -122,21 +249,29 @@ export function sourceStamp(packagePath: string): string {
122
249
  return hash.digest('hex').slice(0, 16)
123
250
  }
124
251
 
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))
252
+ // the registry slot for an entry: the owning package's own name plus the
253
+ // source within it. two packages in one app can hold the same filename, and
254
+ // a bare relative path would share one slot, one instance, and its state.
255
+ // the name travels instead of the path so identical source in two checkouts
256
+ // emits the identical module; two same-named packages sharing one app still
257
+ // collide, and take the rootId override for that.
258
+ export function entryRootId(packagePath: string, file: string): string {
259
+ const relative = path.relative(packagePath, file).split(path.sep).join(path.posix.sep)
260
+ return `${path.basename(packagePath)}${path.posix.sep}${relative}`
130
261
  }
131
262
 
132
- // the app fetches the artifact from a worker whose origin is the shell, so the
133
- // url has to name this dev server rather than resolve relative to the shell.
134
- function artifactUrl(project: SwiftProject, artifact: SwiftArtifact): string {
263
+ // the app fetches from a worker whose origin is the shell, so urls have to
264
+ // name this dev server rather than resolve relative to the shell.
265
+ function serverOrigin(): string {
135
266
  const address = devServer?.httpServer?.address()
136
267
  const port =
137
268
  address && typeof address === 'object' ? address.port : devServer?.config.server.port
138
- if (!port) throw new Error('the swift artifact url needs the dev server port')
139
- return `http://localhost:${port}${ARTIFACT_PATH}${artifact.hash}.wasm`
269
+ if (!port) throw new Error('the swift urls need the dev server port')
270
+ return `http://localhost:${port}`
271
+ }
272
+
273
+ function artifactUrl(project: SwiftProject, artifact: SwiftArtifact): string {
274
+ return `${serverOrigin()}${ARTIFACT_PATH}${artifact.hash}.wasm`
140
275
  }
141
276
 
142
277
  // one executable is the app. a package with several is ambiguous: the host
@@ -261,20 +396,6 @@ function packageFor(file: string): string {
261
396
  )
262
397
  }
263
398
 
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
276
- }
277
-
278
399
  export interface SwiftPluginOptions {
279
400
  // swiftpm package that owns the project's .swift sources. defaults to the
280
401
  // nearest Package.swift above each imported file, which is the vite root
@@ -338,21 +459,26 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
338
459
  this.warn(error instanceof Error ? error.message : String(error))
339
460
  artifact = project.artifact
340
461
  }
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')
462
+ // the bundle image table rides the same module: a photo edit moves
463
+ // its urls without rebuilding the artifact, and the watched files
464
+ // re-run this load so the mount sees the new table on hot reload.
465
+ let resources: Record<string, string> = {}
466
+ try {
467
+ const built = buildSwiftResourceTable(project, serverOrigin())
468
+ resources = built.table
469
+ for (const watched of built.watched) this.addWatchFile(watched)
470
+ } catch (error) {
471
+ this.warn(
472
+ `swift resources for ${packagePath}: ${error instanceof Error ? error.message : String(error)}`,
473
+ )
352
474
  }
475
+ // every import mounts through the one entry: the artifact answers
476
+ // whether it is a view or an app root at runtime, so the transform
477
+ // never re-derives the conformance from source. the entry is rooted by
478
+ // its package and source path so a hot reload finds the same instance.
353
479
  return [
354
- `import { createSwiftModule } from 'rnxsim/swift'`,
355
- `export default createSwiftModule({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)} })`,
480
+ `import { createSwiftEntry } from 'rnxsim/swift'`,
481
+ `export default createSwiftEntry({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)}, rootId: ${JSON.stringify(entryRootId(packagePath, file))}, resources: ${JSON.stringify(resources)} })`,
356
482
  '',
357
483
  ].join('\n')
358
484
  },
@@ -362,6 +488,24 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
362
488
 
363
489
  server.middlewares.use((req, res, next) => {
364
490
  const url = (req.url ?? '').split('?')[0]
491
+ if (url.startsWith(RESOURCE_PATH)) {
492
+ // hash-addressed: the table this load emitted names these hashes,
493
+ // so a hit serves known bytes and anything else is a 404, never a
494
+ // neighboring file. the tenant worker is cross-origin like the
495
+ // artifact fetch, hence the same cors header.
496
+ for (const project of projects.values()) {
497
+ const served = serveSwiftResource(project, url.slice(RESOURCE_PATH.length))
498
+ if (served) {
499
+ res.setHeader('access-control-allow-origin', '*')
500
+ res.setHeader('content-type', served.contentType)
501
+ res.end(served.bytes)
502
+ return
503
+ }
504
+ }
505
+ res.statusCode = 404
506
+ res.end('swift resource is stale')
507
+ return
508
+ }
365
509
  if (!url.startsWith(ARTIFACT_PATH)) {
366
510
  next()
367
511
  return