From ec130efee2ff7f654c56b04c6c6578071ffc6038 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 16 Jul 2026 11:44:30 -0700 Subject: [PATCH 1/4] feat(serve-sim): add per-app CPU/memory sampler for booted simulators --- .../src/__tests__/cpu-mem-sampler.test.ts | 324 ++++++++++++++++++ packages/serve-sim/src/cpu-mem-sampler.ts | 299 ++++++++++++++++ 2 files changed, 623 insertions(+) create mode 100644 packages/serve-sim/src/__tests__/cpu-mem-sampler.test.ts create mode 100644 packages/serve-sim/src/cpu-mem-sampler.ts diff --git a/packages/serve-sim/src/__tests__/cpu-mem-sampler.test.ts b/packages/serve-sim/src/__tests__/cpu-mem-sampler.test.ts new file mode 100644 index 00000000..7f1cc9d5 --- /dev/null +++ b/packages/serve-sim/src/__tests__/cpu-mem-sampler.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from "bun:test"; + +import { + createMetricsSamplerCache, + findUserAppProcesses, + MetricsSampler, + sampleUserApp, + sumPhysFootprintBytes, + type MetricSample, +} from "../cpu-mem-sampler"; + +const UDID = "ABCD1234-0000-0000-0000-0000000000EF"; + +// `ps -axo pid= cputime= rss= args=` — the user app and its extension run from the sim's +// Containers/Bundle/Application path; launchd_sim and a RuntimeRoot daemon do not, +// and an unrelated host process is off-device. Only the first two count. cputime is cumulative. +function psFixture(): string { + return [ + ` 101 0:00.10 15000 launchd_sim /x/Devices/${UDID}/data/var/run/launchd_bootstrap.plist`, + ` 102 0:40.00 32000 /Runtime/RuntimeRoot/System/Library/PrivateFrameworks/ApplePushService.framework/apsd`, + ` 103 0:12.00 80000 /x/Devices/${UDID}/data/Containers/Bundle/Application/AAA/MyApp.app/MyApp`, + ` 104 0:03.00 20000 /x/Devices/${UDID}/data/Containers/Bundle/Application/AAA/MyApp.app/PlugIns/Share.appex/Share`, + ` 105 0:09.90 999999 /some/host/process --unrelated`, + ].join("\n"); +} + +// A second user app ("Two Words.app", a space in its path like real "Expo Go.app") on the same sim. +function psFixtureTwoApps(): string { + return [ + psFixture(), + ` 106 0:02.00 50000 /x/Devices/${UDID}/data/Containers/Bundle/Application/BBB/Two Words.app/Two Words`, + ].join("\n"); +} + +describe("findUserAppProcesses", () => { + it("scopes to the frontmost app's bundle (host + extensions), ignoring other user/system apps", () => { + // frontmost pid 103 -> MyApp.app; sums MyApp (12s) + its Share extension (3s), not the other app + expect(findUserAppProcesses(psFixtureTwoApps(), UDID, 103)).toEqual({ + pids: [103, 104], + cpuSeconds: 15, + rssKb: 80000 + 20000, + }); + // frontmost is the space-named app -> just that bundle + expect(findUserAppProcesses(psFixtureTwoApps(), UDID, 106)).toEqual({ + pids: [106], + cpuSeconds: 2, + rssKb: 50000, + }); + }); + + it("matches the device path case-insensitively", () => { + const ps = `7 0:02.00 1000 /x/Devices/${UDID.toLowerCase()}/data/Containers/Bundle/Application/AAA/App.app/App`; + expect(findUserAppProcesses(ps, UDID, 7)).toEqual({ pids: [7], cpuSeconds: 2, rssKb: 1000 }); + }); + + it("sums every user app when the frontmost pid is unknown or not a user app", () => { + // no frontmost pid, or one that isn't a user process -> aggregate all user apps + for (const pid of [undefined, 999999]) { + expect(findUserAppProcesses(psFixtureTwoApps(), UDID, pid)).toEqual({ + pids: [103, 104, 106], + cpuSeconds: 15 + 2, + rssKb: 80000 + 20000 + 50000, + }); + } + }); + + it("returns null when no user app is running on this sim", () => { + // right sim, but only a system process (no Containers/Bundle path) + expect( + findUserAppProcesses(`1 0.1 15000 launchd_sim /x/Devices/${UDID}/data/var/run/x.plist\n`, UDID), + ).toBeNull(); + expect(findUserAppProcesses("", UDID)).toBeNull(); + }); +}); + +describe("sumPhysFootprintBytes", () => { + // `footprint --noCategories --format bytes ` output shape + const footprintFixture = [ + "======================================================================", + "MyApp [103]: 64-bit Footprint: 253904480 B (16384 bytes per page)", + "======================================================================", + "", + "Auxiliary data:", + " phys_footprint: 253937248 B", + " phys_footprint_peak: 333989448 B", + "", + "======================================================================", + "Share [104]: 64-bit Footprint: 2244968 B (16384 bytes per page)", + "======================================================================", + "", + "Auxiliary data:", + " phys_footprint: 2261352 B", + " phys_footprint_peak: 2310504 B", + "", + "======================================================================", + "Summary Footprint: 256100296 B", + "======================================================================", + ].join("\n"); + + it("sums per-process phys_footprint, ignoring peaks and the summary", () => { + expect(sumPhysFootprintBytes(footprintFixture)).toBe(253937248 + 2261352); + }); + + it("returns null when no process was reported", () => { + expect(sumPhysFootprintBytes("")).toBeNull(); + expect(sumPhysFootprintBytes("footprint: Unable to find pid for process matching '9'")).toBeNull(); + }); +}); + +describe("sampleUserApp", () => { + const footprintFor = (pids: string[]): string => + pids.map((pid) => `App [${pid}]:\nAuxiliary data:\n phys_footprint: 1000000 B\n`).join("\n"); + + const frontmost = (pid: number, bundleId = "dev.expo.MyApp") => async () => ({ pid, bundleId }); + + it("tags the sample with the frontmost bundleId and combines ps (cpu) with footprint (mem)", async () => { + const seen: string[] = []; + const exec = async (file: string, args: string[]): Promise => { + seen.push(file); + if (file === "ps") return psFixtureTwoApps(); + // footprint receives only the frontmost app's pids (MyApp 103 + its extension 104) + const pids = args.filter((a) => /^\d+$/.test(a)); + expect(pids).toEqual(["103", "104"]); + return footprintFor(pids); + }; + const usage = await sampleUserApp(UDID, { exec, frontmostApp: frontmost(103, "dev.expo.MyApp") }); + // cumulative cpu seconds of MyApp (12) + its extension (3); the sampler turns this into a % + expect(usage).toEqual({ bundleId: "dev.expo.MyApp", cpuSeconds: 15, memBytes: 2_000_000 }); + expect(seen.sort()).toEqual(["footprint", "ps"]); + }); + + it("falls back to RSS bytes when footprint fails", async () => { + const exec = async (file: string): Promise => { + if (file === "ps") return psFixture(); + throw new Error("footprint exited non-zero"); + }; + const usage = await sampleUserApp(UDID, { exec, frontmostApp: frontmost(103) }); + expect(usage).toEqual({ bundleId: "dev.expo.MyApp", cpuSeconds: 15, memBytes: (80000 + 20000) * 1024 }); + }); + + it("tags bundleId null and covers all user apps when nothing user-facing is foreground", async () => { + const exec = async (file: string, args: string[]): Promise => + file === "ps" ? psFixtureTwoApps() : footprintFor(args.filter((a) => /^\d+$/.test(a))); + // AX unavailable -> no frontmost app: sum all user apps (103 + 104 + 106), bundleId null + expect(await sampleUserApp(UDID, { exec, frontmostApp: async () => null })).toEqual({ + bundleId: null, + cpuSeconds: 17, + memBytes: 3_000_000, + }); + // a system app is frontmost (pid not among the user-app processes) -> same + expect(await sampleUserApp(UDID, { exec, frontmostApp: frontmost(999999) })).toEqual({ + bundleId: null, + cpuSeconds: 17, + memBytes: 3_000_000, + }); + }); + + it("returns null when no user app is running or ps fails", async () => { + const psFails = async (): Promise => { + throw new Error("ps exited non-zero"); + }; + expect(await sampleUserApp(UDID, { exec: psFails, frontmostApp: frontmost(103) })).toBeNull(); + // ps succeeds but only system processes are present + const systemOnly = async (file: string): Promise => + file === "ps" ? `1 0.1 15000 launchd_sim /x/Devices/${UDID}/data/var/run/x.plist` : ""; + expect(await sampleUserApp(UDID, { exec: systemOnly, frontmostApp: async () => null })).toBeNull(); + }); +}); + +describe("MetricsSampler", () => { + function fakeClock(step = 1000): () => number { + let t = 0; + return () => { + const v = t; + t += step; + return v; + }; + } + + it("exposes meta (schema, udid, hostCores, interval)", () => { + const sampler = new MetricsSampler({ udid: UDID, intervalMs: 500, hostCores: 8 }); + expect(sampler.meta).toEqual({ + schemaVersion: 1, + udid: UDID, + hostCores: 8, + sampleIntervalMs: 500, + }); + }); + + it("derives cpuPct from the cpu-time delta over each 1s interval", async () => { + // Cumulative cpu seconds per tick, at 1s spacing (fakeClock). Expected cpuPct: + // t1 no baseline -> 0; t2 +0.5s/1s -> 50; t3 drop (churn) -> clamped 0; + // t4 app switch (B) -> 0; t5 +0.6s/1s -> 60. + const readings = [ + { bundleId: "dev.expo.A", cpuSeconds: 10.0, memBytes: 100 }, + { bundleId: "dev.expo.A", cpuSeconds: 10.5, memBytes: 400 }, + { bundleId: "dev.expo.A", cpuSeconds: 10.4, memBytes: 250 }, + { bundleId: "dev.expo.B", cpuSeconds: 99.0, memBytes: 260 }, + { bundleId: "dev.expo.B", cpuSeconds: 99.6, memBytes: 270 }, + ]; + let i = 0; + const sampler = new MetricsSampler({ + udid: UDID, + sample: async () => readings[i++]!, + now: fakeClock(), + hostCores: 8, + }); + const got: MetricSample[] = []; + sampler.onSample((s) => got.push(s)); + + for (let n = 0; n < readings.length; n++) await sampler.tickOnce(); + + expect(got).toEqual([ + { t: 1000, bundleId: "dev.expo.A", cpuPct: 0, memBytes: 100 }, + { t: 2000, bundleId: "dev.expo.A", cpuPct: 50, memBytes: 400 }, + { t: 3000, bundleId: "dev.expo.A", cpuPct: 0, memBytes: 250 }, + { t: 4000, bundleId: "dev.expo.B", cpuPct: 0, memBytes: 260 }, + { t: 5000, bundleId: "dev.expo.B", cpuPct: 60, memBytes: 270 }, + ]); + sampler.stop(); + }); + + it("anchors the CPU delta to the observation time, not after the slow memory probe", async () => { + let clockMs = 0; + const now = () => clockMs; + const readings = [ + { bundleId: "dev.expo.A", cpuSeconds: 10, memBytes: 100 }, + { bundleId: "dev.expo.A", cpuSeconds: 10.5, memBytes: 100 }, + ]; + let i = 0; + let footprintMs = 0; + // sample() reads cpu up front, then the footprint probe takes `footprintMs` before returning. + const sample = async (): Promise<(typeof readings)[number]> => { + const reading = readings[i++]!; + clockMs += footprintMs; + return reading; + }; + const sampler = new MetricsSampler({ udid: UDID, sample, now, hostCores: 8 }); + const got: MetricSample[] = []; + sampler.onSample((s) => got.push(s)); + + footprintMs = 0; + await sampler.tickOnce(); // baseline + clockMs += 1000; // 1s until the next observation + footprintMs = 1000; // this tick's footprint probe is slow + await sampler.tickOnce(); + + // 0.5 cpu-seconds over the 1s observation interval = 50%, unaffected by the 1s footprint latency + // (which used to inflate the denominator and would report 25% here). + expect(got.at(-1)!.cpuPct).toBe(50); + sampler.stop(); + }); + + it("skips a tick when the sim isn't up (null reading)", async () => { + const sampler = new MetricsSampler({ + udid: UDID, + sample: async () => null, + now: fakeClock(), + hostCores: 8, + }); + const got: MetricSample[] = []; + sampler.onSample((s) => got.push(s)); + expect(await sampler.tickOnce()).toBeNull(); + expect(got).toHaveLength(0); + }); +}); + +describe("createMetricsSamplerCache", () => { + it("shares one sampler across subscribers for the same udid and stops it on last unsubscribe", () => { + const built: MetricsSampler[] = []; + const cache = createMetricsSamplerCache((udid) => { + const s = new MetricsSampler({ udid, sample: async () => null, hostCores: 8 }); + built.push(s); + return s; + }); + + const a = cache.subscribe(UDID, () => {}); + const b = cache.subscribe(UDID, () => {}); + expect(built).toHaveLength(1); // one shared sampler + expect(a.meta.udid).toBe(UDID); + + a.unsubscribe(); + expect(built[0]!.listenerCount).toBe(1); // still alive for b + b.unsubscribe(); + + // A fresh subscribe after the last leaves builds a new sampler. + cache.subscribe(UDID, () => {}); + expect(built).toHaveLength(2); + }); + + it("fans one sample out to every subscriber", async () => { + let sampler!: MetricsSampler; + const cache = createMetricsSamplerCache((udid) => { + sampler = new MetricsSampler({ udid, sample: async () => ({ bundleId: "dev.expo.A", cpuSeconds: 5, memBytes: 9 }), now: (() => { let t = 0; return () => (t += 1000); })(), hostCores: 8 }); + return sampler; + }); + const seen: number[] = []; + cache.subscribe(UDID, () => seen.push(1)); + cache.subscribe(UDID, () => seen.push(2)); + await sampler.tickOnce(); + expect(seen.sort()).toEqual([1, 2]); + }); + + it("a stale double-unsubscribe does not evict a replacement sampler", () => { + const built: MetricsSampler[] = []; + const cache = createMetricsSamplerCache((udid) => { + const s = new MetricsSampler({ udid, sample: async () => null, hostCores: 8 }); + built.push(s); + return s; + }); + + const first = cache.subscribe(UDID, () => {}); // builds sampler #1 + first.unsubscribe(); // last listener -> stops + evicts #1 + const second = cache.subscribe(UDID, () => {}); // builds sampler #2 for the same udid + expect(built).toHaveLength(2); + + first.unsubscribe(); // stale, replayed: must NOT evict #2 + + // #2 is still the active sampler, so a new subscriber reuses it (no #3 built). + cache.subscribe(UDID, () => {}); + expect(built).toHaveLength(2); + expect(second.meta.udid).toBe(UDID); + }); +}); diff --git a/packages/serve-sim/src/cpu-mem-sampler.ts b/packages/serve-sim/src/cpu-mem-sampler.ts new file mode 100644 index 00000000..52ac789e --- /dev/null +++ b/packages/serve-sim/src/cpu-mem-sampler.ts @@ -0,0 +1,299 @@ +// CPU/mem of the user's app on a booted sim, measured host-side. %CPU is per-core (can exceed 100) +// and computed from the delta in the app's cumulative CPU time between ticks, so it reflects usage +// during the interval rather than ps's decaying ~1-minute average. Scopes to the foreground app via +// axFrontmost, tagging each sample with its bundleId (null when nothing user-facing is foreground, +// in which case the numbers cover every user app). Memory is phys_footprint, with an RSS fallback. + +import { execFile } from "node:child_process"; +import { cpus } from "node:os"; +import { promisify } from "node:util"; + +import { axFrontmostAsync } from "./native"; + +const execFileAsync = promisify(execFile); + +export const METRICS_SCHEMA_VERSION = 1; + +// One poll's raw reading: cumulative CPU time (the sampler diffs it into a %) and current memory. +export interface AppUsage { + bundleId: string | null; // the foreground app these numbers belong to, or null for all user apps + cpuSeconds: number; + memBytes: number; +} + +export interface MetricSample { + t: number; // ms since the sampler started + bundleId: string | null; + cpuPct: number; // usage over the interval since the previous sample (per-core, can exceed 100) + memBytes: number; +} + +export interface MetricsMeta { + schemaVersion: number; + udid: string; + hostCores: number; + sampleIntervalMs: number; +} + +export interface AppProcesses { + pids: number[]; + cpuSeconds: number; + rssKb: number; +} + +interface PsRow { + pid: number; + cpuSeconds: number; + rssKb: number; + appPath: string; // the `.app` bundle this process runs from (host app + its extensions share it) +} + +// `ps` cputime is `[HH:]MM:SS.ss` cumulative CPU time; fold it down to seconds. +function cputimeToSeconds(cputime: string): number { + return cputime.split(":").reduce((acc, part) => acc * 60 + Number(part), 0); +} + +// Processes running from the sim's Containers/Bundle path (the user apps), not the ~190 system daemons. +function parseUserAppRows(output: string, udid: string): PsRow[] { + const device = `/Devices/${udid}/`.toUpperCase(); + const rows: PsRow[] = []; + for (const line of output.split("\n")) { + const m = /^\s*(\d+)\s+([\d:.]+)\s+(\d+)\s+(.*)$/.exec(line); + if (!m) continue; + const args = m[4]!; + const upper = args.toUpperCase(); + if (!upper.includes(device) || !upper.includes("/CONTAINERS/BUNDLE/APPLICATION/")) continue; + // First `.app` in the exec path; extensions live under it (…/MyApp.app/PlugIns/X.appex/X). + const app = /^(.*?\.app)\//.exec(args); + rows.push({ pid: +m[1]!, cpuSeconds: cputimeToSeconds(m[2]!), rssKb: +m[3]!, appPath: app ? app[1]! : args }); + } + return rows; +} + +// Aggregate the user app's processes. When the frontmost pid maps to a user app, narrow to just +// that app's `.app` bundle (its host process + extensions). Otherwise sum every user app on the +// sim (nothing user-facing is foreground). Null only when no user app is running at all. +export function findUserAppProcesses( + output: string, + udid: string, + frontmostPid?: number, +): AppProcesses | null { + const rows = parseUserAppRows(output, udid); + if (!rows.length) return null; + + const front = frontmostPid != null ? rows.find((r) => r.pid === frontmostPid) : undefined; + const scoped = front ? rows.filter((r) => r.appPath === front.appPath) : rows; + + return { + pids: scoped.map((r) => r.pid), + cpuSeconds: scoped.reduce((sum, r) => sum + r.cpuSeconds, 0), + rssKb: scoped.reduce((sum, r) => sum + r.rssKb, 0), + }; +} + +// Sum the per-process `phys_footprint: B` lines (skips _peak and the Summary line). +export function sumPhysFootprintBytes(output: string): number | null { + let bytes = 0; + let found = false; + for (const m of output.matchAll(/^\s*phys_footprint:\s+(\d+) B$/gm)) { + bytes += +m[1]!; + found = true; + } + return found ? bytes : null; +} + +export interface FrontmostApp { + pid: number; + bundleId: string; +} + +// Injected so tests can drive sampleUserApp without spawning real processes. +export interface SampleDeps { + exec?: (file: string, args: string[]) => Promise; + frontmostApp?: (udid: string) => Promise; +} + +const runCommand = (file: string, args: string[]): Promise => + execFileAsync(file, args, { timeout: 3000, maxBuffer: 8 * 1024 * 1024 }).then((r) => r.stdout); + +async function frontmostAppOf(udid: string): Promise { + try { + const { pid, bundleId } = JSON.parse(await axFrontmostAsync(udid)) as { + pid?: number; + bundleId?: string; + }; + return pid != null && bundleId ? { pid, bundleId } : null; + } catch { + // AX bridge warming up or unreachable: skip this tick (caller returns null). + return null; + } +} + +// CPU side: the app's processes + their %CPU, and which app they belong to. `ps` and the +// frontmost probe don't depend on each other, so they run together. bundleId is the frontmost +// app when it's a user app, else null (the numbers then cover every user app). Null only when +// no user app is running. +async function sampleForegroundApp( + udid: string, + deps: Required, +): Promise<{ procs: AppProcesses; bundleId: string | null } | null> { + const [psOutput, frontmost] = await Promise.all([ + deps.exec("ps", ["-axo", "pid=,cputime=,rss=,args="]).catch(() => null), + deps.frontmostApp(udid), + ]); + if (psOutput == null) return null; + const procs = findUserAppProcesses(psOutput, udid, frontmost?.pid); + if (!procs) return null; + const bundleId = frontmost && procs.pids.includes(frontmost.pid) ? frontmost.bundleId : null; + return { procs, bundleId }; +} + +// Memory side: phys_footprint of the app's processes. Depends on the pids the CPU +// side found, so it can't start until those are known; RSS is the fallback. +async function sampleMemoryBytes(procs: AppProcesses, deps: Required): Promise { + try { + const output = await deps.exec("footprint", ["--noCategories", "--format", "bytes", ...procs.pids.map(String)]); + return sumPhysFootprintBytes(output) ?? procs.rssKb * 1024; + } catch { + // footprint can exit non-zero (all pids gone mid-tick); keep the RSS fallback. + return procs.rssKb * 1024; + } +} + +export async function sampleUserApp(udid: string, deps: SampleDeps = {}): Promise { + const resolved: Required = { + exec: deps.exec ?? runCommand, + frontmostApp: deps.frontmostApp ?? frontmostAppOf, + }; + const foreground = await sampleForegroundApp(udid, resolved); + if (!foreground) return null; + return { + bundleId: foreground.bundleId, + cpuSeconds: foreground.procs.cpuSeconds, + memBytes: await sampleMemoryBytes(foreground.procs, resolved), + }; +} + +export interface MetricsSamplerOptions { + udid: string; + intervalMs?: number; + sample?: (udid: string) => Promise; + now?: () => number; + hostCores?: number; +} + +// Polls the sim and fans samples out; reschedules only after each tick settles, so ticks never overlap. +export class MetricsSampler { + readonly meta: MetricsMeta; + + private readonly intervalMs: number; + private readonly sample: (udid: string) => Promise; + private readonly now: () => number; + private readonly listeners = new Set<(sample: MetricSample) => void>(); + private timer: ReturnType | null = null; + private startedAt: number | null = null; + // Previous reading, to turn cumulative CPU time into a per-interval %. + private prev: { t: number; bundleId: string | null; cpuSeconds: number } | null = null; + + constructor(opts: MetricsSamplerOptions) { + this.intervalMs = opts.intervalMs ?? 1000; + this.sample = opts.sample ?? sampleUserApp; + this.now = opts.now ?? Date.now; + this.meta = { + schemaVersion: METRICS_SCHEMA_VERSION, + udid: opts.udid, + hostCores: opts.hostCores ?? cpus().length, + sampleIntervalMs: this.intervalMs, + }; + } + + get listenerCount(): number { + return this.listeners.size; + } + + onSample(listener: (sample: MetricSample) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async tickOnce(): Promise { + this.startedAt ??= this.now(); + // Timestamp the observation up front: sample() reads cumulative CPU via `ps` before the slower + // footprint probe, so anchoring the delta here (not after sample() returns) keeps variable + // footprint latency out of the elapsed-time denominator — otherwise it distorts CPU%. + const t = this.now() - this.startedAt; + const reading = await this.sample(this.meta.udid); + if (!reading) return null; + + const cpuPct = this.cpuPctSince(reading, t); + this.prev = { t, bundleId: reading.bundleId, cpuSeconds: reading.cpuSeconds }; + + const sample: MetricSample = { t, bundleId: reading.bundleId, cpuPct, memBytes: reading.memBytes }; + for (const listener of this.listeners) listener(sample); + return sample; + } + + // %CPU over the interval since the previous reading, from the delta in cumulative CPU time. + // Zero on the first tick or right after an app switch (no comparable baseline); a drop in + // cumulative time (a process exited) clamps to zero rather than going negative. + private cpuPctSince(reading: AppUsage, t: number): number { + const prev = this.prev; + if (!prev || prev.bundleId !== reading.bundleId || t <= prev.t) return 0; + const pct = ((reading.cpuSeconds - prev.cpuSeconds) / ((t - prev.t) / 1000)) * 100; + return pct > 0 ? +pct.toFixed(1) : 0; + } + + start(): void { + if (this.timer) return; + this.startedAt ??= this.now(); + const loop = async (): Promise => { + await this.tickOnce().catch(() => {}); + if (this.timer) this.timer = setTimeout(loop, this.intervalMs); + }; + this.timer = setTimeout(loop, this.intervalMs); + } + + stop(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } +} + +export interface MetricsSubscription { + meta: MetricsMeta; + unsubscribe: () => void; +} + +export type MetricsSamplerCache = ReturnType; + +// One shared sampler per udid (like the ax streamer cache); ref-counted by subscribers. +export function createMetricsSamplerCache( + makeSampler: (udid: string) => MetricsSampler = (udid) => new MetricsSampler({ udid }), +) { + const byUdid = new Map(); + return { + subscribe(udid: string, listener: (sample: MetricSample) => void): MetricsSubscription { + let sampler = byUdid.get(udid); + if (!sampler) { + sampler = makeSampler(udid); + byUdid.set(udid, sampler); + sampler.start(); + } + const off = sampler.onSample(listener); + return { + meta: sampler.meta, + unsubscribe: () => { + off(); + // Identity-guard the eviction: a double-called or stale unsubscribe must not + // delete a replacement sampler that a later subscriber created for this udid. + if (sampler.listenerCount === 0 && byUdid.get(udid) === sampler) { + sampler.stop(); + byUdid.delete(udid); + } + }, + }; + }, + }; +} From cc6b56df34c9cec530804b39d5303e8e33d27ffd Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 16 Jul 2026 11:44:30 -0700 Subject: [PATCH 2/4] feat(serve-sim): stream live CPU/memory over a /metrics SSE endpoint --- .../src/__tests__/metrics-route.test.ts | 151 ++++++++++++++++++ .../__tests__/middleware-selection.test.ts | 1 + .../src/client/utils/sim-endpoint.ts | 1 + packages/serve-sim/src/middleware.ts | 47 ++++++ 4 files changed, 200 insertions(+) create mode 100644 packages/serve-sim/src/__tests__/metrics-route.test.ts diff --git a/packages/serve-sim/src/__tests__/metrics-route.test.ts b/packages/serve-sim/src/__tests__/metrics-route.test.ts new file mode 100644 index 00000000..f81de0b8 --- /dev/null +++ b/packages/serve-sim/src/__tests__/metrics-route.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "events"; +import type { IncomingMessage, ServerResponse } from "http"; +import { MetricsSampler, createMetricsSamplerCache } from "../cpu-mem-sampler"; +import { handleMetricsRequest } from "../middleware"; +import { inProcessServeSimState } from "../state"; + +/** + * Unit tests for the `/metrics` route handler. These exercise the route's own + * contract with a fake req/res and a sampler cache backed by a controllable + * sampler, so they run without a booted simulator or shelling out to `ps`. + * The end-to-end wiring against a real device lives in metrics-endpoint.test.ts. + */ + +function createFakeReq(): { req: IncomingMessage; close: () => void } { + const req = Object.assign(new EventEmitter(), { headers: {} }); + return { req: req as unknown as IncomingMessage, close: () => req.emit("close") }; +} + +function createFakeRes(): { + res: ServerResponse; + writes: string[]; + status: () => number; + ended: () => boolean; +} { + const writes: string[] = []; + let statusCode = 0; + let ended = false; + const res = { + writeHead(status: number) { + statusCode = status; + return res; + }, + write(chunk: string) { + writes.push(chunk); + return true; + }, + end(chunk?: string) { + if (chunk !== undefined) writes.push(chunk); + ended = true; + return res; + }, + get writableEnded() { + return ended; + }, + }; + return { res: res as unknown as ServerResponse, writes, status: () => statusCode, ended: () => ended }; +} + +// A cache whose samplers never fire on their own timer (huge interval); the +// test drives emission explicitly via `created[i].tickOnce()`. +function createTrackingCache() { + const created: MetricsSampler[] = []; + const cache = createMetricsSamplerCache((udid) => { + const sampler = new MetricsSampler({ + udid, + intervalMs: 1_000_000, + now: () => 0, + hostCores: 8, + sample: async () => ({ bundleId: "com.example.app", cpuSeconds: 1, memBytes: 2048 }), + }); + created.push(sampler); + return sampler; + }); + return { cache, created }; +} + +function sampleFrames(writes: string[]): string[] { + return writes.filter((w) => w.startsWith("data:")); +} + +function firstSampler(created: MetricsSampler[]): MetricsSampler { + const sampler = created[0]; + if (!sampler) throw new Error("expected a sampler to have been created"); + return sampler; +} + +describe("handleMetricsRequest", () => { + test("responds 404 for an unknown device without opening a sampler", () => { + const { cache, created } = createTrackingCache(); + const { req } = createFakeReq(); + const { res, status } = createFakeRes(); + + handleMetricsRequest(req, res, null, cache); + + expect(status()).toBe(404); + expect(created).toHaveLength(0); + }); + + test("writes the meta frame before any sample", async () => { + const { cache, created } = createTrackingCache(); + const { req } = createFakeReq(); + const { res, writes } = createFakeRes(); + + handleMetricsRequest(req, res, inProcessServeSimState("UDID-1", 4000), cache); + + const metaFrame = writes[1] ?? ""; + expect(metaFrame).toStartWith("event: meta\ndata:"); + const meta = JSON.parse(metaFrame.slice("event: meta\ndata:".length).trim()); + expect("t" in meta).toBe(false); + expect(sampleFrames(writes)).toHaveLength(0); + + await firstSampler(created).tickOnce(); + expect(sampleFrames(writes)).toHaveLength(1); + + created.forEach((s) => s.stop()); + }); + + test("shares one sampler across concurrent subscribers to the same device", async () => { + const { cache, created } = createTrackingCache(); + const state = inProcessServeSimState("UDID-1", 4000); + const a = createFakeReq(); + const b = createFakeReq(); + const resA = createFakeRes(); + const resB = createFakeRes(); + + handleMetricsRequest(a.req, resA.res, state, cache); + handleMetricsRequest(b.req, resB.res, state, cache); + + expect(created).toHaveLength(1); + + await firstSampler(created).tickOnce(); + expect(sampleFrames(resA.writes)).toEqual(sampleFrames(resB.writes)); + expect(sampleFrames(resA.writes)).toHaveLength(1); + + created.forEach((s) => s.stop()); + }); + + test("stops the sampler only after the last client disconnects", async () => { + const { cache, created } = createTrackingCache(); + const state = inProcessServeSimState("UDID-1", 4000); + const a = createFakeReq(); + const b = createFakeReq(); + + handleMetricsRequest(a.req, createFakeRes().res, state, cache); + handleMetricsRequest(b.req, createFakeRes().res, state, cache); + expect(created).toHaveLength(1); + + a.close(); + expect(firstSampler(created).listenerCount).toBe(1); + + b.close(); + expect(firstSampler(created).listenerCount).toBe(0); + + // With the shared sampler evicted, a fresh subscriber builds a new one. + handleMetricsRequest(createFakeReq().req, createFakeRes().res, state, cache); + expect(created).toHaveLength(2); + + created.forEach((s) => s.stop()); + }); +}); diff --git a/packages/serve-sim/src/__tests__/middleware-selection.test.ts b/packages/serve-sim/src/__tests__/middleware-selection.test.ts index 8e33d35e..3c91bbfa 100644 --- a/packages/serve-sim/src/__tests__/middleware-selection.test.ts +++ b/packages/serve-sim/src/__tests__/middleware-selection.test.ts @@ -51,6 +51,7 @@ describe("previewConfigForState", () => { eventLogEndpoint: "/preview/api/event-log?device=DEVICE-B", eventLogEventsEndpoint: "/preview/api/event-log/events?device=DEVICE-B", axEndpoint: "/preview/ax?device=DEVICE-B", + metricsEndpoint: "/preview/metrics?device=DEVICE-B", cameraStatusEndpoint: "/preview/helper/DEVICE-B/camera/status", devtoolsEndpoint: "/preview/devtools?device=DEVICE-B", serveSimBin: "/bin/serve-sim", diff --git a/packages/serve-sim/src/client/utils/sim-endpoint.ts b/packages/serve-sim/src/client/utils/sim-endpoint.ts index 5ec0dfd4..4625115a 100644 --- a/packages/serve-sim/src/client/utils/sim-endpoint.ts +++ b/packages/serve-sim/src/client/utils/sim-endpoint.ts @@ -9,6 +9,7 @@ declare global { device: string; basePath: string; axEndpoint?: string; + metricsEndpoint?: string; cameraStatusEndpoint?: string; appStateEndpoint?: string; eventLogEndpoint?: string; diff --git a/packages/serve-sim/src/middleware.ts b/packages/serve-sim/src/middleware.ts index 0ef6fbdc..05df0c4b 100644 --- a/packages/serve-sim/src/middleware.ts +++ b/packages/serve-sim/src/middleware.ts @@ -12,6 +12,7 @@ import type { Socket } from "net"; // importing the dependency keeps the proxy working regardless of runtime. import { WebSocket } from "ws"; import { createAxStreamerCache } from "./ax"; +import { createMetricsSamplerCache, type MetricsSamplerCache } from "./cpu-mem-sampler"; import { readCameraStatus } from "./camera-helper"; import { getDeviceSession, closeDeviceSession, type HidSocket } from "./device-session"; import { @@ -109,6 +110,7 @@ type ExecRequestBody = { command?: string }; export type ServeSimState = ServeSimDeviceState; const axStreamerCache = createAxStreamerCache(); +const metricsSamplerCache = createMetricsSamplerCache(); // Hard cap on the SSE line-assembly buffer for child-process stdout. // A malformed log entry without a newline can't grow this beyond 1 MB; @@ -876,6 +878,7 @@ export function previewConfigForState( eventLogEndpoint: string; eventLogEventsEndpoint: string; axEndpoint: string; + metricsEndpoint: string; cameraStatusEndpoint: string; devtoolsEndpoint: string; serveSimBin: string; @@ -897,6 +900,7 @@ export function previewConfigForState( eventLogEndpoint: endpoint(base, "/api/event-log", state.device), eventLogEventsEndpoint: endpoint(base, "/api/event-log/events", state.device), axEndpoint: endpoint(base, "/ax", state.device), + metricsEndpoint: endpoint(base, "/metrics", state.device), cameraStatusEndpoint: `${base === "/" ? "" : base}/helper/${encodeURIComponent(state.device)}/camera/status`, devtoolsEndpoint: endpoint(base, "/devtools", state.device), serveSimBin, @@ -1286,6 +1290,38 @@ function isJsonContentType(value: string | undefined): boolean { * GET {basePath}/api — serve-sim state JSON * GET {basePath}/ax — SSE stream of normalized accessibility snapshots */ +export function handleMetricsRequest( + req: SimReq, + res: SimRes, + state: ServeSimState | null, + samplerCache: MetricsSamplerCache = metricsSamplerCache, +): void { + if (!state) { + res.writeHead(404); + res.end("No serve-sim device"); + return; + } + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.write(":\n\n"); + const { meta, unsubscribe } = samplerCache.subscribe(state.device, (sample) => { + if (!res.writableEnded) res.write("data: " + JSON.stringify(sample) + "\n\n"); + }); + res.write("event: meta\ndata: " + JSON.stringify(meta) + "\n\n"); + // Heartbeat keeps an idle stream alive through buffering proxies. + const heartbeat = setInterval(() => { + if (!res.writableEnded) res.write(":\n\n"); + }, 15000); + req.on("close", () => { + clearInterval(heartbeat); + unsubscribe(); + }); +} + export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { const base = (options?.basePath ?? "/.sim").replace(/\/+$/, ""); const helperPrefix = helperProxyPrefix(base); @@ -1900,6 +1936,16 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { return; } + // GET /metrics — SSE stream of the foreground app's CPU/memory. Mirrors /ax: + // an `event: meta` frame first (schema, udid, host cores, cadence), then one + // `data:` line per sample. One sampler per udid fans out to every viewer. + if (url === base + "/metrics") { + const states = await readServeSimStates(); + const state = selectServeSimState(states, selectedDevice); + handleMetricsRequest(req, res, state, metricsSamplerCache); + return; + } + // POST /exec — run a shell command on the host. Gated by a per-process // bearer token injected only into the same-origin preview HTML, with // Content-Type + Origin checks to block CORS-simple CSRF (a malicious @@ -2114,6 +2160,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { `${base}/api/event-log/events`, `${base}/appstate`, `${base}/ax`, + `${base}/metrics`, ], onUiRequest: handleUiRequest, onCommandResult: (command, result) => recordCommandEvent(command, result), From 91f819e25ce0675393236bd6faab767ce7157007 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 16 Jul 2026 11:44:42 -0700 Subject: [PATCH 3/4] feat(serve-sim): add live CPU/memory readout to the tools panel --- .../src/client/components/metrics-tool.tsx | 140 ++++++++++++++++++ .../src/client/components/tools-panel.tsx | 2 + .../src/client/hooks/use-metrics-stream.ts | 78 ++++++++++ .../src/client/utils/format-metrics.ts | 34 +++++ 4 files changed, 254 insertions(+) create mode 100644 packages/serve-sim/src/client/components/metrics-tool.tsx create mode 100644 packages/serve-sim/src/client/hooks/use-metrics-stream.ts create mode 100644 packages/serve-sim/src/client/utils/format-metrics.ts diff --git a/packages/serve-sim/src/client/components/metrics-tool.tsx b/packages/serve-sim/src/client/components/metrics-tool.tsx new file mode 100644 index 00000000..172f885b --- /dev/null +++ b/packages/serve-sim/src/client/components/metrics-tool.tsx @@ -0,0 +1,140 @@ +import { TriangleAlert } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useMetricsStream } from "../hooks/use-metrics-stream"; +import { formatCpu, formatMem, sparklinePath } from "../utils/format-metrics"; +import { simEndpoint } from "../utils/sim-endpoint"; +import { CollapsibleSection } from "./collapsible-section"; + +const SPARK_W = 96; +const SPARK_H = 24; + +// Live CPU/memory readout for the sim's user app, with a sparkline for each. +export function MetricsTool({ + udid, + currentAppBundleId, + metricsEndpoint, +}: { + udid: string; + currentAppBundleId: string | null; + metricsEndpoint?: string; +}) { + const path = useMemo( + () => metricsEndpoint ?? `${simEndpoint("metrics")}?device=${encodeURIComponent(udid)}`, + [metricsEndpoint, udid], + ); + const { meta, latest, history, errored, stale } = useMetricsStream(path); + const [open, setOpen] = useState(true); + // We only measure user-installed apps. When a system app is in the foreground the readout would + // be a backgrounded user app's numbers, so surface that it isn't supported rather than showing a + // stale graph. Only idle when the foreground signal (currentApp, from /appstate) positively + // reports a system app, so a fresh load — signal not yet known — still shows the running app. + const foregroundIsSystemApp = + currentAppBundleId != null && currentAppBundleId.startsWith("com.apple."); + const live = latest !== null && !errored && !stale && !foregroundIsSystemApp; + // When there's nothing to show, a header glyph carries the reason on hover instead of a body line. + const idleReason = errored + ? "The metrics stream disconnected" + : foregroundIsSystemApp + ? "Only your app is measured; a system app is in the foreground" + : "Waiting for CPU / memory data"; + + return ( + + + Activity + + {live + ? !open && ( + + {formatCpu(latest.cpuPct)} · {formatMem(latest.memBytes)} + + ) + : ( + + + + {idleReason} + + + )} + + } + > + {live ? ( + <> + s.cpuPct)} + className="text-emerald-400" + /> + s.memBytes)} + className="text-sky-400" + /> + + ) : null} + + ); +} + +function MetricRow({ + label, + value, + hint, + values, + className, +}: { + label: string; + value: string; + hint?: string; + values: number[]; + className: string; +}) { + return ( +
+
+ {label} + + {value} + {hint && {hint}} + +
+ +
+ ); +} + +function Sparkline({ values, className }: { values: number[]; className: string }) { + const line = sparklinePath(values, SPARK_W, SPARK_H); + // Close the line down to the baseline and back to fill the area under it. + const area = line ? `${line} L${SPARK_W},${SPARK_H} L0,${SPARK_H} Z` : ""; + return ( + + + + + ); +} diff --git a/packages/serve-sim/src/client/components/tools-panel.tsx b/packages/serve-sim/src/client/components/tools-panel.tsx index e6183b48..d84f549b 100644 --- a/packages/serve-sim/src/client/components/tools-panel.tsx +++ b/packages/serve-sim/src/client/components/tools-panel.tsx @@ -6,6 +6,7 @@ import { AppPermissionsTool } from "./app-permissions-tool"; import { AxTreeTool } from "./ax-tree-tool"; import { CameraTool } from "./camera-tool"; import { EventLogTool } from "./event-log-tool"; +import { MetricsTool } from "./metrics-tool"; import { PANEL_BACKGROUND } from "./panel-colors"; import { SimulatorSettingsTool } from "./simulator-settings-tool"; import { StreamSettingsTool, type CodecPreference } from "./stream-settings-tool"; @@ -49,6 +50,7 @@ export function ToolsPanel({ {open && (
+ (null); + const [history, setHistory] = useState([]); + const [errored, setErrored] = useState(false); + const [stale, setStale] = useState(false); + const lastSampleAt = useRef(0); + + useEffect(() => { + // Reset so a device switch drops the previous device's samples. + setMeta(null); + setHistory([]); + setErrored(false); + setStale(false); + lastSampleAt.current = 0; + const stream = openHostEventStream(path); + stream.onmessage = ({ data }) => { + try { + const parsed = JSON.parse(data) as Record; + setErrored(false); + if ("schemaVersion" in parsed) setMeta(parsed as unknown as MetricsMeta); + else if ("t" in parsed) { + const sample = parsed as unknown as MetricSample; + lastSampleAt.current = Date.now(); + setStale(false); + setHistory((prev) => { + const previous = prev.at(-1); + // Reset only on a real foreground app switch. Ignore flips to/from the + // sampler's null aggregate (a brief loss of the frontmost signal) so the + // series isn't wiped every time the app is opened or closed. + const appSwitched = + previous != null && + previous.bundleId !== null && + sample.bundleId !== null && + previous.bundleId !== sample.bundleId; + return [...(appSwitched ? [] : prev), sample].slice(-MAX_POINTS); + }); + } + } catch { + // ignore a malformed frame + } + }; + stream.onerror = () => setErrored(true); + // Flag the readout stale once samples stop arriving, so the last value isn't shown as live. + const watchdog = setInterval(() => { + if (lastSampleAt.current > 0 && Date.now() - lastSampleAt.current > STALE_AFTER_MS) { + setStale(true); + } + }, 1_000); + return () => { + stream.close(); + clearInterval(watchdog); + }; + }, [path]); + + return { meta, latest: history.at(-1) ?? null, history, errored, stale }; +} diff --git a/packages/serve-sim/src/client/utils/format-metrics.ts b/packages/serve-sim/src/client/utils/format-metrics.ts new file mode 100644 index 00000000..83bc0669 --- /dev/null +++ b/packages/serve-sim/src/client/utils/format-metrics.ts @@ -0,0 +1,34 @@ +export function formatCpu(cpuPct: number): string { + return `${Math.round(cpuPct)}%`; +} + +export function formatMem(memBytes: number): string { + const mb = memBytes / (1024 * 1024); + return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${Math.round(mb)} MB`; +} + +// Smooth sparkline path, auto-scaled to the window peak. Catmull-Rom spline with +// control points clamped per segment so the curve never overshoots the data. +export function sparklinePath(values: number[], width: number, height: number): string { + if (values.length < 2) return ""; + const max = Math.max(...values, 1); + const stepX = width / (values.length - 1); + const pts = values.map((v, i) => ({ x: i * stepX, y: height - (v / max) * height })); + + let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] ?? pts[i]!; + const p1 = pts[i]!; + const p2 = pts[i + 1]!; + const p3 = pts[i + 2] ?? p2; + const lo = Math.min(p1.y, p2.y); + const hi = Math.max(p1.y, p2.y); + const clampY = (y: number) => Math.min(hi, Math.max(lo, y)); + const c1x = p1.x + (p2.x - p0.x) / 6; + const c1y = clampY(p1.y + (p2.y - p0.y) / 6); + const c2x = p2.x - (p3.x - p1.x) / 6; + const c2y = clampY(p2.y - (p3.y - p1.y) / 6); + d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`; + } + return d; +} From 619abfe6f94544fd282b76243c9747f16ac5d49a Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 16 Jul 2026 11:44:42 -0700 Subject: [PATCH 4/4] feat(serve-sim): allow scoped cross-origin reads of /metrics --- .../src/__tests__/cors-allow-origin.test.ts | 47 +++++++++++++++++++ packages/serve-sim/src/index.ts | 16 +++++++ packages/serve-sim/src/middleware-utils.ts | 30 ++++++++++++ packages/serve-sim/src/middleware.ts | 12 ++++- 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 packages/serve-sim/src/__tests__/cors-allow-origin.test.ts create mode 100644 packages/serve-sim/src/middleware-utils.ts diff --git a/packages/serve-sim/src/__tests__/cors-allow-origin.test.ts b/packages/serve-sim/src/__tests__/cors-allow-origin.test.ts new file mode 100644 index 00000000..56716c57 --- /dev/null +++ b/packages/serve-sim/src/__tests__/cors-allow-origin.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; + +import { corsAllowOriginHeaders } from "../middleware-utils"; + +describe("corsAllowOriginHeaders", () => { + test("echoes an allowlisted origin, with Vary", () => { + expect(corsAllowOriginHeaders("https://expo.dev", ["https://expo.dev"])).toEqual({ + "Access-Control-Allow-Origin": "https://expo.dev", + Vary: "Origin", + }); + }); + + test("canonicalizes configured origins (case, default port, trailing slash) before matching", () => { + for (const configured of ["HTTPS://Expo.Dev", "https://expo.dev:443", "https://expo.dev/"]) { + expect(corsAllowOriginHeaders("https://expo.dev", [configured])).toEqual({ + "Access-Control-Allow-Origin": "https://expo.dev", + Vary: "Origin", + }); + } + }); + + test("skips a malformed configured origin instead of throwing", () => { + expect(corsAllowOriginHeaders("https://expo.dev", ["not a url", "https://expo.dev"])).toEqual({ + "Access-Control-Allow-Origin": "https://expo.dev", + Vary: "Origin", + }); + expect(corsAllowOriginHeaders("https://expo.dev", ["not a url"])).toEqual({}); + }); + + test("allows any loopback origin without config", () => { + for (const origin of ["http://localhost:3000", "http://127.0.0.1:8081", "http://[::1]:9000"]) { + expect(corsAllowOriginHeaders(origin, [])).toEqual({ + "Access-Control-Allow-Origin": origin, + Vary: "Origin", + }); + } + }); + + test("emits no header for an unlisted origin", () => { + expect(corsAllowOriginHeaders("https://evil.example", ["https://expo.dev"])).toEqual({}); + }); + + test("emits no header when the origin is absent or malformed", () => { + expect(corsAllowOriginHeaders(null, ["https://expo.dev"])).toEqual({}); + expect(corsAllowOriginHeaders("not a url", ["https://expo.dev"])).toEqual({}); + }); +}); diff --git a/packages/serve-sim/src/index.ts b/packages/serve-sim/src/index.ts index 24d72356..aa5f0ac3 100755 --- a/packages/serve-sim/src/index.ts +++ b/packages/serve-sim/src/index.ts @@ -1600,6 +1600,7 @@ async function serve( codec: string | undefined, initialState: PreviewInitialState | undefined, theme: SimulatorTheme | undefined, + metricsCorsOrigins: string[] = [], ) { // Boot the target simulators; the preview server streams them in-process // (no spawned helper). Sessions are created lazily on the first stream request. @@ -1621,6 +1622,7 @@ async function serve( device: targetDevice, codec, initialState, + metricsCorsOrigins, proxyHelpers: true, }); @@ -1747,6 +1749,19 @@ program return v; }, ) + .option( + "--metrics-cors-origin ", + "Allow a cross-origin dashboard to read the /metrics SSE stream (e.g. a hosted " + + "session page). Repeatable, or comma-separated. Loopback is always allowed.", + (value: string, previous: string[]) => + previous.concat( + value + .split(",") + .map((o) => o.trim()) + .filter(Boolean), + ), + [] as string[], + ) .option("-l, --list [device]", "List running streams") .option("-k, --kill [device]", "Kill running stream(s)") .addHelpText( @@ -1793,6 +1808,7 @@ Examples: opts.codec, initialState, opts.theme, + opts.metricsCorsOrigin, ); } }); diff --git a/packages/serve-sim/src/middleware-utils.ts b/packages/serve-sim/src/middleware-utils.ts new file mode 100644 index 00000000..9d647ed7 --- /dev/null +++ b/packages/serve-sim/src/middleware-utils.ts @@ -0,0 +1,30 @@ +// Echoes the request Origin (never a wildcard) when it's loopback or allowlisted. +export function corsAllowOriginHeaders( + origin: string | null | undefined, + allowedOrigins: readonly string[], +): Record { + if (!origin) return {}; + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return {}; + } + // URL() keeps IPv6 hosts bracketed ("[::1]"); strip them before comparing. + const host = parsed.hostname.replace(/^\[|\]$/g, ""); + const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1"; + // Compare on canonical origins (default port dropped, no trailing slash, host lowercased) so a + // configured `https://expo.dev:443` or `https://expo.dev/` still matches the browser's Origin. + // Malformed configured values throw in URL() and are skipped. + const allowed = allowedOrigins.some((o) => { + try { + return new URL(o).origin === parsed.origin; + } catch { + return false; + } + }); + if (isLoopback || allowed) { + return { "Access-Control-Allow-Origin": origin, Vary: "Origin" }; + } + return {}; +} diff --git a/packages/serve-sim/src/middleware.ts b/packages/serve-sim/src/middleware.ts index 05df0c4b..6eeb40d3 100644 --- a/packages/serve-sim/src/middleware.ts +++ b/packages/serve-sim/src/middleware.ts @@ -13,6 +13,7 @@ import type { Socket } from "net"; import { WebSocket } from "ws"; import { createAxStreamerCache } from "./ax"; import { createMetricsSamplerCache, type MetricsSamplerCache } from "./cpu-mem-sampler"; +import { corsAllowOriginHeaders } from "./middleware-utils"; import { readCameraStatus } from "./camera-helper"; import { getDeviceSession, closeDeviceSession, type HidSocket } from "./device-session"; import { @@ -1237,6 +1238,12 @@ export interface SimMiddlewareOptions { basePath?: string; /** Pin this preview server to a specific simulator UDID. */ device?: string; + /** + * Origins allowed to read the `/metrics` SSE stream cross-origin (e.g. a + * hosted dashboard). Loopback is always allowed; anything else must be + * listed here. Unset means same-origin only. + */ + metricsCorsOrigins?: string[]; /** * Per-session bearer token gating the `/exec` shell-exec route. * Auto-generated if omitted. The token is injected into the preview HTML @@ -1295,6 +1302,7 @@ export function handleMetricsRequest( res: SimRes, state: ServeSimState | null, samplerCache: MetricsSamplerCache = metricsSamplerCache, + corsOrigins: readonly string[] = [], ): void { if (!state) { res.writeHead(404); @@ -1306,6 +1314,7 @@ export function handleMetricsRequest( "Cache-Control": "no-cache", Connection: "keep-alive", "X-Accel-Buffering": "no", + ...corsAllowOriginHeaders(req.headers.origin, corsOrigins), }); res.write(":\n\n"); const { meta, unsubscribe } = samplerCache.subscribe(state.device, (sample) => { @@ -1332,6 +1341,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { // can call /exec; cross-origin pages and LAN clients cannot, because they // can't read this value (it's only injected into the preview page's config). const execToken = options?.execToken ?? randomBytes(32).toString("base64url"); + const metricsCorsOrigins = options?.metricsCorsOrigins ?? []; // Simulator-settings requests run in-process (just the underlying simctl / // ax-tool spawn) instead of round-tripping a full `node ` exec per @@ -1942,7 +1952,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { if (url === base + "/metrics") { const states = await readServeSimStates(); const state = selectServeSimState(states, selectedDevice); - handleMetricsRequest(req, res, state, metricsSamplerCache); + handleMetricsRequest(req, res, state, metricsSamplerCache, metricsCorsOrigins); return; }