From df8d8a5f794af91d7bacbb0416adfa648ec13c12 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Thu, 30 Apr 2026 20:00:06 +0100 Subject: [PATCH 1/3] feat: support tunnel using proxy --- packages/serve-sim/build.ts | 100 +++- packages/serve-sim/dev.ts | 536 +++++++++++++------ packages/serve-sim/package.json | 4 + packages/serve-sim/src/client/client.tsx | 144 +++--- packages/serve-sim/src/index.ts | 625 ++++++++++++++++++----- packages/serve-sim/src/middleware.ts | 267 ++++++++-- 6 files changed, 1259 insertions(+), 417 deletions(-) diff --git a/packages/serve-sim/build.ts b/packages/serve-sim/build.ts index c083ff28..cd4347a1 100644 --- a/packages/serve-sim/build.ts +++ b/packages/serve-sim/build.ts @@ -13,11 +13,27 @@ * via the __PREVIEW_HTML_B64__ build-time define. */ import { resolve, dirname } from "path"; -import { mkdirSync, writeFileSync, rmSync, readFileSync } from "fs"; +import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs"; import { spawnSync } from "child_process"; const root = import.meta.dir; const distDir = resolve(root, "dist"); + +/** Resolve a hoisted workspace dependency (preact may live in repo root `node_modules`). */ +function resolvePackageDir(packageName: string, startDir: string): string { + let dir = startDir; + for (;;) { + const candidate = resolve(dir, "node_modules", packageName); + if (existsSync(resolve(candidate, "package.json"))) return candidate; + const parent = dirname(dir); + if (parent === dir) { + throw new Error( + `Could not find "${packageName}" under node_modules (walked up from ${startDir}). Run install from the repo root.`, + ); + } + dir = parent; + } +} rmSync(distDir, { recursive: true, force: true }); mkdirSync(distDir, { recursive: true }); @@ -27,23 +43,36 @@ function kb(n: number): string { // ─── 1. Bundle the browser client (React aliased to Preact) ─────────────── +const preactRoot = resolvePackageDir("preact", root); +const preactCompat = resolve(preactRoot, "compat/dist/compat.module.js"); +const preactCompatClient = resolve(preactRoot, "compat/client.mjs"); +const preactJsxRuntime = resolve( + preactRoot, + "jsx-runtime/dist/jsxRuntime.module.js", +); + const clientResult = await Bun.build({ entrypoints: [resolve(root, "src/client/client.tsx")], minify: true, target: "browser", format: "esm", define: { "process.env.NODE_ENV": '"production"' }, - plugins: [{ - name: "preact-alias", - setup(build) { - const preactCompat = resolve(root, "node_modules/preact/compat/dist/compat.module.js"); - const preactCompatClient = resolve(root, "node_modules/preact/compat/client.mjs"); - const preactJsxRuntime = resolve(root, "node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js"); - build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ path: preactCompatClient })); - build.onResolve({ filter: /^react(-dom)?$/ }, () => ({ path: preactCompat })); - build.onResolve({ filter: /^react\/jsx(-dev)?-runtime$/ }, () => ({ path: preactJsxRuntime })); + plugins: [ + { + name: "preact-alias", + setup(build) { + build.onResolve({ filter: /^react-dom\/client$/ }, () => ({ + path: preactCompatClient, + })); + build.onResolve({ filter: /^react(-dom)?$/ }, () => ({ + path: preactCompat, + })); + build.onResolve({ filter: /^react\/jsx(-dev)?-runtime$/ }, () => ({ + path: preactJsxRuntime, + })); + }, }, - }], + ], }); if (!clientResult.success) { @@ -52,14 +81,19 @@ if (!clientResult.success) { process.exit(1); } -const clientJs = (await clientResult.outputs[0].text()).replace(/<\/script>/gi, "<\\/script>"); +const clientJs = (await clientResult.outputs[0].text()).replace( + /<\/script>/gi, + "<\\/script>", +); console.log(`client bundle ${kb(clientJs.length)}`); // ─── 2. Inline client into preview HTML, base64-encode for the define ──── // Committed ICO copy of Simulator.app's AppIcon, inlined as a data URI so the // preview tab shows the same icon as the native app. -const faviconBytes = readFileSync(resolve(root, "src/client/simulator-icon.ico")); +const faviconBytes = readFileSync( + resolve(root, "src/client/simulator-icon.ico"), +); const faviconTag = ``; console.log(`favicon ${kb(faviconBytes.length)}`); @@ -77,7 +111,9 @@ ${faviconTag} `; const htmlB64 = Buffer.from(html).toString("base64"); -console.log(`preview html ${kb(html.length)} (base64 ${kb(htmlB64.length)})`); +console.log( + `preview html ${kb(html.length)} (base64 ${kb(htmlB64.length)})`, +); const PREVIEW_DEFINE = { __PREVIEW_HTML_B64__: JSON.stringify(htmlB64) }; @@ -89,7 +125,17 @@ const mwResult = await Bun.build({ format: "esm", minify: true, outdir: distDir, - external: ["fs", "path", "os", "child_process", "url"], + external: [ + "fs", + "path", + "os", + "child_process", + "url", + "http", + "http-proxy", + "net", + "stream", + ], define: PREVIEW_DEFINE, }); if (!mwResult.success) { @@ -115,7 +161,23 @@ const binJsResult = await Bun.build({ minify: true, outdir: distDir, naming: "serve-sim.js", - external: ["fs", "path", "os", "child_process", "url", "net", "tls", "crypto", "stream", "events", "http", "https", "zlib", "buffer"], + external: [ + "fs", + "path", + "os", + "child_process", + "url", + "net", + "tls", + "crypto", + "stream", + "events", + "http", + "https", + "http-proxy", + "zlib", + "buffer", + ], define: PREVIEW_DEFINE, }); if (!binJsResult.success) { @@ -137,8 +199,10 @@ const compile = spawnSync( "--compile", "--minify", resolve(root, "src/index.ts"), - "--outfile", resolve(distDir, "serve-sim"), - "--define", `__PREVIEW_HTML_B64__=${JSON.stringify(htmlB64)}`, + "--outfile", + resolve(distDir, "serve-sim"), + "--define", + `__PREVIEW_HTML_B64__=${JSON.stringify(htmlB64)}`, ], { stdio: "inherit" }, ); diff --git a/packages/serve-sim/dev.ts b/packages/serve-sim/dev.ts index fd416b0c..4fa250a1 100644 --- a/packages/serve-sim/dev.ts +++ b/packages/serve-sim/dev.ts @@ -6,9 +6,16 @@ * Run: bun --watch dev.ts */ import { readdirSync, readFileSync, existsSync, unlinkSync, watch } from "fs"; -import { execSync, spawn, exec, execFile, type ChildProcess } from "child_process"; +import { + execSync, + spawn, + exec, + execFile, + type ChildProcess, +} from "child_process"; import { tmpdir } from "os"; import { join, resolve } from "path"; +import { previewPublicState } from "./src/middleware"; const RN_BUNDLE_IDS = new Set([ "host.exp.Exponent", @@ -24,23 +31,59 @@ const RN_MARKERS = [ function detectReactNative(udid: string, bundleId: string): Promise { if (RN_BUNDLE_IDS.has(bundleId)) return Promise.resolve(true); return new Promise((r) => { - execFile("xcrun", ["simctl", "get_app_container", udid, bundleId, "app"], + execFile( + "xcrun", + ["simctl", "get_app_container", udid, bundleId, "app"], { timeout: 2000 }, (err, stdout) => { if (err) return r(false); const appPath = stdout.trim(); if (!appPath) return r(false); - for (const m of RN_MARKERS) if (existsSync(join(appPath, m))) return r(true); + for (const m of RN_MARKERS) + if (existsSync(join(appPath, m))) return r(true); r(false); - }); + }, + ); }); } -const NON_UI_BUNDLE_RE = /(WidgetRenderer|ExtensionHost|\.extension(\.|$)|Service|PlaceholderApp|InCallService|CallUI|InCallUI|com\.apple\.Preferences\.Cellular|com\.apple\.purplebuddy|com\.apple\.chrono|com\.apple\.shuttle|com\.apple\.usernotificationsui)/i; +const NON_UI_BUNDLE_RE = + /(WidgetRenderer|ExtensionHost|\.extension(\.|$)|Service|PlaceholderApp|InCallService|CallUI|InCallUI|com\.apple\.Preferences\.Cellular|com\.apple\.purplebuddy|com\.apple\.chrono|com\.apple\.shuttle|com\.apple\.usernotificationsui)/i; function isUserFacingBundle(bundleId: string): boolean { return !NON_UI_BUNDLE_RE.test(bundleId); } -const PORT = Number(process.env.PORT) || 3200; +const envPortRaw = process.env.PORT; +const preferredPort = + envPortRaw !== undefined && envPortRaw !== "" + ? Number(envPortRaw) || 3200 + : 3200; +/** When set, bind only `preferredPort` and exit if it is in use. */ +const portPinned = envPortRaw !== undefined && envPortRaw !== ""; + +/** Same preview URL overrides as `serve-sim -H … --tls` (scan full argv for `bun run dev …`). */ +function parsePreviewCli(): { hostname?: string; tls: boolean } { + let hostname: string | undefined; + let tls = false; + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]!; + if (arg === "--hostname" || arg === "-H") { + hostname = process.argv[++i] ?? ""; + continue; + } + if (arg === "--tls") tls = true; + } + const h = hostname?.trim(); + return { hostname: h || undefined, tls }; +} + +const previewCli = parsePreviewCli(); +if (previewCli.tls && !previewCli.hostname) { + console.error( + "[serve-sim dev] --tls requires --hostname (public host for stream URLs).", + ); + process.exit(1); +} + const STATE_DIR = join(tmpdir(), "serve-sim"); const CLIENT_DIR = resolve(import.meta.dir, "src/client"); const CLIENT_ENTRY = resolve(CLIENT_DIR, "client.tsx"); @@ -50,7 +93,10 @@ const CLIENT_ENTRY = resolve(CLIENT_DIR, "client.tsx"); // Cache simctl's booted-device set briefly (1.5s). dev.ts calls // readServeSimStates() on every request, so uncached we'd invoke simctl // per page view / per /logs / per /appstate. -let bootedSnapshot: { at: number; booted: Set | null } = { at: 0, booted: null }; +let bootedSnapshot: { at: number; booted: Set | null } = { + at: 0, + booted: null, +}; function getBootedUdids(): Set | null { const now = Date.now(); if (bootedSnapshot.booted && now - bootedSnapshot.at < 1500) { @@ -96,7 +142,9 @@ function readServeSimStates() { try { process.kill(state.pid, 0); } catch { - try { unlinkSync(path); } catch {} + try { + unlinkSync(path); + } catch {} continue; } // Helper alive but bound to a shutdown simulator — the Swift helper @@ -106,8 +154,12 @@ function readServeSimStates() { console.error( `\x1b[33m[serve-sim] Recycling stale helper pid ${state.pid} — device ${state.device} is no longer booted.\x1b[0m`, ); - try { process.kill(state.pid, "SIGTERM"); } catch {} - try { unlinkSync(path); } catch {} + try { + process.kill(state.pid, "SIGTERM"); + } catch {} + try { + unlinkSync(path); + } catch {} continue; } states.push(state); @@ -134,10 +186,15 @@ async function buildClient() { }, }); if (result.success) { - clientJs = (await result.outputs[0].text()).replace(/<\/script>/gi, "<\\/script>"); + clientJs = (await result.outputs[0].text()).replace( + /<\/script>/gi, + "<\\/script>", + ); clientError = ""; const ms = (performance.now() - start).toFixed(0); - console.log(`\x1b[32m✓\x1b[0m Bundled client.tsx (${(clientJs.length / 1024).toFixed(0)} KB) in ${ms}ms`); + console.log( + `\x1b[32m✓\x1b[0m Bundled client.tsx (${(clientJs.length / 1024).toFixed(0)} KB) in ${ms}ms`, + ); } else { clientError = result.logs.map((l) => String(l)).join("\n"); console.error("\x1b[31m✗\x1b[0m Build failed:\n" + clientError); @@ -167,8 +224,15 @@ watch(CLIENT_DIR, { recursive: true }, (_event, filename) => { function buildHtml(): string { const states = readServeSimStates(); const state = states[0] ?? null; - const configScript = state - ? `` + const previewState = + state && previewCli.hostname ? previewPublicState(state, "") : state; + + const configScript = previewState + ? `` : ""; return ` @@ -182,9 +246,12 @@ function buildHtml(): string { ${configScript} ${clientError ? `
${clientError.replace(/` : ""}
 `;
@@ -192,169 +259,314 @@ ${clientError ? `
) {
+    const port = ws.data.backendPort;
+    const backend = new WebSocket(`ws://127.0.0.1:${port}/ws`);
+    backend.binaryType = "arraybuffer";
+    (ws as unknown as { __b: WebSocket }).__b = backend;
+    backend.addEventListener("message", (ev) => {
+      ws.send(ev.data as ArrayBuffer);
+    });
+    backend.addEventListener("close", () => ws.close());
+    backend.addEventListener("error", () => ws.close());
+  },
+  message(
+    ws: import("bun").ServerWebSocket<{ backendPort: number }>,
+    message: ArrayBuffer | Buffer,
+  ) {
+    (ws as unknown as { __b?: WebSocket }).__b?.send(message);
+  },
+  close(ws: import("bun").ServerWebSocket<{ backendPort: number }>) {
+    try {
+      (ws as unknown as { __b?: WebSocket }).__b?.close();
+    } catch {}
+  },
+};
 
-    // Dev reload SSE
-    if (url.pathname === "/__dev/reload") {
-      const stream = new ReadableStream({
-        start(controller) {
-          reloadClients.add(controller);
-          controller.enqueue(":\n\n");
-        },
-        cancel(controller) {
-          reloadClients.delete(controller);
-        },
-      });
-      return new Response(stream, {
+const devFetch = (
+  req: Request,
+  server?: import("bun").Server<{ backendPort: number }>,
+) => {
+  const url = new URL(req.url);
+
+  // Tunnel mode: same-origin stream + config + ws proxied to the Swift helper
+  if (previewCli.hostname) {
+    if (
+      req.method === "GET" &&
+      (url.pathname === "/stream.mjpeg" || url.pathname === "/config")
+    ) {
+      const states = readServeSimStates();
+      const st = states[0];
+      if (!st) return new Response("No serve-sim device", { status: 404 });
+      return fetch(`http://127.0.0.1:${st.port}${url.pathname}${url.search}`, {
+        signal: req.signal,
+        cache: "no-store",
         headers: {
-          "Content-Type": "text/event-stream",
-          "Cache-Control": "no-cache",
-          Connection: "keep-alive",
+          Accept: req.headers.get("Accept") ?? "*/*",
         },
       });
     }
-
-    // Serve-sim state API
-    if (url.pathname === "/api") {
+    if (url.pathname === "/ws" && server) {
       const states = readServeSimStates();
-      return Response.json(states[0] ?? null, {
-        headers: { "Cache-Control": "no-store" },
-      });
+      const st = states[0];
+      if (!st) return new Response("No serve-sim device", { status: 404 });
+      const upgraded = server.upgrade(req, { data: { backendPort: st.port } });
+      if (upgraded) return undefined as unknown as Response;
     }
+  }
 
-    // POST /exec — run a shell command and return stdout/stderr/exitCode.
-    if (url.pathname === "/exec" && req.method === "POST") {
-      return req.json().then((body: any) => {
-        const command: string = body?.command ?? "";
-        if (!command) {
-          return Response.json({ stdout: "", stderr: "Missing command", exitCode: 1 }, { status: 400 });
-        }
-        return new Promise((resolve) => {
-          exec(command, { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
-            resolve(Response.json({
-              stdout: stdout.toString(),
-              stderr: stderr.toString(),
-              exitCode: err ? (err as any).code ?? 1 : 0,
-            }));
-          });
-        });
-      });
-    }
+  // Dev reload SSE
+  if (url.pathname === "/__dev/reload") {
+    const stream = new ReadableStream({
+      start(controller) {
+        reloadClients.add(controller);
+        controller.enqueue(":\n\n");
+      },
+      cancel(controller) {
+        reloadClients.delete(controller);
+      },
+    });
+    return new Response(stream, {
+      headers: {
+        "Content-Type": "text/event-stream",
+        "Cache-Control": "no-cache",
+        Connection: "keep-alive",
+      },
+    });
+  }
 
-    // SSE logs
-    if (url.pathname === "/logs") {
-      const states = readServeSimStates();
-      if (states.length === 0) {
-        return new Response("No serve-sim device", { status: 404 });
+  // Serve-sim state API
+  if (url.pathname === "/api") {
+    const states = readServeSimStates();
+    return Response.json(states[0] ?? null, {
+      headers: { "Cache-Control": "no-store" },
+    });
+  }
+
+  // POST /exec — run a shell command and return stdout/stderr/exitCode.
+  if (url.pathname === "/exec" && req.method === "POST") {
+    return req.json().then((body: any) => {
+      const command: string = body?.command ?? "";
+      if (!command) {
+        return Response.json(
+          { stdout: "", stderr: "Missing command", exitCode: 1 },
+          { status: 400 },
+        );
       }
-      const udid = states[0].device;
-      const stream = new ReadableStream({
-        start(controller) {
-          const child: ChildProcess = spawn("xcrun", [
-            "simctl", "spawn", udid, "log", "stream",
-            "--style", "ndjson", "--level", "info",
-          ], { stdio: ["ignore", "pipe", "ignore"] });
+      return new Promise((resolve) => {
+        exec(
+          command,
+          { maxBuffer: 16 * 1024 * 1024 },
+          (err, stdout, stderr) => {
+            resolve(
+              Response.json({
+                stdout: stdout.toString(),
+                stderr: stderr.toString(),
+                exitCode: err ? ((err as any).code ?? 1) : 0,
+              }),
+            );
+          },
+        );
+      });
+    });
+  }
+
+  // SSE logs
+  if (url.pathname === "/logs") {
+    const states = readServeSimStates();
+    if (states.length === 0) {
+      return new Response("No serve-sim device", { status: 404 });
+    }
+    const udid = states[0].device;
+    const stream = new ReadableStream({
+      start(controller) {
+        const child: ChildProcess = spawn(
+          "xcrun",
+          [
+            "simctl",
+            "spawn",
+            udid,
+            "log",
+            "stream",
+            "--style",
+            "ndjson",
+            "--level",
+            "info",
+          ],
+          { stdio: ["ignore", "pipe", "ignore"] },
+        );
 
-          let buf = "";
-          child.stdout!.on("data", (chunk: Buffer) => {
-            buf += chunk.toString();
-            let nl: number;
-            while ((nl = buf.indexOf("\n")) !== -1) {
-              const line = buf.slice(0, nl).trim();
-              buf = buf.slice(nl + 1);
-              if (line) {
-                try {
-                  controller.enqueue(`data: ${line}\n\n`);
-                } catch {
-                  child.kill();
-                }
+        let buf = "";
+        child.stdout!.on("data", (chunk: Buffer) => {
+          buf += chunk.toString();
+          let nl: number;
+          while ((nl = buf.indexOf("\n")) !== -1) {
+            const line = buf.slice(0, nl).trim();
+            buf = buf.slice(nl + 1);
+            if (line) {
+              try {
+                controller.enqueue(`data: ${line}\n\n`);
+              } catch {
+                child.kill();
               }
             }
-          });
-          child.on("close", () => {
-            try { controller.close(); } catch {}
-          });
-          // Clean up when client disconnects
-          req.signal.addEventListener("abort", () => child.kill());
-        },
-      });
-      return new Response(stream, {
-        headers: {
-          "Content-Type": "text/event-stream",
-          "Cache-Control": "no-cache",
-          Connection: "keep-alive",
-        },
-      });
-    }
+          }
+        });
+        child.on("close", () => {
+          try {
+            controller.close();
+          } catch {}
+        });
+        // Clean up when client disconnects
+        req.signal.addEventListener("abort", () => child.kill());
+      },
+    });
+    return new Response(stream, {
+      headers: {
+        "Content-Type": "text/event-stream",
+        "Cache-Control": "no-cache",
+        Connection: "keep-alive",
+      },
+    });
+  }
 
-    // SSE foreground-app changes (filtered in the CLI; browser just listens).
-    if (url.pathname === "/appstate") {
-      const states = readServeSimStates();
-      if (states.length === 0) {
-        return new Response("No serve-sim device", { status: 404 });
-      }
-      const udid = states[0].device;
-      const stream = new ReadableStream({
-        start(controller) {
-          const child: ChildProcess = spawn("xcrun", [
-            "simctl", "spawn", udid, "log", "stream",
-            "--style", "ndjson", "--level", "info",
+  // SSE foreground-app changes (filtered in the CLI; browser just listens).
+  if (url.pathname === "/appstate") {
+    const states = readServeSimStates();
+    if (states.length === 0) {
+      return new Response("No serve-sim device", { status: 404 });
+    }
+    const udid = states[0].device;
+    const stream = new ReadableStream({
+      start(controller) {
+        const child: ChildProcess = spawn(
+          "xcrun",
+          [
+            "simctl",
+            "spawn",
+            udid,
+            "log",
+            "stream",
+            "--style",
+            "ndjson",
+            "--level",
+            "info",
             "--predicate",
             'process == "SpringBoard" AND eventMessage CONTAINS "Setting process visibility to: Foreground"',
-          ], { stdio: ["ignore", "pipe", "ignore"] });
-          const FG_RE = /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/;
-          let lastBundle = "";
-          let buf = "";
-          child.stdout!.on("data", (chunk: Buffer) => {
-            buf += chunk.toString();
-            let nl: number;
-            while ((nl = buf.indexOf("\n")) !== -1) {
-              const line = buf.slice(0, nl).trim();
-              buf = buf.slice(nl + 1);
-              if (!line) continue;
-              let msg: string;
-              try { msg = JSON.parse(line).eventMessage ?? ""; } catch { continue; }
-              const m = FG_RE.exec(msg);
-              if (!m) continue;
-              const bundleId = m[1]!;
-              const pid = parseInt(m[2]!, 10);
-              if (!isUserFacingBundle(bundleId)) continue;
-              if (bundleId === lastBundle) continue;
-              lastBundle = bundleId;
-              detectReactNative(udid, bundleId).then((isReactNative) => {
-                try {
-                  controller.enqueue(`data: ${JSON.stringify({ bundleId, pid, isReactNative })}\n\n`);
-                } catch {
-                  child.kill();
-                }
-              });
+          ],
+          { stdio: ["ignore", "pipe", "ignore"] },
+        );
+        const FG_RE =
+          /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/;
+        let lastBundle = "";
+        let buf = "";
+        child.stdout!.on("data", (chunk: Buffer) => {
+          buf += chunk.toString();
+          let nl: number;
+          while ((nl = buf.indexOf("\n")) !== -1) {
+            const line = buf.slice(0, nl).trim();
+            buf = buf.slice(nl + 1);
+            if (!line) continue;
+            let msg: string;
+            try {
+              msg = JSON.parse(line).eventMessage ?? "";
+            } catch {
+              continue;
             }
-          });
-          child.on("close", () => { try { controller.close(); } catch {} });
-          req.signal.addEventListener("abort", () => child.kill());
-        },
-      });
-      return new Response(stream, {
-        headers: {
-          "Content-Type": "text/event-stream",
-          "Cache-Control": "no-cache",
-          Connection: "keep-alive",
-        },
-      });
-    }
-
-    // Serve the HTML page (fresh on every request — picks up state + rebuild)
-    return new Response(buildHtml(), {
+            const m = FG_RE.exec(msg);
+            if (!m) continue;
+            const bundleId = m[1]!;
+            const pid = parseInt(m[2]!, 10);
+            if (!isUserFacingBundle(bundleId)) continue;
+            if (bundleId === lastBundle) continue;
+            lastBundle = bundleId;
+            detectReactNative(udid, bundleId).then((isReactNative) => {
+              try {
+                controller.enqueue(
+                  `data: ${JSON.stringify({ bundleId, pid, isReactNative })}\n\n`,
+                );
+              } catch {
+                child.kill();
+              }
+            });
+          }
+        });
+        child.on("close", () => {
+          try {
+            controller.close();
+          } catch {}
+        });
+        req.signal.addEventListener("abort", () => child.kill());
+      },
+    });
+    return new Response(stream, {
       headers: {
-        "Content-Type": "text/html; charset=utf-8",
-        "Cache-Control": "no-store",
+        "Content-Type": "text/event-stream",
+        "Cache-Control": "no-cache",
+        Connection: "keep-alive",
       },
     });
-  },
-});
+  }
+
+  // Serve the HTML page (fresh on every request — picks up state + rebuild)
+  return new Response(buildHtml(), {
+    headers: {
+      "Content-Type": "text/html; charset=utf-8",
+      "Cache-Control": "no-store",
+    },
+  });
+};
+
+const maxScan = portPinned ? 1 : 50;
+let server: ReturnType | undefined;
+let lastErr: unknown;
+for (let i = 0; i < maxScan; i++) {
+  const p = preferredPort + i;
+  try {
+    const devServeOpts: Record = {
+      port: p,
+      idleTimeout: 255, // SSE / MJPEG streams are long-lived
+      fetch: devFetch,
+    };
+    if (previewCli.hostname) devServeOpts.websocket = devTunnelWs;
+    server = Bun.serve(devServeOpts as Parameters[0]);
+    break;
+  } catch (err: any) {
+    lastErr = err;
+    if (err?.code !== "EADDRINUSE") throw err;
+    if (portPinned) break;
+  }
+}
+if (!server) {
+  if ((lastErr as any)?.code === "EADDRINUSE") {
+    if (portPinned) {
+      console.error(
+        `[serve-sim dev] Port ${preferredPort} is already in use. Set PORT to another value or stop the other process.`,
+      );
+    } else {
+      console.error(
+        `[serve-sim dev] No free port in range ${preferredPort}-${preferredPort + maxScan - 1}.`,
+      );
+    }
+  } else {
+    console.error(
+      `[serve-sim dev] Failed to start server: ${(lastErr as any)?.message ?? lastErr}`,
+    );
+  }
+  process.exit(1);
+}
+
+const boundPort = server.port;
+if (!portPinned && boundPort !== preferredPort) {
+  console.log(
+    `\x1b[33m[serve-sim dev]\x1b[0m Port ${preferredPort} was busy; listening on ${boundPort} instead.\n`,
+  );
+}
 
-console.log(`\n  \x1b[36mserve-sim dev\x1b[0m  http://localhost:${PORT}\n`);
+const previewHint = previewCli.hostname
+  ? `  \x1b[33mtunnel\x1b[0m  /stream.mjpeg /config /ws → helper (expose this port through your tunnel)\n`
+  : "";
+console.log(
+  `\n  \x1b[36mserve-sim dev\x1b[0m  http://localhost:${boundPort}\n${previewHint}`,
+);
diff --git a/packages/serve-sim/package.json b/packages/serve-sim/package.json
index dd609a42..f54bb86b 100644
--- a/packages/serve-sim/package.json
+++ b/packages/serve-sim/package.json
@@ -44,7 +44,11 @@
     "build:swift": "./build.sh",
     "dev": "bun run dev.ts"
   },
+  "dependencies": {
+    "http-proxy": "^1.18.1"
+  },
   "devDependencies": {
+    "@types/http-proxy": "^1.17.15",
     "@types/bun": "latest",
     "serve-sim-client": "workspace:*",
     "preact": "^10.29.1",
diff --git a/packages/serve-sim/src/client/client.tsx b/packages/serve-sim/src/client/client.tsx
index 6010372c..23d6bb50 100644
--- a/packages/serve-sim/src/client/client.tsx
+++ b/packages/serve-sim/src/client/client.tsx
@@ -38,12 +38,13 @@ function useMjpegStream(streamUrl: string | null) {
       .then((c: { width: number; height: number }) => {
         if (c.width > 0 && c.height > 0) setConfig(c);
       })
-      .catch(() => {});
+      .catch(() => { });
 
-    // Read the MJPEG stream and extract JPEG frames
+    const fetchUrlObj = new URL(streamUrl, window.location.href);
+    const fetchUrl = fetchUrlObj.toString();
     (async () => {
       try {
-        const res = await fetch(streamUrl, { signal: controller.signal });
+        const res = await fetch(fetchUrl, { signal: controller.signal });
         const reader = res.body?.getReader();
         if (!reader) return;
 
@@ -152,6 +153,8 @@ declare global {
       port: number;
       device: string;
       logsEndpoint?: string;
+      /** Same-origin reverse-proxied preview (defer auxiliary SSE so MJPEG + WS open first). */
+      proxiedPreview?: boolean;
     };
   }
 }
@@ -433,7 +436,7 @@ async function uploadDroppedFile(
       throw new Error(result.stderr || `${label} failed (exit ${result.exitCode})`);
     }
   } finally {
-    exec(`bash -c 'rm -f ${tmpPath}'`).catch(() => {});
+    exec(`bash -c 'rm -f ${tmpPath}'`).catch(() => { });
   }
 }
 
@@ -588,7 +591,7 @@ function BootEmptyState({
             window.location.reload();
             return;
           }
-        } catch {}
+        } catch { }
         await new Promise((res) => setTimeout(res, 400));
       }
       throw new Error("serve-sim started but no stream state appeared");
@@ -717,11 +720,14 @@ function App() {
 
   useEffect(() => { fetchDevices(); }, [fetchDevices]);
 
-  // Stream simctl logs into the browser console with colors + grouping
+  // Stream simctl logs into the browser console with colors + grouping.
+  // Tunnel mode: defer opening this SSE so Safari (and similar browsers) can
+  // open the MJPEG fetch + WebSocket first — HTTP/1.1 allows only ~6 parallel
+  // connections per host; logs + appstate + dev reload would starve /stream.mjpeg.
   useEffect(() => {
     if (!config?.logsEndpoint) return;
-    const es = new EventSource(config.logsEndpoint);
-
+    const delay = config.proxiedPreview ? 750 : 0;
+    let es: EventSource | null = null;
     const procColors = new Map();
     const palette = [
       "#8be9fd", "#50fa7b", "#ffb86c", "#ff79c6", "#bd93f9",
@@ -742,50 +748,55 @@ function App() {
     let lastProc = "";
     let groupOpen = false;
 
-    es.onmessage = (event) => {
-      try {
-        const entry = JSON.parse(event.data);
-        const proc = entry.processImagePath?.split("/").pop() ?? entry.senderImagePath?.split("/").pop() ?? "";
-        const subsystem = entry.subsystem ?? "";
-        const category = entry.category ?? "";
-        const msg = entry.eventMessage ?? "";
-        if (!msg) return;
-
-        if (proc !== lastProc) {
-          if (groupOpen) console.groupEnd();
-          const color = colorFor(proc);
-          console.groupCollapsed(
-            `%c${proc}${subsystem ? ` %c${subsystem}${category ? ":" + category : ""}` : ""}`,
-            `color:${color};font-weight:bold`,
-            ...(subsystem ? ["color:#888;font-weight:normal"] : []),
-          );
-          groupOpen = true;
-          lastProc = proc;
-        }
+    const wireEs = () => {
+      es = new EventSource(config.logsEndpoint);
+      es.onmessage = (event) => {
+        try {
+          const entry = JSON.parse(event.data);
+          const proc = entry.processImagePath?.split("/").pop() ?? entry.senderImagePath?.split("/").pop() ?? "";
+          const subsystem = entry.subsystem ?? "";
+          const category = entry.category ?? "";
+          const msg = entry.eventMessage ?? "";
+          if (!msg) return;
+
+          if (proc !== lastProc) {
+            if (groupOpen) console.groupEnd();
+            const color = colorFor(proc);
+            console.groupCollapsed(
+              `%c${proc}${subsystem ? ` %c${subsystem}${category ? ":" + category : ""}` : ""}`,
+              `color:${color};font-weight:bold`,
+              ...(subsystem ? ["color:#888;font-weight:normal"] : []),
+            );
+            groupOpen = true;
+            lastProc = proc;
+          }
 
-        const level = (entry.messageType ?? "").toLowerCase();
-        const tag = subsystem && proc === lastProc
-          ? `%c${category || subsystem}%c `
-          : "";
-        const tagStyles = tag
-          ? ["color:#888;font-style:italic", "color:inherit"]
-          : [];
-
-        if (level === "fault" || level === "error") {
-          console.log(`${tag}%c${msg}`, ...tagStyles, "color:#ff5555");
-        } else if (level === "debug") {
-          console.log(`${tag}%c${msg}`, ...tagStyles, "color:#6272a4");
-        } else {
-          console.log(`${tag}%c${msg}`, ...tagStyles, "color:inherit");
-        }
-      } catch {}
+          const level = (entry.messageType ?? "").toLowerCase();
+          const tag = subsystem && proc === lastProc
+            ? `%c${category || subsystem}%c `
+            : "";
+          const tagStyles = tag
+            ? ["color:#888;font-style:italic", "color:inherit"]
+            : [];
+
+          if (level === "fault" || level === "error") {
+            console.log(`${tag}%c${msg}`, ...tagStyles, "color:#ff5555");
+          } else if (level === "debug") {
+            console.log(`${tag}%c${msg}`, ...tagStyles, "color:#6272a4");
+          } else {
+            console.log(`${tag}%c${msg}`, ...tagStyles, "color:inherit");
+          }
+        } catch { }
+      };
     };
+    const timer = delay ? window.setTimeout(wireEs, delay) : (wireEs(), 0);
 
     return () => {
+      if (delay) window.clearTimeout(timer);
       if (groupOpen) console.groupEnd();
-      es.close();
+      es?.close();
     };
-  }, [config?.logsEndpoint]);
+  }, [config?.logsEndpoint, !!config?.proxiedPreview]);
 
   if (!config) {
     return (
@@ -813,8 +824,8 @@ function App() {
   const imgBorderRadius = screenBorderRadius(deviceType);
   const frameMaxWidth = deviceType === "vision" ? 580
     : deviceType === "ipad" ? 400
-    : deviceType === "watch" ? 200
-    : 320;
+      : deviceType === "watch" ? 200
+        : 320;
 
   // Parse MJPEG stream into individual frames (Chrome doesn't support multipart/x-mixed-replace in )
   const mjpeg = useMjpegStream(config.streamUrl);
@@ -853,20 +864,31 @@ function App() {
   // Without this, the reload button flickers while an RN app is still loading.
   const [currentApp, setCurrentApp] = useState<{ bundleId: string; isReactNative: boolean } | null>(null);
   useEffect(() => {
-    const es = new EventSource("/appstate");
+    const openDelay = config.proxiedPreview ? 750 : 0;
+    let es: EventSource | null = null;
     let timer: ReturnType | null = null;
-    es.onmessage = (e) => {
-      try {
-        const next = JSON.parse(e.data);
-        if (timer) clearTimeout(timer);
-        // Commit RN app instantly (show the button ASAP); delay non-RN so a
-        // transient foreground blip doesn't hide it.
-        const delay = next?.isReactNative ? 0 : 600;
-        timer = setTimeout(() => setCurrentApp(next), delay);
-      } catch {}
+    const wireAppstate = () => {
+      es = new EventSource("/appstate");
+      es.onmessage = (e) => {
+        try {
+          const next = JSON.parse(e.data);
+          if (timer) clearTimeout(timer);
+          // Commit RN app instantly (show the button ASAP); delay non-RN so a
+          // transient foreground blip doesn't hide it.
+          const delay = next?.isReactNative ? 0 : 600;
+          timer = setTimeout(() => setCurrentApp(next), delay);
+        } catch { }
+      };
     };
-    return () => { if (timer) clearTimeout(timer); es.close(); };
-  }, []);
+    const openTimer = openDelay
+      ? window.setTimeout(wireAppstate, openDelay)
+      : (wireAppstate(), 0);
+    return () => {
+      if (openDelay) window.clearTimeout(openTimer);
+      if (timer) clearTimeout(timer);
+      es?.close();
+    };
+  }, [!!config.proxiedPreview]);
 
   // Cmd+R to reload the RN/Expo bundle. RCTKeyCommands on iOS listens for
   // this combo and triggers DevSupport reload. We hold Meta, tap R, release.
@@ -934,7 +956,7 @@ function App() {
           execOnHost(`xcrun simctl ui ${config.device} appearance`).then((r) => {
             const next = r.stdout.trim() === "dark" ? "light" : "dark";
             return execOnHost(`xcrun simctl ui ${config.device} appearance ${next}`);
-          }).catch(() => {});
+          }).catch(() => { });
         }
         return;
       }
diff --git a/packages/serve-sim/src/index.ts b/packages/serve-sim/src/index.ts
index 80ca9e66..b1737509 100755
--- a/packages/serve-sim/src/index.ts
+++ b/packages/serve-sim/src/index.ts
@@ -1,6 +1,15 @@
 #!/usr/bin/env bun
 import { execSync, spawn as nodeSpawn, type ChildProcess } from "child_process";
-import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeFileSync } from "fs";
+import {
+  chmodSync,
+  existsSync,
+  mkdirSync,
+  openSync,
+  closeSync,
+  readFileSync,
+  unlinkSync,
+  writeFileSync,
+} from "fs";
 import { createHash } from "crypto";
 import { homedir, networkInterfaces } from "os";
 import { join, resolve } from "path";
@@ -45,7 +54,10 @@ function readState(udid?: string): ServerState | null {
  * by the number of running helpers. We cache for 1 second so a flurry of
  * readStateFile() calls (e.g. readAllStates loop) shares one lookup.
  */
-let bootedSnapshot: { at: number; booted: Set | null } = { at: 0, booted: null };
+let bootedSnapshot: { at: number; booted: Set | null } = {
+  at: 0,
+  booted: null,
+};
 function getBootedUdids(): Set | null {
   const now = Date.now();
   if (bootedSnapshot.booted && now - bootedSnapshot.at < 1000) {
@@ -97,8 +109,12 @@ function readStateFile(file: string): ServerState | null {
       console.error(
         `[serve-sim] Helper pid ${state.pid} is bound to device ${state.device} which is no longer booted — killing stale helper.`,
       );
-      try { process.kill(state.pid, "SIGTERM"); } catch {}
-      try { unlinkSync(file); } catch {}
+      try {
+        process.kill(state.pid, "SIGTERM");
+      } catch {}
+      try {
+        unlinkSync(file);
+      } catch {}
       return null;
     }
     return state;
@@ -118,15 +134,22 @@ function readAllStates(): ServerState[] {
 
 function writeState(state: ServerState) {
   ensureStateDir();
-  writeFileSync(stateFileForDevice(state.device), JSON.stringify(state, null, 2));
+  writeFileSync(
+    stateFileForDevice(state.device),
+    JSON.stringify(state, null, 2),
+  );
 }
 
 function clearState(udid?: string) {
   if (udid) {
-    try { unlinkSync(stateFileForDevice(udid)); } catch {}
+    try {
+      unlinkSync(stateFileForDevice(udid));
+    } catch {}
   } else {
     for (const file of listStateFiles()) {
-      try { unlinkSync(file); } catch {}
+      try {
+        unlinkSync(file);
+      } catch {}
     }
   }
 }
@@ -157,7 +180,11 @@ function findHelperBinary(): string {
     writeFileSync(extracted, bytes);
     chmodSync(extracted, 0o755);
     // Re-apply ad-hoc signature so the macOS kernel will exec it.
-    try { execSync(`codesign -s - -f ${JSON.stringify(extracted)}`, { stdio: "ignore" }); } catch {}
+    try {
+      execSync(`codesign -s - -f ${JSON.stringify(extracted)}`, {
+        stdio: "ignore",
+      });
+    } catch {}
   }
   return extracted;
 }
@@ -166,9 +193,14 @@ function findHelperBinary(): string {
 
 function findBootedDevice(): string | null {
   try {
-    const output = execSync("xcrun simctl list devices booted -j", { encoding: "utf-8" });
+    const output = execSync("xcrun simctl list devices booted -j", {
+      encoding: "utf-8",
+    });
     const data = JSON.parse(output) as {
-      devices: Record>;
+      devices: Record<
+        string,
+        Array<{ udid: string; name: string; state: string }>
+      >;
     };
     for (const runtime of Object.values(data.devices)) {
       for (const device of runtime) {
@@ -185,9 +217,19 @@ function findBootedDevice(): string | null {
  */
 function pickDefaultDevice(): { udid: string; name: string } | null {
   try {
-    const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" });
+    const output = execSync("xcrun simctl list devices -j", {
+      encoding: "utf-8",
+    });
     const data = JSON.parse(output) as {
-      devices: Record>;
+      devices: Record<
+        string,
+        Array<{
+          udid: string;
+          name: string;
+          state: string;
+          isAvailable?: boolean;
+        }>
+      >;
     };
     const iosRuntimes = Object.keys(data.devices)
       .filter((k) => /SimRuntime\.iOS-/i.test(k))
@@ -209,9 +251,14 @@ function pickDefaultDevice(): { udid: string; name: string } | null {
 
 function getDeviceName(udid: string): string | null {
   try {
-    const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" });
+    const output = execSync("xcrun simctl list devices -j", {
+      encoding: "utf-8",
+    });
     const data = JSON.parse(output) as {
-      devices: Record>;
+      devices: Record<
+        string,
+        Array<{ udid: string; name: string; state: string }>
+      >;
     };
     for (const runtime of Object.values(data.devices)) {
       for (const device of runtime) {
@@ -223,17 +270,27 @@ function getDeviceName(udid: string): string | null {
 }
 
 function resolveDevice(nameOrUDID: string): string {
-  if (/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(nameOrUDID)) {
+  if (
+    /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(
+      nameOrUDID,
+    )
+  ) {
     return nameOrUDID;
   }
   try {
-    const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" });
+    const output = execSync("xcrun simctl list devices -j", {
+      encoding: "utf-8",
+    });
     const data = JSON.parse(output) as {
-      devices: Record>;
+      devices: Record<
+        string,
+        Array<{ udid: string; name: string; state: string }>
+      >;
     };
     for (const runtime of Object.values(data.devices)) {
       for (const device of runtime) {
-        if (device.name.toLowerCase() === nameOrUDID.toLowerCase()) return device.udid;
+        if (device.name.toLowerCase() === nameOrUDID.toLowerCase())
+          return device.udid;
       }
     }
   } catch {}
@@ -243,7 +300,9 @@ function resolveDevice(nameOrUDID: string): string {
 
 function isDeviceBooted(udid: string): boolean {
   try {
-    const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" });
+    const output = execSync("xcrun simctl list devices -j", {
+      encoding: "utf-8",
+    });
     const data = JSON.parse(output) as {
       devices: Record>;
     };
@@ -257,12 +316,21 @@ function isDeviceBooted(udid: string): boolean {
 }
 
 function isProcessAlive(pid: number): boolean {
-  try { process.kill(pid, 0); return true; } catch { return false; }
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch {
+    return false;
+  }
 }
 
 /** Kill a process and wait for it to actually exit. */
 function stopProcess(pid: number): void {
-  try { process.kill(pid, "SIGTERM"); } catch { return; }
+  try {
+    process.kill(pid, "SIGTERM");
+  } catch {
+    return;
+  }
   const deadline = Date.now() + 500;
   while (Date.now() < deadline) {
     try {
@@ -272,17 +340,27 @@ function stopProcess(pid: number): void {
       return;
     }
   }
-  try { process.kill(pid, "SIGKILL"); } catch {}
+  try {
+    process.kill(pid, "SIGKILL");
+  } catch {}
   const deadline2 = Date.now() + 500;
   while (Date.now() < deadline2) {
-    try { process.kill(pid, 0); Bun.sleepSync(25); } catch { return; }
+    try {
+      process.kill(pid, 0);
+      Bun.sleepSync(25);
+    } catch {
+      return;
+    }
   }
 }
 
 /** Return PIDs currently holding a TCP port (excluding ourselves). */
 function getPortHolders(port: number): number[] {
   try {
-    const output = execSync(`lsof -ti tcp:${port}`, { encoding: "utf-8", stdio: "pipe" }).trim();
+    const output = execSync(`lsof -ti tcp:${port}`, {
+      encoding: "utf-8",
+      stdio: "pipe",
+    }).trim();
     if (!output) return [];
     const myPid = process.pid;
     return output
@@ -298,9 +376,13 @@ function getPortHolders(port: number): number[] {
 function killPortHolder(port: number): void {
   const pids = getPortHolders(port);
   if (pids.length === 0) return;
-  console.log(`\x1b[90mPort ${port} busy, killing holder pid(s): ${pids.join(", ")}\x1b[0m`);
+  console.log(
+    `\x1b[90mPort ${port} busy, killing holder pid(s): ${pids.join(", ")}\x1b[0m`,
+  );
   for (const pid of pids) {
-    try { process.kill(pid, "SIGKILL"); } catch {}
+    try {
+      process.kill(pid, "SIGKILL");
+    } catch {}
   }
   Bun.sleepSync(100);
 }
@@ -308,11 +390,16 @@ function killPortHolder(port: number): void {
 function bootDevice(udid: string): void {
   if (!isDeviceBooted(udid)) {
     try {
-      execSync(`xcrun simctl boot ${udid}`, { encoding: "utf-8", stdio: "pipe" });
+      execSync(`xcrun simctl boot ${udid}`, {
+        encoding: "utf-8",
+        stdio: "pipe",
+      });
     } catch (err: any) {
       const msg = (err.stderr ?? err.message ?? "").toLowerCase();
       if (!msg.includes("booted") && !msg.includes("current state")) {
-        throw new Error(`Failed to boot device ${udid}: ${err.stderr || err.message}`);
+        throw new Error(
+          `Failed to boot device ${udid}: ${err.stderr || err.message}`,
+        );
       }
     }
   }
@@ -368,7 +455,9 @@ async function ensureBooted(udid: string): Promise {
     });
   } catch (err: any) {
     if (!isDeviceBooted(udid)) {
-      console.error(`Device ${udid} failed to reach booted state: ${err.stderr || err.message}`);
+      console.error(
+        `Device ${udid} failed to reach booted state: ${err.stderr || err.message}`,
+      );
       process.exit(1);
     }
   }
@@ -398,7 +487,10 @@ async function waitForHelperReady(
     if (!isAlive()) break;
     try {
       const res = await fetch(`${url}/health`);
-      if (res.ok) { ready = true; break; }
+      if (res.ok) {
+        ready = true;
+        break;
+      }
     } catch {}
     await new Promise((r) => setTimeout(r, 100));
   }
@@ -420,7 +512,9 @@ async function waitForHelperReady(
   }
 
   let log = "";
-  try { log = readFileSync(logFile, "utf-8").trim(); } catch {}
+  try {
+    log = readFileSync(logFile, "utf-8").trim();
+  } catch {}
   return { ready, log };
 }
 
@@ -445,7 +539,9 @@ async function spawnHelperDetached(opts: SpawnHelperOptions): Promise<{
 
   const childPid = child.pid!;
   let childExited = false;
-  child.once("exit", () => { childExited = true; });
+  child.once("exit", () => {
+    childExited = true;
+  });
 
   const { ready, log } = await waitForHelperReady(
     childPid,
@@ -454,7 +550,12 @@ async function spawnHelperDetached(opts: SpawnHelperOptions): Promise<{
     () => !childExited && isProcessAlive(childPid),
   );
 
-  return { ready, pid: childPid, exited: childExited || !isProcessAlive(childPid), log };
+  return {
+    ready,
+    pid: childPid,
+    exited: childExited || !isProcessAlive(childPid),
+    log,
+  };
 }
 
 /** Spawn the helper attached (for foreground follow mode). Returns the child process. */
@@ -476,7 +577,9 @@ async function spawnHelperAttached(opts: SpawnHelperOptions): Promise<{
 
   const childPid = child.pid!;
   let childExited = false;
-  child.once("exit", () => { childExited = true; });
+  child.once("exit", () => {
+    childExited = true;
+  });
 
   const { ready, log } = await waitForHelperReady(
     childPid,
@@ -499,7 +602,13 @@ async function startHelper(
   const host = "127.0.0.1";
   const helperPath = findHelperBinary();
   const logFile = join(STATE_DIR, `server-${udid}.log`);
-  const spawnOpts: SpawnHelperOptions = { helperPath, udid, port, host, logFile };
+  const spawnOpts: SpawnHelperOptions = {
+    helperPath,
+    udid,
+    port,
+    host,
+    logFile,
+  };
 
   let lastLog = "";
   const MAX_ATTEMPTS = 2;
@@ -546,7 +655,9 @@ async function startHelper(
     }
   }
 
-  const reason = lastLog ? `Helper failed:\n${lastLog}` : "Helper process failed to start";
+  const reason = lastLog
+    ? `Helper failed:\n${lastLog}`
+    : "Helper process failed to start";
   console.error(reason);
   process.exit(1);
 }
@@ -555,21 +666,24 @@ async function startHelper(
 
 /** Foreground follow mode (default). Stays attached, cleans up on Ctrl+C. */
 async function follow(devices: string[], startPort: number, quiet: boolean) {
-  const udids = devices.length > 0
-    ? devices.map(resolveDevice)
-    : (() => {
-        const booted = findBootedDevice();
-        if (booted) return [booted];
-        const fallback = pickDefaultDevice();
-        if (!fallback) {
-          console.error("No device specified and no available iOS simulator found.");
-          process.exit(1);
-        }
-        if (!quiet) {
-          console.log(`No booted simulator — booting ${fallback.name}...`);
-        }
-        return [fallback.udid];
-      })();
+  const udids =
+    devices.length > 0
+      ? devices.map(resolveDevice)
+      : (() => {
+          const booted = findBootedDevice();
+          if (booted) return [booted];
+          const fallback = pickDefaultDevice();
+          if (!fallback) {
+            console.error(
+              "No device specified and no available iOS simulator found.",
+            );
+            process.exit(1);
+          }
+          if (!quiet) {
+            console.log(`No booted simulator — booting ${fallback.name}...`);
+          }
+          return [fallback.udid];
+        })();
 
   const children = new Map();
   const states: ServerState[] = [];
@@ -622,15 +736,27 @@ async function follow(devices: string[], startPort: number, quiet: boolean) {
   // Machine-readable JSON to stdout
   if (states.length === 1) {
     const s = states[0]!;
-    console.log(JSON.stringify({
-      url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl, port: s.port, device: s.device,
-    }));
+    console.log(
+      JSON.stringify({
+        url: s.url,
+        streamUrl: s.streamUrl,
+        wsUrl: s.wsUrl,
+        port: s.port,
+        device: s.device,
+      }),
+    );
   } else {
-    console.log(JSON.stringify({
-      devices: states.map((s) => ({
-        url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl, port: s.port, device: s.device,
-      })),
-    }));
+    console.log(
+      JSON.stringify({
+        devices: states.map((s) => ({
+          url: s.url,
+          streamUrl: s.streamUrl,
+          wsUrl: s.wsUrl,
+          port: s.port,
+          device: s.device,
+        })),
+      }),
+    );
   }
 
   // If no new children were spawned (all already running), exit
@@ -670,8 +796,12 @@ async function follow(devices: string[], startPort: number, quiet: boolean) {
   // Last-resort synchronous cleanup if something else exits the process
   process.on("exit", () => {
     for (const [udid, child] of children) {
-      try { if (child.pid) process.kill(child.pid, "SIGTERM"); } catch {}
-      try { clearState(udid); } catch {}
+      try {
+        if (child.pid) process.kill(child.pid, "SIGTERM");
+      } catch {}
+      try {
+        clearState(udid);
+      } catch {}
     }
   });
 
@@ -680,19 +810,25 @@ async function follow(devices: string[], startPort: number, quiet: boolean) {
 }
 
 /** Detach mode (--detach). Spawns helpers and returns their states. */
-async function detach(devices: string[], startPort: number): Promise {
-  const udids = devices.length > 0
-    ? devices.map(resolveDevice)
-    : (() => {
-        const booted = findBootedDevice();
-        if (booted) return [booted];
-        const fallback = pickDefaultDevice();
-        if (!fallback) {
-          console.error("No device specified and no available iOS simulator found.");
-          process.exit(1);
-        }
-        return [fallback.udid];
-      })();
+async function detach(
+  devices: string[],
+  startPort: number,
+): Promise {
+  const udids =
+    devices.length > 0
+      ? devices.map(resolveDevice)
+      : (() => {
+          const booted = findBootedDevice();
+          if (booted) return [booted];
+          const fallback = pickDefaultDevice();
+          if (!fallback) {
+            console.error(
+              "No device specified and no available iOS simulator found.",
+            );
+            process.exit(1);
+          }
+          return [fallback.udid];
+        })();
 
   const states: ServerState[] = [];
   let port = startPort;
@@ -726,15 +862,27 @@ async function detach(devices: string[], startPort: number): Promise ({
-        url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl, port: s.port, device: s.device,
-      })),
-    }));
+    console.log(
+      JSON.stringify({
+        devices: states.map((s) => ({
+          url: s.url,
+          streamUrl: s.streamUrl,
+          wsUrl: s.wsUrl,
+          port: s.port,
+          device: s.device,
+        })),
+      }),
+    );
   }
 }
 
@@ -746,11 +894,17 @@ function listStreams(deviceArg?: string) {
     if (!state) {
       console.log(JSON.stringify({ running: false, device: udid }));
     } else {
-      console.log(JSON.stringify({
-        running: true,
-        url: state.url, streamUrl: state.streamUrl, wsUrl: state.wsUrl,
-        port: state.port, device: state.device, pid: state.pid,
-      }));
+      console.log(
+        JSON.stringify({
+          running: true,
+          url: state.url,
+          streamUrl: state.streamUrl,
+          wsUrl: state.wsUrl,
+          port: state.port,
+          device: state.device,
+          pid: state.pid,
+        }),
+      );
     }
     return;
   }
@@ -760,19 +914,31 @@ function listStreams(deviceArg?: string) {
     console.log(JSON.stringify({ running: false }));
   } else if (states.length === 1) {
     const s = states[0]!;
-    console.log(JSON.stringify({
-      running: true,
-      url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl,
-      port: s.port, device: s.device, pid: s.pid,
-    }));
+    console.log(
+      JSON.stringify({
+        running: true,
+        url: s.url,
+        streamUrl: s.streamUrl,
+        wsUrl: s.wsUrl,
+        port: s.port,
+        device: s.device,
+        pid: s.pid,
+      }),
+    );
   } else {
-    console.log(JSON.stringify({
-      running: true,
-      streams: states.map((s) => ({
-        url: s.url, streamUrl: s.streamUrl, wsUrl: s.wsUrl,
-        port: s.port, device: s.device, pid: s.pid,
-      })),
-    }));
+    console.log(
+      JSON.stringify({
+        running: true,
+        streams: states.map((s) => ({
+          url: s.url,
+          streamUrl: s.streamUrl,
+          wsUrl: s.wsUrl,
+          port: s.port,
+          device: s.device,
+          pid: s.pid,
+        })),
+      }),
+    );
   }
 }
 
@@ -785,7 +951,9 @@ function killStreams(deviceArg?: string) {
       console.log(JSON.stringify({ disconnected: true, device: udid }));
       return;
     }
-    try { process.kill(state.pid, "SIGTERM"); } catch {}
+    try {
+      process.kill(state.pid, "SIGTERM");
+    } catch {}
     clearState(udid);
     console.log(JSON.stringify({ disconnected: true, device: state.device }));
   } else {
@@ -796,7 +964,9 @@ function killStreams(deviceArg?: string) {
     }
     const devices: string[] = [];
     for (const state of states) {
-      try { process.kill(state.pid, "SIGTERM"); } catch {}
+      try {
+        process.kill(state.pid, "SIGTERM");
+      } catch {}
       devices.push(state.device);
     }
     clearState();
@@ -823,7 +993,9 @@ async function gesture(args: string[]) {
   const jsonStr = filteredArgs[0];
   if (!jsonStr) {
     console.error("Usage: serve-sim gesture ''");
-    console.error('Example: serve-sim gesture \'{"type":"begin","x":0.5,"y":0.5}\'');
+    console.error(
+      'Example: serve-sim gesture \'{"type":"begin","x":0.5,"y":0.5}\'',
+    );
     process.exit(1);
   }
 
@@ -845,7 +1017,10 @@ async function gesture(args: string[]) {
       msg[0] = 0x03;
       msg.set(json, 1);
       ws.send(msg);
-      setTimeout(() => { ws.close(); resolve(); }, 50);
+      setTimeout(() => {
+        ws.close();
+        resolve();
+      }, 50);
     };
 
     ws.onerror = () => {
@@ -895,7 +1070,10 @@ async function rotate(args: string[]) {
       msg[0] = 0x07;
       msg.set(json, 1);
       ws.send(msg);
-      setTimeout(() => { ws.close(); resolve(); }, 50);
+      setTimeout(() => {
+        ws.close();
+        resolve();
+      }, 50);
     };
 
     ws.onerror = () => {
@@ -928,12 +1106,17 @@ async function button(args: string[]) {
     ws.binaryType = "arraybuffer";
 
     ws.onopen = () => {
-      const json = new TextEncoder().encode(JSON.stringify({ button: buttonName }));
+      const json = new TextEncoder().encode(
+        JSON.stringify({ button: buttonName }),
+      );
       const msg = new Uint8Array(1 + json.length);
       msg[0] = 0x04;
       msg.set(json, 1);
       ws.send(msg);
-      setTimeout(() => { ws.close(); resolve(); }, 50);
+      setTimeout(() => {
+        ws.close();
+        resolve();
+      }, 50);
     };
 
     ws.onerror = () => {
@@ -969,7 +1152,10 @@ async function caDebug(args: string[]) {
     slow: "debug_slow_animations",
   };
   const resolved = option ? (aliases[option] ?? option) : undefined;
-  if (!resolved || !["on", "off", "1", "0", "true", "false"].includes(stateArg)) {
+  if (
+    !resolved ||
+    !["on", "off", "1", "0", "true", "false"].includes(stateArg)
+  ) {
     console.error(
       `Usage: serve-sim ca-debug