serve-sim 0.1.13 → 0.1.14

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.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -56,5 +56,8 @@
56
56
  "react": "^19.0.0",
57
57
  "react-dom": "^19.0.0",
58
58
  "typescript": "^5.7.0"
59
+ },
60
+ "dependencies": {
61
+ "inspect-webkit": "^0.0.3"
59
62
  }
60
63
  }
package/src/middleware.ts CHANGED
@@ -2,11 +2,34 @@ import { readdirSync, readFileSync, existsSync, unlinkSync } from "fs";
2
2
  import { execSync, spawn, exec, execFile, type ChildProcess } from "child_process";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
+ import { createServer as createNetServer } from "net";
5
6
  import { createAxStreamerCache } from "./ax";
6
7
 
7
8
  // Injected at build time as a base64-encoded string via `define`
8
9
  declare const __PREVIEW_HTML_B64__: string;
9
10
  const STATE_DIR = join(tmpdir(), "serve-sim");
11
+ const DEVTOOLS_FRONTEND_REV = "854a02be78c7ffea104cb523636efa991bef5c5b";
12
+ const INSPECT_WEBKIT_START_PORT = 9222;
13
+
14
+ type WebKitBridgeTarget = {
15
+ id: string;
16
+ title: string;
17
+ url: string;
18
+ type: string;
19
+ appName?: string;
20
+ bundleId?: string;
21
+ /** udid of the simulator hosting the target, when known. */
22
+ udid?: string;
23
+ inUseByOtherInspector?: boolean;
24
+ };
25
+
26
+ type WebKitBridge = {
27
+ port: number;
28
+ cdpUrl: string;
29
+ listTargets(): Promise<WebKitBridgeTarget[]>;
30
+ highlightTarget?(targetId: string, on: boolean): Promise<void>;
31
+ releaseHighlight?(targetId?: string): void;
32
+ };
10
33
 
