@expo/serve-sim 0.1.37 → 0.1.39

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.37",
3
+ "version": "0.1.39",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -89,7 +89,8 @@
89
89
  "react": "^19.0.0",
90
90
  "react-dom": "^19.0.0",
91
91
  "tailwindcss": "^4.1.7",
92
- "typescript": "^5.7.0"
92
+ "typescript": "^5.7.0",
93
+ "werift": "^0.24.4"
93
94
  },
94
95
  "dependencies": {
95
96
  "inspect-webkit": "^0.0.5",
package/src/middleware.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readdirSync, readFileSync, existsSync, unlinkSync, watch, type FSWatcher } from "fs";
2
+ import { readFile, unlink } from "fs/promises";
2
3
  import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException } from "child_process";
3
4
  import { tmpdir } from "os";
4
5
  import { join } from "path";
@@ -13,7 +14,7 @@ import type { Socket } from "net";
13
14
  import { WebSocket } from "ws";
14
15
  import { createAxStreamerCache } from "./ax";
15
16
  import { readCameraStatus } from "./camera-helper";
16
- import { createMetricsSamplerCache, type MetricsSamplerCache } from "./cpu-mem-sampler";
17
+ import { createMetricsSamplerCache, MetricsSampler, type MetricsSamplerCache } from "./metrics-sampler";
17
18
  import { foregroundTracker, type ForegroundApp, type ForegroundTrackerCache } from "./foreground-tracker";
18
19
  import { corsAllowOriginHeaders } from "./middleware-utils";
19
20
  import { closeDeviceSession, getDeviceSession, sendCorsPreflight, type HidSocket } from "./device-session";
@@ -32,6 +33,7 @@ import {
32
33
  serveDevicePlaceholderAsset,
33
34
  } from "./devicekit-chrome";
34
35
  import { createExecWebSocketHandler, type UiRequestHandler } from "./exec-ws";
36
+ import { claimHelperHidSocket, type UpgradeHandlerWebSocket } from "./middleware-utils";
35
37
  import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
36
38
  import { type WebMiddleware } from "./runtime-utils";
37
39
  import { connectToFetch, type ConnectMiddleware } from "./connect-to-fetch";
