@expo/serve-sim 0.1.46 → 0.1.48

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/serve-sim",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
package/src/middleware.ts CHANGED
@@ -954,6 +954,9 @@ export function previewConfigForState(
954
954
  streamSettingsEndpoint: string;
955
955
  serveSimBin: string;
956
956
  gridApiEndpoint: string;
957
+ gridCatalogEndpoint: string;
958
+ gridStatusEndpoint: string;
959
+ gridStatusEventsEndpoint: string;
957
960
  gridStartEndpoint: string;
958
961
  gridShutdownEndpoint: string;
959
962
  gridMemoryEndpoint: string;
@@ -983,6 +986,9 @@ export function previewConfigForState(
983
986
  streamSettingsEndpoint: streamSettingsEndpointFrom(state.streamUrl),
984
987
  serveSimBin,
985
988
  gridApiEndpoint: gridApiBase,
989
+ gridCatalogEndpoint: gridApiBase + "/catalog",
990
+ gridStatusEndpoint: gridApiBase + "/status",
991
+ gridStatusEventsEndpoint: gridApiBase + "/status/events",
986
992
  gridStartEndpoint: gridApiBase + "/start",
987
993
  gridShutdownEndpoint: gridApiBase + "/shutdown",
988
994
  gridMemoryEndpoint: gridApiBase + "/memory",
@@ -1168,6 +1174,81 @@ interface SimctlDevice {
1168
1174
  runtime: string;
1169
1175
  }
1170
1176
 
1177
+ type GridHelperStatus = Pick<ServeSimState, "port" | "url" | "streamUrl" | "wsUrl">;
1178
+
1179
+ type GridCatalogDevice = {
1180
+ device: string;
1181
+ name: string;
1182
+ runtime: string;
1183
+ chrome: ReturnType<typeof resolveDeviceKitChrome>;
1184
+ placeholderAsset: ReturnType<typeof resolveDevicePlaceholderAsset>;
1185
+ };
1186
+
1187
+ type GridDeviceStatus = {
1188
+ device: string;
1189
+ state: string;
1190
+ helper: GridHelperStatus | null;
1191
+ };
1192
+
1193
+ // DeviceKit lookups are static for a simulator profile. Keep their descriptors
1194
+ // in-process so catalog page refreshes do not repeatedly rebuild the large
1195
+ // objects before JSON serialization.
1196
+ const gridCatalogDeviceCache = new Map<string, { signature: string; device: GridCatalogDevice }>();
1197
+
1198
+ function catalogDeviceForSimulator(device: SimctlDevice): GridCatalogDevice {
1199
+ const signature = [
1200
+ device.name,
1201
+ device.runtime,
1202
+ device.deviceTypeIdentifier ?? "",
1203
+ ].join("\0");
1204
+ const cached = gridCatalogDeviceCache.get(device.udid);
1205
+ if (cached?.signature === signature) return cached.device;
1206
+ const catalogDevice: GridCatalogDevice = {
1207
+ device: device.udid,
1208
+ name: device.name,
1209
+ runtime: device.runtime,
1210
+ chrome: resolveDeviceKitChrome(device),
1211
+ placeholderAsset: resolveDevicePlaceholderAsset(device),
1212
+ };
1213
+ gridCatalogDeviceCache.set(device.udid, { signature, device: catalogDevice });
1214
+ return catalogDevice;
1215
+ }
1216
+
1217
+ function sortGridSimulators(
1218
+ simulators: SimctlDevice[],
1219
+ helperByUdid: ReadonlyMap<string, ServeSimState>,
1220
+ selectedDevice: string | null,
1221
+ ): SimctlDevice[] {
1222
+ const preferredUdid = getPreferredDeviceUdid();
1223
+ const familyRank = (name: string): number => {
1224
+ if (/iphone/i.test(name)) return 0;
1225
+ if (/ipad/i.test(name)) return 1;
1226
+ if (/watch/i.test(name)) return 2;
1227
+ if (/(apple\s*tv|^tv\b)/i.test(name)) return 3;
1228
+ if (/vision|reality/i.test(name)) return 4;
1229
+ return 5;
1230
+ };
1231
+ const stateRank = (device: SimctlDevice): number => {
1232
+ if (helperByUdid.has(device.udid)) return 0;
1233
+ if (selectedDevice && device.udid === selectedDevice) return 1;
1234
+ if (device.state === "Booted") return 2;
1235
+ if (device.udid === preferredUdid) return 3;
1236
+ return 4;
1237
+ };
1238
+ const runtimeRank = (runtime: string): number => {
1239
+ const match = runtime.match(/-(\d+)-(\d+)/);
1240
+ const major = match ? Number(match[1]) : 0;
1241
+ const minor = match ? Number(match[2]) : 0;
1242
+ return -(major * 1000 + minor);
1243
+ };
1244
+ return simulators.sort((a, b) =>
1245
+ stateRank(a) - stateRank(b) ||
1246
+ familyRank(a.name) - familyRank(b.name) ||
1247
+ a.name.localeCompare(b.name) ||
1248
+ runtimeRank(a.runtime) - runtimeRank(b.runtime),
1249
+ );
1250
+ }
1251
+
1171
1252
  function listAllSimulators(): Promise<SimctlDevice[]> {
1172
1253
  return new Promise((resolve) => {
1173
1254
  execFile(
@@ -1197,6 +1278,54 @@ function listAllSimulators(): Promise<SimctlDevice[]> {
1197
1278
  });
1198
1279
  }
1199
1280
 
1281
+ async function readGridSnapshot(selectedDevice: string | null): Promise<{
1282
+ simulators: SimctlDevice[];
1283
+ helperByUdid: Map<string, ServeSimState>;
1284
+ }> {
1285
+ const [states, simulators] = await Promise.all([
1286
+ readServeSimStates(),
1287
+ listAllSimulators(),
1288
+ ]);
1289
+ const helperByUdid = new Map(states.map((state) => [state.device, state] as const));
1290
+ return {
1291
+ simulators: sortGridSimulators(simulators, helperByUdid, selectedDevice),
1292
+ helperByUdid,
1293
+ };
1294
+ }
1295
+
1296
+ function gridStatusesForRequest(
1297
+ simulators: readonly SimctlDevice[],
1298
+ helperByUdid: ReadonlyMap<string, ServeSimState>,
1299
+ req: SimReq,
1300
+ base: string,
1301
+ proxyHelpers: boolean,
1302
+ ): GridDeviceStatus[] {
1303
+ return simulators.map((simulator) => {
1304
+ const helper = helperByUdid.get(simulator.udid);
1305
+ const remoteHelper = helper
1306
+ ? rewriteStateForRequestHost(
1307
+ helper,
1308
+ hostForRequest(req),
1309
+ base,
1310
+ httpProtocolForRequest(req),
1311
+ proxyHelpers,
1312
+ )
1313
+ : null;
1314
+ return {
1315
+ device: simulator.udid,
1316
+ state: simulator.state,
1317
+ helper: remoteHelper
1318
+ ? {
1319
+ port: remoteHelper.port,
1320
+ url: remoteHelper.url,
1321
+ streamUrl: remoteHelper.streamUrl,
1322
+ wsUrl: remoteHelper.wsUrl,
1323
+ }
1324
+ : null,
1325
+ };
1326
+ });
1327
+ }
1328
+
1200
1329
  // Default per-simulator footprint when we have no running sim to measure
1201
1330
  // from — a fresh booted iOS sim with one app launched typically sits in
1202
1331
  // the 1.2–1.8 GB range. Used as a fallback only.
@@ -1563,77 +1692,158 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1563
1692
  return;
1564
1693
  }
1565
1694
 
1566
- // Grid JSON: every supported simulator, annotated with running helper info if any.
1567
- if (url === base + "/grid/api") {
1568
- const states = await readServeSimStates();
1569
- const helperByUdid = new Map(states.map((s) => [s.device, s] as const));
1570
- const sims = await listAllSimulators();
1571
- // Order mirrors Xcode's Devices window: the devices the user is actually
1572
- // using float to the top streaming first, then booted, then the
1573
- // simulator they last opened in Simulator.app — and everything else falls
1574
- // back to a stable family / newest-OS / name grouping. This surfaces the
1575
- // handful of relevant devices instead of burying them in an alphabetical
1576
- // wall of near-identical names. Sort on the cheap metadata BEFORE
1577
- // resolving the DeviceKit chrome descriptor, so pagination resolves chrome
1578
- // only for the page actually returned.
1579
- const preferredUdid = getPreferredDeviceUdid();
1580
- const familyRank = (name: string): number => {
1581
- if (/iphone/i.test(name)) return 0;
1582
- if (/ipad/i.test(name)) return 1;
1583
- if (/watch/i.test(name)) return 2;
1584
- if (/(apple\s*tv|^tv\b)/i.test(name)) return 3;
1585
- if (/vision|reality/i.test(name)) return 4;
1586
- return 5;
1695
+ // Static simulator metadata. The browser keeps this catalog in memory and
1696
+ // receives state/helper changes through the compact status feed below.
1697
+ if (url === base + "/grid/api/catalog") {
1698
+ const { simulators } = await readGridSnapshot(selectedDevice);
1699
+ const total = simulators.length;
1700
+ const { limit, offset } = parseGridPaging(rawUrl);
1701
+ const page = limit == null ? simulators : simulators.slice(offset, offset + limit);
1702
+ const body = JSON.stringify({
1703
+ devices: page.map(catalogDeviceForSimulator),
1704
+ total,
1705
+ offset: limit == null ? 0 : offset,
1706
+ limit: limit ?? total,
1707
+ });
1708
+ const etag = `"${createHash("sha1").update(body).digest("base64url")}"`;
1709
+ if (req.headers["if-none-match"] === etag) {
1710
+ res.writeHead(304, {
1711
+ "Cache-Control": "private, no-cache",
1712
+ ETag: etag,
1713
+ });
1714
+ res.end();
1715
+ return;
1716
+ }
1717
+ res.writeHead(200, {
1718
+ "Content-Type": "application/json",
1719
+ "Cache-Control": "private, no-cache",
1720
+ ETag: etag,
1721
+ });
1722
+ res.end(body);
1723
+ return;
1724
+ }
1725
+
1726
+ const computeGridStatuses = async (): Promise<string> => {
1727
+ const { simulators, helperByUdid } = await readGridSnapshot(selectedDevice);
1728
+ return JSON.stringify({
1729
+ statuses: gridStatusesForRequest(
1730
+ simulators,
1731
+ helperByUdid,
1732
+ req,
1733
+ base,
1734
+ proxyHelpers,
1735
+ ),
1736
+ });
1737
+ };
1738
+
1739
+ // Compact point-in-time form used only while waiting for a start action.
1740
+ if (url === base + "/grid/api/status") {
1741
+ res.writeHead(200, {
1742
+ "Content-Type": "application/json",
1743
+ "Cache-Control": "no-store",
1744
+ });
1745
+ res.end(await computeGridStatuses());
1746
+ return;
1747
+ }
1748
+
1749
+ // Change-only live grid state. It travels through the existing control
1750
+ // WebSocket, so it does not consume another long-lived browser connection.
1751
+ if (url === base + "/grid/api/status/events") {
1752
+ res.writeHead(200, {
1753
+ "Content-Type": "text/event-stream",
1754
+ "Cache-Control": "no-cache",
1755
+ Connection: "keep-alive",
1756
+ "X-Accel-Buffering": "no",
1757
+ });
1758
+ res.write(":\n\n");
1759
+
1760
+ let closed = false;
1761
+ let computing = false;
1762
+ let debounce: ReturnType<typeof setTimeout> | null = null;
1763
+ let watcher: FSWatcher | null = null;
1764
+ let watcherRetry: ReturnType<typeof setTimeout> | null = null;
1765
+ let statusPoll: ReturnType<typeof setInterval> | null = null;
1766
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
1767
+ req.on("close", () => {
1768
+ closed = true;
1769
+ if (debounce) clearTimeout(debounce);
1770
+ if (watcherRetry) clearTimeout(watcherRetry);
1771
+ if (statusPoll) clearInterval(statusPoll);
1772
+ if (heartbeat) clearInterval(heartbeat);
1773
+ watcher?.close();
1774
+ });
1775
+
1776
+ let lastSent = await computeGridStatuses();
1777
+ if (closed || res.writableEnded) return;
1778
+ res.write("data: " + lastSent + "\n\n");
1779
+ const sendIfChanged = async () => {
1780
+ if (closed || computing || res.writableEnded) return;
1781
+ computing = true;
1782
+ try {
1783
+ const next = await computeGridStatuses();
1784
+ if (next === lastSent || closed || res.writableEnded) return;
1785
+ lastSent = next;
1786
+ res.write("data: " + next + "\n\n");
1787
+ } finally {
1788
+ computing = false;
1789
+ }
1587
1790
  };
1588
- // Lower is higher in the list: streaming > selected > booted > last-opened
1589
- // > rest. The active `?device=` selection is ranked near the top so it's
1590
- // always inside the first page otherwise a paginated client that selected
1591
- // a shut-down device deep in the catalog would get no chrome/placeholder
1592
- // for the view it's actually showing.
1593
- const stateRank = (d: (typeof sims)[number]) => {
1594
- if (helperByUdid.has(d.udid)) return 0;
1595
- if (selectedDevice && d.udid === selectedDevice) return 1;
1596
- if (d.state === "Booted") return 2;
1597
- if (d.udid === preferredUdid) return 3;
1598
- return 4;
1791
+
1792
+ // Filesystem notifications make helper start/stop immediate; the slow
1793
+ // poll catches simctl changes made by Xcode or Simulator.app.
1794
+ const onFsEvent = () => {
1795
+ if (debounce) return;
1796
+ debounce = setTimeout(() => {
1797
+ debounce = null;
1798
+ void sendIfChanged();
1799
+ }, 150);
1599
1800
  };
1600
- // Newest runtime first, so "iPhone 17 Pro (27.0)" sorts above its 26.x twins.
1601
- const runtimeRank = (runtime: string): number => {
1602
- const m = runtime.match(/-(\d+)-(\d+)/);
1603
- const major = m ? Number(m[1]) : 0;
1604
- const minor = m ? Number(m[2]) : 0;
1605
- return -(major * 1000 + minor);
1801
+ const ensureWatcher = () => {
1802
+ if (closed || res.writableEnded || watcher || watcherRetry) return;
1803
+ watcherRetry = setTimeout(() => {
1804
+ watcherRetry = null;
1805
+ if (closed || res.writableEnded || watcher) return;
1806
+ try {
1807
+ watcher = watch(STATE_DIR, onFsEvent);
1808
+ watcher.on("error", () => {
1809
+ watcher?.close();
1810
+ watcher = null;
1811
+ ensureWatcher();
1812
+ });
1813
+ void sendIfChanged();
1814
+ } catch {
1815
+ ensureWatcher();
1816
+ }
1817
+ }, 250);
1606
1818
  };
1607
- sims.sort((a, b) =>
1608
- stateRank(a) - stateRank(b) ||
1609
- familyRank(a.name) - familyRank(b.name) ||
1610
- a.name.localeCompare(b.name) ||
1611
- runtimeRank(a.runtime) - runtimeRank(b.runtime),
1612
- );
1819
+ ensureWatcher();
1820
+ statusPoll = setInterval(() => void sendIfChanged(), 3_000);
1821
+ heartbeat = setInterval(() => {
1822
+ if (closed || res.writableEnded) return;
1823
+ res.write(":\n\n");
1824
+ ensureWatcher();
1825
+ }, 15_000);
1826
+ return;
1827
+ }
1613
1828
 
1614
- const total = sims.length;
1829
+ // Grid JSON: every supported simulator, annotated with running helper info if any.
1830
+ if (url === base + "/grid/api") {
1831
+ const { simulators, helperByUdid } = await readGridSnapshot(selectedDevice);
1832
+ const total = simulators.length;
1615
1833
  const { limit, offset } = parseGridPaging(rawUrl);
1616
- const page = limit == null ? sims : sims.slice(offset, offset + limit);
1617
- const devices = page.map((d) => {
1618
- const helper = helperByUdid.get(d.udid);
1619
- const remoteHelper = helper ? rewriteStateForRequestHost(helper, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
1620
- return {
1621
- device: d.udid,
1622
- name: d.name,
1623
- runtime: d.runtime,
1624
- state: d.state,
1625
- chrome: resolveDeviceKitChrome(d),
1626
- placeholderAsset: resolveDevicePlaceholderAsset(d),
1627
- helper: remoteHelper
1628
- ? {
1629
- port: remoteHelper.port,
1630
- url: remoteHelper.url,
1631
- streamUrl: remoteHelper.streamUrl,
1632
- wsUrl: remoteHelper.wsUrl,
1633
- }
1634
- : null,
1635
- };
1636
- });
1834
+ const page = limit == null ? simulators : simulators.slice(offset, offset + limit);
1835
+ const statuses = gridStatusesForRequest(
1836
+ page,
1837
+ helperByUdid,
1838
+ req,
1839
+ base,
1840
+ proxyHelpers,
1841
+ );
1842
+ const devices = page.map((device, index) => ({
1843
+ ...catalogDeviceForSimulator(device),
1844
+ state: statuses[index]!.state,
1845
+ helper: statuses[index]!.helper,
1846
+ }));
1637
1847
  res.writeHead(200, {
1638
1848
  "Content-Type": "application/json",
1639
1849
  "Cache-Control": "no-store",
@@ -1645,7 +1855,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1645
1855
  }
1646
1856
 
1647
1857
  // Shutdown a booted simulator. Any running helper for the device is reaped
1648
- // by readServeSimStates() on the next /grid/api poll (it kills helpers
1858
+ // by readServeSimStates() on the next status sample (it kills helpers
1649
1859
  // whose backing simulator is no longer in the booted set).
1650
1860
  if (url === base + "/grid/api/shutdown" && req.method === "POST") {
1651
1861
  let body = "";
@@ -1664,7 +1874,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1664
1874
  // isn't streamed here). This frees the native session immediately
1665
1875
  // rather than waiting for the next poll's reaper to notice.
1666
1876
  closeDeviceSession(udid);
1667
- // Drop the snapshot so the next /grid/api call re-queries simctl
1877
+ // Drop the snapshot so the next status sample re-queries simctl
1668
1878
  // and prunes any helper bound to this now-shutdown device.
1669
1879
  bootedSnapshot = { at: 0, booted: null, names: new Map() };
1670
1880
  execFile("xcrun", ["simctl", "shutdown", udid], { timeout: 30_000 }, (err, _stdout, stderr) => {
@@ -2342,6 +2552,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2342
2552
  ssePrefixes: [
2343
2553
  `${base}/api/events`,
2344
2554
  `${base}/api/event-log/events`,
2555
+ `${base}/grid/api/status/events`,
2345
2556
  `${base}/appstate`,
2346
2557
  `${base}/logs`,
2347
2558
  `${base}/metrics`,