peachsim 0.1.638 → 0.1.640

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 (81) hide show
  1. package/cli/command-registry.ts +1 -0
  2. package/cli/commands/control.ts +75 -6
  3. package/cli/commands/swift.ts +326 -0
  4. package/cli/main.ts +11 -0
  5. package/cli/outbound-endpoints.ts +5 -1
  6. package/dist-lib/agent-daemon-client.cjs +1 -1
  7. package/dist-lib/agent-events.cjs +1 -1
  8. package/dist-lib/agent-identity.cjs +1 -1
  9. package/dist-lib/agent-sessions.cjs +1 -1
  10. package/dist-lib/attached-projects.cjs +1 -1
  11. package/dist-lib/auth/shared-session.cjs +1 -1
  12. package/dist-lib/backend-origin.cjs +1 -1
  13. package/dist-lib/beta.cjs +1 -1
  14. package/dist-lib/beta.mjs +1 -1
  15. package/dist-lib/bridge-constants.cjs +1 -1
  16. package/dist-lib/bridge-contract-input.cjs +1 -1
  17. package/dist-lib/bridge-contract-input.mjs +1 -1
  18. package/dist-lib/bridge-contract.cjs +1 -1
  19. package/dist-lib/bridge-contract.mjs +1 -1
  20. package/dist-lib/capture-contract.cjs +1 -1
  21. package/dist-lib/capture-contract.mjs +1 -1
  22. package/dist-lib/cli-constants.cjs +1 -1
  23. package/dist-lib/cloud-contract.cjs +1 -1
  24. package/dist-lib/cloud-contract.mjs +1 -1
  25. package/dist-lib/cloud-test-args.cjs +1 -1
  26. package/dist-lib/cloud-test-args.mjs +1 -1
  27. package/dist-lib/cloud.cjs +1 -1
  28. package/dist-lib/cloud.mjs +1 -1
  29. package/dist-lib/config.cjs +1 -1
  30. package/dist-lib/detox/index.cjs +1 -1
  31. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  32. package/dist-lib/home-paths.cjs +1 -1
  33. package/dist-lib/host/bridge-host.cjs +1 -1
  34. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  35. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  36. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  37. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  38. package/dist-lib/host/websocket-proxy.cjs +1 -1
  39. package/dist-lib/index.cjs +13 -4
  40. package/dist-lib/jump-to-source-babel.cjs +1 -1
  41. package/dist-lib/jump-to-source-native.cjs +1 -1
  42. package/dist-lib/kotlin-host.cjs +1 -1
  43. package/dist-lib/menu.cjs +1 -1
  44. package/dist-lib/menu.mjs +1 -1
  45. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  46. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  47. package/dist-lib/metro-production-bundle.cjs +1 -1
  48. package/dist-lib/metro-production-bundle.mjs +1 -1
  49. package/dist-lib/metro.cjs +1 -1
  50. package/dist-lib/native-app-identity.cjs +1 -1
  51. package/dist-lib/native-app-identity.mjs +1 -1
  52. package/dist-lib/profiles.cjs +1 -1
  53. package/dist-lib/public-brand.cjs +1 -1
  54. package/dist-lib/react-native-host-modules.cjs +1 -1
  55. package/dist-lib/react-native-host-modules.mjs +1 -1
  56. package/dist-lib/render-mode.cjs +1 -1
  57. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  58. package/dist-lib/sdk.cjs +1 -1
  59. package/dist-lib/sdk.mjs +1 -1
  60. package/dist-lib/skills.cjs +389 -232
  61. package/dist-lib/swift-compile.cjs +1 -1
  62. package/dist-lib/swift-compile.mjs +1 -1
  63. package/dist-lib/swift-package.cjs +1 -1
  64. package/dist-lib/swift-package.mjs +1 -1
  65. package/dist-lib/swift-submit.cjs +89 -8
  66. package/dist-lib/swift-submit.mjs +78 -8
  67. package/dist-lib/swift.cjs +16 -33308
  68. package/dist-lib/vite-swift.cjs +422 -247
  69. package/dist-lib/vite.cjs +13 -4
  70. package/package.json +1 -1
  71. package/skills/contrast/SKILL.md +1 -0
  72. package/src/assetCatalogContract.ts +16 -0
  73. package/src/dot-env.ts +148 -0
  74. package/src/metro-runtime-source.ts +15 -0
  75. package/src/swift-guest.ts +51 -0
  76. package/src/swift-host.ts +900 -0
  77. package/src/swift-submit.ts +97 -6
  78. package/src/swift.ts +36 -8
  79. package/src/swiftContract.ts +52 -0
  80. package/src/vite-plugin-one.ts +4 -3
  81. package/src/vite-plugin-swift.ts +43 -755