@@ -94,7 +96,7 @@ type CdpHttpListEntry = {
94
96
  type CdpHttpVersion = { Browser?: string };
95
97
 
96
98
  type SimctlBootedList = {
97
- devices: Record<string, Array<{ udid: string; state: string }>>;
99
+ devices: Record<string, Array<{ udid: string; state: string; name: string }>>;
98
100
  };
99
101
 
100
102
  type SimctlAllList = {
@@ -111,8 +113,11 @@ type ExecRequestBody = { command?: string };
111
113
  export type ServeSimState = ServeSimDeviceState;
112
114
 
113
115
  const axStreamerCache = createAxStreamerCache();
114
- // One shared cpu/mem sampler per udid; every /metrics viewer subscribes.
115
- const metricsSamplerCache = createMetricsSamplerCache();
116
+ // One shared cpu/mem sampler per udid; every /metrics viewer subscribes. Stamp the device name
117
+ // (from the last booted-device snapshot) into the sampler's meta frame when we know it.
118
+ const metricsSamplerCache = createMetricsSamplerCache(
119
+ (udid) => new MetricsSampler({ udid, deviceName: bootedDeviceName(udid) }),
120
+ );
116
121
 
117
122
  // Hard cap on the SSE line-assembly buffer for child-process stdout.
118
123
  // A malformed log entry without a newline can't grow this beyond 1 MB;
@@ -162,6 +167,11 @@ function isSimulatorUdid(value: string): boolean {
162
167
  return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(value);
163
168
  }
164
169
 
170
+ const SCREENSHOT_RESPONSE_HEADERS = {
171
+ "Access-Control-Allow-Origin": "*",
172
+ "Cache-Control": "no-store",
173
+ };
174
+
165
175
  /** What to do with a persisted device state when reaping during a grid poll. */
166
176
  type StaleStateAction = "keep" | "recycle-self" | "recycle-helper";
167
177
 
@@ -237,7 +247,11 @@ export function matchInstalledAppByDisplayName(
237
247
  // Cache simctl's booted-device set briefly so per-request cost stays bounded.
238
248
  // The middleware runs inside the user's dev server (Metro etc.) and
239
249
  // readServeSimStates() is called on every /api and every page load.
240
- let bootedSnapshot: { at: number; booted: Set<string> | null } = { at: 0, booted: null };
250
+ let bootedSnapshot: { at: number; booted: Set<string> | null; names: Map<string, string> } = {
251
+ at: 0,
252
+ booted: null,
253
+ names: new Map(),
254
+ };
241
255
  async function getBootedUdids(): Promise<Set<string> | null> {
242
256
  const now = Date.now();
243
257
  if (bootedSnapshot.booted && now - bootedSnapshot.at < 1500) {
@@ -260,18 +274,38 @@ async function getBootedUdids(): Promise<Set<string> | null> {
260
274
  });
261
275
  const data = JSON.parse(stdout) as SimctlBootedList;
262
276
  const booted = new Set<string>();
277
+ const names = new Map<string, string>();
263
278
  for (const runtime of Object.values(data.devices)) {
264
279
  for (const device of runtime) {
265
- if (device.state === "Booted") booted.add(device.udid);
280
+ if (device.state === "Booted") {
281
+ // simctl's JSON is uppercase; canonicalize so Map/Set lookups stay case-insensitive.
282
+ const udid = device.udid.toUpperCase();
283
+ booted.add(udid);
284
+ names.set(udid, device.name);
285
+ }
266
286
  }
267
287
  }
268
- bootedSnapshot = { at: now, booted };
288
+ bootedSnapshot = { at: now, booted, names };
269
289
  return booted;
270
290
  } catch {
271
291
  return null;
272
292
  }
273
293
  }
274
294
 
295
+ /** Look up a display name in a simctl udid→name map. Keys are stored uppercase. */
296
+ export function deviceNameFromBootedNames(
297
+ names: Map<string, string>,
298
+ udid: string,
299
+ ): string | undefined {
300
+ return names.get(udid.toUpperCase());
301
+ }
302
+
303
+ // Display name for a booted udid, from the last simctl snapshot (refreshed on grid polls).
304
+ // Undefined until the first snapshot lands or if the device isn't booted.
305
+ function bootedDeviceName(udid: string): string | undefined {
306
+ return deviceNameFromBootedNames(bootedSnapshot.names, udid);
307
+ }
308
+
275
309
  // The device the user most recently opened in Simulator.app, regardless of
276
310
  // which tool launched it. Simulator.app persists this as CurrentDeviceUDID, so
277
311
  // it's the best signal for "the device this user actually cares about" — we
@@ -1617,7 +1651,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1617
1651
  closeDeviceSession(udid);
1618
1652
  // Drop the snapshot so the next /grid/api call re-queries simctl
1619
1653
  // and prunes any helper bound to this now-shutdown device.
1620
- bootedSnapshot = { at: 0, booted: null };
1654
+ bootedSnapshot = { at: 0, booted: null, names: new Map() };
1621
1655
  execFile("xcrun", ["simctl", "shutdown", udid], { timeout: 30_000 }, (err, _stdout, stderr) => {
1622
1656
  if (err) {
1623
1657
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -1833,6 +1867,78 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1833
1867
  return;
1834
1868
  }
1835
1869
 
1870
+ // Still-PNG capture via `simctl io <udid> screenshot`. Consumed by the
1871
+ // Expo Device Hub dashboard's save-screenshot action (the serve-sim web UI
1872
+ // shells out over exec-ws instead, so it never hits this route). Uses the
1873
+ // ?device= selection with a booted-simulator fallback.
1874
+ if (url === base + "/api/screenshot") {
1875
+ if (req.method !== "POST") {
1876
+ res.writeHead(405, {
1877
+ ...SCREENSHOT_RESPONSE_HEADERS,
1878
+ "Content-Type": "text/plain; charset=utf-8",
1879
+ });
1880
+ res.end("method not allowed");
1881
+ return;
1882
+ }
1883
+ let udid = selectedDevice;
1884
+ if (udid && !isSimulatorUdid(udid)) {
1885
+ res.writeHead(400, {
1886
+ ...SCREENSHOT_RESPONSE_HEADERS,
1887
+ "Content-Type": "application/json",
1888
+ });
1889
+ res.end(JSON.stringify({ ok: false, error: "Invalid simulator device ID" }));
1890
+ return;
1891
+ }
1892
+ if (!udid) {
1893
+ const booted = await getBootedUdids();
1894
+ udid = (booted && [...booted][0]) ?? null;
1895
+ }
1896
+ if (!udid) {
1897
+ res.writeHead(400, {
1898
+ ...SCREENSHOT_RESPONSE_HEADERS,
1899
+ "Content-Type": "application/json",
1900
+ });
1901
+ res.end(JSON.stringify({ ok: false, error: "No booted simulator to screenshot" }));
1902
+ return;
1903
+ }
1904
+ // simctl only writes to a file, so round-trip through a private tmp path
1905
+ // instead of streaming; captures are a few MB at most.
1906
+ const file = join(tmpdir(), `serve-sim-screenshot-${randomBytes(8).toString("hex")}.png`);
1907
+ try {
1908
+ await new Promise<void>((resolve, reject) => {
1909
+ execFile(
1910
+ "xcrun",
1911
+ ["simctl", "io", udid, "screenshot", file],
1912
+ { timeout: 5_000 },
1913
+ (err, _stdout, stderr) => {
1914
+ if (err) reject(Object.assign(err, { stderr: stderr?.toString() }));
1915
+ else resolve();
1916
+ },
1917
+ );
1918
+ });
1919
+ const png = await readFile(file);
1920
+ res.writeHead(200, {
1921
+ ...SCREENSHOT_RESPONSE_HEADERS,
1922
+ "Content-Type": "image/png",
1923
+ });
1924
+ res.end(png);
1925
+ } catch (err) {
1926
+ const stderr = (err as { stderr?: unknown }).stderr;
1927
+ const message =
1928
+ (typeof stderr === "string" && stderr.trim()) ||
1929
+ (err instanceof Error ? err.message : String(err));
1930
+ res.writeHead(500, {
1931
+ ...SCREENSHOT_RESPONSE_HEADERS,
1932
+ "Content-Type": "application/json",
1933
+ });
1934
+ res.end(JSON.stringify({ ok: false, error: message }));
1935
+ } finally {
1936
+ // Best-effort cleanup; the PNG is already in memory by now.
1937
+ await unlink(file).catch(() => {});
1938
+ }
1939
+ return;
1940
+ }
1941
+
1836
1942
  // JSON API: recent simulator action log. This is intentionally in-memory and
1837
1943
  // bounded; it is for live debugging/agent observability, not archival audit.
1838
1944
  if (url === base + "/api/event-log") {
@@ -2215,7 +2321,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2215
2321
  return connectToFetch(connectMiddleware, request);
2216
2322
  }) as SimMiddleware;
2217
2323
 
2218
- fetchMiddleware.handleWebSocket = createExecWebSocketHandler({
2324
+ const execWebSocketHandler = createExecWebSocketHandler({
2219
2325
  path: `${base}/exec-ws`,
2220
2326
  execToken,
2221
2327
  ssePrefixes: [
@@ -2236,6 +2342,16 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2236
2342
  },
2237
2343
  });
2238
2344
 
2345
+ fetchMiddleware.handleWebSocket = (request: Request, websocket: UpgradeHandlerWebSocket): boolean => {
2346
+ if (execWebSocketHandler(request, websocket)) return true;
2347
+ if (claimHelperHidSocket(request, websocket, {
2348
+ helperProxyTarget: (rawUrl) => helperProxyTarget(rawUrl, helperPrefix),
2349
+ fallbackDevice: options?.device ?? null,
2350
+ resolveSession: (device) => getDeviceSession(device, streamSettings),
2351
+ })) return true;
2352
+ return false;
2353
+ };
2354
+
2239
2355
  // WebSocket upgrades owned by the preview: the authenticated exec/control
2240
2356
  // channel plus same-origin helper/devtools proxy sockets.
2241
2357
  fetchMiddleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {