@expo/serve-sim 0.3.1 → 0.3.3

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.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
package/src/middleware.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { execFile, execSync, spawn, type ChildProcess } from "child_process";
1
+ import { openSseStream } from "./sse-stream";
2
+ import { execFile, execSync } from "child_process";
2
3
  import { readdirSync, readFileSync, existsSync, unlinkSync, watch, type FSWatcher } from "fs";
3
4
  import { readFile, unlink } from "fs/promises";
4
5
  import { tmpdir } from "os";
@@ -23,7 +24,12 @@ import {
23
24
  peekDeviceSession,
24
25
  type HidSocket,
25
26
  } from "./device-session";
26
- import { assertPreviewAccess, assertUpgradeAccess } from "./session-auth";
27
+ import {
28
+ acceptedTokenSubprotocol,
29
+ assertPreviewAccess,
30
+ assertUpgradeAccess,
31
+ upgradeAuthHeaders,
32
+ } from "./session-auth";
27
33
  import {
28
34
  eventLogEventForAction,
29
35
  readEventLog,
@@ -42,6 +48,11 @@ import {
42
48
  import { serveDeviceKitModelAsset } from "./devicekit-model";
43
49
  import { validatePanelRoute } from "./panel-route";
44
50
  import { createExecWebSocketHandler, type UiRequestHandler } from "./exec-ws";
51
+ import { crashRuntime } from "./crash/runtime";
52
+ import { handleCrashesRequestAfter, handleCrashReportRequest } from "./crash/routes";
53
+ export { handleCrashesRequest, handleCrashReportRequest } from "./crash/routes";
54
+ import { booleanParam } from "./request-params";
55
+ import { logBufferCache, type LogBufferCache, type LogLine } from "./log-buffer";
45
56
  import { claimHelperHidSocket, type UpgradeHandlerWebSocket } from "./middleware-utils";
46
57
  import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
47
58
  import { type WebMiddleware } from "./runtime-utils";
@@ -128,10 +139,6 @@ const metricsSamplerCache = createMetricsSamplerCache(
128
139
  (udid) => new MetricsSampler({ udid, deviceName: bootedDeviceName(udid) }),
129
140
  );
130
141
 
131
- // Hard cap on the SSE line-assembly buffer for child-process stdout.
132
- // A malformed log entry without a newline can't grow this beyond 1 MB;
133
- // the partial line is dropped rather than retained indefinitely.
134
- const SSE_LINE_BUFFER_LIMIT = 1024 * 1024;
135
142
  let inspectWebKitBridge: Promise<WebKitBridge> | null = null;
136
143
 
137
144
  function eventLogLimit(rawUrl: string): number | undefined {
@@ -254,7 +261,8 @@ export function matchInstalledAppByDisplayName(
254
261
 
255
262
  // Cache simctl's booted-device set briefly so per-request cost stays bounded.
256
263
  // The middleware runs inside the user's dev server (Metro etc.) and
257
- // readServeSimStates() is called on every /api and every page load.
264
+ // The window has to outlast the logs drawer's poll interval.
265
+ const BOOTED_CACHE_TTL_MS = 5_000;
258
266
  let bootedSnapshot: {
259
267
  at: number;
260
268
  booted: Set<string> | null;
@@ -268,7 +276,7 @@ let bootedSnapshot: {
268
276
  };
269
277
  async function getBootedUdids(): Promise<Set<string> | null> {
270
278
  const now = Date.now();
271
- if (bootedSnapshot.booted && now - bootedSnapshot.at < 1500) {
279
+ if (bootedSnapshot.booted && now - bootedSnapshot.at < BOOTED_CACHE_TTL_MS) {
272
280
  return bootedSnapshot.booted;
273
281
  }
274
282
  try {
@@ -651,26 +659,35 @@ function webSocketBinary(payload: Buffer<ArrayBufferLike>): Uint8Array<ArrayBuff
651
659
  * handshake doesn't flush under Bun). Writes the 101 response and resumes the
652
660
  * socket on success; on a missing key writes 400 and returns false.
653
661
  */
654
- function writeWebSocketAccept(req: SimReq, socket: Socket): boolean {
662
+ function writeWebSocketAccept(req: SimReq, socket: Socket, execToken: string): boolean {
655
663
  const key = req.headers["sec-websocket-key"];
656
664
  if (typeof key !== "string") {
657
665
  socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
658
666
  return false;
659
667
  }
660
668
  const accept = createHash("sha1").update(key + WS_ACCEPT_GUID).digest("base64");
669
+ // A client that offered subprotocols fails the handshake unless one is named back.
670
+ const subprotocol = acceptedTokenSubprotocol(req.headers, execToken);
661
671
  socket.write(
662
672
  "HTTP/1.1 101 Switching Protocols\r\n" +
663
673
  "Upgrade: websocket\r\n" +
664
674
  "Connection: Upgrade\r\n" +
665
675
  `Sec-WebSocket-Accept: ${accept}\r\n` +
676
+ (subprotocol ? `Sec-WebSocket-Protocol: ${subprotocol}\r\n` : "") +
666
677
  "\r\n",
667
678
  );
668
679
  socket.resume();
669
680
  return true;
670
681
  }
671
682
 
672
- function bridgeWebSocketFrames(req: SimReq, socket: Socket, head: Buffer, upstreamUrl: string): void {
673
- if (!writeWebSocketAccept(req, socket)) return;
683
+ function bridgeWebSocketFrames(
684
+ req: SimReq,
685
+ socket: Socket,
686
+ head: Buffer,
687
+ upstreamUrl: string,
688
+ execToken: string,
689
+ ): void {
690
+ if (!writeWebSocketAccept(req, socket, execToken)) return;
674
691
 
675
692
  const upstream = new WebSocket(upstreamUrl);
676
693
  upstream.binaryType = "arraybuffer";
@@ -968,6 +985,7 @@ function rawHidSocket(socket: Socket, head: Buffer): HidSocket {
968
985
  function attachHidInProcess(
969
986
  req: SimReq,
970
987
  socket: Socket,
988
+ execToken: string,
971
989
  head: Buffer,
972
990
  device: string | null,
973
991
  initialStreamSettings?: StreamSettings,
@@ -979,7 +997,7 @@ function attachHidInProcess(
979
997
  } catch {
980
998
  return false;
981
999
  }
982
- if (!writeWebSocketAccept(req, socket)) return true; // bad request handled
1000
+ if (!writeWebSocketAccept(req, socket, execToken)) return true; // bad request handled
983
1001
  session.attachHidSocket(rawHidSocket(socket, head));
984
1002
  return true;
985
1003
  }
@@ -995,6 +1013,7 @@ export function previewConfigForState(
995
1013
  ): ServeSimState & {
996
1014
  basePath: string;
997
1015
  logsEndpoint: string;
1016
+ crashesEndpoint: string;
998
1017
  appStateEndpoint: string;
999
1018
  eventLogEndpoint: string;
1000
1019
  eventLogEventsEndpoint: string;
@@ -1033,6 +1052,7 @@ export function previewConfigForState(
1033
1052
  ...publicState,
1034
1053
  basePath: base,
1035
1054
  logsEndpoint: endpoint(base, "/logs", state.device),
1055
+ crashesEndpoint: endpoint(base, "/crashes", state.device),
1036
1056
  appStateEndpoint: endpoint(base, "/appstate", state.device),
1037
1057
  eventLogEndpoint: endpoint(base, "/api/event-log", state.device),
1038
1058
  eventLogEventsEndpoint: endpoint(base, "/api/event-log/events", state.device),
@@ -1549,13 +1569,103 @@ function httpStreamSettingsFromLegacyCodec(codec: string | undefined): StreamSet
1549
1569
  return undefined;
1550
1570
  }
1551
1571
 
1572
+
1573
+ export function handleLogsRequest(
1574
+ req: SimReq,
1575
+ res: SimRes,
1576
+ state: ServeSimState | null,
1577
+ rawUrl: string,
1578
+ cache: LogBufferCache = logBufferCache
1579
+ ): void {
1580
+ if (!state) {
1581
+ res.writeHead(404, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1582
+ res.end(JSON.stringify({ error: "No serve-sim device" }));
1583
+ return;
1584
+ }
1585
+
1586
+ const params = new URL(rawUrl, "http://127.0.0.1").searchParams;
1587
+ const intQuery = (name: string): number | undefined => {
1588
+ const raw = params.get(name)?.trim();
1589
+ const n = Number(raw);
1590
+ return raw && Number.isSafeInteger(n) && n >= 0 ? n : undefined;
1591
+ };
1592
+ const since = intQuery("since");
1593
+ const limit = intQuery("limit");
1594
+ const snapshot = params.get("snapshot");
1595
+ const wantsJson =
1596
+ snapshot === null
1597
+ ? (req.headers.accept ?? "").includes("application/json")
1598
+ : booleanParam(params, "snapshot");
1599
+ const wantsEnvelope = booleanParam(params, "envelope");
1600
+ const wantsFollow = booleanParam(params, "follow");
1601
+
1602
+ if (wantsJson) {
1603
+ const buffer = wantsFollow ? cache.ensure(state.device) : cache.peek(state.device);
1604
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1605
+ res.end(
1606
+ JSON.stringify({
1607
+ device: state.device,
1608
+ latestSeq: buffer?.latestSeq ?? 0,
1609
+ oldestSeq: buffer?.oldestSeq ?? 0,
1610
+ bufferedBytes: buffer?.byteLength ?? 0,
1611
+ status: buffer?.status ?? "stopped",
1612
+ streamError: buffer?.error ?? null,
1613
+ lines: buffer?.read({ since, limit }) ?? [],
1614
+ })
1615
+ );
1616
+ return;
1617
+ }
1618
+
1619
+ const buffer = cache.ensure(state.device);
1620
+ const stream = openSseStream(req, res);
1621
+
1622
+ const frame = (line: LogLine): string =>
1623
+ "data: " +
1624
+ (wantsEnvelope ? JSON.stringify({ seq: line.seq, at: line.at, raw: line.raw }) : line.raw) +
1625
+ "\n\n";
1626
+
1627
+ let lastSent = since ?? 0;
1628
+ for (const line of buffer.read({ since, limit })) {
1629
+ if (!stream.isOpen()) break;
1630
+ stream.write(frame(line));
1631
+ lastSent = line.seq;
1632
+ }
1633
+
1634
+ stream.onClose(
1635
+ buffer.subscribeBatch(
1636
+ (lines) => {
1637
+ let chunk = "";
1638
+ for (const line of lines) {
1639
+ if (line.seq <= lastSent) continue;
1640
+ lastSent = line.seq;
1641
+ chunk += frame(line);
1642
+ }
1643
+ if (chunk) stream.write(chunk);
1644
+ },
1645
+ () => {
1646
+ if (stream.isOpen()) res.end();
1647
+ }
1648
+ )
1649
+ );
1650
+ }
1651
+
1652
+ async function selectDeviceAndReap(selectedDevice: string | null): Promise<ServeSimState | null> {
1653
+ const states = await readServeSimStates();
1654
+ const state = selectServeSimState(states, selectedDevice);
1655
+ const live = states.map((s) => s.device);
1656
+ crashRuntime.prune(live);
1657
+ logBufferCache.prune(live);
1658
+ return state;
1659
+ }
1660
+
1552
1661
  /**
1553
1662
  * Connect-style middleware that serves the simulator preview UI.
1554
1663
  *
1555
1664
  * Routes handled under `basePath` (default `/.sim`):
1556
1665
  * GET {basePath} — the preview HTML page
1557
1666
  * GET {basePath}/api — serve-sim state JSON
1558
- * GET {basePath}/logs — SSE stream of simctl logs
1667
+ * GET {basePath}/logs — simctl logs, JSON snapshot or SSE (bearer token)
1668
+ * GET {basePath}/crashes — crash reports (bearer token)
1559
1669
  * GET {basePath}/ax — SSE stream of normalized accessibility snapshots
1560
1670
  */
1561
1671
  export function handleMetricsRequest(
@@ -1615,6 +1725,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1615
1725
  ? { "Content-Security-Policy": frameAncestorsPolicy(frameAncestors) }
1616
1726
  : {};
1617
1727
 
1728
+ crashRuntime.arm();
1729
+
1618
1730
  // Simulator-settings requests run in-process (just the underlying simctl /
1619
1731
  // ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
1620
1732
  // sidebar interaction.
@@ -2434,50 +2546,51 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2434
2546
  return;
2435
2547
  }
2436
2548
 
2437
- // SSE: simctl log stream
2438
- if (url === base + "/logs") {
2439
- const states = await readServeSimStates();
2440
- const state = selectServeSimState(states, selectedDevice);
2441
- if (!state) {
2442
- res.writeHead(404);
2443
- res.end("No serve-sim device");
2549
+ if (url === base + "/crashes" || url === base + "/crashes/") {
2550
+ const state = await selectDeviceAndReap(selectedDevice);
2551
+ await handleCrashesRequestAfter(
2552
+ () => crashRuntime.start({ deferToRetry: true }).catch(() => {}),
2553
+ req,
2554
+ res,
2555
+ state,
2556
+ rawUrl
2557
+ );
2558
+ return;
2559
+ }
2560
+
2561
+ if (url.startsWith(base + "/crashes/")) {
2562
+ const rawId = url.slice((base + "/crashes/").length);
2563
+ let id: string;
2564
+ try {
2565
+ id = decodeURIComponent(rawId);
2566
+ } catch {
2567
+ res.writeHead(400, { "Content-Type": "application/json" });
2568
+ res.end(
2569
+ JSON.stringify({
2570
+ error:
2571
+ `Malformed percent-escape in the crash id (${rawId}). Copy the id verbatim from ` +
2572
+ "GET {base}/crashes.",
2573
+ })
2574
+ );
2444
2575
  return;
2445
2576
  }
2446
- const udid = state.device;
2447
- res.writeHead(200, {
2448
- "Content-Type": "text/event-stream",
2449
- "Cache-Control": "no-cache",
2450
- Connection: "keep-alive",
2451
- "X-Accel-Buffering": "no",
2452
- });
2453
- res.write(":\n\n");
2454
-
2455
- const child: ChildProcess = spawn("xcrun", [
2456
- "simctl", "spawn", udid, "log", "stream",
2457
- "--style", "ndjson",
2458
- "--level", "info",
2459
- ], { stdio: ["ignore", "pipe", "ignore"] });
2460
-
2461
- let buf = "";
2462
- child.stdout!.on("data", (chunk: Buffer) => {
2463
- buf += chunk.toString();
2464
- let nl: number;
2465
- while ((nl = buf.indexOf("\n")) !== -1) {
2466
- const line = buf.slice(0, nl).trim();
2467
- buf = buf.slice(nl + 1);
2468
- if (line) res.write("data: " + line + "\n\n");
2469
- }
2470
- // Drop a runaway partial line so a malformed/never-terminated
2471
- // log entry can't grow `buf` without bound.
2472
- if (buf.length > SSE_LINE_BUFFER_LIMIT) buf = "";
2473
- });
2577
+ const state = await selectDeviceAndReap(selectedDevice);
2578
+ const params = new URL(rawUrl, "http://127.0.0.1").searchParams;
2579
+ await handleCrashReportRequest(
2580
+ req,
2581
+ res,
2582
+ state,
2583
+ id,
2584
+ params.get("occurrence"),
2585
+ undefined,
2586
+ undefined,
2587
+ params.get("key")
2588
+ );
2589
+ return;
2590
+ }
2474
2591
 
2475
- child.on("error", () => { try { res.end(); } catch {} });
2476
- child.on("close", () => res.end());
2477
- req.on("close", () => {
2478
- child.stdout?.destroy();
2479
- child.kill();
2480
- });
2592
+ if (url === base + "/logs") {
2593
+ handleLogsRequest(req, res, await selectDeviceAndReap(selectedDevice), rawUrl);
2481
2594
  return;
2482
2595
  }
2483
2596
 
@@ -2567,13 +2680,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2567
2680
  // their own, so gate them here too.
2568
2681
  if (
2569
2682
  !assertUpgradeAccess(
2570
- {
2571
- authorization: req.headers.authorization,
2572
- cookie: req.headers.cookie,
2573
- origin: req.headers.origin,
2574
- host: req.headers.host,
2575
- "sec-fetch-site": req.headers["sec-fetch-site"],
2576
- },
2683
+ upgradeAuthHeaders(req.headers),
2577
2684
  execToken,
2578
2685
  { required: requirePreviewToken },
2579
2686
  )
@@ -2589,7 +2696,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2589
2696
  (async () => {
2590
2697
  try {
2591
2698
  const bridge = await getInspectWebKitBridge();
2592
- bridgeWebSocketFrames(req, socket, head, `ws://127.0.0.1:${bridge.port}${devtoolsTarget.upstreamPath}`);
2699
+ bridgeWebSocketFrames(
2700
+ req,
2701
+ socket,
2702
+ head,
2703
+ `ws://127.0.0.1:${bridge.port}${devtoolsTarget.upstreamPath}`,
2704
+ execToken,
2705
+ );
2593
2706
  } catch (err) {
2594
2707
  const message = err instanceof Error ? err.message : "Failed to start inspect-webkit";
2595
2708
  socket.end(`HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${message}`);
@@ -2604,7 +2717,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2604
2717
  const device = helperTarget.device ?? selectedDevice;
2605
2718
  if (helperTarget.upstreamPath === "/ws") {
2606
2719
  // HID input is delivered to the in-process DeviceSession.
2607
- if (attachHidInProcess(req, socket, head, device, streamSettings)) return;
2720
+ if (attachHidInProcess(req, socket, execToken, head, device, streamSettings)) return;
2608
2721
  socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
2609
2722
  return;
2610
2723
  }
@@ -2619,12 +2732,14 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2619
2732
  const execWebSocketHandler = createExecWebSocketHandler({
2620
2733
  path: `${base}/exec-ws`,
2621
2734
  execToken,
2735
+ corsOrigins,
2622
2736
  ssePrefixes: [
2623
2737
  `${base}/api/events`,
2624
2738
  `${base}/api/event-log/events`,
2625
2739
  `${base}/grid/api/status/events`,
2626
2740
  `${base}/appstate`,
2627
2741
  `${base}/logs`,
2742
+ `${base}/crashes`,
2628
2743
  `${base}/metrics`,
2629
2744
  `${base}/ax`,
2630
2745
  ],
@@ -2641,17 +2756,14 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2641
2756
  });
2642
2757
 
2643
2758
  fetchMiddleware.handleWebSocket = (request: Request, websocket: UpgradeHandlerWebSocket): boolean => {
2759
+ // Before any refusal below can close it: an unhandled error from a peer that keeps sending
2760
+ // would exit the process.
2761
+ websocket.on("error", () => websocket.close());
2644
2762
  // Embedded hosts forward accepted sockets and bypass the request gate. The exec channel
2645
- // re-checks the token in its first frame; the helper HID socket does not.
2763
+ // re-checks the token itself; the helper HID socket does not.
2646
2764
  if (
2647
2765
  !assertUpgradeAccess(
2648
- {
2649
- authorization: request.headers.get("authorization") ?? undefined,
2650
- cookie: request.headers.get("cookie") ?? undefined,
2651
- origin: request.headers.get("origin") ?? undefined,
2652
- host: request.headers.get("host") ?? undefined,
2653
- "sec-fetch-site": request.headers.get("sec-fetch-site") ?? undefined,
2654
- },
2766
+ upgradeAuthHeaders(request),
2655
2767
  execToken,
2656
2768
  { required: requirePreviewToken },
2657
2769
  )