rnxsim 0.1.523 → 0.1.525

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 (54) 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 +1 -1
  12. package/dist-lib/bridge-contract-input.mjs +1 -1
  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 +1 -1
  21. package/dist-lib/cloud.mjs +1 -1
  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 +1 -1
  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 -1
  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 +1 -1
  51. package/dist-lib/swift.cjs +342 -28
  52. package/dist-lib/vite.cjs +255 -6
  53. package/package.json +1 -1
  54. package/src/vite-plugin-swift.ts +249 -7
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.523 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.525 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -763,6 +763,93 @@ var import_node_os2 = __toESM(require("node:os"), 1);
763
763
  var import_node_path4 = __toESM(require("node:path"), 1);
764
764
  var import_node_util = require("node:util");
765
765
 
766
+ // ../sootsim-swift/src/bundleAssets.ts
767
+ var RASTER_EXTENSIONS = /* @__PURE__ */ new Set([
768
+ ".avif",
769
+ ".bmp",
770
+ ".gif",
771
+ ".ico",
772
+ ".jpeg",
773
+ ".jpg",
774
+ ".png",
775
+ ".webp"
776
+ ]);
777
+ function extOf(file) {
778
+ const dot = file.lastIndexOf(".");
779
+ return dot < 0 ? "" : file.slice(dot).toLowerCase();
780
+ }
781
+ function baseOf(file) {
782
+ const slash = file.lastIndexOf("/");
783
+ return slash < 0 ? file : file.slice(slash + 1);
784
+ }
785
+ function pickImagesetFile(images) {
786
+ const rasters = images.filter(
787
+ (image) => typeof image.filename === "string" && RASTER_EXTENSIONS.has(extOf(image.filename))
788
+ );
789
+ if (rasters.length === 0) return null;
790
+ const rank = (scale) => scale === "2x" ? 0 : scale === "3x" ? 1 : scale === "1x" ? 2 : 3;
791
+ return [...rasters].sort((a, b) => rank(a.scale) - rank(b.scale))[0].filename;
792
+ }
793
+ function imagesetName(contentsPath) {
794
+ const dir = contentsPath.split("/").slice(0, -1).join("/");
795
+ const leaf = baseOf(dir);
796
+ if (!leaf.endsWith(".imageset")) return null;
797
+ return leaf.slice(0, -".imageset".length) || null;
798
+ }
799
+ function isFlatResourceImage(path7) {
800
+ return path7.split("/").includes("Resources") && RASTER_EXTENSIONS.has(extOf(path7));
801
+ }
802
+ function enumerateBundleImages(paths, readText) {
803
+ const table = [];
804
+ const seen = /* @__PURE__ */ new Set();
805
+ const catalogs = paths.filter((path7) => baseOf(path7) === "Contents.json");
806
+ for (const contentsPath of catalogs) {
807
+ const name = imagesetName(contentsPath);
808
+ if (name === null || seen.has(name)) continue;
809
+ let parsed;
810
+ try {
811
+ const text = readText(contentsPath);
812
+ if (text === null) continue;
813
+ parsed = JSON.parse(text);
814
+ } catch {
815
+ continue;
816
+ }
817
+ const images = parsed?.images;
818
+ if (!Array.isArray(images)) continue;
819
+ const dir = contentsPath.split("/").slice(0, -1).join("/");
820
+ const file = pickImagesetFile(images);
821
+ if (file === null || file.includes("/") || file.includes("\\")) continue;
822
+ seen.add(name);
823
+ table.push({ name, file: `${dir}/${file}` });
824
+ }
825
+ for (const path7 of paths) {
826
+ if (!isFlatResourceImage(path7)) continue;
827
+ const name = baseOf(path7).slice(0, -extOf(path7).length);
828
+ if (name === "" || seen.has(name)) continue;
829
+ seen.add(name);
830
+ table.push({ name, file: path7 });
831
+ }
832
+ return table;
833
+ }
834
+ function enumerateBundleJson(paths) {
835
+ const found = [];
836
+ const seen = /* @__PURE__ */ new Set();
837
+ const ordered = [...paths].filter(
838
+ (path7) => path7.split("/").includes("Resources") && path7.split("/").every((segment) => segment !== "" && segment !== "..") && extOf(path7) === ".json"
839
+ ).sort((a, b) => {
840
+ const aDepth = a.split("/").length;
841
+ const bDepth = b.split("/").length;
842
+ return aDepth - bDepth || (a < b ? -1 : a > b ? 1 : 0);
843
+ });
844
+ for (const file of ordered) {
845
+ const base = baseOf(file);
846
+ if (seen.has(base)) continue;
847
+ seen.add(base);
848
+ found.push({ base, file });
849
+ }
850
+ return found;
851
+ }
852
+
766
853
  // ../sootsim-swift/toolchain.json