@@ -0,0 +1,900 @@
1
+ // the node host for every Swift preview: package inputs, one compile and
2
+ // immutable artifact addresses. vite and the foreground CLI adapt this host.
3
+
4
+ import { createHash } from 'node:crypto'
5
+ import fs from 'node:fs'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+ import {
9
+ enumerateBundleColors,
10
+ enumerateBundleSymbols,
11
+ enumerateBundleImages,
12
+ enumerateBundleJson,
13
+ } from '../../sootsim-swift/src/bundleAssets'
14
+ import { authHeaderValue, resolveCliAuth } from '../cli/auth'
15
+ import { resolveContrastBackendOrigin } from './backend-origin'
16
+ import {
17
+ compileSwiftProjectUnit,
18
+ withNativeSourceGlue,
19
+ swiftModuleInputFiles,
20
+ } from './swift-compile'
21
+ import {
22
+ MAX_SWIFT_COMPILE_FILES,
23
+ MAX_SWIFT_COMPILE_SOURCE_BYTES,
24
+ MAX_SWIFT_COMPILE_RESOURCES,
25
+ MAX_SWIFT_COMPILE_RESOURCE_BYTES,
26
+ MAX_SWIFT_COMPILE_RESOURCE_TOTAL_BYTES,
27
+ MAX_SWIFT_COMPILE_IMAGES,
28
+ MAX_SWIFT_COMPILE_IMAGE_BYTES,
29
+ MAX_SWIFT_COMPILE_IMAGE_TOTAL_BYTES,
30
+ isSwiftNativeModules,
31
+ compileSwiftProject,
32
+ httpSwiftCompileSubmit,
33
+ swiftNativeEntry,
34
+ type SwiftImageUpload,
35
+ type SwiftNativeModules,
36
+ } from './swift-submit'
37
+ import type { SwiftEntryModuleArgs } from '../../sootsim-swift/src/entryModule'
38
+ import type {
39
+ AppCatalogColor,
40
+ SfSymbolVector,
41
+ SwiftImageSource,
42
+ } from './assetCatalogContract'
43
+ import type { SwiftCompileInput, SwiftIcuAsset } from './swift-compile'
44
+ import type { ServerResponse } from 'node:http'
45
+
46
+ const ARTIFACT_PATH = '/__peach/swift/'
47
+ const RESOURCE_PATH = '/__peach/swift-res/'
48
+ const ICU_PATH = '/__peach/swift-icu/'
49
+ const SKIP_SOURCE_DIRS = new Set([
50
+ 'node_modules',
51
+ 'ios',
52
+ 'android',
53
+ 'dist',
54
+ 'types',
55
+ 'build',
56
+ ])
57
+ const NATIVE_DESCRIPTOR = '.one-native-source.json'
58
+ const NATIVE_GLUE = '.one-native-source.swift.txt'
59
+
60
+ // vxrn's `swiftPackageId`: the id One's device registry and typegen name a
61
+ // package by.
62
+ function swiftPackageId(packagePath: string): string {
63
+ return path
64
+ .basename(packagePath)
65
+ .replace(/[^A-Za-z0-9_]/g, '_')
66
+ .replace(/^(\d)/, '_$1')
67
+ }
68
+
69
+ export function nativeSourceDescriptor(packagePath: string): SwiftNativeModules | null {
70
+ packagePath = fs.realpathSync(packagePath)
71
+ const file = path.join(packagePath, NATIVE_DESCRIPTOR)
72
+ if (!fs.existsSync(file)) return null
73
+ const value: unknown = JSON.parse(
74
+ readPackageFile(packagePath, file, MAX_SWIFT_COMPILE_SOURCE_BYTES).toString('utf8'),
75
+ )
76
+ const invalid = () =>
77
+ new Error(`${file}: invalid One native source descriptor; run one typegen`)
78
+ if (
79
+ !value ||
80
+ typeof value !== 'object' ||
81
+ Reflect.get(value, 'generatedBy') !== 'one typegen' ||
82
+ !fs.existsSync(path.join(packagePath, NATIVE_GLUE))
83
+ ) {
84
+ throw invalid()
85
+ }
86
+ if (!isSwiftNativeModules(value) || value.packageId !== swiftPackageId(packagePath)) {
87
+ throw invalid()
88
+ }
89
+ return value
90
+ }
91
+
92
+ export function nativeSourceFiles(packagePath: string): string[] {
93
+ packagePath = fs.realpathSync(packagePath)
94
+ return [NATIVE_DESCRIPTOR, NATIVE_GLUE]
95
+ .map((name) => path.join(packagePath, name))
96
+ .filter((file) => fs.existsSync(file))
97
+ }
98
+
99
+ export interface SwiftArtifact {
100
+ hash: string
101
+ bytes: Buffer
102
+ // the icu common data the artifact reserves: built here and served from
103
+ // this server, or already published by the Contrast compile that built it
104
+ icu: SwiftIcuAsset | { url: string }
105
+ // the sources this artifact was built from. a watcher event and a `load`
106
+ // reach the project in either order, so freshness is read from the sources
107
+ // rather than from whichever of them ran first.
108
+ stamp: string
109
+ package: {
110
+ resources: Omit<SwiftResourceTable, 'images'>
111
+ bundleFiles: SwiftBundlePack
112
+ native: SwiftNativeModules | null
113
+ }
114
+ }
115
+
116
+ export interface SwiftProject {
117
+ packagePath: string
118
+ artifact: SwiftArtifact | null
119
+ // the build in flight and the sources it was started from
120
+ building: { stamp: string; promise: Promise<SwiftArtifact> } | null
121
+ // two bounded generations of immutable bytes cover in-flight asset reads.
122
+ resourceFiles: Map<string, { bytes: Buffer; contentType: string }>
123
+ resourcesPrevious: Map<string, { bytes: Buffer; contentType: string }>
124
+ artifacts: SwiftArtifact[]
125
+ hosted?: SwiftHostedCompileOptions
126
+ }
127
+
128
+ // a project is its swiftpm package: the build, the artifact, and the dev
129
+ // server that serves it are shared by every plugin instance in this process.
130
+ const projects = new Map<string, SwiftProject>()
131
+
132
+ export interface SwiftHostedCompileOptions {
133
+ origin: string
134
+ authorization: string
135
+ signal: AbortSignal
136
+ }
137
+
138
+ export function projectFor(
139
+ packagePath: string,
140
+ hosted?: SwiftHostedCompileOptions,
141
+ ): SwiftProject {
142
+ packagePath = fs.realpathSync(packagePath)
143
+ const existing = projects.get(packagePath)
144
+ if (existing) {
145
+ if (hosted && !existing.hosted) {
146
+ throw new Error('a Swift package cannot share local and hosted compile ownership')
147
+ }
148
+ return existing
149
+ }
150
+ const created: SwiftProject = {
151
+ packagePath,
152
+ artifact: null,
153
+ building: null,
154
+ resourceFiles: new Map(),
155
+ resourcesPrevious: new Map(),
156
+ artifacts: [],
157
+ hosted,
158
+ }
159
+ projects.set(packagePath, created)
160
+ return created
161
+ }
162
+
163
+ // preflight the entire input tree before reading any source or resource bytes.
164
+ // a nested package is a separate compile unit; ignored build trees never enter it.
165
+ function packageFiles(root: string): string[] {
166
+ const canonicalRoot = fs.realpathSync(root)
167
+ const found: string[] = []
168
+ const walk = (dir: string): void => {
169
+ for (const entry of fs
170
+ .readdirSync(dir, { withFileTypes: true })
171
+ .sort((left, right) => left.name.localeCompare(right.name))) {
172
+ if (
173
+ (entry.name.startsWith('.') &&
174
+ entry.name !== NATIVE_DESCRIPTOR &&
175
+ entry.name !== NATIVE_GLUE) ||
176
+ SKIP_SOURCE_DIRS.has(entry.name)
177
+ )
178
+ continue
179
+ const full = path.join(dir, entry.name)
180
+ if (entry.isSymbolicLink()) throw new Error(`swift input is a symlink: ${full}`)
181
+ const real = fs.realpathSync(full)
182
+ const relative = path.relative(canonicalRoot, real)
183
+ if (
184
+ relative.startsWith(`..${path.sep}`) ||
185
+ relative === '..' ||
186
+ path.isAbsolute(relative)
187
+ ) {
188
+ throw new Error(`swift input escapes the selected package: ${full}`)
189
+ }
190
+ if (entry.isDirectory()) {
191
+ if (fs.existsSync(path.join(full, 'Package.swift'))) continue
192
+ walk(full)
193
+ } else if (entry.isFile()) {
194
+ found.push(full)
195
+ if (found.length > 4096) throw new Error('swift package exceeds 4096 input files')
196
+ }
197
+ }
198
+ }
199
+ walk(canonicalRoot)
200
+ return found
201
+ }
202
+
203
+ function readPackageFile(root: string, file: string, maxBytes: number): Buffer {
204
+ const canonicalRoot = fs.realpathSync(root)
205
+ const relative = path.relative(canonicalRoot, fs.realpathSync(file))
206
+ if (
207
+ relative === '..' ||
208
+ relative.startsWith(`..${path.sep}`) ||
209
+ path.isAbsolute(relative)
210
+ ) {
211
+ throw new Error(`swift input escapes the selected package: ${file}`)
212
+ }
213
+ let ancestor = path.resolve(file)
214
+ while (ancestor !== canonicalRoot) {
215
+ if (fs.lstatSync(ancestor).isSymbolicLink()) {
216
+ throw new Error(`swift input is a symlink: ${ancestor}`)
217
+ }
218
+ const parent = path.dirname(ancestor)
219
+ if (parent === ancestor)
220
+ throw new Error(`swift input escapes the selected package: ${file}`)
221
+ ancestor = parent
222
+ }
223
+ const expected = fs.statSync(file)
224
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
225
+ try {
226
+ const stat = fs.fstatSync(fd)
227
+ if (stat.dev !== expected.dev || stat.ino !== expected.ino) {
228
+ throw new Error(`swift input changed while opening: ${file}`)
229
+ }
230
+ if (!stat.isFile() || stat.size > maxBytes) {
231
+ throw new Error(`swift input exceeds ${maxBytes} bytes: ${file}`)
232
+ }
233
+ const bytes = Buffer.alloc(stat.size + 1)
234
+ let offset = 0
235
+ while (offset < bytes.length) {
236
+ const count = fs.readSync(fd, bytes, offset, bytes.length - offset, null)
237
+ if (count === 0) break
238
+ offset += count
239
+ }
240
+ if (offset !== stat.size || fs.fstatSync(fd).size !== stat.size) {
241
+ throw new Error(`swift input changed while reading: ${file}`)
242
+ }
243
+ return bytes.subarray(0, offset)
244
+ } finally {
245
+ fs.closeSync(fd)
246
+ }
247
+ }
248
+
249
+ export function sourceFiles(root: string): string[] {
250
+ const files = packageFiles(root).filter((file) => file.endsWith('.swift'))
251
+ if (
252
+ files.length > MAX_SWIFT_COMPILE_FILES ||
253
+ files.reduce((total, file) => total + fs.statSync(file).size, 0) >
254
+ MAX_SWIFT_COMPILE_SOURCE_BYTES
255
+ ) {
256
+ throw new Error('swift sources exceed the compile service caps')
257
+ }
258
+ return files
259
+ }
260
+
261
+ function resourceCandidateFiles(root: string): string[] {
262
+ return packageFiles(root).filter((file) => !file.endsWith('.swift'))
263
+ }
264
+
265
+ const RESOURCE_CONTENT_TYPES: Record<string, string> = {
266
+ '.avif': 'image/avif',
267
+ '.bmp': 'image/bmp',
268
+ '.gif': 'image/gif',
269
+ '.ico': 'image/x-icon',
270
+ '.jpeg': 'image/jpeg',
271
+ '.jpg': 'image/jpeg',
272
+ '.png': 'image/png',
273
+ '.webp': 'image/webp',
274
+ }
275
+
276
+ export interface SwiftResourceTable {
277
+ /** bundle asset name to its absolute serving url and scale. */
278
+ table: Record<string, SwiftImageSource>
279
+ /** the same rasters as compile-address inputs. */
280
+ images: Record<string, SwiftImageUpload>
281
+ /** asset-catalog color name to its value. */
282
+ colors: Record<string, AppCatalogColor>
283
+ symbols: Record<string, SfSymbolVector>
284
+ /** project-relative image and color files this load's table covers (for watching). */
285
+ watched: string[]
286
+ }
287
+
288
+ // the producer for the local vehicle: enumerate the package's bundle images
289
+ // through the shared rule both hosts use, hash each raster's bytes into its
290
+ // url (an edited photo moves its url, so no stale paint survives a reload),
291
+ // and retain immutable bytes for the serving middleware below.
292
+ export function buildSwiftResourceTable(
293
+ project: SwiftProject,
294
+ baseUrl: string,
295
+ ): SwiftResourceTable {
296
+ const absolute = resourceCandidateFiles(project.packagePath)
297
+ const relative = absolute.map((file) =>
298
+ path.relative(project.packagePath, file).split(path.sep).join(path.posix.sep),
299
+ )
300
+ const byRelative = new Map(absolute.map((file, index) => [relative[index], file]))
301
+ const readContents = (candidate: string): string | null => {
302
+ const file = byRelative.get(candidate)
303
+ // catalog json, and the glyph template a symbolset names
304
+ if (!file || !/(^|\/)Contents\.json$|\.symbolset\/[^/]+\.svg$/.test(candidate))
305
+ return null
306
+ return readPackageFile(
307
+ project.packagePath,
308
+ file,
309
+ MAX_SWIFT_COMPILE_RESOURCE_BYTES,
310
+ ).toString('utf8')
311
+ }
312
+ const found = enumerateBundleImages(relative, readContents)
313
+ const colors = enumerateBundleColors(relative, readContents)
314
+ const symbols = enumerateBundleSymbols(relative, readContents)
315
+ const table: Record<string, SwiftImageSource> = {}
316
+ const images: Record<string, SwiftImageUpload> = {}
317
+ const watched: string[] = []
318
+ const registered = new Map<string, { bytes: Buffer; contentType: string }>()
319
+ let imageBytes = 0
320
+ for (const { name, file, scale } of found) {
321
+ const disk = byRelative.get(file)
322
+ if (!disk) continue
323
+ if (
324
+ Object.keys(images).length >= MAX_SWIFT_COMPILE_IMAGES ||
325
+ imageBytes + fs.statSync(disk).size > MAX_SWIFT_COMPILE_IMAGE_TOTAL_BYTES
326
+ ) {
327
+ throw new Error('swift image pack exceeds the compile service caps')
328
+ }
329
+ const bytes = readPackageFile(
330
+ project.packagePath,
331
+ disk,
332
+ MAX_SWIFT_COMPILE_IMAGE_BYTES,
333
+ )
334
+ imageBytes += bytes.length
335
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
336
+ const ext = path.posix.extname(file).toLowerCase()
337
+ table[name] = { uri: `${baseUrl}${RESOURCE_PATH}${hash}${ext}`, scale }
338
+ images[name] = {
339
+ file: path.posix.basename(file),
340
+ data: bytes.toString('base64'),
341
+ }
342
+ watched.push(file)
343
+ // a Contents.json edit (scale swap, file rename) re-resolves the same
344
+ // way a raster edit does, so the catalog rides the watcher too.
345
+ const contents = file.replace(/\/[^/]+$/, '/Contents.json')
346
+ if (byRelative.has(contents)) watched.push(contents)
347
+ registered.set(`${hash}${ext}`, {
348
+ bytes,
349
+ contentType: RESOURCE_CONTENT_TYPES[ext] ?? 'application/octet-stream',
350
+ })
351
+ }
352
+ // a colorset or symbolset edit re-resolves like an imageset edit.
353
+ for (const file of relative) {
354
+ if (file.endsWith('.colorset/Contents.json') || file.includes('.symbolset/'))
355
+ watched.push(file)
356
+ }
357
+ if (
358
+ registered.size !== project.resourceFiles.size ||
359
+ [...registered].some(
360
+ ([key, value]) => !project.resourceFiles.get(key)?.bytes.equals(value.bytes),
361
+ )
362
+ ) {
363
+ project.resourcesPrevious = project.resourceFiles
364
+ project.resourceFiles = registered
365
+ }
366
+ return { table, images, colors, symbols, watched }
367
+ }
368
+
369
+ // the /res pack: every Resources/ json in the package, basename-keyed and
370
+ // base64, riding the entry module so the mount serves it without a fetch.
371
+ // the namespace is flat like the bundle the wasm side reads: shallower
372
+ // paths pack first (the Resources root is the bundle root) with a lexical
373
+ // tiebreak, and the first spelling of a basename wins a collision. data json
374
+ // bundle caps match the hosted compiler; oversized input refuses before reading.
375
+ const BUNDLE_MAX_FILES = MAX_SWIFT_COMPILE_RESOURCES
376
+ const BUNDLE_MAX_FILE_BYTES = MAX_SWIFT_COMPILE_RESOURCE_BYTES
377
+ const BUNDLE_MAX_TOTAL_BYTES = MAX_SWIFT_COMPILE_RESOURCE_TOTAL_BYTES
378
+
379
+ export interface SwiftBundlePack {
380
+ /** basename to base64 bytes for the mount's /res. */
381
+ files: Record<string, string>
382
+ /** project-relative json files this load's pack covers (for watching). */
383
+ watched: string[]
384
+ }
385
+
386
+ export function buildSwiftBundleFiles(packagePath: string): SwiftBundlePack {
387
+ packagePath = fs.realpathSync(packagePath)
388
+ // resourceCandidateFiles walks every non-ignored file with the nested
389
+ // package exclusion; the shared enumeration decides namespace, order and
390
+ // collisions, identically for every host that packs.
391
+ const relative = resourceCandidateFiles(packagePath).map((file) =>
392
+ path.relative(packagePath, file).split(path.sep).join(path.posix.sep),
393
+ )
394
+ const files: Record<string, string> = {}
395
+ const watched: string[] = []
396
+ let total = 0
397
+ for (const { base, file } of enumerateBundleJson(relative)) {
398
+ watched.push(file)
399
+ const disk = path.join(packagePath, ...file.split('/'))
400
+ if (
401
+ Object.keys(files).length >= BUNDLE_MAX_FILES ||
402
+ total + fs.statSync(disk).size > BUNDLE_MAX_TOTAL_BYTES
403
+ ) {
404
+ throw new Error(
405
+ `swift bundle pack exceeds ${BUNDLE_MAX_FILES} files / ${BUNDLE_MAX_TOTAL_BYTES} bytes`,
406
+ )
407
+ }
408
+ const bytes = readPackageFile(packagePath, disk, BUNDLE_MAX_FILE_BYTES)
409
+ files[base] = bytes.toString('base64')
410
+ total += bytes.length
411
+ }
412
+ return { files, watched }
413
+ }
414
+
415
+ // the middleware's lookup, exported for the producer's own proof: a hit is
416
+ // known bytes for a known hash, anything else is null, never a file.
417
+ export function serveSwiftResource(
418
+ project: SwiftProject,
419
+ key: string,
420
+ ): { bytes: Buffer; contentType: string } | null {
421
+ return project.resourceFiles.get(key) ?? project.resourcesPrevious.get(key) ?? null
422
+ }
423
+
424
+ // the SwiftUI module the project links: its manifest names it as a path
425
+ // dependency, and `swift build` compiles it with the project, so an edit
426
+ // there changes the artifact without moving any of the project's own mtimes.
427
+ export function linkedModuleRoots(packagePath: string): string[] {
428
+ packagePath = fs.realpathSync(packagePath)
429
+ let manifest: string
430
+ try {
431
+ manifest = readPackageFile(
432
+ packagePath,
433
+ path.join(packagePath, 'Package.swift'),
434
+ MAX_SWIFT_COMPILE_SOURCE_BYTES,
435
+ ).toString('utf8')
436
+ } catch {
437
+ return []
438
+ }
439
+ const roots = new Set<string>()
440
+ const dependency = /\.package\(\s*path:\s*"([^"]+)"\s*\)/g
441
+ for (const match of manifest.replace(/^\s*\/\/.*$/gm, '').matchAll(dependency)) {
442
+ const root = path.resolve(packagePath, match[1])
443
+ if (fs.existsSync(path.join(root, 'Package.swift'))) roots.add(root)
444
+ }
445
+ return [...roots].sort()
446
+ }
447
+
448
+ // the stamp covers every file that contributes to compileInput plus the
449
+ // linked module and toolchain release that the shared artifact hash names. a
450
+ // running dev server rebuilds only when this moves, so its input closure must
451
+ // stay identical to the artifact address's even though this cheap watcher
452
+ // signal hashes mtimes while that stable address hashes content. a checkout
453
+ // restoring byte-identical files rebuilds here and reuses the same address
454
+ // there, by design.
455
+ export function sourceStamp(
456
+ packagePath: string,
457
+ moduleRootOverride?: string,
458
+ hosted = false,
459
+ ): string {
460
+ const hash = createHash('sha256')
461
+ hash.update(packagePath)
462
+ const projectInputs = new Set(packageFiles(packagePath))
463
+ for (const file of [...projectInputs].sort()) {
464
+ hash.update(path.relative(packagePath, file))
465
+ const stat = fs.statSync(file)
466
+ hash.update(`${stat.mtimeMs}/${stat.ctimeMs}/${stat.size}`)
467
+ }
468
+ const moduleRoot = hosted
469
+ ? undefined
470
+ : (moduleRootOverride ??
471
+ linkedModuleRoots(packagePath).find((root) =>
472
+ fs.existsSync(path.join(root, 'toolchain.json')),
473
+ ))
474
+ if (moduleRoot) {
475
+ hash.update(moduleRoot)
476
+ for (const relative of swiftModuleInputFiles(moduleRoot)) {
477
+ hash.update(relative)
478
+ hash.update(String(fs.statSync(path.join(moduleRoot, relative)).mtimeMs))
479
+ }
480
+ }
481
+ return hash.digest('hex').slice(0, 16)
482
+ }
483
+
484
+ // the registry slot for an entry: the owning package's own name plus the
485
+ // source within it. two packages in one app can hold the same filename, and
486
+ // a bare relative path would share one slot, one instance, and its state.
487
+ // the name travels instead of the path so identical source in two checkouts
488
+ // emits the identical module; two same-named packages sharing one app still
489
+ // collide, and take the rootId override for that.
490
+ export function entryRootId(packagePath: string, file: string): string {
491
+ const relative = path.relative(packagePath, file).split(path.sep).join(path.posix.sep)
492
+ return `${path.basename(packagePath)}${path.posix.sep}${relative}`
493
+ }
494
+
495
+ export function icuUrl(origin: string, artifact: SwiftArtifact): string {
496
+ return 'url' in artifact.icu
497
+ ? artifact.icu.url
498
+ : `${origin}${ICU_PATH}${artifact.icu.sha256}`
499
+ }
500
+
501
+ function packageSources(packagePath: string): Record<string, string> {
502
+ const sources = sourceFiles(packagePath)
503
+ if (sources.length > MAX_SWIFT_COMPILE_FILES)
504
+ throw new Error('swift source file cap exceeded')
505
+ let total = 0
506
+ for (const file of sources) total += fs.statSync(file).size
507
+ if (total > MAX_SWIFT_COMPILE_SOURCE_BYTES)
508
+ throw new Error('swift source byte cap exceeded')
509
+ const files: Record<string, string> = {}
510
+ for (const file of sources) {
511
+ const relative = path.relative(packagePath, file).split(path.sep).join(path.posix.sep)
512
+ files[relative] = readPackageFile(
513
+ packagePath,
514
+ file,
515
+ MAX_SWIFT_COMPILE_SOURCE_BYTES,
516
+ ).toString('utf8')
517
+ }
518
+ return files
519
+ }
520
+
521
+ interface SwiftPackageSnapshot {
522
+ files: Record<string, string>
523
+ resources: SwiftResourceTable
524
+ bundleFiles: SwiftBundlePack
525
+ native: SwiftNativeModules | null
526
+ glue?: string
527
+ }
528
+
529
+ function packageSnapshot(project: SwiftProject): SwiftPackageSnapshot {
530
+ const files = packageSources(project.packagePath)
531
+ const native = nativeSourceDescriptor(project.packagePath)
532
+ return {
533
+ files,
534
+ resources: buildSwiftResourceTable(project, ''),
535
+ bundleFiles: buildSwiftBundleFiles(project.packagePath),
536
+ native,
537
+ ...(native
538
+ ? {
539
+ glue: readPackageFile(
540
+ project.packagePath,
541
+ path.join(project.packagePath, NATIVE_GLUE),
542
+ MAX_SWIFT_COMPILE_SOURCE_BYTES,
543
+ ).toString('utf8'),
544
+ }
545
+ : {}),
546
+ }
547
+ }
548
+
549
+ function snapshotCompileInput(
550
+ snapshot: SwiftPackageSnapshot,
551
+ entry: string,
552
+ ): SwiftCompileInput {
553
+ const input: SwiftCompileInput = {
554
+ files: snapshot.files,
555
+ resources: snapshot.bundleFiles.files,
556
+ images: snapshot.resources.images,
557
+ entry,
558
+ oneModule: true,
559
+ }
560
+ return snapshot.glue ? withNativeSourceGlue(input, snapshot.glue) : input
561
+ }
562
+
563
+ export function compileInput(packagePath: string, entry: string): SwiftCompileInput {
564
+ return snapshotCompileInput(packageSnapshot(projectFor(packagePath)), entry)
565
+ }
566
+
567
+ async function compile(
568
+ packagePath: string,
569
+ entry: string,
570
+ stamp: string,
571
+ moduleRootOverride: string | undefined,
572
+ hosted: SwiftHostedCompileOptions | undefined,
573
+ snapshot: SwiftPackageSnapshot,
574
+ ): Promise<SwiftArtifact> {
575
+ const resources = {
576
+ table: snapshot.resources.table,
577
+ colors: snapshot.resources.colors,
578
+ symbols: snapshot.resources.symbols,
579
+ watched: snapshot.resources.watched,
580
+ }
581
+ const content = {
582
+ resources,
583
+ bundleFiles: snapshot.bundleFiles,
584
+ native: snapshot.native,
585
+ }
586
+ if (hosted)
587
+ return {
588
+ ...(await compileRemote(packagePath, entry, snapshot, hosted)),
589
+ stamp,
590
+ package: content,
591
+ }
592
+ const moduleRoot =
593
+ moduleRootOverride ??
594
+ linkedModuleRoots(packagePath).find((root) =>
595
+ fs.existsSync(path.join(root, 'toolchain.json')),
596
+ )
597
+ if (!moduleRoot)
598
+ return {
599
+ ...(await compileRemote(packagePath, entry, snapshot)),
600
+ stamp,
601
+ package: content,
602
+ }
603
+ const toolchain: unknown = JSON.parse(
604
+ fs.readFileSync(path.join(moduleRoot, 'toolchain.json'), 'utf8'),
605
+ )
606
+ const release =
607
+ toolchain && typeof toolchain === 'object'
608
+ ? Reflect.get(toolchain, 'release')
609
+ : undefined
610
+ if (typeof release !== 'string') {
611
+ throw new Error(`swift toolchain manifest at ${moduleRoot} has no release`)
612
+ }
613
+ const artifact = await compileSwiftProjectUnit(snapshotCompileInput(snapshot, entry), {
614
+ // the compile rewrites Sources/App in its root before swiftpm builds, and
615
+ // its lock orders one process only: two vites on one root (a lane's dev
616
+ // server and a capture, or two checkouts) build each other's sources. so
617
+ // each app and module checkout gets its own root, which keeps the same
618
+ // app's incremental build across restarts.
619
+ buildRoot:
620
+ process.env.CONTRAST_SWIFT_BUILD_ROOT ??
621
+ path.join(
622
+ os.tmpdir(),
623
+ 'contrast-swift-build',
624
+ createHash('sha256')
625
+ .update(`${moduleRoot}\0${path.resolve(packagePath)}`)
626
+ .digest('hex')
627
+ .slice(0, 16),
628
+ ),
629
+ moduleRoot,
630
+ release,
631
+ toolchainBin: path.join(
632
+ os.homedir(),
633
+ `Library/Developer/Toolchains/${release}.xctoolchain/usr/bin`,
634
+ ),
635
+ })
636
+ return { ...artifact, stamp, package: content }
637
+ }
638
+
639
+ // an npm-installed peachsim ships neither the SwiftUI module nor a toolchain,
640
+ // so the package compiles where a Contrast project's does: Contrast's compile
641
+ // route, authorized by `peach login` or PEACH_API_KEY. the service renders the
642
+ // native dispatch glue itself under the package's id and answers the wasm at a
643
+ // public content address, which is fetched once and served from this dev
644
+ // server like a local build. resource bytes share the compile snapshot and
645
+ // immutable local asset addresses.
646
+ async function compileRemote(
647
+ packagePath: string,
648
+ entry: string,
649
+ snapshot: SwiftPackageSnapshot,
650
+ hosted?: SwiftHostedCompileOptions,
651
+ ): Promise<Omit<SwiftArtifact, 'stamp' | 'package'>> {
652
+ const auth = hosted ? null : resolveCliAuth()
653
+ if (!hosted && (!auth || auth.kind === 'github')) {
654
+ throw new Error(
655
+ `compiling ${path.basename(packagePath)} for Peach needs \`peach login\` or PEACH_API_KEY: this peachsim has no local SwiftUI module, so Contrast compiles it`,
656
+ )
657
+ }
658
+ const origin =
659
+ hosted?.origin ??
660
+ resolveContrastBackendOrigin(auth?.kind === 'session' ? auth.origin : null)
661
+ const authorization =
662
+ hosted?.authorization ?? (auth ? authHeaderValue(auth) : undefined)
663
+ const signal = AbortSignal.any([
664
+ ...(hosted ? [hosted.signal] : []),
665
+ AbortSignal.timeout(5 * 60_000),
666
+ ])
667
+ const submit = httpSwiftCompileSubmit({
668
+ endpoint: origin,
669
+ authorization,
670
+ signal,
671
+ })
672
+ if (!submit) throw new Error('swift compile: no credential to submit with')
673
+ const descriptor = snapshot.native
674
+ const address = await compileSwiftProject(
675
+ submit,
676
+ {
677
+ files: snapshot.files,
678
+ resources: snapshot.bundleFiles.files,
679
+ images: snapshot.resources.images,
680
+ entry,
681
+ packageId: descriptor?.packageId ?? swiftPackageId(packagePath),
682
+ },
683
+ origin,
684
+ signal,
685
+ )
686
+ // the app's JS calls with the hash its own `one typegen` wrote; glue the
687
+ // service rendered from another One would reject every call as stale.
688
+ if (descriptor && address.native?.hash !== descriptor.hash) {
689
+ throw new Error(
690
+ `${path.basename(packagePath)}: Contrast compiled native contract ${address.native?.hash ?? 'none'} but one typegen wrote ${descriptor.hash}; the app's One and the compile host's differ, or the compile host is older than this peachsim`,
691
+ )
692
+ }
693
+ const response = await fetch(address.url, { signal })
694
+ if (!response.ok) {
695
+ throw new Error(`swift compile: ${address.url} answered ${response.status}`)
696
+ }
697
+ const maxArtifactBytes = 64 * 1024 * 1024
698
+ if (
699
+ Number(response.headers.get('content-length')) > maxArtifactBytes ||
700
+ !response.body
701
+ ) {
702
+ throw new Error('swift compile: artifact exceeds 64 MiB or has no body')
703
+ }
704
+ const reader = response.body.getReader()
705
+ const chunks: Uint8Array[] = []
706
+ let total = 0
707
+ for (;;) {
708
+ signal.throwIfAborted()
709
+ const next = await reader.read()
710
+ if (next.done) break
711
+ total += next.value.byteLength
712
+ if (total > maxArtifactBytes) {
713
+ await reader.cancel()
714
+ throw new Error('swift compile: artifact exceeds 64 MiB')
715
+ }
716
+ chunks.push(next.value)
717
+ }
718
+ return {
719
+ hash: address.hash,
720
+ bytes: Buffer.concat(chunks),
721
+ icu: { url: address.icuUrl },
722
+ }
723
+ }
724
+
725
+ export function artifactFor(
726
+ project: SwiftProject,
727
+ entry: string,
728
+ moduleRoot?: string,
729
+ ): Promise<SwiftArtifact> {
730
+ const stamp = sourceStamp(project.packagePath, moduleRoot, Boolean(project.hosted))
731
+ if (project.artifact?.stamp === stamp) return Promise.resolve(project.artifact)
732
+ if (project.building) {
733
+ // a second edit during a build: the build in flight was started from
734
+ // older sources, so its artifact is not the answer for these. wait it out
735
+ // and ask again, which starts the build these sources need; joining it
736
+ // would emit the older artifact and nothing would trigger another load.
737
+ const running = project.building
738
+ if (running.stamp === stamp) return running.promise
739
+ const again = () => artifactFor(project, entry, moduleRoot)
740
+ return running.promise.then(again, again)
741
+ }
742
+ const snapshot = packageSnapshot(project)
743
+ if (sourceStamp(project.packagePath, moduleRoot, Boolean(project.hosted)) !== stamp) {
744
+ return artifactFor(project, entry, moduleRoot)
745
+ }
746
+ const promise = compile(
747
+ project.packagePath,
748
+ entry,
749
+ stamp,
750
+ moduleRoot,
751
+ project.hosted,
752
+ snapshot,
753
+ ).then(
754
+ (artifact) => {
755
+ project.artifact = artifact
756
+ project.artifacts = [
757
+ artifact,
758
+ ...project.artifacts.filter((old) => old.hash !== artifact.hash),
759
+ ].slice(0, 2)
760
+ project.building = null
761
+ return artifact
762
+ },
763
+ (error: unknown) => {
764
+ project.building = null
765
+ throw error
766
+ },
767
+ )
768
+ const fresh = promise.then((artifact) =>
769
+ sourceStamp(project.packagePath, moduleRoot, Boolean(project.hosted)) ===
770
+ artifact.stamp
771
+ ? artifact
772
+ : artifactFor(project, entry, moduleRoot),
773
+ )
774
+ project.building = { stamp, promise: fresh }
775
+ return fresh
776
+ }
777
+
778
+ export function swiftPackageWatchFiles(
779
+ packagePath: string,
780
+ moduleRoot: string | undefined,
781
+ ): string[] {
782
+ packagePath = fs.realpathSync(packagePath)
783
+ return [
784
+ path.join(packagePath, 'Package.swift'),
785
+ ...sourceFiles(packagePath),
786
+ ...nativeSourceFiles(packagePath),
787
+ ...resourceCandidateFiles(packagePath),
788
+ ...(moduleRoot
789
+ ? swiftModuleInputFiles(moduleRoot).map((file) => path.join(moduleRoot, file))
790
+ : []),
791
+ ]
792
+ }
793
+
794
+ export async function swiftPackageMetadata(
795
+ packagePath: string,
796
+ packageName: string,
797
+ moduleRoot: string | undefined,
798
+ origin: string,
799
+ hosted?: SwiftHostedCompileOptions,
800
+ ): Promise<SwiftEntryModuleArgs> {
801
+ packagePath = fs.realpathSync(packagePath)
802
+ const mains = sourceFiles(packagePath).filter((file) =>
803
+ /@main\s+(?:public\s+)?struct\s+\w+\s*:/.test(
804
+ readPackageFile(packagePath, file, MAX_SWIFT_COMPILE_SOURCE_BYTES).toString('utf8'),
805
+ ),
806
+ )
807
+ const native = nativeSourceDescriptor(packagePath)
808
+ if (mains.length > 1 || (mains.length === 0 && !native)) {
809
+ throw new Error(
810
+ `swift package ${packageName} needs exactly one @main source or generated native modules; found ${mains.length}`,
811
+ )
812
+ }
813
+ const project = projectFor(packagePath, hosted)
814
+ const entry =
815
+ mains.length === 1
816
+ ? path.relative(packagePath, mains[0]).split(path.sep).join(path.posix.sep)
817
+ : Object.entries(native?.files ?? {}).find(
818
+ ([_file, names]) => names.length > 0,
819
+ )?.[0]
820
+ if (!entry) throw new Error(`swift package ${packageName} has no native module source`)
821
+ const artifact = await artifactFor(project, entry, moduleRoot)
822
+ const resources = artifact.package.resources
823
+ const bundleFiles = artifact.package.bundleFiles
824
+ return {
825
+ url: `${origin}${ARTIFACT_PATH}${artifact.hash}.wasm`,
826
+ hash: artifact.hash,
827
+ icuUrl: icuUrl(origin, artifact),
828
+ rootId: `${packageName}/${entry}`,
829
+ resources: Object.fromEntries(
830
+ Object.entries(resources.table).map(([name, image]) => [
831
+ name,
832
+ { ...image, uri: `${origin}${image.uri}` },
833
+ ]),
834
+ ),
835
+ bundleFiles: bundleFiles.files,
836
+ colors: resources.colors,
837
+ symbols: resources.symbols,
838
+ native: artifact.package.native
839
+ ? {
840
+ packageId: artifact.package.native.packageId,
841
+ contractHash: artifact.package.native.hash,
842
+ modules: artifact.package.native.modules,
843
+ defaultView: mains.length === 1,
844
+ }
845
+ : undefined,
846
+ }
847
+ }
848
+
849
+ export function serveSwiftPackageAsset(pathname: string, res: ServerResponse): boolean {
850
+ if (pathname.startsWith(RESOURCE_PATH)) {
851
+ for (const project of projects.values()) {
852
+ const served = serveSwiftResource(project, pathname.slice(RESOURCE_PATH.length))
853
+ if (!served) continue
854
+ res.setHeader('access-control-allow-origin', '*')
855
+ res.setHeader('content-type', served.contentType)
856
+ res.end(served.bytes)
857
+ return true
858
+ }
859
+ res.statusCode = 404
860
+ res.end('swift resource is stale')
861
+ return true
862
+ }
863
+ if (pathname.startsWith(ICU_PATH)) {
864
+ for (const project of projects.values()) {
865
+ const icu = project.artifacts.find(
866
+ (artifact) =>
867
+ 'sha256' in artifact.icu && pathname === `${ICU_PATH}${artifact.icu.sha256}`,
868
+ )?.icu
869
+ if (!icu || 'url' in icu || pathname !== `${ICU_PATH}${icu.sha256}`) continue
870
+ // the address is the content, so the browser keeps it across reloads,
871
+ // restarts and every app built with this toolchain
872
+ res.setHeader('access-control-allow-origin', '*')
873
+ res.setHeader('content-type', 'application/gzip')
874
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable')
875
+ res.end(icu.gzip)
876
+ return true
877
+ }
878
+ res.statusCode = 404
879
+ res.end('swift icu data is stale')
880
+ return true
881
+ }
882
+ if (!pathname.startsWith(ARTIFACT_PATH)) return false
883
+ for (const project of projects.values()) {
884
+ const artifact = project.artifacts.find(
885
+ (artifact) => pathname === `${ARTIFACT_PATH}${artifact.hash}.wasm`,
886
+ )
887
+ if (!artifact || pathname !== `${ARTIFACT_PATH}${artifact.hash}.wasm`) continue
888
+ res.setHeader('access-control-allow-origin', '*')
889
+ res.setHeader('content-type', 'application/wasm')
890
+ res.end(artifact.bytes)
891
+ return true
892
+ }
893
+ res.statusCode = 404
894
+ res.end('swift artifact is stale')
895
+ return true
896
+ }
897
+
898
+ export function disposeSwiftProject(packagePath: string): void {
899
+ projects.delete(fs.realpathSync(packagePath))
900
+ }