11
34
  export interface ServeSimState {
12
35
  pid: number;
@@ -18,6 +41,7 @@ export interface ServeSimState {
18
41
  }
19
42
 
20
43
  const axStreamerCache = createAxStreamerCache();
44
+ let inspectWebKitBridge: Promise<WebKitBridge> | null = null;
21
45
 
22
46
  // Known bundle IDs that are always React Native shells (used as a fallback
23
47
  // before the app-container path resolves, since simctl can lag after launch).
@@ -149,6 +173,133 @@ function endpoint(base: string, path: string, device: string): string {
149
173
  return `${value}?device=${encodeURIComponent(device)}`;
150
174
  }
151
175
 
176
+ async function isLocalPortFree(port: number): Promise<boolean> {
177
+ return new Promise((resolve) => {
178
+ const server = createNetServer();
179
+ server.once("error", () => resolve(false));
180
+ server.once("listening", () => server.close(() => resolve(true)));
181
+ server.listen(port, "localhost");
182
+ });
183
+ }
184
+
185
+ async function existingInspectWebKitBridge(port: number): Promise<WebKitBridge | null> {
186
+ const cdpUrl = `http://localhost:${port}`;
187
+ try {
188
+ const versionRes = await fetch(`${cdpUrl}/json/version`);
189
+ if (!versionRes.ok) return null;
190
+ const version = await versionRes.json() as { Browser?: string };
191
+ if (version.Browser !== "Safari/inspect-webkit") return null;
192
+ return {
193
+ port,
194
+ cdpUrl,
195
+ async listTargets() {
196
+ // Hitting the bridge over HTTP loses the rich fields available to
197
+ // an in-process consumer (appName, inUseByOtherInspector). The id
198
+ // shape `sim:<udid>:<appId>:<pageId>` and the description string
199
+ // `<deviceLabel> (<bundleId>)` are all we have here.
200
+ const listRes = await fetch(`${cdpUrl}/json/list`);
201
+ const targets = await listRes.json() as Array<{
202
+ id: string;
203
+ title: string;
204
+ url: string;
205
+ type: string;
206
+ description?: string;
207
+ }>;
208
+ return targets
209
+ .filter((target) => target.id.startsWith("sim:"))
210
+ .map((target) => {
211
+ const idParts = target.id.split(":");
212
+ const udid = idParts[1];
213
+ const bundleId = target.description?.match(/\(([^)]+)\)/)?.[1];
214
+ return {
215
+ id: target.id,
216
+ title: target.title || target.url || "Untitled",
217
+ url: /^https?:/i.test(target.url) ? target.url : "about:blank",
218
+ type: target.type || "page",
219
+ udid,
220
+ bundleId,
221
+ };
222
+ });
223
+ },
224
+ };
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ async function ensureInspectWebKitBridge(): Promise<WebKitBridge> {
231
+ if (inspectWebKitBridge) {
232
+ try {
233
+ // Probe so a dead bridge gets retired instead of poisoning every call.
234
+ await (await inspectWebKitBridge).listTargets();
235
+ return inspectWebKitBridge;
236
+ } catch {
237
+ inspectWebKitBridge = null;
238
+ }
239
+ }
240
+ inspectWebKitBridge = (async () => {
241
+ const { startCdpServer } = await import("inspect-webkit");
242
+ for (let port = INSPECT_WEBKIT_START_PORT; port < INSPECT_WEBKIT_START_PORT + 50; port++) {
243
+ if (!(await isLocalPortFree(port))) {
244
+ const existing = await existingInspectWebKitBridge(port);
245
+ if (existing) return existing;
246
+ continue;
247
+ }
248
+ try {
249
+ const server = await startCdpServer({ host: "localhost", port });
250
+ return {
251
+ port,
252
+ cdpUrl: `http://localhost:${port}`,
253
+ async listTargets() {
254
+ return server.getTargets()
255
+ .filter((target: any) => target.source?.kind === "simulator")
256
+ .map((target: any) => ({
257
+ id: target.targetId,
258
+ title: target.title || target.appName || target.url || "Untitled",
259
+ url: /^https?:/i.test(target.url) ? target.url : "about:blank",
260
+ type: target.type || "page",
261
+ appName: target.appName,
262
+ bundleId: target.bundleId,
263
+ udid: target.source?.id,
264
+ inUseByOtherInspector: !!target.inUseByOtherInspector,
265
+ }));
266
+ },
267
+ highlightTarget: server.highlightTarget?.bind(server),
268
+ releaseHighlight: server.releaseHighlight?.bind(server),
269
+ };
270
+ } catch (err: any) {
271
+ if (err?.code === "EADDRINUSE") {
272
+ const existing = await existingInspectWebKitBridge(port);
273
+ if (existing) return existing;
274
+ continue;
275
+ }
276
+ throw err;
277
+ }
278
+ }
279
+ throw new Error(`No available inspect-webkit port found in ${INSPECT_WEBKIT_START_PORT}-${INSPECT_WEBKIT_START_PORT + 49}`);
280
+ })().catch((err) => {
281
+ inspectWebKitBridge = null;
282
+ throw err;
283
+ });
284
+ return inspectWebKitBridge;
285
+ }
286
+
287
+ function devtoolsFrontendUrl(frontendBase: string, wsHost: string, targetId: string): string {
288
+ const url = new URL(`${frontendBase}/inspector.html`, "http://serve-sim.local");
289
+ url.searchParams.set("ws", `${wsHost}/devtools/page/${targetId}`);
290
+ return `${url.pathname}${url.search}`;
291
+ }
292
+
293
+ // The inspect-webkit bridge binds to localhost only. When the preview is
294
+ // loaded from a different hostname (e.g. another device on the LAN), strip
295
+ // to the request's hostname-less form `localhost:<port>` so the iframe at
296
+ // least tries the right port; document the limitation in the README.
297
+ function bridgeWsHost(reqHost: string | undefined, bridgePort: number): string {
298
+ const hostname = (reqHost ?? "localhost").split(":")[0];
299
+ const isLocal = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
300
+ return `${isLocal ? hostname : "localhost"}:${bridgePort}`;
301
+ }
302
+
152
303
  let _html: string | null = null;
153
304
  function loadHtml(): string {
154
305
  if (!_html) {
@@ -181,6 +332,41 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
181
332
  const qIndex = rawUrl.indexOf("?");
182
333
  const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
183
334
  const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
335
+ const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
336
+
337
+ // Same-origin proxy for Chrome DevTools frontend assets. Loading the
338
+ // appspot-hosted frontend directly works as a top-level tab, but is flaky
339
+ // inside embedded browser iframes. Serving it from the preview origin keeps
340
+ // the frontend's relative assets and CSP on the local page.
341
+ if (url === devtoolsFrontendBase || url.startsWith(`${devtoolsFrontendBase}/`)) {
342
+ (async () => {
343
+ const assetPath = url === devtoolsFrontendBase
344
+ ? "inspector.html"
345
+ : url.slice(devtoolsFrontendBase.length + 1);
346
+ // Reject path-traversal segments before they reach the upstream URL.
347
+ if (assetPath.split("/").some((seg) => seg === "..")) {
348
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
349
+ res.end("Invalid asset path");
350
+ return;
351
+ }
352
+ try {
353
+ const upstream = await fetch(
354
+ `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${qIndex === -1 ? "" : rawUrl.slice(qIndex)}`,
355
+ );
356
+ const headers: Record<string, string> = {
357
+ "Cache-Control": "public, max-age=604800",
358
+ };
359
+ const contentType = upstream.headers.get("content-type");
360
+ if (contentType) headers["Content-Type"] = contentType;
361
+ res.writeHead(upstream.status, headers);
362
+ res.end(Buffer.from(await upstream.arrayBuffer()));
363
+ } catch (err) {
364
+ res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
365
+ res.end(err instanceof Error ? err.message : "Failed to load DevTools frontend");
366
+ }
367
+ })();
368
+ return;
369
+ }
184
370
 
185
371
  // Serve the preview page
186
372
  if (url === base || url === base + "/") {
@@ -198,6 +384,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
198
384
  logsEndpoint: endpoint(base, "/logs", state.device),
199
385
  appStateEndpoint: endpoint(base, "/appstate", state.device),
200
386
  axEndpoint: endpoint(base, "/ax", state.device),
387
+ devtoolsEndpoint: endpoint(base, "/devtools", state.device),
201
388
  });
202
389
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
203
390
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
@@ -211,6 +398,110 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
211
398
  return;
212
399
  }
213
400
 
401
+ // JSON API: start the inspect-webkit CDP bridge and list WebKit targets
402
+ // for the selected simulator. The bridge itself serves /json/list and
403
+ // /devtools/page/:id on localhost; the preview adds iframe-safe frontend
404
+ // URLs so the browser UI can embed Chrome DevTools.
405
+ if (url === base + "/devtools") {
406
+ (async () => {
407
+ const states = readServeSimStates();
408
+ const state = selectServeSimState(states, selectedDevice);
409
+ if (!state) {
410
+ res.writeHead(404, { "Content-Type": "application/json" });
411
+ res.end(JSON.stringify({ error: "No serve-sim device" }));
412
+ return;
413
+ }
414
+ try {
415
+ const bridge = await ensureInspectWebKitBridge();
416
+ const bridgeTargets = await bridge.listTargets();
417
+ const wsHost = bridgeWsHost(req.headers?.host, bridge.port);
418
+ // The bridge enumerates targets across every booted simulator.
419
+ // Filter to the device this preview is pinned to so the picker
420
+ // doesn't surface webviews that belong to a different sim.
421
+ const scoped = bridgeTargets.filter((target) =>
422
+ // If we couldn't resolve a udid (e.g. degraded HTTP path), keep
423
+ // the target rather than dropping it silently.
424
+ !target.udid || target.udid === state.device,
425
+ );
426
+ const targets = scoped.map((target) => ({
427
+ ...target,
428
+ webSocketDebuggerUrl: `ws://${wsHost}/devtools/page/${encodeURIComponent(target.id)}`,
429
+ devtoolsFrontendUrl: devtoolsFrontendUrl(devtoolsFrontendBase, wsHost, target.id),
430
+ }));
431
+ res.writeHead(200, {
432
+ "Content-Type": "application/json",
433
+ "Cache-Control": "no-store",
434
+ });
435
+ res.end(JSON.stringify({
436
+ port: bridge.port,
437
+ targets,
438
+ }));
439
+ } catch (err) {
440
+ res.writeHead(500, { "Content-Type": "application/json" });
441
+ res.end(JSON.stringify({
442
+ error: err instanceof Error ? err.message : "Failed to start inspect-webkit",
443
+ }));
444
+ }
445
+ })();
446
+ return;
447
+ }
448
+
449
+ // POST /devtools/release — drop hover-highlight CDP sessions so we don't
450
+ // sit on a WIR slot when the picker is dismissed (or the tab is closed).
451
+ // Optional body { targetId } releases just one; empty body releases all.
452
+ if (url === base + "/devtools/release" && req.method === "POST") {
453
+ let body = "";
454
+ req.on("data", (chunk) => (body += chunk));
455
+ req.on("end", async () => {
456
+ try {
457
+ const parsed = body ? JSON.parse(body) as { targetId?: string } : {};
458
+ const bridge = await ensureInspectWebKitBridge();
459
+ bridge.releaseHighlight?.(parsed.targetId);
460
+ res.writeHead(200, { "Content-Type": "application/json" });
461
+ res.end("{}");
462
+ } catch (err) {
463
+ res.writeHead(500, { "Content-Type": "application/json" });
464
+ res.end(JSON.stringify({
465
+ error: err instanceof Error ? err.message : "Failed to release",
466
+ }));
467
+ }
468
+ });
469
+ return;
470
+ }
471
+
472
+ // POST /devtools/highlight — flash an inspectable target in the
473
+ // simulator the way Safari's Develop menu hover does. Body shape:
474
+ // { targetId: string, on: boolean }.
475
+ if (url === base + "/devtools/highlight" && req.method === "POST") {
476
+ let body = "";
477
+ req.on("data", (chunk) => (body += chunk));
478
+ req.on("end", async () => {
479
+ try {
480
+ const { targetId, on } = JSON.parse(body || "{}") as { targetId?: string; on?: boolean };
481
+ if (!targetId) {
482
+ res.writeHead(400, { "Content-Type": "application/json" });
483
+ res.end(JSON.stringify({ error: "Missing targetId" }));
484
+ return;
485
+ }
486
+ const bridge = await ensureInspectWebKitBridge();
487
+ if (!bridge.highlightTarget) {
488
+ res.writeHead(501, { "Content-Type": "application/json" });
489
+ res.end(JSON.stringify({ error: "highlightTarget not supported by inspect-webkit" }));
490
+ return;
491
+ }
492
+ await bridge.highlightTarget(targetId, !!on);
493
+ res.writeHead(200, { "Content-Type": "application/json" });
494
+ res.end("{}");
495
+ } catch (err) {
496
+ res.writeHead(500, { "Content-Type": "application/json" });
497
+ res.end(JSON.stringify({
498
+ error: err instanceof Error ? err.message : "Failed to highlight target",
499
+ }));
500
+ }
501
+ });
502
+ return;
503
+ }
504
+
214
505
  // JSON API: serve-sim state
215
506
  if (url === base + "/api") {
216
507
  const states = readServeSimStates();