@expo/serve-sim 0.1.35-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +279 -0
  3. package/Sources/SimAXSettings/build.sh +21 -0
  4. package/Sources/SimAXSettings/sim-ax-settings.m +273 -0
  5. package/Sources/SimCameraHelper/build.sh +33 -0
  6. package/Sources/SimCameraHelper/main.m +955 -0
  7. package/Sources/SimCameraInjector/SimCamFakes.h +88 -0
  8. package/Sources/SimCameraInjector/SimCamFakes.m +704 -0
  9. package/Sources/SimCameraInjector/SimCamFrameSource.h +26 -0
  10. package/Sources/SimCameraInjector/SimCamFrameSource.m +577 -0
  11. package/Sources/SimCameraInjector/SimCamLog.h +5 -0
  12. package/Sources/SimCameraInjector/SimCamLog.m +9 -0
  13. package/Sources/SimCameraInjector/SimCamSwizzles.h +3 -0
  14. package/Sources/SimCameraInjector/SimCamSwizzles.m +1338 -0
  15. package/Sources/SimCameraInjector/SimCameraInjector.m +19 -0
  16. package/Sources/SimCameraInjector/build.sh +39 -0
  17. package/Sources/SimCameraInjector/include/SimCamShared.h +79 -0
  18. package/dist/bin/LiveKitWebRTC.framework/LiveKitWebRTC +0 -0
  19. package/dist/bin/LiveKitWebRTC.framework/Resources/Info.plist +36 -0
  20. package/dist/bin/LiveKitWebRTC.framework/Resources/LICENSE.webrtc +29 -0
  21. package/dist/bin/LiveKitWebRTC.framework/Resources/PrivacyInfo.xcprivacy +32 -0
  22. package/dist/bin/LiveKitWebRTC.framework/_CodeSignature/CodeResources +150 -0
  23. package/dist/middleware.cjs +2 -0
  24. package/dist/middleware.js +123 -0
  25. package/dist/native/serve-sim-native.node +0 -0
  26. package/dist/serve-sim.js +218 -0
  27. package/dist/simax/serve-sim-ax-settings +0 -0
  28. package/dist/simcam/libSimCameraInjector.dylib +0 -0
  29. package/dist/simcam/serve-sim-camera-helper +0 -0
  30. package/dist/state.js +1 -0
  31. package/package.json +99 -0
  32. package/src/ax-shared.ts +25 -0
  33. package/src/ax.ts +258 -0
  34. package/src/camera-helper.ts +150 -0
  35. package/src/connect-to-fetch.ts +239 -0
  36. package/src/middleware.ts +2208 -0
  37. package/src/native.ts +294 -0
  38. package/src/state.ts +86 -0
  39. package/src/stream-settings.ts +202 -0
