serve-sim 0.1.21 → 0.1.23

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.21",
3
+ "version": "0.1.23",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -51,10 +51,14 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/bun": "latest",
54
- "serve-sim-client": "workspace:*",
54
+ "@types/react": "^19.0.0",
55
+ "@types/react-dom": "^19.0.0",
56
+ "bun-plugin-tailwind": "^0.1.2",
55
57
  "preact": "^10.29.1",
56
58
  "react": "^19.0.0",
57
59
  "react-dom": "^19.0.0",
60
+ "serve-sim-client": "workspace:*",
61
+ "tailwindcss": "^4.1.7",
58
62
  "typescript": "^5.7.0"
59
63
  },
60
64
  "dependencies": {
package/src/ax.ts CHANGED
@@ -62,12 +62,12 @@ function normalizeAxTree(roots: RawAxeNode[]): AxSnapshot {
62
62
  }
63
63
 
64
64
  for (let index = 0; index < node.children.length && elements.length < MAX_ELEMENTS; index++) {
65
- visit(node.children[index], `${path}.${index}`);
65
+ visit(node.children[index]!, `${path}.${index}`);
66
66
  }
67
67
  };
68
68
 
69
69
  for (let index = 0; index < roots.length && elements.length < MAX_ELEMENTS; index++) {
70
- visit(roots[index], String(index));
70
+ visit(roots[index]!, String(index));
71
71
  }
72
72
 
73
73
  return {
package/src/middleware.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { readdirSync, readFileSync, existsSync, unlinkSync } from "fs";
2
- import { execSync, spawn, exec, execFile, type ChildProcess } from "child_process";
2
+ import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException } from "child_process";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { createServer as createNetServer } from "net";
6
+ import type { IncomingMessage, ServerResponse } from "http";
6
7
  import { createAxStreamerCache } from "./ax";
7
8
 
9
+ type SimReq = IncomingMessage;
10
+ type SimRes = ServerResponse;
11
+ type SimNext = (err?: unknown) => void;
12
+
8
13
  // Injected at build time as a base64-encoded string via `define`
9
14
  declare const __PREVIEW_HTML_B64__: string;
10
15
  const STATE_DIR = join(tmpdir(), "serve-sim");
@@ -31,6 +36,41 @@ type WebKitBridge = {
31
36
  releaseHighlight?(targetId?: string): void;
32
37
  };
33
38
 
39
+ type InspectWebKitBridgeTarget = {
40
+ targetId: string;
41
+ title?: string;
42
+ appName?: string;
43
+ url?: string;
44
+ type?: string;
45
+ bundleId?: string;
46
+ inUseByOtherInspector?: boolean;
47
+ source?: { kind?: string; id?: string };
48
+ };
49
+
50
+ type CdpHttpListEntry = {
51
+ id: string;
52
+ title: string;
53
+ url: string;
54
+ type: string;
55
+ description?: string;
56
+ };
57
+
58
+ type CdpHttpVersion = { Browser?: string };
59
+
60
+ type SimctlBootedList = {
61
+ devices: Record<string, Array<{ udid: string; state: string }>>;
62
+ };
63
+
64
+ type SimctlAllList = {
65
+ devices: Record<string, Array<Omit<SimctlDevice, "runtime">>>;
66
+ };
67
+
68
+ type ShutdownRequestBody = { udid?: string };
69
+ type StartRequestBody = { udid?: string };
70
+ type ReleaseRequestBody = { targetId?: string };
71
+ type HighlightRequestBody = { targetId?: string; on?: boolean };
72
+ type ExecRequestBody = { command?: string };
73
+
34
74
  export interface ServeSimState {
35
75
  pid: number;
36
76
  port: number;
@@ -123,53 +163,6 @@ export function matchInstalledAppByDisplayName(
123
163
  return null;
124
164
  }
125
165
 
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
-
173
166
  // Cache simctl's booted-device set briefly so per-request cost stays bounded.
174
167
  // The middleware runs inside the user's dev server (Metro etc.) and
175
168
  // readServeSimStates() is called on every /api and every page load.
@@ -185,9 +178,7 @@ function getBootedUdids(): Set<string> | null {
185
178
  stdio: ["ignore", "pipe", "pipe"],
186
179
  timeout: 3_000,
187
180
  });
