@expo/serve-sim 0.1.45 → 0.1.47
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/dist/middleware.js +61 -53
- package/dist/serve-sim.js +91 -83
- package/package.json +1 -1
- package/src/middleware.ts +279 -68
package/package.json
CHANGED
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
|
-
//
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
const
|
|
1570
|
-
const
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
const
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
return
|
|
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
|
-
|
|
1589
|
-
//
|
|
1590
|
-
//
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
-
|
|
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 ?
|
|
1617
|
-
const
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
|
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
|
|
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`,
|