767
854
  var toolchain_default = {
768
855
  release: "swift-6.3-RELEASE",
@@ -778,13 +865,19 @@ var TOOLCHAIN_BIN = import_node_path4.default.join(
778
865
  var WASM_SDK = `${toolchain_default.release}_wasm`;
779
866
  var WASM_TRIPLE = "wasm32-unknown-wasip1";
780
867
  var ARTIFACT_PATH = "/__rnx/swift/";
868
+ var RESOURCE_PATH = "/__rnx/swift-res/";
781
869
  var VIRTUAL_PREFIX = "\0rnx-swift:";
782
870
  var projects = /* @__PURE__ */ new Map();
783
871
  var devServer;
784
872
  function projectFor(packagePath) {
785
873
  const existing = projects.get(packagePath);
786
874
  if (existing) return existing;
787
- const created = { packagePath, artifact: null, building: null };
875
+ const created = {
876
+ packagePath,
877
+ artifact: null,
878
+ building: null,
879
+ resourceFiles: /* @__PURE__ */ new Map()
880
+ };
788
881
  projects.set(packagePath, created);
789
882
  return created;
790
883
  }
@@ -803,6 +896,118 @@ function sourceFiles(root) {
803
896
  walk(root);
804
897
  return found;
805
898
  }
899
+ function resourceCandidateFiles(root) {
900
+ const found = [];
901
+ const walk = (dir) => {
902
+ for (const entry of import_node_fs3.default.readdirSync(dir, { withFileTypes: true })) {
903
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".build")
904
+ continue;
905
+ const full = import_node_path4.default.join(dir, entry.name);
906
+ if (entry.isDirectory()) {
907
+ if (full !== root && import_node_fs3.default.existsSync(import_node_path4.default.join(full, "Package.swift"))) continue;
908
+ walk(full);
909
+ } else {
910
+ found.push(full);
911
+ }
912
+ }
913
+ };
914
+ walk(root);
915
+ return found;
916
+ }
917
+ var RESOURCE_CONTENT_TYPES = {
918
+ ".avif": "image/avif",
919
+ ".bmp": "image/bmp",
920
+ ".gif": "image/gif",
921
+ ".ico": "image/x-icon",
922
+ ".jpeg": "image/jpeg",
923
+ ".jpg": "image/jpeg",
924
+ ".png": "image/png",
925
+ ".webp": "image/webp"
926
+ };
927
+ function buildSwiftResourceTable(project, baseUrl) {
928
+ const absolute = resourceCandidateFiles(project.packagePath);
929
+ const relative2 = absolute.map(
930
+ (file) => import_node_path4.default.relative(project.packagePath, file).split(import_node_path4.default.sep).join(import_node_path4.default.posix.sep)
931
+ );
932
+ const byRelative = new Map(absolute.map((file, index) => [relative2[index], file]));
933
+ const found = enumerateBundleImages(relative2, (candidate) => {
934
+ const file = byRelative.get(candidate);
935
+ if (!file || import_node_path4.default.basename(file) !== "Contents.json") return null;
936
+ try {
937
+ return import_node_fs3.default.readFileSync(file, "utf8");
938
+ } catch {
939
+ return null;
940
+ }
941
+ });
942
+ const table = {};
943
+ const watched = [];
944
+ const registered = /* @__PURE__ */ new Map();
945
+ for (const { name, file } of found) {
946
+ const disk = byRelative.get(file);
947
+ if (!disk) continue;
948
+ let bytes;
949
+ try {
950
+ bytes = import_node_fs3.default.readFileSync(disk);
951
+ } catch {
952
+ continue;
953
+ }
954
+ const hash = (0, import_node_crypto2.createHash)("sha256").update(bytes).digest("hex").slice(0, 16);
955
+ const ext = import_node_path4.default.posix.extname(file).toLowerCase();
956
+ table[name] = `${baseUrl}${RESOURCE_PATH}${hash}${ext}`;
957
+ watched.push(file);
958
+ const contents = file.replace(/\/[^/]+$/, "/Contents.json");
959
+ if (byRelative.has(contents)) watched.push(contents);
960
+ registered.set(`${hash}${ext}`, disk);
961
+ }
962
+ project.resourceFiles = registered;
963
+ return { table, watched };
964
+ }
965
+ var BUNDLE_MAX_FILES = 128;
966
+ var BUNDLE_MAX_FILE_BYTES = 2 * 1024 * 1024;
967
+ var BUNDLE_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
968
+ function buildSwiftBundleFiles(packagePath) {
969
+ const relative2 = resourceCandidateFiles(packagePath).map(
970
+ (file) => import_node_path4.default.relative(packagePath, file).split(import_node_path4.default.sep).join(import_node_path4.default.posix.sep)
971
+ );
972
+ const files = {};
973
+ const watched = [];
974
+ const skipped = [];
975
+ let total = 0;
976
+ for (const { base, file } of enumerateBundleJson(relative2)) {
977
+ watched.push(file);
978
+ let bytes;
979
+ try {
980
+ bytes = import_node_fs3.default.readFileSync(import_node_path4.default.join(packagePath, ...file.split("/")));
981
+ } catch {
982
+ continue;
983
+ }
984
+ if (bytes.length > BUNDLE_MAX_FILE_BYTES) {
985
+ skipped.push(`${file} (${bytes.length} bytes past the per-file cap)`);
986
+ continue;
987
+ }
988
+ if (Object.keys(files).length >= BUNDLE_MAX_FILES || total + bytes.length > BUNDLE_MAX_TOTAL_BYTES) {
989
+ skipped.push(
990
+ `${file} (pack caps: ${BUNDLE_MAX_FILES} files / ${BUNDLE_MAX_TOTAL_BYTES} bytes)`
991
+ );
992
+ continue;
993
+ }
994
+ files[base] = bytes.toString("base64");
995
+ total += bytes.length;
996
+ }
997
+ return { files, watched, skipped };
998
+ }
999
+ function serveSwiftResource(project, key) {
1000
+ const disk = project.resourceFiles.get(key);
1001
+ if (!disk) return null;
1002
+ try {
1003
+ return {
1004
+ bytes: import_node_fs3.default.readFileSync(disk),
1005
+ contentType: RESOURCE_CONTENT_TYPES[import_node_path4.default.extname(disk).toLowerCase()] ?? "application/octet-stream"
1006
+ };
1007
+ } catch {
1008
+ return null;
1009
+ }
1010
+ }
806
1011
  function linkedModuleRoots(packagePath) {
807
1012
  let manifest;
808
1013
  try {
@@ -833,11 +1038,14 @@ function entryRootId(packagePath, file) {
833
1038
  const relative2 = import_node_path4.default.relative(packagePath, file).split(import_node_path4.default.sep).join(import_node_path4.default.posix.sep);
834
1039
  return `${import_node_path4.default.basename(packagePath)}${import_node_path4.default.posix.sep}${relative2}`;
835
1040
  }
836
- function artifactUrl(project, artifact) {
1041
+ function serverOrigin() {
837
1042
  const address = devServer?.httpServer?.address();
838
1043
  const port = address && typeof address === "object" ? address.port : devServer?.config.server.port;
839
- if (!port) throw new Error("the swift artifact url needs the dev server port");
840
- return `http://localhost:${port}${ARTIFACT_PATH}${artifact.hash}.wasm`;
1044
+ if (!port) throw new Error("the swift urls need the dev server port");
1045
+ return `http://localhost:${port}`;
1046
+ }
1047
+ function artifactUrl(project, artifact) {
1048
+ return `${serverOrigin()}${ARTIFACT_PATH}${artifact.hash}.wasm`;
841
1049
  }
842
1050
  function builtArtifact(packagePath) {
843
1051
  const binDir = import_node_path4.default.join(packagePath, ".build", WASM_TRIPLE, "release");
@@ -961,9 +1169,36 @@ function swiftPlugin(options = {}) {
961
1169
  this.warn(error instanceof Error ? error.message : String(error));
962
1170
  artifact = project.artifact;
963
1171
  }
1172
+ let resources = {};
1173
+ try {
1174
+ const built = buildSwiftResourceTable(project, serverOrigin());
1175
+ resources = built.table;
1176
+ for (const watched of built.watched) this.addWatchFile(watched);
1177
+ } catch (error) {
1178
+ this.warn(
1179
+ `swift resources for ${packagePath}: ${error instanceof Error ? error.message : String(error)}`
1180
+ );
1181
+ }
1182
+ let bundleFiles = {};
1183
+ try {
1184
+ const packed = buildSwiftBundleFiles(packagePath);
1185
+ bundleFiles = packed.files;
1186
+ for (const watched of packed.watched) {
1187
+ this.addWatchFile(import_node_path4.default.join(packagePath, ...watched.split(import_node_path4.default.posix.sep)));
1188
+ }
1189
+ if (packed.skipped.length > 0) {
1190
+ this.warn(
1191
+ `swift bundle files skipped for ${packagePath}: ${packed.skipped.join("; ")}`
1192
+ );
1193
+ }
1194
+ } catch (error) {
1195
+ this.warn(
1196
+ `swift bundle files for ${packagePath}: ${error instanceof Error ? error.message : String(error)}`
1197
+ );
1198
+ }
964
1199
  return [
965
1200
  `import { createSwiftEntry } from 'rnxsim/swift'`,
966
- `export default createSwiftEntry({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)}, rootId: ${JSON.stringify(entryRootId(packagePath, file))} })`,
1201
+ `export default createSwiftEntry({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)}, rootId: ${JSON.stringify(entryRootId(packagePath, file))}, resources: ${JSON.stringify(resources)}, bundleFiles: ${JSON.stringify(bundleFiles)} })`,
967
1202
  ""
968
1203
  ].join("\n");
969
1204
  },
@@ -971,6 +1206,20 @@ function swiftPlugin(options = {}) {
971
1206
  devServer = server;
972
1207
  server.middlewares.use((req, res, next) => {
973
1208
  const url = (req.url ?? "").split("?")[0];
1209
+ if (url.startsWith(RESOURCE_PATH)) {
1210
+ for (const project of projects.values()) {
1211
+ const served = serveSwiftResource(project, url.slice(RESOURCE_PATH.length));
1212
+ if (served) {
1213
+ res.setHeader("access-control-allow-origin", "*");
1214
+ res.setHeader("content-type", served.contentType);
1215
+ res.end(served.bytes);
1216
+ return;
1217
+ }
1218
+ }
1219
+ res.statusCode = 404;
1220
+ res.end("swift resource is stale");
1221
+ return;
1222
+ }
974
1223
  if (!url.startsWith(ARTIFACT_PATH)) {
975
1224
  next();
976
1225
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rnxsim",
3
- "version": "0.1.523",
3
+ "version": "0.1.525",
4
4
  "description": "Vite and Metro plugins, testing drivers, and SDK exports for rnx.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -16,6 +16,10 @@ 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 {
20
+ enumerateBundleImages,
21
+ enumerateBundleJson,
22
+ } from '../../sootsim-swift/src/bundleAssets'
19
23
  import toolchain from '../../sootsim-swift/toolchain.json'
20
24
  import type { Plugin, ViteDevServer } from 'vite'
21
25
 
@@ -33,6 +37,7 @@ const WASM_SDK = `${toolchain.release}_wasm`
33
37
  const WASM_TRIPLE = 'wasm32-unknown-wasip1'
34
38
 
35
39
  const ARTIFACT_PATH = '/__rnx/swift/'
40
+ const RESOURCE_PATH = '/__rnx/swift-res/'
36
41
  const VIRTUAL_PREFIX = '\0rnx-swift:'
37
42
 
38
43
  interface SwiftArtifact {
@@ -49,6 +54,9 @@ interface SwiftProject {
49
54
  artifact: SwiftArtifact | null
50
55
  // the build in flight and the sources it was started from
51
56
  building: { stamp: string; promise: Promise<SwiftArtifact> } | null
57
+ // content hash to absolute path for this load's bundle images: the served
58
+ // urls name a hash, never a path, so there is nothing to traverse.
59
+ resourceFiles: Map<string, string>
52
60
  }
53
61
 
54
62
  // a project is its swiftpm package: the build, the artifact, and the dev
@@ -59,7 +67,12 @@ let devServer: ViteDevServer | undefined
59
67
  function projectFor(packagePath: string): SwiftProject {
60
68
  const existing = projects.get(packagePath)
61
69
  if (existing) return existing
62
- const created: SwiftProject = { packagePath, artifact: null, building: null }
70
+ const created: SwiftProject = {
71
+ packagePath,
72
+ artifact: null,
73
+ building: null,
74
+ resourceFiles: new Map(),
75
+ }
63
76
  projects.set(packagePath, created)
64
77
  return created
65
78
  }
@@ -86,6 +99,180 @@ function sourceFiles(root: string): string[] {
86
99
  return found
87
100
  }
88
101
 
102
+ // every file that can carry bundle content: catalogs and flat Resources
103
+ // rasters anywhere under the package. `.build` is skipped where sourceFiles
104
+ // does not need to: a release tree holds tens of thousands of files and no
105
+ // bundle content, and walking it per import would tax every hot reload.
106
+ function resourceCandidateFiles(root: string): string[] {
107
+ const found: string[] = []
108
+ const walk = (dir: string): void => {
109
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
110
+ if (
111
+ entry.name.startsWith('.') ||
112
+ entry.name === 'node_modules' ||
113
+ entry.name === '.build'
114
+ )
115
+ continue
116
+ const full = path.join(dir, entry.name)
117
+ if (entry.isDirectory()) {
118
+ if (full !== root && fs.existsSync(path.join(full, 'Package.swift'))) continue
119
+ walk(full)
120
+ } else {
121
+ found.push(full)
122
+ }
123
+ }
124
+ }
125
+ walk(root)
126
+ return found
127
+ }
128
+
129
+ const RESOURCE_CONTENT_TYPES: Record<string, string> = {
130
+ '.avif': 'image/avif',
131
+ '.bmp': 'image/bmp',
132
+ '.gif': 'image/gif',
133
+ '.ico': 'image/x-icon',
134
+ '.jpeg': 'image/jpeg',
135
+ '.jpg': 'image/jpeg',
136
+ '.png': 'image/png',
137
+ '.webp': 'image/webp',
138
+ }
139
+
140
+ export interface SwiftResourceTable {
141
+ /** bundle asset name to absolute serving url. */
142
+ table: Record<string, string>
143
+ /** project-relative image files this load's table covers (for watching). */
144
+ watched: string[]
145
+ }
146
+
147
+ // the producer for the local vehicle: enumerate the package's bundle images
148
+ // through the shared rule both hosts use, hash each raster's bytes into its
149
+ // url (an edited photo moves its url, so no stale paint survives a reload),
150
+ // and register hash to path for the serving middleware below.
151
+ export function buildSwiftResourceTable(
152
+ project: SwiftProject,
153
+ baseUrl: string,
154
+ ): SwiftResourceTable {
155
+ const absolute = resourceCandidateFiles(project.packagePath)
156
+ const relative = absolute.map((file) =>
157
+ path.relative(project.packagePath, file).split(path.sep).join(path.posix.sep),
158
+ )
159
+ const byRelative = new Map(absolute.map((file, index) => [relative[index], file]))
160
+ const found = enumerateBundleImages(relative, (candidate) => {
161
+ const file = byRelative.get(candidate)
162
+ if (!file || path.basename(file) !== 'Contents.json') return null
163
+ try {
164
+ return fs.readFileSync(file, 'utf8')
165
+ } catch {
166
+ return null
167
+ }
168
+ })
169
+ const table: Record<string, string> = {}
170
+ const watched: string[] = []
171
+ const registered = new Map<string, string>()
172
+ for (const { name, file } of found) {
173
+ const disk = byRelative.get(file)
174
+ if (!disk) continue
175
+ let bytes: Buffer
176
+ try {
177
+ bytes = fs.readFileSync(disk)
178
+ } catch {
179
+ continue
180
+ }
181
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
182
+ const ext = path.posix.extname(file).toLowerCase()
183
+ table[name] = `${baseUrl}${RESOURCE_PATH}${hash}${ext}`
184
+ watched.push(file)
185
+ // a Contents.json edit (scale swap, file rename) re-resolves the same
186
+ // way a raster edit does, so the catalog rides the watcher too.
187
+ const contents = file.replace(/\/[^/]+$/, '/Contents.json')
188
+ if (byRelative.has(contents)) watched.push(contents)
189
+ registered.set(`${hash}${ext}`, disk)
190
+ }
191
+ project.resourceFiles = registered
192
+ return { table, watched }
193
+ }
194
+
195
+ // the /res pack: every Resources/ json in the package, basename-keyed and
196
+ // base64, riding the entry module so the mount serves it without a fetch.
197
+ // the namespace is flat like the bundle the wasm side reads: shallower
198
+ // paths pack first (the Resources root is the bundle root) with a lexical
199
+ // tiebreak, and the first spelling of a basename wins a collision. caps are
200
+ // INFERRED (no app in either rung is near them; landmarkData-scale json is
201
+ // tens of KB): a file past them is skipped and named in `skipped` so the
202
+ // load warns instead of silently serving a partial bundle. binary bytes
203
+ // travel the same map when a port reaches past json; the worker half packs
204
+ // text only until then (see the gaps ledger).
205
+ const BUNDLE_MAX_FILES = 128
206
+ const BUNDLE_MAX_FILE_BYTES = 2 * 1024 * 1024
207
+ const BUNDLE_MAX_TOTAL_BYTES = 8 * 1024 * 1024
208
+
209
+ export interface SwiftBundlePack {
210
+ /** basename to base64 bytes for the mount's /res. */
211
+ files: Record<string, string>
212
+ /** project-relative json files this load's pack covers (for watching). */
213
+ watched: string[]
214
+ /** project-relative files skipped by a cap, for the load warning. */
215
+ skipped: string[]
216
+ }
217
+
218
+ export function buildSwiftBundleFiles(packagePath: string): SwiftBundlePack {
219
+ // resourceCandidateFiles walks every non-ignored file with the nested
220
+ // package exclusion; the shared enumeration decides namespace, order and
221
+ // collisions, identically for every host that packs.
222
+ const relative = resourceCandidateFiles(packagePath).map((file) =>
223
+ path.relative(packagePath, file).split(path.sep).join(path.posix.sep),
224
+ )
225
+ const files: Record<string, string> = {}
226
+ const watched: string[] = []
227
+ const skipped: string[] = []
228
+ let total = 0
229
+ for (const { base, file } of enumerateBundleJson(relative)) {
230
+ watched.push(file)
231
+ let bytes: Buffer
232
+ try {
233
+ bytes = fs.readFileSync(path.join(packagePath, ...file.split('/')))
234
+ } catch {
235
+ continue
236
+ }
237
+ if (bytes.length > BUNDLE_MAX_FILE_BYTES) {
238
+ skipped.push(`${file} (${bytes.length} bytes past the per-file cap)`)
239
+ continue
240
+ }
241
+ if (
242
+ Object.keys(files).length >= BUNDLE_MAX_FILES ||
243
+ total + bytes.length > BUNDLE_MAX_TOTAL_BYTES
244
+ ) {
245
+ skipped.push(
246
+ `${file} (pack caps: ${BUNDLE_MAX_FILES} files / ${BUNDLE_MAX_TOTAL_BYTES} bytes)`,
247
+ )
248
+ continue
249
+ }
250
+ files[base] = bytes.toString('base64')
251
+ total += bytes.length
252
+ }
253
+ return { files, watched, skipped }
254
+ }
255
+
256
+ // the middleware's lookup, exported for the producer's own proof: a hit is
257
+ // known bytes for a known hash, anything else is null, never a file.
258
+ export function serveSwiftResource(
259
+ project: SwiftProject,
260
+ key: string,
261
+ ): { bytes: Buffer; contentType: string } | null {
262
+ const disk = project.resourceFiles.get(key)
263
+ if (!disk) return null
264
+ try {
265
+ return {
266
+ bytes: fs.readFileSync(disk),
267
+ contentType:
268
+ RESOURCE_CONTENT_TYPES[path.extname(disk).toLowerCase()] ??
269
+ 'application/octet-stream',
270
+ }
271
+ } catch {
272
+ return null
273
+ }
274
+ }
275
+
89
276
  // the SwiftUI module the project links: its manifest names it as a path
90
277
  // dependency, and `swift build` compiles it with the project, so an edit
91
278
  // there changes the artifact without moving any of the project's own mtimes.
@@ -137,14 +324,18 @@ export function entryRootId(packagePath: string, file: string): string {
137
324
  return `${path.basename(packagePath)}${path.posix.sep}${relative}`
138
325
  }
139
326
 
140
- // the app fetches the artifact from a worker whose origin is the shell, so the
141
- // url has to name this dev server rather than resolve relative to the shell.
142
- function artifactUrl(project: SwiftProject, artifact: SwiftArtifact): string {
327
+ // the app fetches from a worker whose origin is the shell, so urls have to
328
+ // name this dev server rather than resolve relative to the shell.
329
+ function serverOrigin(): string {
143
330
  const address = devServer?.httpServer?.address()
144
331
  const port =
145
332
  address && typeof address === 'object' ? address.port : devServer?.config.server.port
146
- if (!port) throw new Error('the swift artifact url needs the dev server port')
147
- return `http://localhost:${port}${ARTIFACT_PATH}${artifact.hash}.wasm`
333
+ if (!port) throw new Error('the swift urls need the dev server port')
334
+ return `http://localhost:${port}`
335
+ }
336
+
337
+ function artifactUrl(project: SwiftProject, artifact: SwiftArtifact): string {
338
+ return `${serverOrigin()}${ARTIFACT_PATH}${artifact.hash}.wasm`
148
339
  }
149
340
 
150
341
  // one executable is the app. a package with several is ambiguous: the host
@@ -332,13 +523,46 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
332
523
  this.warn(error instanceof Error ? error.message : String(error))
333
524
  artifact = project.artifact
334
525
  }
526
+ // the bundle image table rides the same module: a photo edit moves
527
+ // its urls without rebuilding the artifact, and the watched files
528
+ // re-run this load so the mount sees the new table on hot reload.
529
+ let resources: Record<string, string> = {}
530
+ try {
531
+ const built = buildSwiftResourceTable(project, serverOrigin())
532
+ resources = built.table
533
+ for (const watched of built.watched) this.addWatchFile(watched)
534
+ } catch (error) {
535
+ this.warn(
536
+ `swift resources for ${packagePath}: ${error instanceof Error ? error.message : String(error)}`,
537
+ )
538
+ }
539
+ // the /res pack rides the same module as bytes: a json edit moves the
540
+ // pack, the watched files re-run this load, and the mount's
541
+ // bundleFiles compare treats it as a new app and remounts through it.
542
+ let bundleFiles: Record<string, string> = {}
543
+ try {
544
+ const packed = buildSwiftBundleFiles(packagePath)
545
+ bundleFiles = packed.files
546
+ for (const watched of packed.watched) {
547
+ this.addWatchFile(path.join(packagePath, ...watched.split(path.posix.sep)))
548
+ }
549
+ if (packed.skipped.length > 0) {
550
+ this.warn(
551
+ `swift bundle files skipped for ${packagePath}: ${packed.skipped.join('; ')}`,
552
+ )
553
+ }
554
+ } catch (error) {
555
+ this.warn(
556
+ `swift bundle files for ${packagePath}: ${error instanceof Error ? error.message : String(error)}`,
557
+ )
558
+ }
335
559
  // every import mounts through the one entry: the artifact answers
336
560
  // whether it is a view or an app root at runtime, so the transform
337
561
  // never re-derives the conformance from source. the entry is rooted by
338
562
  // its package and source path so a hot reload finds the same instance.
339
563
  return [
340
564
  `import { createSwiftEntry } from 'rnxsim/swift'`,
341
- `export default createSwiftEntry({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)}, rootId: ${JSON.stringify(entryRootId(packagePath, file))} })`,
565
+ `export default createSwiftEntry({ url: ${JSON.stringify(artifactUrl(project, artifact))}, hash: ${JSON.stringify(artifact.hash)}, rootId: ${JSON.stringify(entryRootId(packagePath, file))}, resources: ${JSON.stringify(resources)}, bundleFiles: ${JSON.stringify(bundleFiles)} })`,
342
566
  '',
343
567
  ].join('\n')
344
568
  },
@@ -348,6 +572,24 @@ export function swiftPlugin(options: SwiftPluginOptions = {}): Plugin {
348
572
 
349
573
  server.middlewares.use((req, res, next) => {
350
574
  const url = (req.url ?? '').split('?')[0]
575
+ if (url.startsWith(RESOURCE_PATH)) {
576
+ // hash-addressed: the table this load emitted names these hashes,
577
+ // so a hit serves known bytes and anything else is a 404, never a
578
+ // neighboring file. the tenant worker is cross-origin like the
579
+ // artifact fetch, hence the same cors header.
580
+ for (const project of projects.values()) {
581
+ const served = serveSwiftResource(project, url.slice(RESOURCE_PATH.length))
582
+ if (served) {
583
+ res.setHeader('access-control-allow-origin', '*')
584
+ res.setHeader('content-type', served.contentType)
585
+ res.end(served.bytes)
586
+ return
587
+ }
588
+ }
589
+ res.statusCode = 404
590
+ res.end('swift resource is stale')
591
+ return
592
+ }
351
593
  if (!url.startsWith(ARTIFACT_PATH)) {
352
594
  next()
353
595
  return