serve-sim 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serve-sim",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
package/src/middleware.ts CHANGED
@@ -68,6 +68,13 @@ function isUserFacingBundle(bundleId: string): boolean {
68
68
  return !NON_UI_BUNDLE_RE.test(bundleId);
69
69
  }
70
70
 
71
+ export function parseForegroundAppLogMessage(message: string): { bundleId: string; pid: number } | null {
72
+ // e.g. "[app<com.apple.mobilesafari>:43117] Setting process visibility to: Foreground"
73
+ const match = /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/.exec(message);
74
+ if (!match) return null;
75
+ return { bundleId: match[1]!, pid: parseInt(match[2]!, 10) };
76
+ }
77
+
71
78
  function detectReactNative(udid: string, bundleId: string): Promise<boolean> {
72
79
  if (RN_BUNDLE_IDS.has(bundleId)) return Promise.resolve(true);
73
80
  return new Promise((resolve) => {
@@ -85,6 +92,84 @@ function detectReactNative(udid: string, bundleId: string): Promise<boolean> {
85
92
  });
86
93
  }
87
94
 
95
+ type InstalledApp = {
96
+ CFBundleDisplayName?: string;
97
+ CFBundleExecutable?: string;
98
+ CFBundleIdentifier?: string;
99
+ CFBundleName?: string;
100
+ };
101
+
102
+ function normalizeAppName(name: string): string {
103
+ return name.trim().replace(/\s+/g, " ").toLowerCase();
104
+ }
105
+
106
+ export function matchInstalledAppByDisplayName(
107
+ apps: Record<string, InstalledApp>,
108
+ displayName: string,
109
+ ): string | null {
110
+ const wanted = normalizeAppName(displayName);
111
+ if (!wanted) return null;
112
+
113
+ for (const [bundleId, app] of Object.entries(apps)) {
114
+ const names = [
115
+ app.CFBundleDisplayName,
116
+ app.CFBundleName,
117
+ app.CFBundleExecutable,
118
+ ].filter((value): value is string => typeof value === "string");
119
+ if (names.some((name) => normalizeAppName(name) === wanted)) {
120
+ return app.CFBundleIdentifier || bundleId;
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ function shellQuote(value: string): string {
127
+ return "'" + value.replace(/'/g, "'\\''") + "'";
128
+ }
129
+
130
+ function installedAppsForDevice(udid: string): Promise<Record<string, InstalledApp>> {
131
+ return new Promise((resolve) => {
132
+ exec(
133
+ `xcrun simctl listapps ${shellQuote(udid)} | plutil -convert json -o - -`,
134
+ { timeout: 3_000 },
135
+ (err, stdout) => {
136
+ if (err || !stdout) return resolve({});
137
+ try {
138
+ resolve(JSON.parse(stdout) as Record<string, InstalledApp>);
139
+ } catch {
140
+ resolve({});
141
+ }
142
+ },
143
+ );
144
+ });
145
+ }
146
+
147
+ async function detectCurrentForegroundApp(
148
+ udid: string,
149
+ stateUrl: string,
150
+ ): Promise<{ bundleId: string; isReactNative: boolean } | null> {
151
+ const controller = new AbortController();
152
+ const timer = setTimeout(() => controller.abort(), 2_000);
153
+ try {
154
+ const res = await fetch(`${stateUrl}/ax`, { signal: controller.signal });
155
+ if (!res.ok) return null;
156
+ const tree = await res.json() as Array<{ AXLabel?: unknown }>;
157
+ const displayName = tree?.[0]?.AXLabel;
158
+ if (typeof displayName !== "string") return null;
159
+
160
+ const bundleId = matchInstalledAppByDisplayName(
161
+ await installedAppsForDevice(udid),
162
+ displayName,
163
+ );
164
+ if (!bundleId || !isUserFacingBundle(bundleId)) return null;
165
+ return { bundleId, isReactNative: await detectReactNative(udid, bundleId) };
166
+ } catch {
167
+ return null;
168
+ } finally {
169
+ clearTimeout(timer);
170
+ }
171
+ }
172
+
88
173
  // Cache simctl's booted-device set briefly so per-request cost stays bounded.
89
174
  // The middleware runs inside the user's dev server (Metro etc.) and
90
175
  // readServeSimStates() is called on every /api and every page load.
@@ -314,6 +399,186 @@ function loadHtml(): string {
314
399
  return _html;
315
400
  }
316
401
 
402
+ interface SimctlDevice {
403
+ udid: string;
404
+ name: string;
405
+ state: string;
406
+ isAvailable?: boolean;
407
+ runtime: string;
408
+ }
409
+
410
+ function listAllSimulators(): SimctlDevice[] {
411
+ try {
412
+ const output = execSync("xcrun simctl list devices -j", {
413
+ encoding: "utf-8",
414
+ stdio: ["ignore", "pipe", "ignore"],
415
+ timeout: 3_000,
416
+ });
417
+ const data = JSON.parse(output) as {
418
+ devices: Record<string, Array<Omit<SimctlDevice, "runtime">>>;
419
+ };
420
+ const out: SimctlDevice[] = [];
421
+ for (const [runtime, devices] of Object.entries(data.devices)) {
422
+ // Only iOS (skip watchOS / tvOS / visionOS for the grid MVP — the helper
423
+ // is iOS-focused and the bezel/touch model assumes a phone-shaped device).
424
+ if (!/SimRuntime\.iOS-/i.test(runtime)) continue;
425
+ for (const d of devices) {
426
+ if (d.isAvailable === false) continue;
427
+ out.push({ ...d, runtime: runtime.replace(/^.*SimRuntime\./, "") });
428
+ }
429
+ }
430
+ return out;
431
+ } catch {
432
+ return [];
433
+ }
434
+ }
435
+
436
+ function deviceNameFor(udid: string): string | null {
437
+ return listAllSimulators().find((d) => d.udid === udid)?.name ?? null;
438
+ }
439
+
440
+ // Default per-simulator footprint when we have no running sim to measure
441
+ // from — a fresh booted iOS sim with one app launched typically sits in
442
+ // the 1.2–1.8 GB range. Used as a fallback only.
443
+ const DEFAULT_PER_SIM_BYTES = 1.5 * 1024 * 1024 * 1024;
444
+
445
+ interface MemoryReport {
446
+ totalBytes: number;
447
+ availableBytes: number;
448
+ runningSimulators: number;
449
+ perSimAvgBytes: number;
450
+ perSimSource: "measured" | "estimated";
451
+ estimatedAdditional: number;
452
+ }
453
+
454
+ function readSystemMemory(): { totalBytes: number; availableBytes: number } {
455
+ try {
456
+ const totalBytes = Number(
457
+ execSync("sysctl -n hw.memsize", {
458
+ encoding: "utf-8",
459
+ stdio: ["ignore", "pipe", "ignore"],
460
+ timeout: 1500,
461
+ }).trim(),
462
+ );
463
+ const pageSize = Number(
464
+ execSync("sysctl -n hw.pagesize", {
465
+ encoding: "utf-8",
466
+ stdio: ["ignore", "pipe", "ignore"],
467
+ timeout: 1500,
468
+ }).trim(),
469
+ );
470
+ const vmStat = execSync("vm_stat", {
471
+ encoding: "utf-8",
472
+ stdio: ["ignore", "pipe", "ignore"],
473
+ timeout: 1500,
474
+ });
475
+ const pages = (re: RegExp) => {
476
+ const m = vmStat.match(re);
477
+ return m ? Number(m[1]) : 0;
478
+ };
479
+ // "Available" mirrors what Activity Monitor treats as reclaimable: free
480
+ // + inactive + speculative pages. Excludes wired and active.
481
+ const availablePages =
482
+ pages(/Pages free:\s+(\d+)/) +
483
+ pages(/Pages inactive:\s+(\d+)/) +
484
+ pages(/Pages speculative:\s+(\d+)/);
485
+ return {
486
+ totalBytes: Number.isFinite(totalBytes) ? totalBytes : 0,
487
+ availableBytes: availablePages * (Number.isFinite(pageSize) ? pageSize : 4096),
488
+ };
489
+ } catch {
490
+ return { totalBytes: 0, availableBytes: 0 };
491
+ }
492
+ }
493
+
494
+ // Sum RSS across every process whose argv path includes a CoreSimulator
495
+ // device directory. Groups by UDID so we get a real per-sim footprint that
496
+ // covers launchd_sim plus all child processes the runtime spawns.
497
+ function readSimulatorMemoryUsage(): { perUdid: Record<string, number>; totalBytes: number } {
498
+ try {
499
+ const output = execSync("ps -axo rss=,args=", {
500
+ encoding: "utf-8",
501
+ stdio: ["ignore", "pipe", "ignore"],
502
+ timeout: 3000,
503
+ maxBuffer: 8 * 1024 * 1024,
504
+ });
505
+ const perUdid: Record<string, number> = {};
506
+ let totalBytes = 0;
507
+ const re = /\/Devices\/([0-9A-F-]{36})\//i;
508
+ for (const raw of output.split("\n")) {
509
+ const line = raw.trimStart();
510
+ if (!line) continue;
511
+ const m = re.exec(line);
512
+ if (!m) continue;
513
+ const rssKb = Number(line.split(/\s+/, 1)[0]);
514
+ if (!Number.isFinite(rssKb)) continue;
515
+ const bytes = rssKb * 1024;
516
+ const udid = m[1].toUpperCase();
517
+ perUdid[udid] = (perUdid[udid] ?? 0) + bytes;
518
+ totalBytes += bytes;
519
+ }
520
+ return { perUdid, totalBytes };
521
+ } catch {
522
+ return { perUdid: {}, totalBytes: 0 };
523
+ }
524
+ }
525
+
526
+ function buildMemoryReport(): MemoryReport {
527
+ const { totalBytes, availableBytes } = readSystemMemory();
528
+ const usage = readSimulatorMemoryUsage();
529
+ const runningSimulators = Object.keys(usage.perUdid).length;
530
+ const measuredAvg = runningSimulators > 0
531
+ ? usage.totalBytes / runningSimulators
532
+ : 0;
533
+ // Below ~256MB, the measurement is almost certainly catching a sim mid-boot
534
+ // before its app processes are resident — fall back to the default so we
535
+ // don't over-promise capacity.
536
+ const perSimSource: MemoryReport["perSimSource"] =
537
+ measuredAvg >= 256 * 1024 * 1024 ? "measured" : "estimated";
538
+ const perSimAvgBytes =
539
+ perSimSource === "measured" ? measuredAvg : DEFAULT_PER_SIM_BYTES;
540
+ const estimatedAdditional = perSimAvgBytes > 0
541
+ ? Math.max(0, Math.floor(availableBytes / perSimAvgBytes))
542
+ : 0;
543
+ return {
544
+ totalBytes,
545
+ availableBytes,
546
+ runningSimulators,
547
+ perSimAvgBytes,
548
+ perSimSource,
549
+ estimatedAdditional,
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Locate the `serve-sim` CLI binary so the grid can spawn helpers via
555
+ * `serve-sim --detach <udid>`. Tries, in order:
556
+ * 1. argv[0] if it ends in `serve-sim` (we're running inside the
557
+ * compiled standalone binary, which IS the CLI)
558
+ * 2. `serve-sim` on PATH (npm-installed / bun-installed CLI)
559
+ * Returns the resolved command + args ready for spawn.
560
+ */
561
+ function resolveServeSimCommand(): { command: string; baseArgs: string[] } | null {
562
+ // 1. Compiled standalone binary: argv[0] is the serve-sim binary itself.
563
+ if (process.argv[0] && /(^|\/)serve-sim$/.test(process.argv[0])) {
564
+ return { command: process.argv[0], baseArgs: [] };
565
+ }
566
+ // 2. Running the JS bundle directly: `node /path/to/serve-sim.js`.
567
+ if (process.argv[1] && /(^|\/)serve-sim\.js$/.test(process.argv[1])) {
568
+ return { command: process.argv[0]!, baseArgs: [process.argv[1]!] };
569
+ }
570
+ // 3. Global install: serve-sim is on PATH.
571
+ try {
572
+ const path = execSync("command -v serve-sim", {
573
+ encoding: "utf-8",
574
+ stdio: ["ignore", "pipe", "ignore"],
575
+ timeout: 1_500,
576
+ }).trim();
577
+ if (path) return { command: path, baseArgs: [] };
578
+ } catch {}
579
+ return null;
580
+ }
581
+
317
582
  export interface SimMiddlewareOptions {
318
583
  /** Base path to serve the preview at. Default: "/.sim" */
319
584
  basePath?: string;
@@ -384,6 +649,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
384
649
  // Pass real serve-sim URLs directly. The client parses the MJPEG
385
650
  // stream via fetch() (CORS is fine — serve-sim sends Access-Control-Allow-Origin: *)
386
651
  // and connects to the WS directly (WS has no CORS).
652
+ const gridApiBase = (base === "" ? "" : base) + "/grid/api";
387
653
  const config = JSON.stringify({
388
654
  ...state,
389
655
  basePath: base,
@@ -391,6 +657,11 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
391
657
  appStateEndpoint: endpoint(base, "/appstate", state.device),
392
658
  axEndpoint: endpoint(base, "/ax", state.device),
393
659
  devtoolsEndpoint: endpoint(base, "/devtools", state.device),
660
+ gridApiEndpoint: gridApiBase,
661
+ gridStartEndpoint: gridApiBase + "/start",
662
+ gridShutdownEndpoint: gridApiBase + "/shutdown",
663
+ gridMemoryEndpoint: gridApiBase + "/memory",
664
+ previewEndpoint: base === "" ? "/" : base,
394
665
  });
395
666
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
396
667
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
@@ -404,6 +675,157 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
404
675
  return;
405
676
  }
406
677
 
678
+ // Memory capacity estimate: how much room is left to boot more sims.
679
+ if (url === base + "/grid/api/memory") {
680
+ res.writeHead(200, {
681
+ "Content-Type": "application/json",
682
+ "Cache-Control": "no-store",
683
+ });
684
+ res.end(JSON.stringify(buildMemoryReport()));
685
+ return;
686
+ }
687
+
688
+ // Grid JSON: every iOS simulator, annotated with running helper info if any.
689
+ if (url === base + "/grid/api") {
690
+ const states = readServeSimStates();
691
+ const helperByUdid = new Map(states.map((s) => [s.device, s] as const));
692
+ const sims = listAllSimulators();
693
+ const devices = sims.map((d) => {
694
+ const helper = helperByUdid.get(d.udid);
695
+ return {
696
+ device: d.udid,
697
+ name: d.name,
698
+ runtime: d.runtime,
699
+ state: d.state,
700
+ helper: helper
701
+ ? {
702
+ port: helper.port,
703
+ url: helper.url,
704
+ streamUrl: helper.streamUrl,
705
+ wsUrl: helper.wsUrl,
706
+ }
707
+ : null,
708
+ };
709
+ });
710
+ // Stable order: family (iPhone, iPad, Watch, TV, Vision, other) →
711
+ // state (helper > booted > shutdown) → alpha. Keeps the most
712
+ // commonly used devices visible without scrolling.
713
+ const familyRank = (name: string): number => {
714
+ if (/iphone/i.test(name)) return 0;
715
+ if (/ipad/i.test(name)) return 1;
716
+ if (/watch/i.test(name)) return 2;
717
+ if (/(apple\s*tv|^tv\b)/i.test(name)) return 3;
718
+ if (/vision|reality/i.test(name)) return 4;
719
+ return 5;
720
+ };
721
+ const stateRank = (x: typeof devices[number]) =>
722
+ x.helper ? 0 : x.state === "Booted" ? 1 : 2;
723
+ devices.sort((a, b) =>
724
+ familyRank(a.name) - familyRank(b.name) ||
725
+ stateRank(a) - stateRank(b) ||
726
+ a.name.localeCompare(b.name),
727
+ );
728
+ res.writeHead(200, {
729
+ "Content-Type": "application/json",
730
+ "Cache-Control": "no-store",
731
+ });
732
+ res.end(JSON.stringify({ devices }));
733
+ return;
734
+ }
735
+
736
+ // Shutdown a booted simulator. Any running helper for the device is reaped
737
+ // by readServeSimStates() on the next /grid/api poll (it kills helpers
738
+ // whose backing simulator is no longer in the booted set).
739
+ if (url === base + "/grid/api/shutdown" && req.method === "POST") {
740
+ let body = "";
741
+ req.on("data", (chunk: Buffer | string) => {
742
+ body += typeof chunk === "string" ? chunk : chunk.toString();
743
+ });
744
+ req.on("end", () => {
745
+ let udid = "";
746
+ try { udid = JSON.parse(body).udid ?? ""; } catch {}
747
+ if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)) {
748
+ res.writeHead(400, { "Content-Type": "application/json" });
749
+ res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
750
+ return;
751
+ }
752
+ // Drop the snapshot so the next /grid/api call re-queries simctl
753
+ // and prunes any helper bound to this now-shutdown device.
754
+ bootedSnapshot = { at: 0, booted: null };
755
+ execFile("xcrun", ["simctl", "shutdown", udid], { timeout: 30_000 }, (err, _stdout, stderr) => {
756
+ if (err) {
757
+ res.writeHead(500, { "Content-Type": "application/json" });
758
+ res.end(JSON.stringify({
759
+ ok: false,
760
+ error: stderr?.toString().trim() || err.message,
761
+ }));
762
+ return;
763
+ }
764
+ res.writeHead(200, { "Content-Type": "application/json" });
765
+ res.end(JSON.stringify({ ok: true }));
766
+ });
767
+ });
768
+ return;
769
+ }
770
+
771
+ // Spawn a serve-sim helper (auto-boots if needed).
772
+ if (url === base + "/grid/api/start" && req.method === "POST") {
773
+ let body = "";
774
+ req.on("data", (chunk: Buffer | string) => {
775
+ body += typeof chunk === "string" ? chunk : chunk.toString();
776
+ });
777
+ req.on("end", () => {
778
+ let udid = "";
779
+ try { udid = JSON.parse(body).udid ?? ""; } catch {}
780
+ if (!/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(udid)) {
781
+ res.writeHead(400, { "Content-Type": "application/json" });
782
+ res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
783
+ return;
784
+ }
785
+ const resolved = resolveServeSimCommand();
786
+ if (!resolved) {
787
+ res.writeHead(500, { "Content-Type": "application/json" });
788
+ res.end(JSON.stringify({
789
+ ok: false,
790
+ error: "serve-sim CLI not found in PATH. Install it (npm i -g serve-sim) and retry.",
791
+ }));
792
+ return;
793
+ }
794
+ const child = spawn(
795
+ resolved.command,
796
+ [...resolved.baseArgs, "--detach", udid],
797
+ { stdio: ["ignore", "pipe", "pipe"], detached: false },
798
+ );
799
+ let stdout = "";
800
+ let stderr = "";
801
+ child.stdout?.on("data", (c: Buffer) => { stdout += c.toString(); });
802
+ child.stderr?.on("data", (c: Buffer) => { stderr += c.toString(); });
803
+ // A cold iOS simulator can take 60-90s to reach `bootstatus -b`
804
+ // readiness; the prior 60s ceiling was killing serve-sim mid-boot
805
+ // and the helper never got a chance to spawn, so the click ended
806
+ // with an error and no state file. 3 minutes is a comfortable
807
+ // upper bound that covers slow first-boots without leaving a
808
+ // wedged child around indefinitely.
809
+ const timer = setTimeout(() => {
810
+ try { child.kill("SIGTERM"); } catch {}
811
+ }, 180_000);
812
+ child.on("close", (code) => {
813
+ clearTimeout(timer);
814
+ if (code === 0) {
815
+ res.writeHead(200, { "Content-Type": "application/json" });
816
+ res.end(JSON.stringify({ ok: true, stdout: stdout.trim() }));
817
+ } else {
818
+ res.writeHead(500, { "Content-Type": "application/json" });
819
+ res.end(JSON.stringify({
820
+ ok: false,
821
+ error: stderr.trim() || stdout.trim() || `serve-sim exited with code ${code}`,
822
+ }));
823
+ }
824
+ });
825
+ });
826
+ return;
827
+ }
828
+
407
829
  // JSON API: start the inspect-webkit CDP bridge and list WebKit targets
408
830
  // for the selected simulator. The bridge itself serves /json/list and
409
831
  // /devtools/page/:id on localhost; the preview adds iframe-safe frontend
@@ -637,9 +1059,29 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
637
1059
  'process == "SpringBoard" AND eventMessage CONTAINS "Setting process visibility to: Foreground"',
638
1060
  ], { stdio: ["ignore", "pipe", "ignore"] });
639
1061
 
640
- // e.g. "[app<com.apple.mobilesafari>:43117] Setting process visibility to: Foreground"
641
- const FG_RE = /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/;
642
1062
  let lastBundle = "";
1063
+ let hasEmitted = false;
1064
+ let closed = false;
1065
+ const emitApp = async (bundleId: string, pid?: number) => {
1066
+ if (!isUserFacingBundle(bundleId)) return;
1067
+ if (bundleId === lastBundle) return;
1068
+ lastBundle = bundleId;
1069
+ hasEmitted = true;
1070
+ const isReactNative = await detectReactNative(udid, bundleId);
1071
+ if (!closed) {
1072
+ res.write("data: " + JSON.stringify({ bundleId, pid, isReactNative }) + "\n\n");
1073
+ }
1074
+ };
1075
+
1076
+ // The seed loses to any live log event — a SpringBoard log that fires
1077
+ // while the AX call is in flight is fresher than the AX snapshot.
1078
+ detectCurrentForegroundApp(udid, state.url).then((app) => {
1079
+ if (!app || closed || hasEmitted) return;
1080
+ lastBundle = app.bundleId;
1081
+ hasEmitted = true;
1082
+ res.write("data: " + JSON.stringify(app) + "\n\n");
1083
+ });
1084
+
643
1085
  let buf = "";
644
1086
  child.stdout!.on("data", (chunk: Buffer) => {
645
1087
  buf += chunk.toString();
@@ -650,21 +1092,17 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
650
1092
  if (!line) continue;
651
1093
  let msg: string;
652
1094
  try { msg = JSON.parse(line).eventMessage ?? ""; } catch { continue; }
653
- const m = FG_RE.exec(msg);
654
- if (!m) continue;
655
- const bundleId = m[1]!;
656
- const pid = parseInt(m[2]!, 10);
657
- if (!isUserFacingBundle(bundleId)) continue;
658
- if (bundleId === lastBundle) continue;
659
- lastBundle = bundleId;
660
- detectReactNative(udid, bundleId).then((isReactNative) => {
661
- res.write("data: " + JSON.stringify({ bundleId, pid, isReactNative }) + "\n\n");
662
- });
1095
+ const event = parseForegroundAppLogMessage(msg);
1096
+ if (!event) continue;
1097
+ emitApp(event.bundleId, event.pid);
663
1098
  }
664
1099
  });
665
1100
 
666
1101
  child.on("close", () => res.end());
667
- req.on("close", () => child.kill());
1102
+ req.on("close", () => {
1103
+ closed = true;
1104
+ child.kill();
1105
+ });
668
1106
  return;
669
1107
  }
670
1108