@expo/serve-sim 0.3.1 → 0.3.2

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.2",
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";
@@ -42,6 +43,11 @@ import {
42
43
  import { serveDeviceKitModelAsset } from "./devicekit-model";
43
44
  import { validatePanelRoute } from "./panel-route";
44
45
  import { createExecWebSocketHandler, type UiRequestHandler } from "./exec-ws";
46
+ import { crashRuntime } from "./crash/runtime";
47
+ import { handleCrashesRequestAfter, handleCrashReportRequest } from "./crash/routes";
48
+ export { handleCrashesRequest, handleCrashReportRequest } from "./crash/routes";
49
+ import { booleanParam } from "./request-params";
50
+ import { logBufferCache, type LogBufferCache, type LogLine } from "./log-buffer";
45
51
  import { claimHelperHidSocket, type UpgradeHandlerWebSocket } from "./middleware-utils";
46
52
  import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
47
53
  import { type WebMiddleware } from "./runtime-utils";
@@ -128,10 +134,6 @@ const metricsSamplerCache = createMetricsSamplerCache(
128
134
  (udid) => new MetricsSampler({ udid, deviceName: bootedDeviceName(udid) }),
129
135
  );
130
136
 
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
137
  let inspectWebKitBridge: Promise<WebKitBridge> | null = null;
136
138
 
137
139
  function eventLogLimit(rawUrl: string): number | undefined {
@@ -254,7 +256,8 @@ export function matchInstalledAppByDisplayName(
254
256
 
255
257
  // Cache simctl's booted-device set briefly so per-request cost stays bounded.
256
258
  // The middleware runs inside the user's dev server (Metro etc.) and
257
- // readServeSimStates() is called on every /api and every page load.
259
+ // The window has to outlast the logs drawer's poll interval.
260
+ const BOOTED_CACHE_TTL_MS = 5_000;
258
261
  let bootedSnapshot: {
259
262
  at: number;
260
263
  booted: Set<string> | null;
@@ -268,7 +271,7 @@ let bootedSnapshot: {
268
271
  };
269
272
  async function getBootedUdids(): Promise<Set<string> | null> {
270
273
  const now = Date.now();
271
- if (bootedSnapshot.booted && now - bootedSnapshot.at < 1500) {
274
+ if (bootedSnapshot.booted && now - bootedSnapshot.at < BOOTED_CACHE_TTL_MS) {
272
275
  return bootedSnapshot.booted;
273
276
  }
274
277
  try {
@@ -995,6 +998,7 @@ export function previewConfigForState(
995
998
  ): ServeSimState & {
996
999
  basePath: string;
997
1000
  logsEndpoint: string;
1001
+ crashesEndpoint: string;
998
1002
  appStateEndpoint: string;
999
1003
  eventLogEndpoint: string;
1000
1004
  eventLogEventsEndpoint: string;
@@ -1033,6 +1037,7 @@ export function previewConfigForState(
1033
1037
  ...publicState,
1034
1038
  basePath: base,
1035
1039
  logsEndpoint: endpoint(base, "/logs", state.device),
1040
+ crashesEndpoint: endpoint(base, "/crashes", state.device),
1036
1041
  appStateEndpoint: endpoint(base, "/appstate", state.device),
1037
1042
  eventLogEndpoint: endpoint(base, "/api/event-log", state.device),
1038
1043
  eventLogEventsEndpoint: endpoint(base, "/api/event-log/events", state.device),
@@ -1549,13 +1554,103 @@ function httpStreamSettingsFromLegacyCodec(codec: string | undefined): StreamSet
1549
1554
  return undefined;
1550
1555
  }
1551
1556
 
1557
+
1558
+ export function handleLogsRequest(
1559
+ req: SimReq,
1560
+ res: SimRes,
1561
+ state: ServeSimState | null,
1562
+ rawUrl: string,
1563
+ cache: LogBufferCache = logBufferCache
1564
+ ): void {
1565
+ if (!state) {
1566
+ res.writeHead(404, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1567
+ res.end(JSON.stringify({ error: "No serve-sim device" }));
1568
+ return;
1569
+ }
1570
+
1571
+ const params = new URL(rawUrl, "http://127.0.0.1").searchParams;
1572
+ const intQuery = (name: string): number | undefined => {
1573
+ const raw = params.get(name)?.trim();
1574
+ const n = Number(raw);
1575
+ return raw && Number.isSafeInteger(n) && n >= 0 ? n : undefined;
1576
+ };
1577
+ const since = intQuery("since");
1578
+ const limit = intQuery("limit");
1579
+ const snapshot = params.get("snapshot");
1580
+ const wantsJson =
1581
+ snapshot === null
1582
+ ? (req.headers.accept ?? "").includes("application/json")
1583
+ : booleanParam(params, "snapshot");
1584
+ const wantsEnvelope = booleanParam(params, "envelope");
1585
+ const wantsFollow = booleanParam(params, "follow");
1586
+
1587
+ if (wantsJson) {
1588
+ const buffer = wantsFollow ? cache.ensure(state.device) : cache.peek(state.device);
1589
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1590
+ res.end(
1591
+ JSON.stringify({
1592
+ device: state.device,
1593
+ latestSeq: buffer?.latestSeq ?? 0,
1594
+ oldestSeq: buffer?.oldestSeq ?? 0,
1595
+ bufferedBytes: buffer?.byteLength ?? 0,
1596
+ status: buffer?.status ?? "stopped",
1597
+ streamError: buffer?.error ?? null,
1598
+ lines: buffer?.read({ since, limit }) ?? [],
1599
+ })
1600
+ );
1601
+ return;
1602
+ }
1603
+
1604
+ const buffer = cache.ensure(state.device);
1605
+ const stream = openSseStream(req, res);
1606
+
1607
+ const frame = (line: LogLine): string =>
1608
+ "data: " +
1609
+ (wantsEnvelope ? JSON.stringify({ seq: line.seq, at: line.at, raw: line.raw }) : line.raw) +
1610
+ "\n\n";
1611
+
1612
+ let lastSent = since ?? 0;
1613
+ for (const line of buffer.read({ since, limit })) {
1614
+ if (!stream.isOpen()) break;
1615
+ stream.write(frame(line));
1616
+ lastSent = line.seq;
1617
+ }
1618
+
1619
+ stream.onClose(
1620
+ buffer.subscribeBatch(
1621
+ (lines) => {
1622
+ let chunk = "";
1623
+ for (const line of lines) {
1624
+ if (line.seq <= lastSent) continue;
1625
+ lastSent = line.seq;
1626
+ chunk += frame(line);
1627
+ }
1628
+ if (chunk) stream.write(chunk);
1629
+ },
1630
+ () => {
1631
+ if (stream.isOpen()) res.end();
1632
+ }
1633
+ )
1634
+ );
1635
+ }
1636
+
1637
+ async function selectDeviceAndReap(selectedDevice: string | null): Promise<ServeSimState | null> {
1638
+ const states = await readServeSimStates();
1639
+ const state = selectServeSimState(states, selectedDevice);
1640
+ const live = states.map((s) => s.device);
1641
+ crashRuntime.prune(live);
1642
+ logBufferCache.prune(live);
1643
+ return state;
1644
+ }
1645
+
1552
1646
  /**
1553
1647
  * Connect-style middleware that serves the simulator preview UI.
1554
1648
  *
1555
1649
  * Routes handled under `basePath` (default `/.sim`):
1556
1650
  * GET {basePath} — the preview HTML page
1557
1651
  * GET {basePath}/api — serve-sim state JSON
1558
- * GET {basePath}/logs — SSE stream of simctl logs
1652
+ * GET {basePath}/logs — simctl logs, JSON snapshot or SSE (bearer token)
1653
+ * GET {basePath}/crashes — crash reports (bearer token)
1559
1654
  * GET {basePath}/ax — SSE stream of normalized accessibility snapshots
1560
1655
  */
1561
1656
  export function handleMetricsRequest(
@@ -1615,6 +1710,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1615
1710
  ? { "Content-Security-Policy": frameAncestorsPolicy(frameAncestors) }
1616
1711
  : {};
1617
1712
 
1713
+ crashRuntime.arm();
1714
+
1618
1715
  // Simulator-settings requests run in-process (just the underlying simctl /
1619
1716
  // ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
1620
1717
  // sidebar interaction.
@@ -2434,50 +2531,51 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2434
2531
  return;
2435
2532
  }
2436
2533
 
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");
2534
+ if (url === base + "/crashes" || url === base + "/crashes/") {
2535
+ const state = await selectDeviceAndReap(selectedDevice);
2536
+ await handleCrashesRequestAfter(
2537
+ () => crashRuntime.start({ deferToRetry: true }).catch(() => {}),
2538
+ req,
2539
+ res,
2540
+ state,
2541
+ rawUrl
2542
+ );
2543
+ return;
2544
+ }
2545
+
2546
+ if (url.startsWith(base + "/crashes/")) {
2547
+ const rawId = url.slice((base + "/crashes/").length);
2548
+ let id: string;
2549
+ try {
2550
+ id = decodeURIComponent(rawId);
2551
+ } catch {
2552
+ res.writeHead(400, { "Content-Type": "application/json" });
2553
+ res.end(
2554
+ JSON.stringify({
2555
+ error:
2556
+ `Malformed percent-escape in the crash id (${rawId}). Copy the id verbatim from ` +
2557
+ "GET {base}/crashes.",
2558
+ })
2559
+ );
2444
2560
  return;
2445
2561
  }
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
- });
2562
+ const state = await selectDeviceAndReap(selectedDevice);
2563
+ const params = new URL(rawUrl, "http://127.0.0.1").searchParams;
2564
+ await handleCrashReportRequest(
2565
+ req,
2566
+ res,
2567
+ state,
2568
+ id,
2569
+ params.get("occurrence"),
2570
+ undefined,
2571
+ undefined,
2572
+ params.get("key")
2573
+ );
2574
+ return;
2575
+ }
2474
2576
 
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
- });
2577
+ if (url === base + "/logs") {
2578
+ handleLogsRequest(req, res, await selectDeviceAndReap(selectedDevice), rawUrl);
2481
2579
  return;
2482
2580
  }
2483
2581
 
@@ -2625,6 +2723,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2625
2723
  `${base}/grid/api/status/events`,
2626
2724
  `${base}/appstate`,
2627
2725
  `${base}/logs`,
2726
+ `${base}/crashes`,
2628
2727
  `${base}/metrics`,
2629
2728
  `${base}/ax`,
2630
2729
  ],