@@ -0,0 +1,2208 @@
1
+ import { readdirSync, readFileSync, existsSync, unlinkSync, watch, type FSWatcher } from "fs";
2
+ import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException } from "child_process";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { createServer as createNetServer } from "net";
6
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
7
+ import type { IncomingMessage, ServerResponse } from "http";
8
+ import type { Socket } from "net";
9
+ // `ws` (kept external in the build) supplies a WebSocket *client* for the
10
+ // helper/devtools proxy. Node only exposes a global `WebSocket` on newer LTS
11
+ // lines, and `serve-sim/middleware` is embedded in third-party dev servers, so
12
+ // importing the dependency keeps the proxy working regardless of runtime.
13
+ import { WebSocket } from "ws";
14
+ import { createAxStreamerCache } from "./ax";
15
+ import { readCameraStatus } from "./camera-helper";
16
+ import { createMetricsSamplerCache, type MetricsSamplerCache } from "./cpu-mem-sampler";
17
+ import { foregroundTracker, type ForegroundApp, type ForegroundTrackerCache } from "./foreground-tracker";
18
+ import { corsAllowOriginHeaders } from "./middleware-utils";
19
+ import { closeDeviceSession, getDeviceSession, sendCorsPreflight, type HidSocket } from "./device-session";
20
+ import {
21
+ eventLogEventForCommand,
22
+ readEventLog,
23
+ recordEventLogEvent,
24
+ subscribeEventLog,
25
+ } from "./event-log";
26
+ import { inProcessServeSimState, writeServeSimState, type ServeSimDeviceState, type StreamSettings } from "./state";
27
+ import { debugMw } from "./debug";
28
+ import {
29
+ resolveDevicePlaceholderAsset,
30
+ resolveDeviceKitChrome,
31
+ serveDeviceKitChromeAsset,
32
+ serveDevicePlaceholderAsset,
33
+ } from "./devicekit-chrome";
34
+ import { createExecWebSocketHandler, type UiRequestHandler } from "./exec-ws";
35
+ import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
36
+ import { type WebMiddleware } from "./runtime-utils";
37
+ import { connectToFetch, type ConnectMiddleware } from "./connect-to-fetch";
38
+
39
+ type SimReq = IncomingMessage;
40
+ type SimRes = ServerResponse;
41
+ type SimNext = (err?: unknown) => Promise<void>;
42
+ export type SimMiddleware = WebMiddleware & {
43
+ handleUpgrade(req: SimReq, socket: Socket, head: Buffer): void;
44
+ };
45
+
46
+ // Injected at build time as a base64-encoded string via `define`
47
+ declare const __PREVIEW_HTML_B64__: string;
48
+ const STATE_DIR = join(tmpdir(), "serve-sim");
49
+ // Last logged result of a GET /api selection, used to suppress the
50
+ // once-every-poll duplicate debugMw lines (the UI polls /api every ~2s).
51
+ let lastApiLogKey: string | undefined;
52
+ const DEVTOOLS_FRONTEND_REV = "854a02be78c7ffea104cb523636efa991bef5c5b";
53
+ const INSPECT_WEBKIT_START_PORT = 9222;
54
+
55
+ type WebKitBridgeTarget = {
56
+ id: string;
57
+ title: string;
58
+ url: string;
59
+ type: string;
60
+ appName?: string;
61
+ bundleId?: string;
62
+ /** udid of the simulator hosting the target, when known. */
63
+ udid?: string;
64
+ inUseByOtherInspector?: boolean;
65
+ };
66
+
67
+ export type WebKitBridge = {
68
+ port: number;
69
+ cdpUrl: string;
70
+ listTargets(): Promise<WebKitBridgeTarget[]>;
71
+ highlightTarget?(targetId: string, on: boolean): Promise<void>;
72
+ releaseHighlight?(targetId?: string): void;
73
+ };
74
+
75
+ type InspectWebKitBridgeTarget = {
76
+ targetId: string;
77
+ title?: string;
78
+ appName?: string;
79
+ url?: string;
80
+ type?: string;
81
+ bundleId?: string;
82
+ inUseByOtherInspector?: boolean;
83
+ source?: { kind?: string; id?: string };
84
+ };
85
+
86
+ type CdpHttpListEntry = {
87
+ id: string;
88
+ title: string;
89
+ url: string;
90
+ type: string;
91
+ description?: string;
92
+ };
93
+
94
+ type CdpHttpVersion = { Browser?: string };
95
+
96
+ type SimctlBootedList = {
97
+ devices: Record<string, Array<{ udid: string; state: string }>>;
98
+ };
99
+
100
+ type SimctlAllList = {
101
+ devices: Record<string, Array<Omit<SimctlDevice, "runtime">>>;
102
+ };
103
+
104
+ type ShutdownRequestBody = { udid?: string };
105
+ type StartRequestBody = { udid?: string };
106
+ type ReleaseRequestBody = { targetId?: string };
107
+ type HighlightRequestBody = { targetId?: string; on?: boolean };
108
+ type ExecRequestBody = { command?: string };
109
+
110
+ /** Re-exported alias for the canonical device-state record in `./state`. */
111
+ export type ServeSimState = ServeSimDeviceState;
112
+
113
+ const axStreamerCache = createAxStreamerCache();
114
+ // One shared cpu/mem sampler per udid; every /metrics viewer subscribes.
115
+ const metricsSamplerCache = createMetricsSamplerCache();
116
+
117
+ // Hard cap on the SSE line-assembly buffer for child-process stdout.
118
+ // A malformed log entry without a newline can't grow this beyond 1 MB;
119
+ // the partial line is dropped rather than retained indefinitely.
120
+ const SSE_LINE_BUFFER_LIMIT = 1024 * 1024;
121
+ let inspectWebKitBridge: Promise<WebKitBridge> | null = null;
122
+
123
+ function eventLogLimit(rawUrl: string): number | undefined {
124
+ const value = new URL(rawUrl, "http://x").searchParams.get("limit");
125
+ if (!value) return undefined;
126
+ const limit = Number(value);
127
+ return Number.isFinite(limit) ? limit : undefined;
128
+ }
129
+
130
+ function eventLogSinceId(rawUrl: string): number | undefined {
131
+ const value = new URL(rawUrl, "http://x").searchParams.get("since");
132
+ if (!value) return undefined;
133
+ const since = Number(value);
134
+ return Number.isFinite(since) ? since : undefined;
135
+ }
136
+
137
+ function recordCommandEvent(command: string, result: { exitCode?: number }): void {
138
+ try {
139
+ const event = eventLogEventForCommand(command, result);
140
+ if (event) recordEventLogEvent(event);
141
+ } catch {
142
+ // Event-log recording is diagnostic; it must never break the exec path.
143
+ }
144
+ }
145
+
146
+ // Known bundle IDs that are always React Native shells (used as a fallback
147
+ // before the app-container path resolves, since simctl can lag after launch).
148
+ const RN_BUNDLE_IDS = new Set<string>([
149
+ "host.exp.Exponent", // Expo Go (App Store)
150
+ "dev.expo.Exponent", // Expo Go dev builds
151
+ ]);
152
+
153
+ const RN_MARKERS = [
154
+ "Frameworks/React.framework",
155
+ "Frameworks/hermes.framework",
156
+ "Frameworks/Hermes.framework",
157
+ "Frameworks/ExpoModulesCore.framework",
158
+ "main.jsbundle",
159
+ ];
160
+
161
+ function isSimulatorUdid(value: string): boolean {
162
+ return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(value);
163
+ }
164
+
165
+ /** What to do with a persisted device state when reaping during a grid poll. */
166
+ type StaleStateAction = "keep" | "recycle-self" | "recycle-helper";
167
+
168
+ /**
169
+ * Decide how to reap a state record whose backing simulator may have been shut
170
+ * down. A booted device (or a non-simulator/unknown `booted` set) is kept.
171
+ *
172
+ * The critical distinction is `recycle-self` vs `recycle-helper`: in in-process
173
+ * mode `inProcessServeSimState` records the *server's own* pid, so SIGTERMing it
174
+ * (as we do for a separate stale helper) would kill the whole server — and
175
+ * index.ts converts SIGTERM into `process.exit`. When the dead device is ours,
176
+ * we stop just that device's capture session instead of signalling the pid.
177
+ */
178
+ function classifyStaleState(
179
+ state: { pid: number; device: string },
180
+ booted: Set<string> | null,
181
+ selfPid: number,
182
+ ): StaleStateAction {
183
+ if (booted && isSimulatorUdid(state.device) && !booted.has(state.device)) {
184
+ return state.pid === selfPid ? "recycle-self" : "recycle-helper";
185
+ }
186
+ return "keep";
187
+ }
188
+
189
+ function detectReactNative(udid: string, bundleId: string): Promise<boolean> {
190
+ if (RN_BUNDLE_IDS.has(bundleId)) return Promise.resolve(true);
191
+ return new Promise((resolve) => {
192
+ execFile("xcrun", ["simctl", "get_app_container", udid, bundleId, "app"],
193
+ { timeout: 2000 },
194
+ (err, stdout) => {
195
+ if (err) return resolve(false);
196
+ const appPath = stdout.trim();
197
+ if (!appPath) return resolve(false);
198
+ for (const marker of RN_MARKERS) {
199
+ if (existsSync(join(appPath, marker))) return resolve(true);
200
+ }
201
+ resolve(false);
202
+ });
203
+ });
204
+ }
205
+
206
+ type InstalledApp = {
207
+ CFBundleDisplayName?: string;
208
+ CFBundleExecutable?: string;
209
+ CFBundleIdentifier?: string;
210
+ CFBundleName?: string;
211
+ };
212
+
213
+ function normalizeAppName(name: string): string {
214
+ return name.trim().replace(/\s+/g, " ").toLowerCase();
215
+ }
216
+
217
+ export function matchInstalledAppByDisplayName(
218
+ apps: Record<string, InstalledApp>,
219
+ displayName: string,
220
+ ): string | null {
221
+ const wanted = normalizeAppName(displayName);
222
+ if (!wanted) return null;
223
+
224
+ for (const [bundleId, app] of Object.entries(apps)) {
225
+ const names = [
226
+ app.CFBundleDisplayName,
227
+ app.CFBundleName,
228
+ app.CFBundleExecutable,
229
+ ].filter((value): value is string => typeof value === "string");
230
+ if (names.some((name) => normalizeAppName(name) === wanted)) {
231
+ return app.CFBundleIdentifier || bundleId;
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+
237
+ // Cache simctl's booted-device set briefly so per-request cost stays bounded.
238
+ // The middleware runs inside the user's dev server (Metro etc.) and
239
+ // readServeSimStates() is called on every /api and every page load.
240
+ let bootedSnapshot: { at: number; booted: Set<string> | null } = { at: 0, booted: null };
241
+ async function getBootedUdids(): Promise<Set<string> | null> {
242
+ const now = Date.now();
243
+ if (bootedSnapshot.booted && now - bootedSnapshot.at < 1500) {
244
+ return bootedSnapshot.booted;
245
+ }
246
+ try {
247
+ const stdout = await new Promise<string>((resolve, reject) => {
248
+ execFile(
249
+ "xcrun",
250
+ ["simctl", "list", "devices", "booted", "-j"],
251
+ { encoding: "utf-8", timeout: 3_000 },
252
+ (err, stdout) => {
253
+ if (err) {
254
+ reject(err);
255
+ } else {
256
+ resolve(stdout);
257
+ }
258
+ },
259
+ );
260
+ });
261
+ const data = JSON.parse(stdout) as SimctlBootedList;
262
+ const booted = new Set<string>();
263
+ for (const runtime of Object.values(data.devices)) {
264
+ for (const device of runtime) {
265
+ if (device.state === "Booted") booted.add(device.udid);
266
+ }
267
+ }
268
+ bootedSnapshot = { at: now, booted };
269
+ return booted;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ // The device the user most recently opened in Simulator.app, regardless of
276
+ // which tool launched it. Simulator.app persists this as CurrentDeviceUDID, so
277
+ // it's the best signal for "the device this user actually cares about" — we
278
+ // surface it near the top of the grid the way Xcode's Devices window does.
279
+ let preferredSnapshot: { at: number; udid: string | null } = { at: 0, udid: null };
280
+ function getPreferredDeviceUdid(): string | null {
281
+ const now = Date.now();
282
+ if (now - preferredSnapshot.at < 1500) return preferredSnapshot.udid;
283
+ let udid: string | null = null;
284
+ try {
285
+ udid =
286
+ execSync("defaults read com.apple.iphonesimulator CurrentDeviceUDID", {
287
+ encoding: "utf-8",
288
+ stdio: ["ignore", "pipe", "ignore"],
289
+ timeout: 1500,
290
+ }).trim() || null;
291
+ } catch {
292
+ udid = null;
293
+ }
294
+ preferredSnapshot = { at: now, udid };
295
+ return udid;
296
+ }
297
+
298
+ export async function readServeSimStates(): Promise<ServeSimState[]> {
299
+ let files: string[];
300
+ try {
301
+ files = readdirSync(STATE_DIR).filter(
302
+ (f) => f.startsWith("server-") && f.endsWith(".json"),
303
+ );
304
+ } catch {
305
+ return [];
306
+ }
307
+ const booted = await getBootedUdids();
308
+ const states: ServeSimState[] = [];
309
+ for (const f of files) {
310
+ const path = join(STATE_DIR, f);
311
+ try {
312
+ const state: ServeSimState = JSON.parse(readFileSync(path, "utf-8"));
313
+ try {
314
+ process.kill(state.pid, 0);
315
+ } catch {
316
+ debugMw("helper pid=%d gone, removing %s", state.pid, path);
317
+ try { unlinkSync(path); } catch {}
318
+ continue;
319
+ }
320
+ // Helper alive but its simulator was shut down — the MJPEG stream
321
+ // would accept connections yet never produce frames, leaving the
322
+ // preview stuck on "Connecting...". Recycle the stale state so the
323
+ // caller can spawn a fresh helper bound to whatever is booted.
324
+ const action = classifyStaleState(state, booted, process.pid);
325
+ if (action !== "keep") {
326
+ if (action === "recycle-self") {
327
+ // This device is streamed in-process by *us* (the close button just
328
+ // shut its sim down). SIGTERMing state.pid would kill the whole
329
+ // server; instead stop just this device's capture session.
330
+ debugMw(
331
+ "closing in-process session for shut-down device %s (own pid %d)",
332
+ state.device,
333
+ state.pid,
334
+ );
335
+ closeDeviceSession(state.device);
336
+ } else {
337
+ debugMw(
338
+ "recycling stale helper pid=%d (device %s no longer booted)",
339
+ state.pid,
340
+ state.device,
341
+ );
342
+ try { process.kill(state.pid, "SIGTERM"); } catch {}
343
+ }
344
+ try { unlinkSync(path); } catch {}
345
+ continue;
346
+ }
347
+ states.push(state);
348
+ } catch {}
349
+ }
350
+ return states;
351
+ }
352
+
353
+ export function selectServeSimState(
354
+ states: ServeSimState[],
355
+ device?: string | null,
356
+ ): ServeSimState | null {
357
+ if (device) {
358
+ return states.find((state) => state.device === device) ?? null;
359
+ }
360
+ return states[0] ?? null;
361
+ }
362
+
363
+ function queryDevice(rawUrl: string): string | null {
364
+ const qIndex = rawUrl.indexOf("?");
365
+ if (qIndex === -1) return null;
366
+ return new URLSearchParams(rawUrl.slice(qIndex + 1)).get("device");
367
+ }
368
+
369
+ /**
370
+ * Parse `/grid/api` pagination params. `limit` absent → return the whole list
371
+ * (back-compat for embedded mounts that expect every device in one response).
372
+ * The full DeviceKit `chrome` descriptor is only resolved for the returned
373
+ * page, so a remote viewer over a tunnel fetches a small first page instead of
374
+ * the whole simulator catalog (~150KB) up front.
375
+ */
376
+ export function parseGridPaging(rawUrl: string): { limit: number | null; offset: number } {
377
+ const qIndex = rawUrl.indexOf("?");
378
+ if (qIndex === -1) return { limit: null, offset: 0 };
379
+ const params = new URLSearchParams(rawUrl.slice(qIndex + 1));
380
+ const rawLimit = params.get("limit");
381
+ const rawOffset = params.get("offset");
382
+ // Clamp to sane bounds; ignore non-numeric/negative input rather than erroring.
383
+ const limit =
384
+ rawLimit == null || !/^\d+$/.test(rawLimit)
385
+ ? null
386
+ : Math.min(Math.max(Number(rawLimit), 1), 1000);
387
+ const offset =
388
+ rawOffset == null || !/^\d+$/.test(rawOffset) ? 0 : Math.max(Number(rawOffset), 0);
389
+ return { limit, offset };
390
+ }
391
+
392
+ function hostForRequest(req: SimReq): string | undefined {
393
+ const host = req.headers?.host;
394
+ if (host) return host;
395
+ const port = req.socket.localPort;
396
+ return port ? `localhost:${port}` : undefined;
397
+ }
398
+
399
+ function endpoint(base: string, path: string, device: string): string {
400
+ const value = `${base}${path}`;
401
+ return `${value}?device=${encodeURIComponent(device)}`;
402
+ }
403
+
404
+ function streamSettingsEndpointFrom(streamUrl: string): string {
405
+ const url = new URL(streamUrl);
406
+ url.pathname = `${url.pathname.slice(0, url.pathname.lastIndexOf("/") + 1)}stream-settings`;
407
+ url.search = "";
408
+ url.hash = "";
409
+ return url.toString();
410
+ }
411
+
412
+ /**
413
+ * Rewrite the helper URLs in a state for the requesting browser.
414
+ *
415
+ * When `proxy` is set (standalone `serve-sim`, which owns its server and wires
416
+ * WebSocket upgrades), the URLs point at the preview's same-origin `/helper`
417
+ * proxy so remote viewers only need the one preview port. When it's off — the
418
+ * default for embedded `app.use(simMiddleware(...))` mounts, where the host's
419
+ * server doesn't forward `upgrade` events to `handleUpgrade` — the helper's
420
+ * loopback URLs are emitted directly (with `127.0.0.1` swapped for the request
421
+ * hostname so LAN/tunnel viewers can still reach the separate helper port).
422
+ */
423
+ export function rewriteStateForRequestHost(
424
+ state: ServeSimState,
425
+ hostHeader: string | undefined,
426
+ base = "",
427
+ protocol: "http" | "https" = "http",
428
+ proxy = false,
429
+ ): ServeSimState {
430
+ if (!hostHeader) {
431
+ return state;
432
+ }
433
+ if (!proxy) {
434
+ let hostname: string;
435
+ try {
436
+ hostname = new URL(`http://${hostHeader}`).hostname;
437
+ } catch {
438
+ return state;
439
+ }
440
+ // `URL.hostname` keeps brackets around IPv6 literals, so the IPv6 loopback
441
+ // comparison is against the bracketed form rather than `::1`.
442
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") {
443
+ return state;
444
+ }
445
+ const rewrite = (s: string) => s.replace("127.0.0.1", hostname);
446
+ return {
447
+ ...state,
448
+ url: rewrite(state.url),
449
+ streamUrl: rewrite(state.streamUrl),
450
+ wsUrl: rewrite(state.wsUrl),
451
+ };
452
+ }
453
+ const normalizedBase = base === "/" ? "" : base.replace(/\/+$/, "");
454
+ const helperBase = `${normalizedBase}/helper`;
455
+ const devicePath = `${helperBase}/${encodeURIComponent(state.device)}`;
456
+ // Match the request's scheme so an HTTPS-served preview doesn't hand the
457
+ // browser `http`/`ws` helper URLs (blocked as mixed content). Behind a proxy
458
+ // the original scheme arrives via `x-forwarded-proto`.
459
+ const origin = `${protocol}://${hostHeader}`;
460
+ const wsOrigin = `${protocol === "https" ? "wss" : "ws"}://${hostHeader}`;
461
+ return {
462
+ ...state,
463
+ url: `${origin}${devicePath}`,
464
+ streamUrl: `${origin}${devicePath}/stream.mjpeg`,
465
+ wsUrl: `${wsOrigin}${devicePath}/ws`,
466
+ };
467
+ }
468
+
469
+ function helperProxyPrefix(base: string): string {
470
+ return `${base === "/" ? "" : base}/helper`;
471
+ }
472
+
473
+ function devtoolsProxyPrefix(base: string): string {
474
+ return `${base === "/" ? "" : base}/devtools`;
475
+ }
476
+
477
+ function devtoolsProxyTarget(rawUrl: string, prefix: string): { upstreamPath: string } | null {
478
+ const parsed = new URL(rawUrl, "http://serve-sim.local");
479
+ if (!parsed.pathname.startsWith(`${prefix}/page/`)) {
480
+ return null;
481
+ }
482
+ const suffix = parsed.pathname.slice(prefix.length);
483
+ return { upstreamPath: `/devtools${suffix}${parsed.search}` };
484
+ }
485
+
486
+ function helperProxyTarget(rawUrl: string, prefix: string): { device: string | null; upstreamPath: string } | null {
487
+ const parsed = new URL(rawUrl, "http://serve-sim.local");
488
+ if (parsed.pathname !== prefix && !parsed.pathname.startsWith(`${prefix}/`)) {
489
+ return null;
490
+ }
491
+ const rawSuffix = parsed.pathname.slice(prefix.length);
492
+ const segments = rawSuffix.replace(/^\/+/, "").split("/").filter(Boolean);
493
+ const directHelperEndpoints = new Set([
494
+ "ax",
495
+ "config",
496
+ "foreground",
497
+ "health",
498
+ "stream.avcc",
499
+ "stream.mjpeg",
500
+ "webrtc",
501
+ "ws",
502
+ ]);
503
+ let device = parsed.searchParams.get("device");
504
+ let upstreamSegments = segments;
505
+ if (segments[0] && !directHelperEndpoints.has(segments[0])) {
506
+ device = decodeURIComponent(segments[0]);
507
+ upstreamSegments = segments.slice(1);
508
+ }
509
+ const suffix = upstreamSegments.length > 0 ? `/${upstreamSegments.join("/")}` : "/";
510
+ parsed.searchParams.delete("device");
511
+ return { device, upstreamPath: `${suffix}${parsed.search}` };
512
+ }
513
+
514
+ const WS_ACCEPT_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
515
+
516
+ function websocketFrame(opcode: number, payload: Buffer<ArrayBufferLike>): Buffer {
517
+ const length = payload.length;
518
+ let header: Buffer;
519
+ if (length < 126) {
520
+ header = Buffer.from([0x80 | opcode, length]);
521
+ } else if (length <= 0xffff) {
522
+ header = Buffer.alloc(4);
523
+ header[0] = 0x80 | opcode;
524
+ header[1] = 126;
525
+ header.writeUInt16BE(length, 2);
526
+ } else {
527
+ header = Buffer.alloc(10);
528
+ header[0] = 0x80 | opcode;
529
+ header[1] = 127;
530
+ header.writeBigUInt64BE(BigInt(length), 2);
531
+ }
532
+ return Buffer.concat([header, payload]);
533
+ }
534
+
535
+ type ParsedWebSocketFrame = {
536
+ opcode: number;
537
+ payload: Buffer<ArrayBufferLike>;
538
+ consumed: number;
539
+ };
540
+
541
+ function parseWebSocketFrame(buffer: Buffer): ParsedWebSocketFrame | null {
542
+ if (buffer.length < 2) return null;
543
+ const opcode = buffer[0]! & 0x0f;
544
+ const masked = (buffer[1]! & 0x80) !== 0;
545
+ let length = buffer[1]! & 0x7f;
546
+ let offset = 2;
547
+ if (length === 126) {
548
+ if (buffer.length < offset + 2) return null;
549
+ length = buffer.readUInt16BE(offset);
550
+ offset += 2;
551
+ } else if (length === 127) {
552
+ if (buffer.length < offset + 8) return null;
553
+ const bigLength = buffer.readBigUInt64BE(offset);
554
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
555
+ throw new Error("WebSocket frame too large");
556
+ }
557
+ length = Number(bigLength);
558
+ offset += 8;
559
+ }
560
+ const maskOffset = offset;
561
+ if (masked) offset += 4;
562
+ if (buffer.length < offset + length) return null;
563
+ const payload = Buffer.from(buffer.subarray(offset, offset + length));
564
+ if (masked) {
565
+ const mask = buffer.subarray(maskOffset, maskOffset + 4);
566
+ for (let i = 0; i < payload.length; i++) {
567
+ payload[i] = payload[i]! ^ mask[i % 4]!;
568
+ }
569
+ }
570
+ return { opcode, payload, consumed: offset + length };
571
+ }
572
+
573
+ function sendBrowserFrame(socket: Socket, opcode: number, payload: Buffer<ArrayBufferLike> = Buffer.alloc(0)): void {
574
+ if (socket.destroyed || !socket.writable) return;
575
+ socket.write(websocketFrame(opcode, payload));
576
+ }
577
+
578
+ type PendingWebSocketFrame = {
579
+ opcode: number;
580
+ payload: Buffer<ArrayBufferLike>;
581
+ };
582
+
583
+ function webSocketBinary(payload: Buffer<ArrayBufferLike>): Uint8Array<ArrayBuffer> {
584
+ const bytes = new Uint8Array(payload.length);
585
+ bytes.set(payload);
586
+ return bytes;
587
+ }
588
+
589
+ /**
590
+ * Complete the server side of a WebSocket upgrade by hand (the `ws` server's
591
+ * handshake doesn't flush under Bun). Writes the 101 response and resumes the
592
+ * socket on success; on a missing key writes 400 and returns false.
593
+ */
594
+ function writeWebSocketAccept(req: SimReq, socket: Socket): boolean {
595
+ const key = req.headers["sec-websocket-key"];
596
+ if (typeof key !== "string") {
597
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
598
+ return false;
599
+ }
600
+ const accept = createHash("sha1").update(key + WS_ACCEPT_GUID).digest("base64");
601
+ socket.write(
602
+ "HTTP/1.1 101 Switching Protocols\r\n" +
603
+ "Upgrade: websocket\r\n" +
604
+ "Connection: Upgrade\r\n" +
605
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
606
+ "\r\n",
607
+ );
608
+ socket.resume();
609
+ return true;
610
+ }
611
+
612
+ function bridgeWebSocketFrames(req: SimReq, socket: Socket, head: Buffer, upstreamUrl: string): void {
613
+ if (!writeWebSocketAccept(req, socket)) return;
614
+
615
+ const upstream = new WebSocket(upstreamUrl);
616
+ upstream.binaryType = "arraybuffer";
617
+ let upstreamOpen = false;
618
+ let closed = false;
619
+ let pendingToUpstream: PendingWebSocketFrame[] = [];
620
+ let buffered = Buffer.from(head);
621
+
622
+ const closeBoth = () => {
623
+ if (closed) return;
624
+ closed = true;
625
+ try { upstream.close(); } catch {}
626
+ try { socket.end(websocketFrame(0x8, Buffer.alloc(0))); } catch {}
627
+ try { socket.destroy(); } catch {}
628
+ };
629
+
630
+ const sendToUpstream = (frame: PendingWebSocketFrame) => {
631
+ if (upstreamOpen && upstream.readyState === WebSocket.OPEN) {
632
+ upstream.send(frame.opcode === 0x1 ? frame.payload.toString("utf8") : webSocketBinary(frame.payload));
633
+ return;
634
+ }
635
+ pendingToUpstream.push({ opcode: frame.opcode, payload: Buffer.from(frame.payload) });
636
+ };
637
+
638
+ const drainFrames = () => {
639
+ try {
640
+ while (buffered.length > 0) {
641
+ const frame = parseWebSocketFrame(buffered);
642
+ if (!frame) break;
643
+ buffered = buffered.subarray(frame.consumed);
644
+ if (frame.opcode === 0x8) {
645
+ sendBrowserFrame(socket, 0x8, frame.payload);
646
+ closeBoth();
647
+ return;
648
+ }
649
+ if (frame.opcode === 0x9) {
650
+ sendBrowserFrame(socket, 0xA, frame.payload);
651
+ continue;
652
+ }
653
+ if (frame.opcode === 0x1 || frame.opcode === 0x2) {
654
+ sendToUpstream({ opcode: frame.opcode, payload: frame.payload });
655
+ }
656
+ }
657
+ } catch {
658
+ closeBoth();
659
+ }
660
+ };
661
+
662
+ upstream.onopen = () => {
663
+ upstreamOpen = true;
664
+ for (const frame of pendingToUpstream) {
665
+ upstream.send(frame.opcode === 0x1 ? frame.payload.toString("utf8") : webSocketBinary(frame.payload));
666
+ }
667
+ pendingToUpstream = [];
668
+ };
669
+ upstream.onmessage = (event) => {
670
+ const data = event.data;
671
+ const payload = typeof data === "string"
672
+ ? Buffer.from(data)
673
+ : Buffer.from(data as ArrayBuffer);
674
+ sendBrowserFrame(socket, typeof data === "string" ? 0x1 : 0x2, payload);
675
+ };
676
+ upstream.onerror = closeBoth;
677
+ upstream.onclose = closeBoth;
678
+
679
+ socket.on("data", (chunk) => {
680
+ if (typeof chunk === "string") chunk = Buffer.from(chunk);
681
+ buffered = Buffer.concat([buffered, chunk]);
682
+ drainFrames();
683
+ });
684
+ socket.on("error", closeBoth);
685
+ socket.on("close", closeBoth);
686
+ drainFrames();
687
+ }
688
+
689
+ /** Read camera-helper state without opening the simulator capture session. */
690
+ async function handleCameraStatus(req: SimReq, res: SimRes, device: string): Promise<void> {
691
+ if (!isSimulatorUdid(device)) {
692
+ res.writeHead(400, {
693
+ "Content-Type": "application/json",
694
+ "Cache-Control": "no-store",
695
+ });
696
+ res.end(JSON.stringify({ error: "invalid_device" }));
697
+ return;
698
+ }
699
+ if (req.method !== "GET") {
700
+ res.writeHead(405, {
701
+ Allow: "GET",
702
+ "Content-Type": "application/json",
703
+ "Cache-Control": "no-store",
704
+ });
705
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
706
+ return;
707
+ }
708
+ try {
709
+ const status = await readCameraStatus(device);
710
+ res.writeHead(200, {
711
+ "Content-Type": "application/json",
712
+ "Cache-Control": "no-store",
713
+ });
714
+ res.end(JSON.stringify(status));
715
+ } catch (error) {
716
+ res.writeHead(500, {
717
+ "Content-Type": "application/json",
718
+ "Cache-Control": "no-store",
719
+ });
720
+ res.end(JSON.stringify({
721
+ udid: device,
722
+ alive: false,
723
+ error: error instanceof Error ? error.message : String(error),
724
+ }));
725
+ }
726
+ }
727
+
728
+ /**
729
+ * Serve a device-scoped helper endpoint in-process. Camera status reads the
730
+ * camera helper's persisted state; stream and input routes lazily create a
731
+ * DeviceSession. Returns false for paths this function doesn't own or when a
732
+ * session-backed route cannot open the requested simulator.
733
+ */
734
+ function serveHelperInProcess(
735
+ req: SimReq,
736
+ res: SimRes,
737
+ device: string | null,
738
+ upstreamPath: string,
739
+ initialStreamSettings?: StreamSettings,
740
+ ): boolean {
741
+ if (!device) return false;
742
+ const endpoint = upstreamPath.split("?")[0];
743
+ if (endpoint === "/camera/status") {
744
+ void handleCameraStatus(req, res, device);
745
+ return true;
746
+ }
747
+ if (
748
+ (endpoint === "/webrtc/offer" || endpoint === "/webrtc/close" || endpoint === "/stream-settings")
749
+ && req.method === "OPTIONS"
750
+ ) {
751
+ sendCorsPreflight(res);
752
+ return true;
753
+ }
754
+ let session;
755
+ try {
756
+ session = getDeviceSession(device, initialStreamSettings);
757
+ } catch {
758
+ return false; // not booted / capture unavailable → 404
759
+ }
760
+ switch (endpoint) {
761
+ case "/stream.mjpeg": session.handleMjpeg(req, res); return true;
762
+ case "/stream.avcc": session.handleAvcc(req, res); return true;
763
+ case "/stream-settings": void session.handleStreamSettings(req, res); return true;
764
+ case "/config": session.handleConfig(req, res); return true;
765
+ case "/health": session.handleHealth(req, res); return true;
766
+ case "/webrtc/offer": void session.handleWebRTCOffer(req, res); return true;
767
+ case "/webrtc/close": void session.handleWebRTCClose(req, res); return true;
768
+ case "/ax": session.handleAx(req, res); return true;
769
+ case "/foreground": session.handleForeground(req, res); return true;
770
+ default: return false;
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Boot a simulator (if needed) and record its in-process state so the grid /
776
+ * preview enumerate it. Replaces spawning `serve-sim --detach <udid>`; the
777
+ * preview server itself serves the device's /helper routes in-process. Resolves
778
+ * to an error string on boot failure, or null on success.
779
+ */
780
+ export async function startDeviceInProcess(
781
+ udid: string,
782
+ port: number,
783
+ base: string,
784
+ streamSettings?: StreamSettings,
785
+ ): Promise<string | null> {
786
+ // `simctl boot` errors when already booted — ignore and let bootstatus confirm.
787
+ await new Promise<void>((resolve) => execFile("xcrun", ["simctl", "boot", udid], () => resolve()));
788
+ const ready = await new Promise<boolean>((resolve) => {
789
+ execFile("xcrun", ["simctl", "bootstatus", udid, "-b"], { timeout: 180_000 }, (err) => resolve(!err));
790
+ });
791
+ if (!ready) {
792
+ // bootstatus can exit non-zero even when the device is actually ready;
793
+ // confirm against the real state before reporting failure.
794
+ const booted = await new Promise<boolean>((resolve) => {
795
+ execFile("xcrun", ["simctl", "list", "devices", "-j"], (err, stdout) => {
796
+ if (err) return resolve(false);
797
+ try {
798
+ const data = JSON.parse(stdout) as { devices: Record<string, Array<{ udid: string; state: string }>> };
799
+ resolve(Object.values(data.devices).flat().some((d) => d.udid === udid && d.state === "Booted"));
800
+ } catch {
801
+ resolve(false);
802
+ }
803
+ });
804
+ });
805
+ if (!booted) return `Device ${udid} failed to reach booted state`;
806
+ }
807
+ writeServeSimState(inProcessServeSimState(udid, port, base, "127.0.0.1", streamSettings));
808
+ return null;
809
+ }
810
+
811
+ /**
812
+ * Adapt a raw upgraded socket into the minimal HidSocket the DeviceSession
813
+ * needs. We do the WebSocket framing by hand (same helpers as the DevTools
814
+ * bridge) rather than via `ws`'s server, whose handshake doesn't flush under
815
+ * Bun — and the production CLI is a bun-compiled binary.
816
+ */
817
+ function rawHidSocket(socket: Socket, head: Buffer): HidSocket {
818
+ const messageCbs: Array<(d: Buffer) => void> = [];
819
+ const closeCbs: Array<() => void> = [];
820
+ let buffered = Buffer.from(head);
821
+ let closed = false;
822
+
823
+ const fireClose = () => {
824
+ if (closed) return;
825
+ closed = true;
826
+ for (const cb of closeCbs) cb();
827
+ };
828
+ const shutdown = () => {
829
+ fireClose();
830
+ try { socket.end(websocketFrame(0x8, Buffer.alloc(0))); } catch {}
831
+ try { socket.destroy(); } catch {}
832
+ };
833
+
834
+ const drain = () => {
835
+ for (;;) {
836
+ let frame: ParsedWebSocketFrame | null;
837
+ try {
838
+ frame = parseWebSocketFrame(buffered);
839
+ } catch {
840
+ shutdown();
841
+ return;
842
+ }
843
+ if (!frame) return;
844
+ buffered = buffered.subarray(frame.consumed);
845
+ if (frame.opcode === 0x8) return shutdown(); // close
846
+ if (frame.opcode === 0x9) { sendBrowserFrame(socket, 0xa, frame.payload); continue; } // ping → pong
847
+ if (frame.opcode === 0x1 || frame.opcode === 0x2) {
848
+ for (const cb of messageCbs) cb(frame.payload);
849
+ }
850
+ }
851
+ };
852
+
853
+ socket.on("data", (chunk: Buffer) => { buffered = Buffer.concat([buffered, chunk]); drain(); });
854
+ socket.on("close", fireClose);
855
+ socket.on("error", fireClose);
856
+ if (head.length) drain();
857
+
858
+ return {
859
+ send(data: Buffer) { sendBrowserFrame(socket, 0x2, data); },
860
+ on(event: "message" | "close" | "error", cb: (data: Buffer) => void) {
861
+ if (event === "message") messageCbs.push(cb);
862
+ else closeCbs.push(cb as () => void);
863
+ },
864
+ close: shutdown,
865
+ };
866
+ }
867
+
868
+ /** Upgrade an in-process HID `/ws` socket onto a DeviceSession. Returns false when no session can serve it. */
869
+ function attachHidInProcess(
870
+ req: SimReq,
871
+ socket: Socket,
872
+ head: Buffer,
873
+ device: string | null,
874
+ initialStreamSettings?: StreamSettings,
875
+ ): boolean {
876
+ if (!device) return false;
877
+ let session;
878
+ try {
879
+ session = getDeviceSession(device, initialStreamSettings);
880
+ } catch {
881
+ return false;
882
+ }
883
+ if (!writeWebSocketAccept(req, socket)) return true; // bad request handled
884
+ session.attachHidSocket(rawHidSocket(socket, head));
885
+ return true;
886
+ }
887
+
888
+ export function previewConfigForState(
889
+ state: ServeSimState,
890
+ base: string,
891
+ serveSimBin: string,
892
+ execToken: string,
893
+ streamSettingsOrCodec?: StreamSettings | string,
894
+ proxyHelpers = false,
895
+ ): ServeSimState & {
896
+ basePath: string;
897
+ logsEndpoint: string;
898
+ appStateEndpoint: string;
899
+ eventLogEndpoint: string;
900
+ eventLogEventsEndpoint: string;
901
+ metricsEndpoint: string;
902
+ axEndpoint: string;
903
+ cameraStatusEndpoint: string;
904
+ devtoolsEndpoint: string;
905
+ streamSettingsEndpoint: string;
906
+ serveSimBin: string;
907
+ gridApiEndpoint: string;
908
+ gridStartEndpoint: string;
909
+ gridShutdownEndpoint: string;
910
+ gridMemoryEndpoint: string;
911
+ previewEndpoint: string;
912
+ execToken: string;
913
+ /** @deprecated Use streamSettings. */
914
+ codec?: string;
915
+ streamSettings?: StreamSettings;
916
+ proxyHelpers?: boolean;
917
+ } {
918
+ const gridApiBase = (base === "" ? "" : base) + "/grid/api";
919
+ const legacyCodec = typeof streamSettingsOrCodec === "string" ? streamSettingsOrCodec : undefined;
920
+ const streamSettings = typeof streamSettingsOrCodec === "object"
921
+ ? streamSettingsOrCodec
922
+ : httpStreamSettingsFromLegacyCodec(legacyCodec);
923
+ return {
924
+ ...state,
925
+ basePath: base,
926
+ logsEndpoint: endpoint(base, "/logs", state.device),
927
+ appStateEndpoint: endpoint(base, "/appstate", state.device),
928
+ eventLogEndpoint: endpoint(base, "/api/event-log", state.device),
929
+ eventLogEventsEndpoint: endpoint(base, "/api/event-log/events", state.device),
930
+ metricsEndpoint: endpoint(base, "/metrics", state.device),
931
+ axEndpoint: endpoint(base, "/ax", state.device),
932
+ cameraStatusEndpoint: `${base === "/" ? "" : base}/helper/${encodeURIComponent(state.device)}/camera/status`,
933
+ devtoolsEndpoint: endpoint(base, "/devtools", state.device),
934
+ streamSettingsEndpoint: streamSettingsEndpointFrom(state.streamUrl),
935
+ serveSimBin,
936
+ gridApiEndpoint: gridApiBase,
937
+ gridStartEndpoint: gridApiBase + "/start",
938
+ gridShutdownEndpoint: gridApiBase + "/shutdown",
939
+ gridMemoryEndpoint: gridApiBase + "/memory",
940
+ previewEndpoint: base === "" ? "/" : base,
941
+ execToken,
942
+ ...(legacyCodec ? { codec: legacyCodec } : {}),
943
+ ...(streamSettings ? { streamSettings } : {}),
944
+ ...(proxyHelpers ? { proxyHelpers: true } : {}),
945
+ };
946
+ }
947
+
948
+ async function isLocalPortFree(port: number): Promise<boolean> {
949
+ return new Promise((resolve) => {
950
+ const server = createNetServer();
951
+ server.once("error", () => resolve(false));
952
+ server.once("listening", () => server.close(() => resolve(true)));
953
+ server.listen(port, "127.0.0.1");
954
+ });
955
+ }
956
+
957
+ async function existingInspectWebKitBridge(port: number): Promise<WebKitBridge | null> {
958
+ const cdpUrl = `http://127.0.0.1:${port}`;
959
+ try {
960
+ const versionRes = await fetch(`${cdpUrl}/json/version`);
961
+ if (!versionRes.ok) return null;
962
+ const version = await versionRes.json() as CdpHttpVersion;
963
+ if (version.Browser !== "Safari/inspect-webkit") return null;
964
+ return {
965
+ port,
966
+ cdpUrl,
967
+ async listTargets() {
968
+ // Hitting the bridge over HTTP loses the rich fields available to
969
+ // an in-process consumer (appName, inUseByOtherInspector). The id
970
+ // shape `sim:<udid>:<appId>:<pageId>` and the description string
971
+ // `<deviceLabel> (<bundleId>)` are all we have here.
972
+ const listRes = await fetch(`${cdpUrl}/json/list`);
973
+ const targets = await listRes.json() as CdpHttpListEntry[];
974
+ return targets
975
+ .filter((target) => target.id.startsWith("sim:"))
976
+ .map((target) => {
977
+ const idParts = target.id.split(":");
978
+ const udid = idParts[1];
979
+ const bundleId = target.description?.match(/\(([^)]+)\)/)?.[1];
980
+ return {
981
+ id: target.id,
982
+ title: target.title || target.url || "Untitled",
983
+ url: /^https?:/i.test(target.url) ? target.url : "about:blank",
984
+ type: target.type || "page",
985
+ udid,
986
+ bundleId,
987
+ };
988
+ });
989
+ },
990
+ };
991
+ } catch {
992
+ return null;
993
+ }
994
+ }
995
+
996
+ async function ensureInspectWebKitBridge(): Promise<WebKitBridge> {
997
+ if (inspectWebKitBridge) {
998
+ try {
999
+ // Probe so a dead bridge gets retired instead of poisoning every call.
1000
+ await (await inspectWebKitBridge).listTargets();
1001
+ return inspectWebKitBridge;
1002
+ } catch {
1003
+ inspectWebKitBridge = null;
1004
+ }
1005
+ }
1006
+ inspectWebKitBridge = (async () => {
1007
+ const { startCdpServer } = await import("inspect-webkit");
1008
+ for (let port = INSPECT_WEBKIT_START_PORT; port < INSPECT_WEBKIT_START_PORT + 50; port++) {
1009
+ if (!(await isLocalPortFree(port))) {
1010
+ const existing = await existingInspectWebKitBridge(port);
1011
+ if (existing) return existing;
1012
+ continue;
1013
+ }
1014
+ try {
1015
+ // Bind explicitly to IPv4 127.0.0.1 so the preview's DevTools
1016
+ // websocket proxy has a stable loopback upstream. `localhost` resolves
1017
+ // to ::1 first on some setups, which would leave the bridge unreachable.
1018
+ const server = await startCdpServer({ host: "127.0.0.1", port }) as Awaited<ReturnType<typeof startCdpServer>> & {
1019
+ highlightTarget?(targetId: string, on: boolean): Promise<void>;
1020
+ releaseHighlight?(targetId?: string): void;
1021
+ };
1022
+ return {
1023
+ port,
1024
+ cdpUrl: `http://127.0.0.1:${port}`,
1025
+ async listTargets() {
1026
+ return (server.getTargets() as InspectWebKitBridgeTarget[])
1027
+ .filter((target) => target.source?.kind === "simulator")
1028
+ .map((target) => {
1029
+ const url = target.url ?? "";
1030
+ return {
1031
+ id: target.targetId,
1032
+ title: target.title || target.appName || url || "Untitled",
1033
+ url: /^https?:/i.test(url) ? url : "about:blank",
1034
+ type: target.type || "page",
1035
+ appName: target.appName,
1036
+ bundleId: target.bundleId,
1037
+ udid: target.source?.id,
1038
+ inUseByOtherInspector: !!target.inUseByOtherInspector,
1039
+ };
1040
+ });
1041
+ },
1042
+ highlightTarget: server.highlightTarget?.bind(server),
1043
+ releaseHighlight: server.releaseHighlight?.bind(server),
1044
+ };
1045
+ } catch (err) {
1046
+ if ((err as NodeJS.ErrnoException)?.code === "EADDRINUSE") {
1047
+ const existing = await existingInspectWebKitBridge(port);
1048
+ if (existing) return existing;
1049
+ continue;
1050
+ }
1051
+ throw err;
1052
+ }
1053
+ }
1054
+ throw new Error(`No available inspect-webkit port found in ${INSPECT_WEBKIT_START_PORT}-${INSPECT_WEBKIT_START_PORT + 49}`);
1055
+ })().catch((err) => {
1056
+ inspectWebKitBridge = null;
1057
+ throw err;
1058
+ });
1059
+ return inspectWebKitBridge;
1060
+ }
1061
+
1062
+ function firstHeaderValue(value: string | string[] | undefined): string | undefined {
1063
+ return Array.isArray(value) ? value[0] : value;
1064
+ }
1065
+
1066
+ function forwardedProtoForRequest(req: SimReq): string | undefined {
1067
+ return firstHeaderValue(req.headers["x-forwarded-proto"])
1068
+ ?.split(",", 1)[0]
1069
+ ?.trim()
1070
+ .toLowerCase();
1071
+ }
1072
+
1073
+ function websocketProtocolForRequest(req: SimReq): "ws" | "wss" {
1074
+ return forwardedProtoForRequest(req) === "https" ? "wss" : "ws";
1075
+ }
1076
+
1077
+ function httpProtocolForRequest(req: SimReq): "http" | "https" {
1078
+ return forwardedProtoForRequest(req) === "https" ? "https" : "http";
1079
+ }
1080
+
1081
+ function devtoolsFrontendUrl(
1082
+ frontendBase: string,
1083
+ wsParamName: "ws" | "wss",
1084
+ wsTargetBase: string,
1085
+ targetId: string,
1086
+ ): string {
1087
+ const url = new URL(`${frontendBase}/inspector.html`, "http://serve-sim.local");
1088
+ url.searchParams.set(wsParamName, `${wsTargetBase}/page/${encodeURIComponent(targetId)}`);
1089
+ return `${url.pathname}${url.search}`;
1090
+ }
1091
+
1092
+ let _html: string | null = null;
1093
+ /**
1094
+ * Best-effort absolute path to the running serve-sim entry script. Used so
1095
+ * the in-page Camera tool can `node <path> camera ...` regardless of PATH.
1096
+ * Falls back to the literal `serve-sim` if we can't determine a usable path.
1097
+ */
1098
+ function serveSimBinPath(): string {
1099
+ try {
1100
+ const argv = process.argv;
1101
+ if (argv[1] && existsSync(argv[1])) return argv[1];
1102
+ } catch {}
1103
+ return "serve-sim";
1104
+ }
1105
+
1106
+ function loadHtml(): string {
1107
+ if (!_html) {
1108
+ _html = Buffer.from(__PREVIEW_HTML_B64__, "base64").toString("utf-8");
1109
+ }
1110
+ return _html;
1111
+ }
1112
+
1113
+ interface SimctlDevice {
1114
+ udid: string;
1115
+ name: string;
1116
+ state: string;
1117
+ isAvailable?: boolean;
1118
+ deviceTypeIdentifier?: string;
1119
+ runtime: string;
1120
+ }
1121
+
1122
+ function listAllSimulators(): Promise<SimctlDevice[]> {
1123
+ return new Promise((resolve) => {
1124
+ execFile(
1125
+ "xcrun",
1126
+ ["simctl", "list", "devices", "-j"],
1127
+ { encoding: "utf-8", timeout: 3_000 },
1128
+ (err, stdout) => {
1129
+ if (err) return resolve([]);
1130
+ try {
1131
+ const data = JSON.parse(stdout) as SimctlAllList;
1132
+ const out: SimctlDevice[] = [];
1133
+ for (const [runtime, devices] of Object.entries(data.devices)) {
1134
+ // Keep this to touch-capable simulator families that serve-sim can
1135
+ // frame and inject into. tvOS is intentionally left out for now.
1136
+ if (!/SimRuntime\.(iOS|watchOS|visionOS|xrOS)-/i.test(runtime)) continue;
1137
+ for (const d of devices) {
1138
+ if (d.isAvailable === false) continue;
1139
+ out.push({ ...d, runtime: runtime.replace(/^.*SimRuntime\./, "") });
1140
+ }
1141
+ }
1142
+ resolve(out);
1143
+ } catch {
1144
+ resolve([]);
1145
+ }
1146
+ },
1147
+ );
1148
+ });
1149
+ }
1150
+
1151
+ // Default per-simulator footprint when we have no running sim to measure
1152
+ // from — a fresh booted iOS sim with one app launched typically sits in
1153
+ // the 1.2–1.8 GB range. Used as a fallback only.
1154
+ const DEFAULT_PER_SIM_BYTES = 1.5 * 1024 * 1024 * 1024;
1155
+
1156
+ interface MemoryReport {
1157
+ totalBytes: number;
1158
+ availableBytes: number;
1159
+ runningSimulators: number;
1160
+ perSimAvgBytes: number;
1161
+ perSimSource: "measured" | "estimated";
1162
+ estimatedAdditional: number;
1163
+ }
1164
+
1165
+ function readSystemMemory(): { totalBytes: number; availableBytes: number } {
1166
+ try {
1167
+ const totalBytes = Number(
1168
+ execSync("sysctl -n hw.memsize", {
1169
+ encoding: "utf-8",
1170
+ stdio: ["ignore", "pipe", "ignore"],
1171
+ timeout: 1500,
1172
+ }).trim(),
1173
+ );
1174
+ const pageSize = Number(
1175
+ execSync("sysctl -n hw.pagesize", {
1176
+ encoding: "utf-8",
1177
+ stdio: ["ignore", "pipe", "ignore"],
1178
+ timeout: 1500,
1179
+ }).trim(),
1180
+ );
1181
+ const vmStat = execSync("vm_stat", {
1182
+ encoding: "utf-8",
1183
+ stdio: ["ignore", "pipe", "ignore"],
1184
+ timeout: 1500,
1185
+ });
1186
+ const pages = (re: RegExp) => {
1187
+ const m = vmStat.match(re);
1188
+ return m ? Number(m[1]) : 0;
1189
+ };
1190
+ // "Available" mirrors what Activity Monitor treats as reclaimable: free
1191
+ // + inactive + speculative pages. Excludes wired and active.
1192
+ const availablePages =
1193
+ pages(/Pages free:\s+(\d+)/) +
1194
+ pages(/Pages inactive:\s+(\d+)/) +
1195
+ pages(/Pages speculative:\s+(\d+)/);
1196
+ return {
1197
+ totalBytes: Number.isFinite(totalBytes) ? totalBytes : 0,
1198
+ availableBytes: availablePages * (Number.isFinite(pageSize) ? pageSize : 4096),
1199
+ };
1200
+ } catch {
1201
+ return { totalBytes: 0, availableBytes: 0 };
1202
+ }
1203
+ }
1204
+
1205
+ // Sum RSS across every process whose argv path includes a CoreSimulator
1206
+ // device directory. Groups by UDID so we get a real per-sim footprint that
1207
+ // covers launchd_sim plus all child processes the runtime spawns.
1208
+ function readSimulatorMemoryUsage(): { perUdid: Record<string, number>; totalBytes: number } {
1209
+ try {
1210
+ const output = execSync("ps -axo rss=,args=", {
1211
+ encoding: "utf-8",
1212
+ stdio: ["ignore", "pipe", "ignore"],
1213
+ timeout: 3000,
1214
+ maxBuffer: 8 * 1024 * 1024,
1215
+ });
1216
+ const perUdid: Record<string, number> = {};
1217
+ let totalBytes = 0;
1218
+ const re = /\/Devices\/([0-9A-F-]{36})\//i;
1219
+ for (const raw of output.split("\n")) {
1220
+ const line = raw.trimStart();
1221
+ if (!line) continue;
1222
+ const m = re.exec(line);
1223
+ if (!m) continue;
1224
+ const rssKb = Number(line.split(/\s+/, 1)[0]);
1225
+ if (!Number.isFinite(rssKb)) continue;
1226
+ const bytes = rssKb * 1024;
1227
+ const udid = m[1]!.toUpperCase();
1228
+ perUdid[udid] = (perUdid[udid] ?? 0) + bytes;
1229
+ totalBytes += bytes;
1230
+ }
1231
+ return { perUdid, totalBytes };
1232
+ } catch {
1233
+ return { perUdid: {}, totalBytes: 0 };
1234
+ }
1235
+ }
1236
+
1237
+ function buildMemoryReport(): MemoryReport {
1238
+ const { totalBytes, availableBytes } = readSystemMemory();
1239
+ const usage = readSimulatorMemoryUsage();
1240
+ const runningSimulators = Object.keys(usage.perUdid).length;
1241
+ const measuredAvg = runningSimulators > 0
1242
+ ? usage.totalBytes / runningSimulators
1243
+ : 0;
1244
+ // Below ~256MB, the measurement is almost certainly catching a sim mid-boot
1245
+ // before its app processes are resident — fall back to the default so we
1246
+ // don't over-promise capacity.
1247
+ const perSimSource: MemoryReport["perSimSource"] =
1248
+ measuredAvg >= 256 * 1024 * 1024 ? "measured" : "estimated";
1249
+ const perSimAvgBytes =
1250
+ perSimSource === "measured" ? measuredAvg : DEFAULT_PER_SIM_BYTES;
1251
+ const estimatedAdditional = perSimAvgBytes > 0
1252
+ ? Math.max(0, Math.floor(availableBytes / perSimAvgBytes))
1253
+ : 0;
1254
+ return {
1255
+ totalBytes,
1256
+ availableBytes,
1257
+ runningSimulators,
1258
+ perSimAvgBytes,
1259
+ perSimSource,
1260
+ estimatedAdditional,
1261
+ };
1262
+ }
1263
+
1264
+ export interface SimMiddlewareOptions {
1265
+ /** Base path to serve the preview at. Default: "/.sim" */
1266
+ basePath?: string;
1267
+ /** Pin this preview server to a specific simulator UDID. */
1268
+ device?: string;
1269
+ /**
1270
+ * Per-session bearer token gating the `/exec` shell-exec route.
1271
+ * Auto-generated if omitted. The token is injected into the preview HTML
1272
+ * so the in-page UI can call `/exec` same-origin; LAN attackers and
1273
+ * cross-origin pages cannot read it.
1274
+ */
1275
+ execToken?: string;
1276
+ /** Stream transport and codec settings for the preview. */
1277
+ streamSettings?: StreamSettings;
1278
+ /**
1279
+ * Origins allowed to read the `/metrics` SSE stream cross-origin (e.g. a
1280
+ * hosted dashboard). Read-only telemetry only; the control routes stay
1281
+ * same-origin + token-gated regardless. Loopback is always allowed.
1282
+ */
1283
+ metricsCorsOrigins?: string[];
1284
+ /** @deprecated Use `streamSettings: { transport: "http", codec }`. */
1285
+ codec?: string;
1286
+ /**
1287
+ * Route the browser's helper stream/control and DevTools sockets through the
1288
+ * preview's same-origin `/helper` and `/devtools` proxies instead of the
1289
+ * helper's own loopback port — so a single exposed preview port is enough for
1290
+ * remote viewers. Requires the mounting server to forward WebSocket `upgrade`
1291
+ * events to {@link SimMiddleware.handleUpgrade}. Standalone `serve-sim`
1292
+ * enables this; plain `app.use(simMiddleware(...))` mounts leave it off (and
1293
+ * keep direct helper URLs) unless they also wire upgrades. See the README's
1294
+ * "Embed in your dev server" section.
1295
+ */
1296
+ proxyHelpers?: boolean;
1297
+ /** Test hook for supplying a fake inspect-webkit bridge. */
1298
+ inspectWebKitBridge?: () => Promise<WebKitBridge>;
1299
+ }
1300
+
1301
+ function httpStreamSettingsFromLegacyCodec(codec: string | undefined): StreamSettings | undefined {
1302
+ if (codec === "auto" || codec === "h264" || codec === "mjpeg") {
1303
+ return { transport: "http", codec };
1304
+ }
1305
+ return undefined;
1306
+ }
1307
+
1308
+ function safeEqualString(a: string, b: string): boolean {
1309
+ const ab = Buffer.from(a);
1310
+ const bb = Buffer.from(b);
1311
+ if (ab.length !== bb.length) return false;
1312
+ return timingSafeEqual(ab, bb);
1313
+ }
1314
+
1315
+ function isJsonContentType(value: string | undefined): boolean {
1316
+ if (!value) return false;
1317
+ // `application/json; charset=utf-8` etc. — only the media type matters.
1318
+ const mediaType = value.split(";", 1)[0]!.trim().toLowerCase();
1319
+ return mediaType === "application/json";
1320
+ }
1321
+
1322
+ /**
1323
+ * Connect-style middleware that serves the simulator preview UI.
1324
+ *
1325
+ * Routes handled under `basePath` (default `/.sim`):
1326
+ * GET {basePath} — the preview HTML page
1327
+ * GET {basePath}/api — serve-sim state JSON
1328
+ * GET {basePath}/logs — SSE stream of simctl logs
1329
+ * GET {basePath}/ax — SSE stream of normalized accessibility snapshots
1330
+ */
1331
+ export function handleMetricsRequest(
1332
+ req: SimReq,
1333
+ res: SimRes,
1334
+ state: ServeSimState | null,
1335
+ samplerCache: MetricsSamplerCache = metricsSamplerCache,
1336
+ corsOrigins: readonly string[] = [],
1337
+ tracker: ForegroundTrackerCache = foregroundTracker,
1338
+ ): void {
1339
+ if (!state) {
1340
+ res.writeHead(404);
1341
+ res.end("No serve-sim device");
1342
+ return;
1343
+ }
1344
+ res.writeHead(200, {
1345
+ "Content-Type": "text/event-stream",
1346
+ "Cache-Control": "no-cache",
1347
+ Connection: "keep-alive",
1348
+ "X-Accel-Buffering": "no",
1349
+ ...corsAllowOriginHeaders(req.headers.origin, corsOrigins),
1350
+ });
1351
+ res.write(":\n\n");
1352
+ // Keep the foreground tail warm for this stream's lifetime so the sampler can scope to the
1353
+ // current app even when no /appstate client is open.
1354
+ const foreground = tracker.subscribe(state.device);
1355
+ const { meta, unsubscribe } = samplerCache.subscribe(state.device, (sample) => {
1356
+ if (!res.writableEnded) res.write("data: " + JSON.stringify(sample) + "\n\n");
1357
+ });
1358
+ res.write("event: meta\ndata: " + JSON.stringify(meta) + "\n\n");
1359
+ // Heartbeat keeps an idle stream alive through buffering proxies.
1360
+ const heartbeat = setInterval(() => {
1361
+ if (!res.writableEnded) res.write(":\n\n");
1362
+ }, 15000);
1363
+ req.on("close", () => {
1364
+ clearInterval(heartbeat);
1365
+ unsubscribe();
1366
+ foreground.unsubscribe();
1367
+ });
1368
+ }
1369
+
1370
+ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1371
+ const streamSettings = options?.streamSettings ?? httpStreamSettingsFromLegacyCodec(options?.codec);
1372
+ const base = (options?.basePath ?? "/.sim").replace(/\/+$/, "");
1373
+ const helperPrefix = helperProxyPrefix(base);
1374
+ const devtoolsPrefix = devtoolsProxyPrefix(base);
1375
+ const proxyHelpers = options?.proxyHelpers ?? false;
1376
+ const getInspectWebKitBridge = options?.inspectWebKitBridge ?? ensureInspectWebKitBridge;
1377
+ // Per-process random token. Anyone who can read the preview HTML same-origin
1378
+ // can call /exec; cross-origin pages and LAN clients cannot, because they
1379
+ // can't read this value (it's only injected into the preview page's config).
1380
+ const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
1381
+ const metricsCorsOrigins = options?.metricsCorsOrigins ?? [];
1382
+
1383
+ // Simulator-settings requests run in-process (just the underlying simctl /
1384
+ // ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
1385
+ // sidebar interaction.
1386
+ const handleUiRequest: UiRequestHandler = async (payload) => {
1387
+ const p = (payload ?? {}) as { device?: string; option?: string; value?: string };
1388
+ if (typeof p.device !== "string" || !/^[0-9A-Za-z-]+$/.test(p.device)) {
1389
+ throw new Error("missing or invalid device udid");
1390
+ }
1391
+ if (p.option === undefined) {
1392
+ return { status: await getUiStatus(p.device) };
1393
+ }
1394
+ if (!UI_OPTIONS[p.option]) throw new Error(`unknown option: ${p.option}`);
1395
+ const value = typeof p.value === "string" ? normalizeUiValue(p.option, p.value) : null;
1396
+ if (value === null) throw new Error(`invalid value for ${p.option}: ${p.value}`);
1397
+ await setUiOption(p.device, p.option, value);
1398
+ try {
1399
+ recordEventLogEvent({
1400
+ device: p.device,
1401
+ source: "ui",
1402
+ kind: "ui-setting",
1403
+ action: p.option,
1404
+ status: "ok",
1405
+ summary: `UI ${p.option} ${value}`,
1406
+ details: { option: p.option, value },
1407
+ });
1408
+ } catch {
1409
+ // Event-log recording is diagnostic; it must not fail the UI request.
1410
+ }
1411
+ return { ok: true };
1412
+ };
1413
+
1414
+ const connectMiddleware = (async (req: SimReq, res: SimRes, next?: SimNext) => {
1415
+ const rawUrl: string = req.url ?? "";
1416
+ const qIndex = rawUrl.indexOf("?");
1417
+ const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
1418
+ const requestedDevice = queryDevice(rawUrl);
1419
+ const selectedDevice = requestedDevice ?? options?.device ?? null;
1420
+ const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
1421
+
1422
+ const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
1423
+ if (helperTarget) {
1424
+ const device = helperTarget.device ?? selectedDevice;
1425
+ // The device's helper endpoints are served from an in-process
1426
+ // NativeCapture/NativeHid DeviceSession.
1427
+ if (serveHelperInProcess(req, res, device, helperTarget.upstreamPath, streamSettings)) return;
1428
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1429
+ res.end("No serve-sim device");
1430
+ return;
1431
+ }
1432
+
1433
+ // Same-origin proxy for Chrome DevTools frontend assets. Loading the
1434
+ // appspot-hosted frontend directly works as a top-level tab, but is flaky
1435
+ // inside embedded browser iframes. Serving it from the preview origin keeps
1436
+ // the frontend's relative assets and CSP on the local page.
1437
+ if (url === devtoolsFrontendBase || url.startsWith(`${devtoolsFrontendBase}/`)) {
1438
+ const assetPath = url === devtoolsFrontendBase
1439
+ ? "inspector.html"
1440
+ : url.slice(devtoolsFrontendBase.length + 1);
1441
+ // Reject path-traversal segments before they reach the upstream URL.
1442
+ if (assetPath.split("/").some((seg) => seg === "..")) {
1443
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
1444
+ res.end("Invalid asset path");
1445
+ return;
1446
+ }
1447
+ try {
1448
+ const upstream = await fetch(
1449
+ `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${qIndex === -1 ? "" : rawUrl.slice(qIndex)}`,
1450
+ );
1451
+ const headers: Record<string, string> = {
1452
+ "Cache-Control": "public, max-age=604800",
1453
+ };
1454
+ const contentType = upstream.headers.get("content-type");
1455
+ if (contentType) headers["Content-Type"] = contentType;
1456
+ res.writeHead(upstream.status, headers);
1457
+ res.end(Buffer.from(await upstream.arrayBuffer()));
1458
+ } catch (err) {
1459
+ res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
1460
+ res.end(err instanceof Error ? err.message : "Failed to load DevTools frontend");
1461
+ }
1462
+ return;
1463
+ }
1464
+
1465
+ // Serve the preview page
1466
+ if (url === base || url === base + "/") {
1467
+ const states = await readServeSimStates();
1468
+ const state = selectServeSimState(states, selectedDevice);
1469
+ let html = loadHtml();
1470
+
1471
+ if (!state) {
1472
+ // Empty-state UI still polls /exec (boot/list helpers), so the page
1473
+ // needs the bearer token even before a helper attaches. Inject a
1474
+ // minimal config with just the basePath + token.
1475
+ const minimal = JSON.stringify({ basePath: base, execToken });
1476
+ html = html.replace(
1477
+ "<!--__SIM_PREVIEW_CONFIG__-->",
1478
+ `<script>window.__SIM_PREVIEW__=${minimal}</script>`,
1479
+ );
1480
+ }
1481
+
1482
+ if (state) {
1483
+ const remoteState = rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers);
1484
+ const config = JSON.stringify(previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers));
1485
+ const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
1486
+ html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
1487
+ }
1488
+
1489
+ res.writeHead(200, {
1490
+ "Content-Type": "text/html; charset=utf-8",
1491
+ "Cache-Control": "no-store",
1492
+ });
1493
+ res.end(html);
1494
+ return;
1495
+ }
1496
+
1497
+ // Memory capacity estimate: how much room is left to boot more sims.
1498
+ if (url === base + "/grid/api/memory") {
1499
+ res.writeHead(200, {
1500
+ "Content-Type": "application/json",
1501
+ "Cache-Control": "no-store",
1502
+ });
1503
+ res.end(JSON.stringify(buildMemoryReport()));
1504
+ return;
1505
+ }
1506
+
1507
+ if (url === base + "/grid/api/devicekit-chrome") {
1508
+ serveDeviceKitChromeAsset(new URL(rawUrl || "/", "http://serve-sim.local"), res);
1509
+ return;
1510
+ }
1511
+
1512
+ if (url === base + "/grid/api/device-placeholder-asset") {
1513
+ serveDevicePlaceholderAsset(new URL(rawUrl || "/", "http://serve-sim.local"), res);
1514
+ return;
1515
+ }
1516
+
1517
+ // Grid JSON: every supported simulator, annotated with running helper info if any.
1518
+ if (url === base + "/grid/api") {
1519
+ const states = await readServeSimStates();
1520
+ const helperByUdid = new Map(states.map((s) => [s.device, s] as const));
1521
+ const sims = await listAllSimulators();
1522
+ // Order mirrors Xcode's Devices window: the devices the user is actually
1523
+ // using float to the top — streaming first, then booted, then the
1524
+ // simulator they last opened in Simulator.app — and everything else falls
1525
+ // back to a stable family / newest-OS / name grouping. This surfaces the
1526
+ // handful of relevant devices instead of burying them in an alphabetical
1527
+ // wall of near-identical names. Sort on the cheap metadata BEFORE
1528
+ // resolving the DeviceKit chrome descriptor, so pagination resolves chrome
1529
+ // only for the page actually returned.
1530
+ const preferredUdid = getPreferredDeviceUdid();
1531
+ const familyRank = (name: string): number => {
1532
+ if (/iphone/i.test(name)) return 0;
1533
+ if (/ipad/i.test(name)) return 1;
1534
+ if (/watch/i.test(name)) return 2;
1535
+ if (/(apple\s*tv|^tv\b)/i.test(name)) return 3;
1536
+ if (/vision|reality/i.test(name)) return 4;
1537
+ return 5;
1538
+ };
1539
+ // Lower is higher in the list: streaming > selected > booted > last-opened
1540
+ // > rest. The active `?device=` selection is ranked near the top so it's
1541
+ // always inside the first page — otherwise a paginated client that selected
1542
+ // a shut-down device deep in the catalog would get no chrome/placeholder
1543
+ // for the view it's actually showing.
1544
+ const stateRank = (d: (typeof sims)[number]) => {
1545
+ if (helperByUdid.has(d.udid)) return 0;
1546
+ if (selectedDevice && d.udid === selectedDevice) return 1;
1547
+ if (d.state === "Booted") return 2;
1548
+ if (d.udid === preferredUdid) return 3;
1549
+ return 4;
1550
+ };
1551
+ // Newest runtime first, so "iPhone 17 Pro (27.0)" sorts above its 26.x twins.
1552
+ const runtimeRank = (runtime: string): number => {
1553
+ const m = runtime.match(/-(\d+)-(\d+)/);
1554
+ const major = m ? Number(m[1]) : 0;
1555
+ const minor = m ? Number(m[2]) : 0;
1556
+ return -(major * 1000 + minor);
1557
+ };
1558
+ sims.sort((a, b) =>
1559
+ stateRank(a) - stateRank(b) ||
1560
+ familyRank(a.name) - familyRank(b.name) ||
1561
+ a.name.localeCompare(b.name) ||
1562
+ runtimeRank(a.runtime) - runtimeRank(b.runtime),
1563
+ );
1564
+
1565
+ const total = sims.length;
1566
+ const { limit, offset } = parseGridPaging(rawUrl);
1567
+ const page = limit == null ? sims : sims.slice(offset, offset + limit);
1568
+ const devices = page.map((d) => {
1569
+ const helper = helperByUdid.get(d.udid);
1570
+ const remoteHelper = helper ? rewriteStateForRequestHost(helper, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
1571
+ return {
1572
+ device: d.udid,
1573
+ name: d.name,
1574
+ runtime: d.runtime,
1575
+ state: d.state,
1576
+ chrome: resolveDeviceKitChrome(d),
1577
+ placeholderAsset: resolveDevicePlaceholderAsset(d),
1578
+ helper: remoteHelper
1579
+ ? {
1580
+ port: remoteHelper.port,
1581
+ url: remoteHelper.url,
1582
+ streamUrl: remoteHelper.streamUrl,
1583
+ wsUrl: remoteHelper.wsUrl,
1584
+ }
1585
+ : null,
1586
+ };
1587
+ });
1588
+ res.writeHead(200, {
1589
+ "Content-Type": "application/json",
1590
+ "Cache-Control": "no-store",
1591
+ });
1592
+ // `total` lets the client show "X of Y" and know when to stop paging;
1593
+ // older clients that read only `devices` are unaffected.
1594
+ res.end(JSON.stringify({ devices, total, offset: limit == null ? 0 : offset, limit: limit ?? total }));
1595
+ return;
1596
+ }
1597
+
1598
+ // Shutdown a booted simulator. Any running helper for the device is reaped
1599
+ // by readServeSimStates() on the next /grid/api poll (it kills helpers
1600
+ // whose backing simulator is no longer in the booted set).
1601
+ if (url === base + "/grid/api/shutdown" && req.method === "POST") {
1602
+ let body = "";
1603
+ req.on("data", (chunk: Buffer | string) => {
1604
+ body += typeof chunk === "string" ? chunk : chunk.toString();
1605
+ });
1606
+ req.on("end", () => {
1607
+ let udid = "";
1608
+ try { udid = (JSON.parse(body) as ShutdownRequestBody).udid ?? ""; } catch {}
1609
+ if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)) {
1610
+ res.writeHead(400, { "Content-Type": "application/json" });
1611
+ res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
1612
+ return;
1613
+ }
1614
+ // Stop our own in-process capture for this device first (no-op if it
1615
+ // isn't streamed here). This frees the native session immediately
1616
+ // rather than waiting for the next poll's reaper to notice.
1617
+ closeDeviceSession(udid);
1618
+ // Drop the snapshot so the next /grid/api call re-queries simctl
1619
+ // and prunes any helper bound to this now-shutdown device.
1620
+ bootedSnapshot = { at: 0, booted: null };
1621
+ execFile("xcrun", ["simctl", "shutdown", udid], { timeout: 30_000 }, (err, _stdout, stderr) => {
1622
+ if (err) {
1623
+ res.writeHead(500, { "Content-Type": "application/json" });
1624
+ res.end(JSON.stringify({
1625
+ ok: false,
1626
+ error: stderr?.toString().trim() || err.message,
1627
+ }));
1628
+ return;
1629
+ }
1630
+ res.writeHead(200, { "Content-Type": "application/json" });
1631
+ res.end(JSON.stringify({ ok: true }));
1632
+ });
1633
+ });
1634
+ return;
1635
+ }
1636
+
1637
+ // Start streaming a device in-process (auto-boots if needed). The preview
1638
+ // server serves its /helper routes directly — no spawned helper.
1639
+ if (url === base + "/grid/api/start" && req.method === "POST") {
1640
+ let body = "";
1641
+ req.on("data", (chunk: Buffer | string) => {
1642
+ body += typeof chunk === "string" ? chunk : chunk.toString();
1643
+ });
1644
+ req.on("end", () => {
1645
+ let udid = "";
1646
+ try { udid = (JSON.parse(body) as StartRequestBody).udid ?? ""; } catch {}
1647
+ if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)) {
1648
+ res.writeHead(400, { "Content-Type": "application/json" });
1649
+ res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
1650
+ return;
1651
+ }
1652
+ const port = req.socket.localPort ?? 0;
1653
+ void startDeviceInProcess(udid, port, base, streamSettings).then((error) => {
1654
+ if (res.writableEnded) return;
1655
+ if (error) {
1656
+ res.writeHead(500, { "Content-Type": "application/json" });
1657
+ res.end(JSON.stringify({ ok: false, error }));
1658
+ } else {
1659
+ res.writeHead(200, { "Content-Type": "application/json" });
1660
+ res.end(JSON.stringify({ ok: true }));
1661
+ }
1662
+ });
1663
+ });
1664
+ return;
1665
+ }
1666
+
1667
+ // JSON API: start the inspect-webkit CDP bridge and list WebKit targets
1668
+ // for the selected simulator. The bridge itself serves /json/list and
1669
+ // /devtools/page/:id on localhost; the preview adds iframe-safe frontend
1670
+ // URLs so the browser UI can embed Chrome DevTools.
1671
+ if (url === base + "/devtools") {
1672
+ const states = await readServeSimStates();
1673
+ const state = selectServeSimState(states, selectedDevice);
1674
+ if (!state) {
1675
+ res.writeHead(404, { "Content-Type": "application/json" });
1676
+ res.end(JSON.stringify({ error: "No serve-sim device" }));
1677
+ return;
1678
+ }
1679
+ try {
1680
+ const bridge = await getInspectWebKitBridge();
1681
+ const bridgeTargets = await bridge.listTargets();
1682
+ // Proxy mode routes the inspector socket through the preview's
1683
+ // same-origin `/devtools` proxy; otherwise the browser talks to the
1684
+ // bridge's loopback port directly (the pre-proxy behavior).
1685
+ const wsProtocol = proxyHelpers ? websocketProtocolForRequest(req) : "ws";
1686
+ const wsTargetBase = proxyHelpers
1687
+ ? `${hostForRequest(req) ?? `127.0.0.1:${bridge.port}`}${devtoolsPrefix}`
1688
+ : `127.0.0.1:${bridge.port}/devtools`;
1689
+ // inspect-webkit@0.0.3 only exposes `sim:<webinspectord-pid>` for
1690
+ // simulator targets, which can't be reconciled against a sim UDID.
1691
+ // Surface every booted sim's targets (Safari Develop-menu behavior)
1692
+ // until inspect-webkit grows a real UDID we can filter on.
1693
+ const targets = bridgeTargets.map((target) => ({
1694
+ ...target,
1695
+ webSocketDebuggerUrl: `${wsProtocol}://${wsTargetBase}/page/${encodeURIComponent(target.id)}`,
1696
+ devtoolsFrontendUrl: devtoolsFrontendUrl(devtoolsFrontendBase, wsProtocol, wsTargetBase, target.id),
1697
+ }));
1698
+ res.writeHead(200, {
1699
+ "Content-Type": "application/json",
1700
+ "Cache-Control": "no-store",
1701
+ });
1702
+ res.end(JSON.stringify({
1703
+ port: bridge.port,
1704
+ targets,
1705
+ }));
1706
+ } catch (err) {
1707
+ res.writeHead(500, { "Content-Type": "application/json" });
1708
+ res.end(JSON.stringify({
1709
+ error: err instanceof Error ? err.message : "Failed to start inspect-webkit",
1710
+ }));
1711
+ }
1712
+ return;
1713
+ }
1714
+
1715
+ // POST /devtools/release — drop hover-highlight CDP sessions so we don't
1716
+ // sit on a WIR slot when the picker is dismissed (or the tab is closed).
1717
+ // Optional body { targetId } releases just one; empty body releases all.
1718
+ if (url === base + "/devtools/release" && req.method === "POST") {
1719
+ let body = "";
1720
+ req.on("data", (chunk: Buffer) => (body += chunk));
1721
+ req.on("end", async () => {
1722
+ try {
1723
+ const parsed: ReleaseRequestBody = body ? JSON.parse(body) : {};
1724
+ const bridge = await getInspectWebKitBridge();
1725
+ bridge.releaseHighlight?.(parsed.targetId);
1726
+ res.writeHead(200, { "Content-Type": "application/json" });
1727
+ res.end("{}");
1728
+ } catch (err) {
1729
+ res.writeHead(500, { "Content-Type": "application/json" });
1730
+ res.end(JSON.stringify({
1731
+ error: err instanceof Error ? err.message : "Failed to release",
1732
+ }));
1733
+ }
1734
+ });
1735
+ return;
1736
+ }
1737
+
1738
+ // POST /devtools/highlight — flash an inspectable target in the
1739
+ // simulator the way Safari's Develop menu hover does. Body shape:
1740
+ // { targetId: string, on: boolean }.
1741
+ if (url === base + "/devtools/highlight" && req.method === "POST") {
1742
+ let body = "";
1743
+ req.on("data", (chunk: Buffer) => (body += chunk));
1744
+ req.on("end", async () => {
1745
+ try {
1746
+ const { targetId, on } = JSON.parse(body || "{}") as HighlightRequestBody;
1747
+ if (!targetId) {
1748
+ res.writeHead(400, { "Content-Type": "application/json" });
1749
+ res.end(JSON.stringify({ error: "Missing targetId" }));
1750
+ return;
1751
+ }
1752
+ const bridge = await getInspectWebKitBridge();
1753
+ if (!bridge.highlightTarget) {
1754
+ res.writeHead(501, { "Content-Type": "application/json" });
1755
+ res.end(JSON.stringify({ error: "highlightTarget not supported by inspect-webkit" }));
1756
+ return;
1757
+ }
1758
+ await bridge.highlightTarget(targetId, !!on);
1759
+ res.writeHead(200, { "Content-Type": "application/json" });
1760
+ res.end("{}");
1761
+ } catch (err) {
1762
+ res.writeHead(500, { "Content-Type": "application/json" });
1763
+ res.end(JSON.stringify({
1764
+ error: err instanceof Error ? err.message : "Failed to highlight target",
1765
+ }));
1766
+ }
1767
+ });
1768
+ return;
1769
+ }
1770
+
1771
+ // JSON API: serve-sim state
1772
+ if (url === base + "/api") {
1773
+ const states = await readServeSimStates();
1774
+ const state = selectServeSimState(states, selectedDevice);
1775
+ // The web UI polls /api every ~2s, so logging every hit floods the
1776
+ // debug stream with identical lines. Only log when the selection
1777
+ // result changes.
1778
+ const apiLogKey = `${selectedDevice ?? "(any)"}|${states.length}|${
1779
+ state ? `${state.device}@${state.port}` : "none"
1780
+ }`;
1781
+ if (apiLogKey !== lastApiLogKey) {
1782
+ lastApiLogKey = apiLogKey;
1783
+ debugMw(
1784
+ "GET /api selectedDevice=%s states=%d chose=%s",
1785
+ selectedDevice ?? "(any)",
1786
+ states.length,
1787
+ state ? `${state.device}@${state.port}` : "none",
1788
+ );
1789
+ }
1790
+ res.writeHead(200, {
1791
+ "Content-Type": "application/json",
1792
+ "Cache-Control": "no-store",
1793
+ });
1794
+ const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
1795
+ res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers) : null));
1796
+ return;
1797
+ }
1798
+
1799
+ // JSON API: recent simulator action log. This is intentionally in-memory and
1800
+ // bounded; it is for live debugging/agent observability, not archival audit.
1801
+ if (url === base + "/api/event-log") {
1802
+ res.writeHead(200, {
1803
+ "Content-Type": "application/json",
1804
+ "Cache-Control": "no-store",
1805
+ });
1806
+ res.end(JSON.stringify({
1807
+ events: readEventLog({
1808
+ device: requestedDevice,
1809
+ sinceId: eventLogSinceId(rawUrl),
1810
+ limit: eventLogLimit(rawUrl),
1811
+ }),
1812
+ }));
1813
+ return;
1814
+ }
1815
+
1816
+ // SSE: action log stream. Sends a snapshot first, then individual new
1817
+ // entries. The exec-ws control channel proxies this route for the browser UI.
1818
+ if (url === base + "/api/event-log/events") {
1819
+ res.writeHead(200, {
1820
+ "Content-Type": "text/event-stream",
1821
+ "Cache-Control": "no-cache",
1822
+ Connection: "keep-alive",
1823
+ "X-Accel-Buffering": "no",
1824
+ });
1825
+ res.write(":\n\n");
1826
+ res.write("data: " + JSON.stringify({
1827
+ events: readEventLog({
1828
+ device: requestedDevice,
1829
+ sinceId: eventLogSinceId(rawUrl),
1830
+ limit: eventLogLimit(rawUrl),
1831
+ }),
1832
+ }) + "\n\n");
1833
+
1834
+ const unsubscribe = subscribeEventLog((event) => {
1835
+ if (requestedDevice && event.device !== requestedDevice) return;
1836
+ if (res.writableEnded) return;
1837
+ res.write("data: " + JSON.stringify({ event }) + "\n\n");
1838
+ });
1839
+ const heartbeat = setInterval(() => {
1840
+ if (!res.writableEnded) res.write(":\n\n");
1841
+ }, 15000);
1842
+ req.on("close", () => {
1843
+ clearInterval(heartbeat);
1844
+ unsubscribe();
1845
+ });
1846
+ return;
1847
+ }
1848
+
1849
+ // SSE: serve-sim state stream. Push replacement for the web UI's old ~1.5s
1850
+ // /api poll — the PreviewConfig only changes when a helper boots/shuts down
1851
+ // or the device selection changes, so we watch the state dir and emit only
1852
+ // on change instead of re-sending identical JSON on a fixed interval.
1853
+ if (url === base + "/api/events") {
1854
+ const computeConfig = async (): Promise<string> => {
1855
+ const states = await readServeSimStates();
1856
+ const state = selectServeSimState(states, selectedDevice);
1857
+ const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
1858
+ return JSON.stringify(
1859
+ remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers) : null,
1860
+ );
1861
+ };
1862
+
1863
+ res.writeHead(200, {
1864
+ "Content-Type": "text/event-stream",
1865
+ "Cache-Control": "no-cache",
1866
+ Connection: "keep-alive",
1867
+ "X-Accel-Buffering": "no",
1868
+ });
1869
+ res.write(":\n\n");
1870
+
1871
+ let lastSent = await computeConfig();
1872
+ res.write("data: " + lastSent + "\n\n");
1873
+
1874
+ let closed = false;
1875
+ const sendIfChanged = async () => {
1876
+ if (closed || res.writableEnded) return;
1877
+ const next = await computeConfig();
1878
+ if (next === lastSent) return;
1879
+ lastSent = next;
1880
+ res.write("data: " + next + "\n\n");
1881
+ };
1882
+
1883
+ // Debounce filesystem events: a helper boot rewrites the state file a few
1884
+ // times in quick succession, and selectServeSimState also shells out to
1885
+ // refresh booted devices, so coalesce bursts into one recompute.
1886
+ let debounce: ReturnType<typeof setTimeout> | null = null;
1887
+ const onFsEvent = () => {
1888
+ if (debounce) return;
1889
+ debounce = setTimeout(() => {
1890
+ debounce = null;
1891
+ sendIfChanged();
1892
+ }, 150);
1893
+ };
1894
+
1895
+ let watcher: FSWatcher | null = null;
1896
+ let watcherRetry: ReturnType<typeof setTimeout> | null = null;
1897
+ const ensureWatcher = () => {
1898
+ if (closed || res.writableEnded || watcher || watcherRetry) return;
1899
+ watcherRetry = setTimeout(() => {
1900
+ watcherRetry = null;
1901
+ if (closed || res.writableEnded || watcher) return;
1902
+ try {
1903
+ watcher = watch(STATE_DIR, onFsEvent);
1904
+ watcher.on("error", () => {
1905
+ watcher?.close();
1906
+ watcher = null;
1907
+ ensureWatcher();
1908
+ });
1909
+ sendIfChanged();
1910
+ } catch {
1911
+ ensureWatcher();
1912
+ }
1913
+ }, 250);
1914
+ };
1915
+ ensureWatcher();
1916
+
1917
+ // Keep the connection alive through buffering proxies + catch any change
1918
+ // an fs event missed (e.g. dir created after we failed to watch it).
1919
+ const heartbeat = setInterval(() => {
1920
+ if (closed || res.writableEnded) return;
1921
+ res.write(":\n\n");
1922
+ ensureWatcher();
1923
+ }, 15000);
1924
+
1925
+ req.on("close", () => {
1926
+ closed = true;
1927
+ if (debounce) clearTimeout(debounce);
1928
+ if (watcherRetry) clearTimeout(watcherRetry);
1929
+ clearInterval(heartbeat);
1930
+ watcher?.close();
1931
+ });
1932
+ return;
1933
+ }
1934
+
1935
+ // SSE: simctl log stream
1936
+ if (url === base + "/logs") {
1937
+ const states = await readServeSimStates();
1938
+ const state = selectServeSimState(states, selectedDevice);
1939
+ if (!state) {
1940
+ res.writeHead(404);
1941
+ res.end("No serve-sim device");
1942
+ return;
1943
+ }
1944
+ const udid = state.device;
1945
+ res.writeHead(200, {
1946
+ "Content-Type": "text/event-stream",
1947
+ "Cache-Control": "no-cache",
1948
+ Connection: "keep-alive",
1949
+ "X-Accel-Buffering": "no",
1950
+ });
1951
+ res.write(":\n\n");
1952
+
1953
+ const child: ChildProcess = spawn("xcrun", [
1954
+ "simctl", "spawn", udid, "log", "stream",
1955
+ "--style", "ndjson",
1956
+ "--level", "info",
1957
+ ], { stdio: ["ignore", "pipe", "ignore"] });
1958
+
1959
+ let buf = "";
1960
+ child.stdout!.on("data", (chunk: Buffer) => {
1961
+ buf += chunk.toString();
1962
+ let nl: number;
1963
+ while ((nl = buf.indexOf("\n")) !== -1) {
1964
+ const line = buf.slice(0, nl).trim();
1965
+ buf = buf.slice(nl + 1);
1966
+ if (line) res.write("data: " + line + "\n\n");
1967
+ }
1968
+ // Drop a runaway partial line so a malformed/never-terminated
1969
+ // log entry can't grow `buf` without bound.
1970
+ if (buf.length > SSE_LINE_BUFFER_LIMIT) buf = "";
1971
+ });
1972
+
1973
+ child.on("error", () => { try { res.end(); } catch {} });
1974
+ child.on("close", () => res.end());
1975
+ req.on("close", () => {
1976
+ child.stdout?.destroy();
1977
+ child.kill();
1978
+ });
1979
+ return;
1980
+ }
1981
+
1982
+ // SSE: normalized accessibility snapshot stream
1983
+ if (url === base + "/ax") {
1984
+ const states = await readServeSimStates();
1985
+ const state = selectServeSimState(states, selectedDevice);
1986
+ if (!state) {
1987
+ res.writeHead(404);
1988
+ res.end("No serve-sim device");
1989
+ return;
1990
+ }
1991
+ res.writeHead(200, {
1992
+ "Content-Type": "text/event-stream",
1993
+ "Cache-Control": "no-cache",
1994
+ Connection: "keep-alive",
1995
+ "X-Accel-Buffering": "no",
1996
+ });
1997
+ res.write(":\n\n");
1998
+ axStreamerCache.prune(states.map((s) => s.device));
1999
+ const ax = axStreamerCache.get(state.device);
2000
+ const removeClient = ax.addClient(res);
2001
+ req.on("close", removeClient);
2002
+ return;
2003
+ }
2004
+
2005
+ // SSE of the user app's live CPU/memory: an `event: meta` frame (schema,
2006
+ // udid, hostCores, cadence), then one `data:` line per sample.
2007
+ if (url === base + "/metrics") {
2008
+ const states = await readServeSimStates();
2009
+ const state = selectServeSimState(states, selectedDevice);
2010
+ handleMetricsRequest(req, res, state, metricsSamplerCache, metricsCorsOrigins);
2011
+ return;
2012
+ }
2013
+
2014
+ // POST /exec — run a shell command on the host. Gated by a per-process
2015
+ // bearer token injected only into the same-origin preview HTML, with
2016
+ // Content-Type + Origin checks to block CORS-simple CSRF (a malicious
2017
+ // page POSTing `text/plain` JSON to a dev server bound to a public iface)
2018
+ // and LAN attackers who can reach the port but can't read the token.
2019
+ if ((url === base + "/exec" || url === base + "/exec/") && req.method === "POST") {
2020
+ // 1. Reject anything that isn't a JSON request, killing the
2021
+ // `enctype="text/plain"` CORS-simple form-POST path.
2022
+ if (!isJsonContentType(req.headers["content-type"])) {
2023
+ res.writeHead(415, { "Content-Type": "application/json" });
2024
+ res.end(JSON.stringify({ stdout: "", stderr: "Unsupported Media Type", exitCode: 1 }));
2025
+ return;
2026
+ }
2027
+ // 2. If the browser supplied an Origin, require it match this server.
2028
+ // Same-origin XHR from the preview page sets Origin to our own URL;
2029
+ // a cross-origin page's Origin won't match.
2030
+ const origin = req.headers.origin;
2031
+ if (origin) {
2032
+ try {
2033
+ const originHost = new URL(origin).host;
2034
+ if (originHost !== req.headers.host) {
2035
+ res.writeHead(403, { "Content-Type": "application/json" });
2036
+ res.end(JSON.stringify({ stdout: "", stderr: "Cross-origin request blocked", exitCode: 1 }));
2037
+ return;
2038
+ }
2039
+ } catch {
2040
+ res.writeHead(403, { "Content-Type": "application/json" });
2041
+ res.end(JSON.stringify({ stdout: "", stderr: "Invalid Origin", exitCode: 1 }));
2042
+ return;
2043
+ }
2044
+ }
2045
+ // 3. Require the per-session bearer token. Cross-origin pages cannot
2046
+ // read it from window.__SIM_PREVIEW__; non-browser callers must
2047
+ // have copied it from the CLI output.
2048
+ const authHeader = req.headers.authorization ?? "";
2049
+ const match = /^Bearer\s+(.+)$/i.exec(authHeader);
2050
+ if (!match || !safeEqualString(match[1]!.trim(), execToken)) {
2051
+ res.writeHead(401, { "Content-Type": "application/json" });
2052
+ res.end(JSON.stringify({ stdout: "", stderr: "Unauthorized", exitCode: 1 }));
2053
+ return;
2054
+ }
2055
+ let body = "";
2056
+ let aborted = false;
2057
+ req.on("data", (chunk: Buffer | string) => {
2058
+ body += typeof chunk === "string" ? chunk : chunk.toString();
2059
+ // Cheap belt-and-braces cap so a runaway POST can't OOM the dev server.
2060
+ if (body.length > 4 * 1024 * 1024) {
2061
+ aborted = true;
2062
+ res.writeHead(413, { "Content-Type": "application/json" });
2063
+ res.end(JSON.stringify({ stdout: "", stderr: "Payload Too Large", exitCode: 1 }));
2064
+ req.destroy();
2065
+ }
2066
+ });
2067
+ req.on("end", () => {
2068
+ if (aborted) return;
2069
+ let command = "";
2070
+ try {
2071
+ command = (JSON.parse(body) as ExecRequestBody).command ?? "";
2072
+ } catch {}
2073
+ if (!command) {
2074
+ res.writeHead(400, { "Content-Type": "application/json" });
2075
+ res.end(JSON.stringify({ stdout: "", stderr: "Missing command", exitCode: 1 }));
2076
+ return;
2077
+ }
2078
+ exec(command, { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
2079
+ const exitCode = err ? (err as ExecException).code ?? 1 : 0;
2080
+ recordCommandEvent(command, { exitCode });
2081
+ res.writeHead(200, { "Content-Type": "application/json" });
2082
+ res.end(JSON.stringify({
2083
+ stdout: stdout.toString(),
2084
+ stderr: stderr.toString(),
2085
+ exitCode,
2086
+ }));
2087
+ });
2088
+ });
2089
+ return;
2090
+ }
2091
+
2092
+ // SSE: foreground-app change stream. Emits `{bundleId, pid}` events
2093
+ // parsed from SpringBoard's "Setting process visibility to: Foreground"
2094
+ // log line. Filtering is done here (not in the browser) so the SSE stream
2095
+ // stays narrow and the client can listen without rate-limit concerns.
2096
+ if (url === base + "/appstate") {
2097
+ const states = await readServeSimStates();
2098
+ const state = selectServeSimState(states, selectedDevice);
2099
+ if (!state) {
2100
+ res.writeHead(404);
2101
+ res.end("No serve-sim device");
2102
+ return;
2103
+ }
2104
+ const udid = state.device;
2105
+ res.writeHead(200, {
2106
+ "Content-Type": "text/event-stream",
2107
+ "Cache-Control": "no-cache",
2108
+ Connection: "keep-alive",
2109
+ "X-Accel-Buffering": "no",
2110
+ });
2111
+ res.write(":\n\n");
2112
+
2113
+ // SpringBoard's foreground feed is edge-triggered, so a fresh subscriber sees nothing until
2114
+ // the next app switch. The shared tracker seeds itself from the AX bridge on start, so replay
2115
+ // its current app (once known) before streaming changes.
2116
+ let lastApp: ForegroundApp | null = null;
2117
+ let generation = 0;
2118
+ const emit = async (app: ForegroundApp) => {
2119
+ // Dedup on bundleId and pid: the tracker emits same-bundle relaunches with a fresh pid, and
2120
+ // clients need the live pid.
2121
+ if (res.writableEnded || (app.bundleId === lastApp?.bundleId && app.pid === lastApp.pid)) return;
2122
+ lastApp = app;
2123
+ // detectReactNative is awaited, so a later switch can resolve first; only write if no newer
2124
+ // emit has started, otherwise a slow lookup could overwrite the client with a stale app.
2125
+ const generationAtStart = ++generation;
2126
+ const isReactNative = await detectReactNative(udid, app.bundleId);
2127
+ if (!res.writableEnded && generationAtStart === generation) {
2128
+ res.write("data: " + JSON.stringify({ bundleId: app.bundleId, pid: app.pid, isReactNative }) + "\n\n");
2129
+ }
2130
+ };
2131
+ const subscription = foregroundTracker.subscribe(udid, (app) => void emit(app));
2132
+ const current = foregroundTracker.peek(udid);
2133
+ if (current) void emit(current);
2134
+ req.on("close", () => subscription.unsubscribe());
2135
+ return;
2136
+ }
2137
+
2138
+ // Not ours — pass through
2139
+ if (next) return next();
2140
+ }) as ConnectMiddleware;
2141
+ connectMiddleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
2142
+ const rawUrl = req.url ?? "";
2143
+ const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
2144
+ const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
2145
+ const devtoolsTarget = devtoolsProxyTarget(rawUrl, devtoolsPrefix);
2146
+ if (devtoolsTarget) {
2147
+ (async () => {
2148
+ try {
2149
+ const bridge = await getInspectWebKitBridge();
2150
+ bridgeWebSocketFrames(req, socket, head, `ws://127.0.0.1:${bridge.port}${devtoolsTarget.upstreamPath}`);
2151
+ } catch (err) {
2152
+ const message = err instanceof Error ? err.message : "Failed to start inspect-webkit";
2153
+ socket.end(`HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${message}`);
2154
+ }
2155
+ })();
2156
+ return;
2157
+ }
2158
+ if (!helperTarget) {
2159
+ socket.destroy();
2160
+ return;
2161
+ }
2162
+ const device = helperTarget.device ?? selectedDevice;
2163
+ if (helperTarget.upstreamPath === "/ws") {
2164
+ // HID input is delivered to the in-process DeviceSession.
2165
+ if (attachHidInProcess(req, socket, head, device, streamSettings)) return;
2166
+ socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
2167
+ return;
2168
+ }
2169
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
2170
+ };
2171
+ // WebSocket exec channel — same auth/origin policy as POST /exec, but off
2172
+ // the browser's per-origin HTTP connection pool so multiple preview tabs
2173
+ // (each holding MJPEG + SSE streams) can't starve exec actions. Servers
2174
+ // mounting this middleware should forward `upgrade` events here (the
2175
+ // built-in preview server does); the client falls back to POST /exec when
2176
+ // the upgrade never completes.
2177
+ const fetchMiddleware = (async (request: Request) => {
2178
+ return connectToFetch(connectMiddleware, request);
2179
+ }) as SimMiddleware;
2180
+
2181
+ fetchMiddleware.handleWebSocket = createExecWebSocketHandler({
2182
+ path: `${base}/exec-ws`,
2183
+ execToken,
2184
+ ssePrefixes: [
2185
+ `${base}/api/events`,
2186
+ `${base}/api/event-log/events`,
2187
+ `${base}/appstate`,
2188
+ `${base}/logs`,
2189
+ `${base}/metrics`,
2190
+ `${base}/ax`,
2191
+ ],
2192
+ onUiRequest: handleUiRequest,
2193
+ onCommandResult: (command, result) => recordCommandEvent(command, result),
2194
+ onSseRequest(path, websocketRequest) {
2195
+ const url = new URL(path, websocketRequest.url);
2196
+ return fetchMiddleware(new Request(url, {
2197
+ headers: { accept: "text/event-stream" },
2198
+ }));
2199
+ },
2200
+ });
2201
+
2202
+ // WebSocket upgrades owned by the preview: the authenticated exec/control
2203
+ // channel plus same-origin helper/devtools proxy sockets.
2204
+ fetchMiddleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
2205
+ connectMiddleware.handleUpgrade?.(req, socket, head);
2206
+ };
2207
+ return fetchMiddleware;
2208
+ }