188
- const data = JSON.parse(output) as {
189
- devices: Record<string, Array<{ udid: string; state: string }>>;
190
- };
181
+ const data = JSON.parse(output) as SimctlBootedList;
191
182
  const booted = new Set<string>();
192
183
  for (const runtime of Object.values(data.devices)) {
193
184
  for (const device of runtime) {
@@ -272,7 +263,7 @@ async function existingInspectWebKitBridge(port: number): Promise<WebKitBridge |
272
263
  try {
273
264
  const versionRes = await fetch(`${cdpUrl}/json/version`);
274
265
  if (!versionRes.ok) return null;
275
- const version = await versionRes.json() as { Browser?: string };
266
+ const version = await versionRes.json() as CdpHttpVersion;
276
267
  if (version.Browser !== "Safari/inspect-webkit") return null;
277
268
  return {
278
269
  port,
@@ -283,13 +274,7 @@ async function existingInspectWebKitBridge(port: number): Promise<WebKitBridge |
283
274
  // shape `sim:<udid>:<appId>:<pageId>` and the description string
284
275
  // `<deviceLabel> (<bundleId>)` are all we have here.
285
276
  const listRes = await fetch(`${cdpUrl}/json/list`);
286
- const targets = await listRes.json() as Array<{
287
- id: string;
288
- title: string;
289
- url: string;
290
- type: string;
291
- description?: string;
292
- }>;
277
+ const targets = await listRes.json() as CdpHttpListEntry[];
293
278
  return targets
294
279
  .filter((target) => target.id.startsWith("sim:"))
295
280
  .map((target) => {
@@ -335,29 +320,35 @@ async function ensureInspectWebKitBridge(): Promise<WebKitBridge> {
335
320
  // (and what the DevTools frontend CSP whitelists). `localhost` resolves
336
321
  // to ::1 first on some setups, which would leave the iframe's
337
322
  // ws://127.0.0.1:9222 connection refused.
338
- const server = await startCdpServer({ host: "127.0.0.1", port });
323
+ const server = await startCdpServer({ host: "127.0.0.1", port }) as Awaited<ReturnType<typeof startCdpServer>> & {
324
+ highlightTarget?(targetId: string, on: boolean): Promise<void>;
325
+ releaseHighlight?(targetId?: string): void;
326
+ };
339
327
  return {
340
328
  port,
341
329
  cdpUrl: `http://127.0.0.1:${port}`,
342
330
  async listTargets() {
343
- return server.getTargets()
344
- .filter((target: any) => target.source?.kind === "simulator")
345
- .map((target: any) => ({
346
- id: target.targetId,
347
- title: target.title || target.appName || target.url || "Untitled",
348
- url: /^https?:/i.test(target.url) ? target.url : "about:blank",
349
- type: target.type || "page",
350
- appName: target.appName,
351
- bundleId: target.bundleId,
352
- udid: target.source?.id,
353
- inUseByOtherInspector: !!target.inUseByOtherInspector,
354
- }));
331
+ return (server.getTargets() as InspectWebKitBridgeTarget[])
332
+ .filter((target) => target.source?.kind === "simulator")
333
+ .map((target) => {
334
+ const url = target.url ?? "";
335
+ return {
336
+ id: target.targetId,
337
+ title: target.title || target.appName || url || "Untitled",
338
+ url: /^https?:/i.test(url) ? url : "about:blank",
339
+ type: target.type || "page",
340
+ appName: target.appName,
341
+ bundleId: target.bundleId,
342
+ udid: target.source?.id,
343
+ inUseByOtherInspector: !!target.inUseByOtherInspector,
344
+ };
345
+ });
355
346
  },
356
347
  highlightTarget: server.highlightTarget?.bind(server),
357
348
  releaseHighlight: server.releaseHighlight?.bind(server),
358
349
  };
359
- } catch (err: any) {
360
- if (err?.code === "EADDRINUSE") {
350
+ } catch (err) {
351
+ if ((err as NodeJS.ErrnoException)?.code === "EADDRINUSE") {
361
352
  const existing = await existingInspectWebKitBridge(port);
362
353
  if (existing) return existing;
363
354
  continue;
@@ -414,9 +405,7 @@ function listAllSimulators(): SimctlDevice[] {
414
405
  stdio: ["ignore", "pipe", "ignore"],
415
406
  timeout: 3_000,
416
407
  });
417
- const data = JSON.parse(output) as {
418
- devices: Record<string, Array<Omit<SimctlDevice, "runtime">>>;
419
- };
408
+ const data = JSON.parse(output) as SimctlAllList;
420
409
  const out: SimctlDevice[] = [];
421
410
  for (const [runtime, devices] of Object.entries(data.devices)) {
422
411
  // Only iOS (skip watchOS / tvOS / visionOS for the grid MVP — the helper
@@ -433,10 +422,6 @@ function listAllSimulators(): SimctlDevice[] {
433
422
  }
434
423
  }
435
424
 
436
- function deviceNameFor(udid: string): string | null {
437
- return listAllSimulators().find((d) => d.udid === udid)?.name ?? null;
438
- }
439
-
440
425
  // Default per-simulator footprint when we have no running sim to measure
441
426
  // from — a fresh booted iOS sim with one app launched typically sits in
442
427
  // the 1.2–1.8 GB range. Used as a fallback only.
@@ -513,7 +498,7 @@ function readSimulatorMemoryUsage(): { perUdid: Record<string, number>; totalByt
513
498
  const rssKb = Number(line.split(/\s+/, 1)[0]);
514
499
  if (!Number.isFinite(rssKb)) continue;
515
500
  const bytes = rssKb * 1024;
516
- const udid = m[1].toUpperCase();
501
+ const udid = m[1]!.toUpperCase();
517
502
  perUdid[udid] = (perUdid[udid] ?? 0) + bytes;
518
503
  totalBytes += bytes;
519
504
  }
@@ -598,7 +583,7 @@ export interface SimMiddlewareOptions {
598
583
  export function simMiddleware(options?: SimMiddlewareOptions) {
599
584
  const base = (options?.basePath ?? "/.sim").replace(/\/+$/, "");
600
585
 
601
- return (req: any, res: any, next?: () => void) => {
586
+ return (req: SimReq, res: SimRes, next?: SimNext) => {
602
587
  const rawUrl: string = req.url ?? "";
603
588
  const qIndex = rawUrl.indexOf("?");
604
589
  const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
@@ -743,7 +728,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
743
728
  });
744
729
  req.on("end", () => {
745
730
  let udid = "";
746
- try { udid = JSON.parse(body).udid ?? ""; } catch {}
731
+ try { udid = (JSON.parse(body) as ShutdownRequestBody).udid ?? ""; } catch {}
747
732
  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
733
  res.writeHead(400, { "Content-Type": "application/json" });
749
734
  res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
@@ -776,7 +761,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
776
761
  });
777
762
  req.on("end", () => {
778
763
  let udid = "";
779
- try { udid = JSON.parse(body).udid ?? ""; } catch {}
764
+ try { udid = (JSON.parse(body) as StartRequestBody).udid ?? ""; } catch {}
780
765
  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
766
  res.writeHead(400, { "Content-Type": "application/json" });
782
767
  res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" }));
@@ -875,10 +860,10 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
875
860
  // Optional body { targetId } releases just one; empty body releases all.
876
861
  if (url === base + "/devtools/release" && req.method === "POST") {
877
862
  let body = "";
878
- req.on("data", (chunk) => (body += chunk));
863
+ req.on("data", (chunk: Buffer) => (body += chunk));
879
864
  req.on("end", async () => {
880
865
  try {
881
- const parsed = body ? JSON.parse(body) as { targetId?: string } : {};
866
+ const parsed: ReleaseRequestBody = body ? JSON.parse(body) : {};
882
867
  const bridge = await ensureInspectWebKitBridge();
883
868
  bridge.releaseHighlight?.(parsed.targetId);
884
869
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -898,10 +883,10 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
898
883
  // { targetId: string, on: boolean }.
899
884
  if (url === base + "/devtools/highlight" && req.method === "POST") {
900
885
  let body = "";
901
- req.on("data", (chunk) => (body += chunk));
886
+ req.on("data", (chunk: Buffer) => (body += chunk));
902
887
  req.on("end", async () => {
903
888
  try {
904
- const { targetId, on } = JSON.parse(body || "{}") as { targetId?: string; on?: boolean };
889
+ const { targetId, on } = JSON.parse(body || "{}") as HighlightRequestBody;
905
890
  if (!targetId) {
906
891
  res.writeHead(400, { "Content-Type": "application/json" });
907
892
  res.end(JSON.stringify({ error: "Missing targetId" }));
@@ -971,7 +956,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
971
956
  req.on("end", () => {
972
957
  let command = "";
973
958
  try {
974
- command = JSON.parse(body).command ?? "";
959
+ command = (JSON.parse(body) as ExecRequestBody).command ?? "";
975
960
  } catch {}
976
961
  if (!command) {
977
962
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -983,7 +968,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
983
968
  res.end(JSON.stringify({
984
969
  stdout: stdout.toString(),
985
970
  stderr: stderr.toString(),
986
- exitCode: err ? (err as any).code ?? 1 : 0,
971
+ exitCode: err ? (err as ExecException).code ?? 1 : 0,
987
972
  }));
988
973
  });
989
974
  });
@@ -1051,6 +1036,31 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1051
1036
  });
1052
1037
  res.write(":\n\n");
1053
1038
 
1039
+ // Bootstrap: SpringBoard's log feed is edge-triggered, so a fresh
1040
+ // subscriber would otherwise see nothing until the user re-foregrounds
1041
+ // an app (the bug: tools couldn't reconnect after a page reload). Ask
1042
+ // the helper's AX bridge for the current frontmost app via
1043
+ // `proc_pidpath`+Info.plist resolution and emit it before tailing.
1044
+ let lastBundle = "";
1045
+ void (async () => {
1046
+ try {
1047
+ const ctrl = new AbortController();
1048
+ const timer = setTimeout(() => ctrl.abort(), 1500);
1049
+ const r = await fetch(`http://127.0.0.1:${state.port}/foreground`, { signal: ctrl.signal });
1050
+ clearTimeout(timer);
1051
+ if (!r.ok) return;
1052
+ const info = await r.json() as { bundleId?: string; pid?: number };
1053
+ if (!info.bundleId || !isUserFacingBundle(info.bundleId)) return;
1054
+ if (res.writableEnded) return;
1055
+ lastBundle = info.bundleId;
1056
+ const isReactNative = await detectReactNative(udid, info.bundleId);
1057
+ if (res.writableEnded) return;
1058
+ res.write("data: " + JSON.stringify({ bundleId: info.bundleId, pid: info.pid, isReactNative }) + "\n\n");
1059
+ } catch {
1060
+ // Helper may be coming up — log tail will fill in once anything moves.
1061
+ }
1062
+ })();
1063
+
1054
1064
  const child: ChildProcess = spawn("xcrun", [
1055
1065
  "simctl", "spawn", udid, "log", "stream",
1056
1066
  "--style", "ndjson",
@@ -1059,28 +1069,17 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1059
1069
  'process == "SpringBoard" AND eventMessage CONTAINS "Setting process visibility to: Foreground"',
1060
1070
  ], { stdio: ["ignore", "pipe", "ignore"] });
1061
1071
 
1062
- let lastBundle = "";
1063
- let hasEmitted = false;
1064
1072
  let closed = false;
1065
1073
  const emitApp = async (bundleId: string, pid?: number) => {
1066
1074
  if (!isUserFacingBundle(bundleId)) return;
1067
1075
  if (bundleId === lastBundle) return;
1068
1076
  lastBundle = bundleId;
1069
- hasEmitted = true;
1070
1077
  const isReactNative = await detectReactNative(udid, bundleId);
1071
1078
  if (!closed) {
1072
1079
  res.write("data: " + JSON.stringify({ bundleId, pid, isReactNative }) + "\n\n");
1073
1080
  }
1074
1081
  };
1075
1082
 
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
1083
 
1085
1084
  let buf = "";
1086
1085
  child.stdout!.on("data", (chunk: Buffer) => {