serve-sim 0.1.43 → 0.1.44-beta.91.2

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/src/middleware.ts CHANGED
@@ -3,8 +3,14 @@ import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { createServer as createNetServer } from "net";
6
- import { randomBytes, timingSafeEqual } from "crypto";
7
- import type { IncomingMessage, ServerResponse } from "http";
6
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
7
+ import { request as httpRequest, type IncomingMessage, type 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";
8
14
  import { createAxStreamerCache } from "./ax";
9
15
  import { debugMw } from "./debug";
10
16
  import {
@@ -19,6 +25,10 @@ import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-set
19
25
  type SimReq = IncomingMessage;
20
26
  type SimRes = ServerResponse;
21
27
  type SimNext = (err?: unknown) => void;
28
+ export type SimMiddleware = {
29
+ (req: SimReq, res: SimRes, next?: SimNext): void;
30
+ handleUpgrade(req: SimReq, socket: Socket, head: Buffer): void;
31
+ };
22
32
 
23
33
  // Injected at build time as a base64-encoded string via `define`
24
34
  declare const __PREVIEW_HTML_B64__: string;
@@ -41,7 +51,7 @@ type WebKitBridgeTarget = {
41
51
  inUseByOtherInspector?: boolean;
42
52
  };
43
53
 
44
- type WebKitBridge = {
54
+ export type WebKitBridge = {
45
55
  port: number;
46
56
  cdpUrl: string;
47
57
  listTargets(): Promise<WebKitBridgeTarget[]>;
@@ -126,6 +136,10 @@ function isUserFacingBundle(bundleId: string): boolean {
126
136
  return !NON_UI_BUNDLE_RE.test(bundleId);
127
137
  }
128
138
 
139
+ function isSimulatorUdid(value: string): boolean {
140
+ return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(value);
141
+ }
142
+
129
143
  export function parseForegroundAppLogMessage(message: string): { bundleId: string; pid: number } | null {
130
144
  // e.g. "[app<com.apple.mobilesafari>:43117] Setting process visibility to: Foreground"
131
145
  const match = /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/.exec(message);
@@ -259,7 +273,7 @@ export function readServeSimStates(): ServeSimState[] {
259
273
  // would accept connections yet never produce frames, leaving the
260
274
  // preview stuck on "Connecting...". Recycle the stale state so the
261
275
  // caller can spawn a fresh helper bound to whatever is booted.
262
- if (booted && !booted.has(state.device)) {
276
+ if (booted && isSimulatorUdid(state.device) && !booted.has(state.device)) {
263
277
  debugMw(
264
278
  "recycling stale helper pid=%d (device %s no longer booted)",
265
279
  state.pid,
@@ -291,41 +305,316 @@ function queryDevice(rawUrl: string): string | null {
291
305
  return new URLSearchParams(rawUrl.slice(qIndex + 1)).get("device");
292
306
  }
293
307
 
308
+ function hostForRequest(req: SimReq): string | undefined {
309
+ const host = req.headers?.host;
310
+ if (host) return host;
311
+ const port = req.socket.localPort;
312
+ return port ? `localhost:${port}` : undefined;
313
+ }
314
+
294
315
  function endpoint(base: string, path: string, device: string): string {
295
316
  const value = `${base}${path}`;
296
317
  return `${value}?device=${encodeURIComponent(device)}`;
297
318
  }
298
319
 
299
320
  /**
300
- * Rewrite the helper URLs in a state so they point at the hostname the request
301
- * came in on. The helper binds on `*:<port>`, so once the host portion matches
302
- * the dev-server origin, a remote viewer (LAN, or tunnel exposing the helper
303
- * port under the same hostname) can reach the stream. Loopback callers get
304
- * the state untouched.
321
+ * Rewrite the helper URLs in a state for the requesting browser.
322
+ *
323
+ * When `proxy` is set (standalone `serve-sim`, which owns its server and wires
324
+ * WebSocket upgrades), the URLs point at the preview's same-origin `/helper`
325
+ * proxy so remote viewers only need the one preview port. When it's off — the
326
+ * default for embedded `app.use(simMiddleware(...))` mounts, where the host's
327
+ * server doesn't forward `upgrade` events to `handleUpgrade` — the helper's
328
+ * loopback URLs are emitted directly (with `127.0.0.1` swapped for the request
329
+ * hostname so LAN/tunnel viewers can still reach the separate helper port).
305
330
  */
306
331
  export function rewriteStateForRequestHost(
307
332
  state: ServeSimState,
308
333
  hostHeader: string | undefined,
334
+ base = "",
335
+ protocol: "http" | "https" = "http",
336
+ proxy = false,
309
337
  ): ServeSimState {
310
- if (!hostHeader) return state;
311
- let hostname: string;
312
- try {
313
- hostname = new URL(`http://${hostHeader}`).hostname;
314
- } catch {
338
+ if (!hostHeader) {
315
339
  return state;
316
340
  }
317
- // `URL.hostname` keeps brackets around IPv6 literals, so the IPv6 loopback
318
- // comparison is against the bracketed form rather than `::1`.
319
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") {
320
- return state;
341
+ if (!proxy) {
342
+ let hostname: string;
343
+ try {
344
+ hostname = new URL(`http://${hostHeader}`).hostname;
345
+ } catch {
346
+ return state;
347
+ }
348
+ // `URL.hostname` keeps brackets around IPv6 literals, so the IPv6 loopback
349
+ // comparison is against the bracketed form rather than `::1`.
350
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") {
351
+ return state;
352
+ }
353
+ const rewrite = (s: string) => s.replace("127.0.0.1", hostname);
354
+ return {
355
+ ...state,
356
+ url: rewrite(state.url),
357
+ streamUrl: rewrite(state.streamUrl),
358
+ wsUrl: rewrite(state.wsUrl),
359
+ };
321
360
  }
322
- const rewrite = (s: string) => s.replace("127.0.0.1", hostname);
361
+ const normalizedBase = base === "/" ? "" : base.replace(/\/+$/, "");
362
+ const helperBase = `${normalizedBase}/helper`;
363
+ const devicePath = `${helperBase}/${encodeURIComponent(state.device)}`;
364
+ // Match the request's scheme so an HTTPS-served preview doesn't hand the
365
+ // browser `http`/`ws` helper URLs (blocked as mixed content). Behind a proxy
366
+ // the original scheme arrives via `x-forwarded-proto`.
367
+ const origin = `${protocol}://${hostHeader}`;
368
+ const wsOrigin = `${protocol === "https" ? "wss" : "ws"}://${hostHeader}`;
323
369
  return {
324
370
  ...state,
325
- url: rewrite(state.url),
326
- streamUrl: rewrite(state.streamUrl),
327
- wsUrl: rewrite(state.wsUrl),
371
+ url: `${origin}${devicePath}`,
372
+ streamUrl: `${origin}${devicePath}/stream.mjpeg`,
373
+ wsUrl: `${wsOrigin}${devicePath}/ws`,
374
+ };
375
+ }
376
+
377
+ function helperProxyPrefix(base: string): string {
378
+ return `${base === "/" ? "" : base}/helper`;
379
+ }
380
+
381
+ function devtoolsProxyPrefix(base: string): string {
382
+ return `${base === "/" ? "" : base}/devtools`;
383
+ }
384
+
385
+ function devtoolsProxyTarget(rawUrl: string, prefix: string): { upstreamPath: string } | null {
386
+ const parsed = new URL(rawUrl, "http://serve-sim.local");
387
+ if (!parsed.pathname.startsWith(`${prefix}/page/`)) {
388
+ return null;
389
+ }
390
+ const suffix = parsed.pathname.slice(prefix.length);
391
+ return { upstreamPath: `/devtools${suffix}${parsed.search}` };
392
+ }
393
+
394
+ function helperProxyTarget(rawUrl: string, prefix: string): { device: string | null; upstreamPath: string } | null {
395
+ const parsed = new URL(rawUrl, "http://serve-sim.local");
396
+ if (parsed.pathname !== prefix && !parsed.pathname.startsWith(`${prefix}/`)) {
397
+ return null;
398
+ }
399
+ const rawSuffix = parsed.pathname.slice(prefix.length);
400
+ const segments = rawSuffix.replace(/^\/+/, "").split("/").filter(Boolean);
401
+ const directHelperEndpoints = new Set([
402
+ "ax",
403
+ "config",
404
+ "foreground",
405
+ "health",
406
+ "stream.avcc",
407
+ "stream.mjpeg",
408
+ "ws",
409
+ ]);
410
+ let device = parsed.searchParams.get("device");
411
+ let upstreamSegments = segments;
412
+ if (segments[0] && !directHelperEndpoints.has(segments[0])) {
413
+ device = decodeURIComponent(segments[0]);
414
+ upstreamSegments = segments.slice(1);
415
+ }
416
+ const suffix = upstreamSegments.length > 0 ? `/${upstreamSegments.join("/")}` : "/";
417
+ parsed.searchParams.delete("device");
418
+ return { device, upstreamPath: `${suffix}${parsed.search}` };
419
+ }
420
+
421
+ function proxyHelperHttp(req: SimReq, res: SimRes, state: ServeSimState, upstreamPath: string): void {
422
+ const proxyReq = httpRequest(
423
+ {
424
+ host: "127.0.0.1",
425
+ port: state.port,
426
+ method: req.method,
427
+ path: upstreamPath,
428
+ headers: {
429
+ ...req.headers,
430
+ host: `127.0.0.1:${state.port}`,
431
+ },
432
+ },
433
+ (proxyRes) => {
434
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
435
+ proxyRes.pipe(res);
436
+ },
437
+ );
438
+ proxyReq.on("error", (err) => {
439
+ if (res.headersSent) {
440
+ res.destroy(err);
441
+ return;
442
+ }
443
+ res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
444
+ res.end(err instanceof Error ? err.message : "Helper proxy failed");
445
+ });
446
+ req.pipe(proxyReq);
447
+ }
448
+
449
+ const WS_ACCEPT_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
450
+
451
+ function websocketFrame(opcode: number, payload: Buffer<ArrayBufferLike>): Buffer {
452
+ const length = payload.length;
453
+ let header: Buffer;
454
+ if (length < 126) {
455
+ header = Buffer.from([0x80 | opcode, length]);
456
+ } else if (length <= 0xffff) {
457
+ header = Buffer.alloc(4);
458
+ header[0] = 0x80 | opcode;
459
+ header[1] = 126;
460
+ header.writeUInt16BE(length, 2);
461
+ } else {
462
+ header = Buffer.alloc(10);
463
+ header[0] = 0x80 | opcode;
464
+ header[1] = 127;
465
+ header.writeBigUInt64BE(BigInt(length), 2);
466
+ }
467
+ return Buffer.concat([header, payload]);
468
+ }
469
+
470
+ type ParsedWebSocketFrame = {
471
+ opcode: number;
472
+ payload: Buffer<ArrayBufferLike>;
473
+ consumed: number;
474
+ };
475
+
476
+ function parseWebSocketFrame(buffer: Buffer): ParsedWebSocketFrame | null {
477
+ if (buffer.length < 2) return null;
478
+ const opcode = buffer[0]! & 0x0f;
479
+ const masked = (buffer[1]! & 0x80) !== 0;
480
+ let length = buffer[1]! & 0x7f;
481
+ let offset = 2;
482
+ if (length === 126) {
483
+ if (buffer.length < offset + 2) return null;
484
+ length = buffer.readUInt16BE(offset);
485
+ offset += 2;
486
+ } else if (length === 127) {
487
+ if (buffer.length < offset + 8) return null;
488
+ const bigLength = buffer.readBigUInt64BE(offset);
489
+ if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
490
+ throw new Error("WebSocket frame too large");
491
+ }
492
+ length = Number(bigLength);
493
+ offset += 8;
494
+ }
495
+ const maskOffset = offset;
496
+ if (masked) offset += 4;
497
+ if (buffer.length < offset + length) return null;
498
+ const payload = Buffer.from(buffer.subarray(offset, offset + length));
499
+ if (masked) {
500
+ const mask = buffer.subarray(maskOffset, maskOffset + 4);
501
+ for (let i = 0; i < payload.length; i++) {
502
+ payload[i] = payload[i]! ^ mask[i % 4]!;
503
+ }
504
+ }
505
+ return { opcode, payload, consumed: offset + length };
506
+ }
507
+
508
+ function sendBrowserFrame(socket: Socket, opcode: number, payload: Buffer<ArrayBufferLike> = Buffer.alloc(0)): void {
509
+ if (socket.destroyed || !socket.writable) return;
510
+ socket.write(websocketFrame(opcode, payload));
511
+ }
512
+
513
+ type PendingWebSocketFrame = {
514
+ opcode: number;
515
+ payload: Buffer<ArrayBufferLike>;
516
+ };
517
+
518
+ function webSocketBinary(payload: Buffer<ArrayBufferLike>): Uint8Array<ArrayBuffer> {
519
+ const bytes = new Uint8Array(payload.length);
520
+ bytes.set(payload);
521
+ return bytes;
522
+ }
523
+
524
+ function bridgeWebSocketFrames(req: SimReq, socket: Socket, head: Buffer, upstreamUrl: string): void {
525
+ const key = req.headers["sec-websocket-key"];
526
+ if (typeof key !== "string") {
527
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
528
+ return;
529
+ }
530
+ const accept = createHash("sha1")
531
+ .update(key + WS_ACCEPT_GUID)
532
+ .digest("base64");
533
+ socket.write(
534
+ "HTTP/1.1 101 Switching Protocols\r\n" +
535
+ "Upgrade: websocket\r\n" +
536
+ "Connection: Upgrade\r\n" +
537
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
538
+ "\r\n",
539
+ );
540
+ socket.resume();
541
+
542
+ const upstream = new WebSocket(upstreamUrl);
543
+ upstream.binaryType = "arraybuffer";
544
+ let upstreamOpen = false;
545
+ let closed = false;
546
+ let pendingToUpstream: PendingWebSocketFrame[] = [];
547
+ let buffered = Buffer.from(head);
548
+
549
+ const closeBoth = () => {
550
+ if (closed) return;
551
+ closed = true;
552
+ try { upstream.close(); } catch {}
553
+ try { socket.end(websocketFrame(0x8, Buffer.alloc(0))); } catch {}
554
+ try { socket.destroy(); } catch {}
555
+ };
556
+
557
+ const sendToUpstream = (frame: PendingWebSocketFrame) => {
558
+ if (upstreamOpen && upstream.readyState === WebSocket.OPEN) {
559
+ upstream.send(frame.opcode === 0x1 ? frame.payload.toString("utf8") : webSocketBinary(frame.payload));
560
+ return;
561
+ }
562
+ pendingToUpstream.push({ opcode: frame.opcode, payload: Buffer.from(frame.payload) });
328
563
  };
564
+
565
+ const drainFrames = () => {
566
+ try {
567
+ while (buffered.length > 0) {
568
+ const frame = parseWebSocketFrame(buffered);
569
+ if (!frame) break;
570
+ buffered = buffered.subarray(frame.consumed);
571
+ if (frame.opcode === 0x8) {
572
+ sendBrowserFrame(socket, 0x8, frame.payload);
573
+ closeBoth();
574
+ return;
575
+ }
576
+ if (frame.opcode === 0x9) {
577
+ sendBrowserFrame(socket, 0xA, frame.payload);
578
+ continue;
579
+ }
580
+ if (frame.opcode === 0x1 || frame.opcode === 0x2) {
581
+ sendToUpstream({ opcode: frame.opcode, payload: frame.payload });
582
+ }
583
+ }
584
+ } catch {
585
+ closeBoth();
586
+ }
587
+ };
588
+
589
+ upstream.onopen = () => {
590
+ upstreamOpen = true;
591
+ for (const frame of pendingToUpstream) {
592
+ upstream.send(frame.opcode === 0x1 ? frame.payload.toString("utf8") : webSocketBinary(frame.payload));
593
+ }
594
+ pendingToUpstream = [];
595
+ };
596
+ upstream.onmessage = (event) => {
597
+ const data = event.data;
598
+ const payload = typeof data === "string"
599
+ ? Buffer.from(data)
600
+ : Buffer.from(data as ArrayBuffer);
601
+ sendBrowserFrame(socket, typeof data === "string" ? 0x1 : 0x2, payload);
602
+ };
603
+ upstream.onerror = closeBoth;
604
+ upstream.onclose = closeBoth;
605
+
606
+ socket.on("data", (chunk) => {
607
+ if (typeof chunk === "string") chunk = Buffer.from(chunk);
608
+ buffered = Buffer.concat([buffered, chunk]);
609
+ drainFrames();
610
+ });
611
+ socket.on("error", closeBoth);
612
+ socket.on("close", closeBoth);
613
+ drainFrames();
614
+ }
615
+
616
+ function bridgeHelperWebSocket(req: SimReq, socket: Socket, head: Buffer, state: ServeSimState): void {
617
+ bridgeWebSocketFrames(req, socket, head, state.wsUrl);
329
618
  }
330
619
 
331
620
  export function previewConfigForState(
@@ -333,6 +622,8 @@ export function previewConfigForState(
333
622
  base: string,
334
623
  serveSimBin: string,
335
624
  execToken: string,
625
+ codec?: string,
626
+ proxyHelpers = false,
336
627
  ): ServeSimState & {
337
628
  basePath: string;
338
629
  appStateEndpoint: string;
@@ -345,6 +636,8 @@ export function previewConfigForState(
345
636
  gridMemoryEndpoint: string;
346
637
  previewEndpoint: string;
347
638
  execToken: string;
639
+ codec?: string;
640
+ proxyHelpers?: boolean;
348
641
  } {
349
642
  const gridApiBase = (base === "" ? "" : base) + "/grid/api";
350
643
  return {
@@ -360,6 +653,8 @@ export function previewConfigForState(
360
653
  gridMemoryEndpoint: gridApiBase + "/memory",
361
654
  previewEndpoint: base === "" ? "/" : base,
362
655
  execToken,
656
+ ...(codec ? { codec } : {}),
657
+ ...(proxyHelpers ? { proxyHelpers: true } : {}),
363
658
  };
364
659
  }
365
660
 
@@ -430,10 +725,9 @@ async function ensureInspectWebKitBridge(): Promise<WebKitBridge> {
430
725
  continue;
431
726
  }
432
727
  try {
433
- // Bind explicitly to IPv4 127.0.0.1 to match what bridgeWsHost emits
434
- // (and what the DevTools frontend CSP whitelists). `localhost` resolves
435
- // to ::1 first on some setups, which would leave the iframe's
436
- // ws://127.0.0.1:9222 connection refused.
728
+ // Bind explicitly to IPv4 127.0.0.1 so the preview's DevTools
729
+ // websocket proxy has a stable loopback upstream. `localhost` resolves
730
+ // to ::1 first on some setups, which would leave the bridge unreachable.
437
731
  const server = await startCdpServer({ host: "127.0.0.1", port }) as Awaited<ReturnType<typeof startCdpServer>> & {
438
732
  highlightTarget?(targetId: string, on: boolean): Promise<void>;
439
733
  releaseHighlight?(targetId?: string): void;
@@ -478,22 +772,34 @@ async function ensureInspectWebKitBridge(): Promise<WebKitBridge> {
478
772
  return inspectWebKitBridge;
479
773
  }
480
774
 
481
- function devtoolsFrontendUrl(frontendBase: string, wsHost: string, targetId: string): string {
482
- const url = new URL(`${frontendBase}/inspector.html`, "http://serve-sim.local");
483
- url.searchParams.set("ws", `${wsHost}/devtools/page/${targetId}`);
484
- return `${url.pathname}${url.search}`;
775
+ function firstHeaderValue(value: string | string[] | undefined): string | undefined {
776
+ return Array.isArray(value) ? value[0] : value;
777
+ }
778
+
779
+ function forwardedProtoForRequest(req: SimReq): string | undefined {
780
+ return firstHeaderValue(req.headers["x-forwarded-proto"])
781
+ ?.split(",", 1)[0]
782
+ ?.trim()
783
+ .toLowerCase();
485
784
  }
486
785
 
487
- // The inspect-webkit bridge binds locally. Always emit `127.0.0.1` rather
488
- // than `localhost` for the iframe's WS URL: the chrome-devtools-frontend
489
- // inspector.html ships a CSP whose connect-src only whitelists
490
- // `ws://127.0.0.1:*` (plus `'self'`, which doesn't cover the bridge's
491
- // different port). A `ws://localhost:9222/...` connection from the iframe
492
- // gets CSP-blocked and surfaces as "WebSocket disconnected."
493
- // Non-local hostnames fall back to 127.0.0.1 since the bridge isn't
494
- // reachable from off-host anyway.
495
- function bridgeWsHost(_reqHost: string | undefined, bridgePort: number): string {
496
- return `127.0.0.1:${bridgePort}`;
786
+ function websocketProtocolForRequest(req: SimReq): "ws" | "wss" {
787
+ return forwardedProtoForRequest(req) === "https" ? "wss" : "ws";
788
+ }
789
+
790
+ function httpProtocolForRequest(req: SimReq): "http" | "https" {
791
+ return forwardedProtoForRequest(req) === "https" ? "https" : "http";
792
+ }
793
+
794
+ function devtoolsFrontendUrl(
795
+ frontendBase: string,
796
+ wsParamName: "ws" | "wss",
797
+ wsTargetBase: string,
798
+ targetId: string,
799
+ ): string {
800
+ const url = new URL(`${frontendBase}/inspector.html`, "http://serve-sim.local");
801
+ url.searchParams.set(wsParamName, `${wsTargetBase}/page/${encodeURIComponent(targetId)}`);
802
+ return `${url.pathname}${url.search}`;
497
803
  }
498
804
 
499
805
  let _html: string | null = null;
@@ -704,6 +1010,26 @@ export interface SimMiddlewareOptions {
704
1010
  * cross-origin pages cannot read it.
705
1011
  */
706
1012
  execToken?: string;
1013
+ /**
1014
+ * Pin the preview stream codec. `"mjpeg"` forces the software JPEG path for
1015
+ * hosts whose hardware can't encode H.264 (e.g. VMs without the high/low-
1016
+ * latency H.264 profiles); `"auto"`/undefined lets the browser pick H.264.
1017
+ * Reserved for future values such as `"hevc"`/`"av1"`.
1018
+ */
1019
+ codec?: string;
1020
+ /**
1021
+ * Route the browser's helper stream/control and DevTools sockets through the
1022
+ * preview's same-origin `/helper` and `/devtools` proxies instead of the
1023
+ * helper's own loopback port — so a single exposed preview port is enough for
1024
+ * remote viewers. Requires the mounting server to forward WebSocket `upgrade`
1025
+ * events to {@link SimMiddleware.handleUpgrade}. Standalone `serve-sim`
1026
+ * enables this; plain `app.use(simMiddleware(...))` mounts leave it off (and
1027
+ * keep direct helper URLs) unless they also wire upgrades. See the README's
1028
+ * "Embed in your dev server" section.
1029
+ */
1030
+ proxyHelpers?: boolean;
1031
+ /** Test hook for supplying a fake inspect-webkit bridge. */
1032
+ inspectWebKitBridge?: () => Promise<WebKitBridge>;
707
1033
  }
708
1034
 
709
1035
  function safeEqualString(a: string, b: string): boolean {
@@ -728,8 +1054,12 @@ function isJsonContentType(value: string | undefined): boolean {
728
1054
  * GET {basePath}/api — serve-sim state JSON
729
1055
  * GET {basePath}/ax — SSE stream of normalized accessibility snapshots
730
1056
  */
731
- export function simMiddleware(options?: SimMiddlewareOptions) {
1057
+ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
732
1058
  const base = (options?.basePath ?? "/.sim").replace(/\/+$/, "");
1059
+ const helperPrefix = helperProxyPrefix(base);
1060
+ const devtoolsPrefix = devtoolsProxyPrefix(base);
1061
+ const proxyHelpers = options?.proxyHelpers ?? false;
1062
+ const getInspectWebKitBridge = options?.inspectWebKitBridge ?? ensureInspectWebKitBridge;
733
1063
  // Per-process random token. Anyone who can read the preview HTML same-origin
734
1064
  // can call /exec; cross-origin pages and LAN clients cannot, because they
735
1065
  // can't read this value (it's only injected into the preview page's config).
@@ -753,13 +1083,26 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
753
1083
  return { ok: true };
754
1084
  };
755
1085
 
756
- const middleware = (req: SimReq, res: SimRes, next?: SimNext) => {
1086
+ const middleware = ((req: SimReq, res: SimRes, next?: SimNext) => {
757
1087
  const rawUrl: string = req.url ?? "";
758
1088
  const qIndex = rawUrl.indexOf("?");
759
1089
  const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
760
1090
  const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
761
1091
  const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
762
1092
 
1093
+ const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
1094
+ if (helperTarget) {
1095
+ const states = readServeSimStates();
1096
+ const state = selectServeSimState(states, helperTarget.device ?? selectedDevice);
1097
+ if (!state) {
1098
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
1099
+ res.end("No serve-sim device");
1100
+ return;
1101
+ }
1102
+ proxyHelperHttp(req, res, state, helperTarget.upstreamPath);
1103
+ return;
1104
+ }
1105
+
763
1106
  // Same-origin proxy for Chrome DevTools frontend assets. Loading the
764
1107
  // appspot-hosted frontend directly works as a top-level tab, but is flaky
765
1108
  // inside embedded browser iframes. Serving it from the preview origin keeps
@@ -812,8 +1155,8 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
812
1155
  }
813
1156
 
814
1157
  if (state) {
815
- const remoteState = rewriteStateForRequestHost(state, req.headers?.host);
816
- const config = JSON.stringify(previewConfigForState(remoteState, base, serveSimBinPath(), execToken));
1158
+ const remoteState = rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers);
1159
+ const config = JSON.stringify(previewConfigForState(remoteState, base, serveSimBinPath(), execToken, options?.codec, proxyHelpers));
817
1160
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
818
1161
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
819
1162
  }
@@ -853,7 +1196,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
853
1196
  const sims = listAllSimulators();
854
1197
  const devices = sims.map((d) => {
855
1198
  const helper = helperByUdid.get(d.udid);
856
- const remoteHelper = helper ? rewriteStateForRequestHost(helper, req.headers?.host) : null;
1199
+ const remoteHelper = helper ? rewriteStateForRequestHost(helper, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
857
1200
  return {
858
1201
  device: d.udid,
859
1202
  name: d.name,
@@ -1017,17 +1360,23 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1017
1360
  return;
1018
1361
  }
1019
1362
  try {
1020
- const bridge = await ensureInspectWebKitBridge();
1363
+ const bridge = await getInspectWebKitBridge();
1021
1364
  const bridgeTargets = await bridge.listTargets();
1022
- const wsHost = bridgeWsHost(req.headers?.host, bridge.port);
1365
+ // Proxy mode routes the inspector socket through the preview's
1366
+ // same-origin `/devtools` proxy; otherwise the browser talks to the
1367
+ // bridge's loopback port directly (the pre-proxy behavior).
1368
+ const wsProtocol = proxyHelpers ? websocketProtocolForRequest(req) : "ws";
1369
+ const wsTargetBase = proxyHelpers
1370
+ ? `${hostForRequest(req) ?? `127.0.0.1:${bridge.port}`}${devtoolsPrefix}`
1371
+ : `127.0.0.1:${bridge.port}/devtools`;
1023
1372
  // inspect-webkit@0.0.3 only exposes `sim:<webinspectord-pid>` for
1024
1373
  // simulator targets, which can't be reconciled against a sim UDID.
1025
1374
  // Surface every booted sim's targets (Safari Develop-menu behavior)
1026
1375
  // until inspect-webkit grows a real UDID we can filter on.
1027
1376
  const targets = bridgeTargets.map((target) => ({
1028
1377
  ...target,
1029
- webSocketDebuggerUrl: `ws://${wsHost}/devtools/page/${encodeURIComponent(target.id)}`,
1030
- devtoolsFrontendUrl: devtoolsFrontendUrl(devtoolsFrontendBase, wsHost, target.id),
1378
+ webSocketDebuggerUrl: `${wsProtocol}://${wsTargetBase}/page/${encodeURIComponent(target.id)}`,
1379
+ devtoolsFrontendUrl: devtoolsFrontendUrl(devtoolsFrontendBase, wsProtocol, wsTargetBase, target.id),
1031
1380
  }));
1032
1381
  res.writeHead(200, {
1033
1382
  "Content-Type": "application/json",
@@ -1056,7 +1405,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1056
1405
  req.on("end", async () => {
1057
1406
  try {
1058
1407
  const parsed: ReleaseRequestBody = body ? JSON.parse(body) : {};
1059
- const bridge = await ensureInspectWebKitBridge();
1408
+ const bridge = await getInspectWebKitBridge();
1060
1409
  bridge.releaseHighlight?.(parsed.targetId);
1061
1410
  res.writeHead(200, { "Content-Type": "application/json" });
1062
1411
  res.end("{}");
@@ -1084,7 +1433,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1084
1433
  res.end(JSON.stringify({ error: "Missing targetId" }));
1085
1434
  return;
1086
1435
  }
1087
- const bridge = await ensureInspectWebKitBridge();
1436
+ const bridge = await getInspectWebKitBridge();
1088
1437
  if (!bridge.highlightTarget) {
1089
1438
  res.writeHead(501, { "Content-Type": "application/json" });
1090
1439
  res.end(JSON.stringify({ error: "highlightTarget not supported by inspect-webkit" }));
@@ -1126,8 +1475,8 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1126
1475
  "Content-Type": "application/json",
1127
1476
  "Cache-Control": "no-store",
1128
1477
  });
1129
- const remoteState = state ? rewriteStateForRequestHost(state, req.headers?.host) : null;
1130
- res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken) : null));
1478
+ const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
1479
+ res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, options?.codec, proxyHelpers) : null));
1131
1480
  return;
1132
1481
  }
1133
1482
 
@@ -1139,9 +1488,9 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1139
1488
  const computeConfig = (): string => {
1140
1489
  const states = readServeSimStates();
1141
1490
  const state = selectServeSimState(states, selectedDevice);
1142
- const remoteState = state ? rewriteStateForRequestHost(state, req.headers?.host) : null;
1491
+ const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
1143
1492
  return JSON.stringify(
1144
- remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken) : null,
1493
+ remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, options?.codec, proxyHelpers) : null,
1145
1494
  );
1146
1495
  };
1147
1496
 
@@ -1414,24 +1763,63 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1414
1763
 
1415
1764
  // Not ours — pass through
1416
1765
  if (next) next();
1766
+ }) as SimMiddleware;
1767
+ middleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
1768
+ const rawUrl = req.url ?? "";
1769
+ const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
1770
+ const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
1771
+ const devtoolsTarget = devtoolsProxyTarget(rawUrl, devtoolsPrefix);
1772
+ if (devtoolsTarget) {
1773
+ (async () => {
1774
+ try {
1775
+ const bridge = await getInspectWebKitBridge();
1776
+ bridgeWebSocketFrames(req, socket, head, `ws://127.0.0.1:${bridge.port}${devtoolsTarget.upstreamPath}`);
1777
+ } catch (err) {
1778
+ const message = err instanceof Error ? err.message : "Failed to start inspect-webkit";
1779
+ socket.end(`HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${message}`);
1780
+ }
1781
+ })();
1782
+ return;
1783
+ }
1784
+ if (!helperTarget) {
1785
+ socket.destroy();
1786
+ return;
1787
+ }
1788
+ const states = readServeSimStates();
1789
+ const state = selectServeSimState(states, helperTarget.device ?? selectedDevice);
1790
+ if (!state) {
1791
+ socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
1792
+ return;
1793
+ }
1794
+ if (helperTarget.upstreamPath === "/ws") {
1795
+ bridgeHelperWebSocket(req, socket, head, state);
1796
+ return;
1797
+ }
1798
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
1417
1799
  };
1418
-
1419
1800
  // WebSocket exec channel — same auth/origin policy as POST /exec, but off
1420
1801
  // the browser's per-origin HTTP connection pool so multiple preview tabs
1421
1802
  // (each holding MJPEG + SSE streams) can't starve exec actions. Servers
1422
1803
  // mounting this middleware should forward `upgrade` events here (the
1423
1804
  // built-in preview server does); the client falls back to POST /exec when
1424
1805
  // the upgrade never completes.
1425
- return Object.assign(middleware, {
1426
- handleUpgrade: createExecUpgradeHandler({
1427
- path: `${base}/exec-ws`,
1428
- execToken,
1429
- ssePrefixes: [
1430
- `${base}/api/events`,
1431
- `${base}/appstate`,
1432
- `${base}/ax`,
1433
- ],
1434
- onUiRequest: handleUiRequest,
1435
- }),
1806
+ const handleExecUpgrade = createExecUpgradeHandler({
1807
+ path: `${base}/exec-ws`,
1808
+ execToken,
1809
+ ssePrefixes: [
1810
+ `${base}/api/events`,
1811
+ `${base}/appstate`,
1812
+ `${base}/ax`,
1813
+ ],
1814
+ onUiRequest: handleUiRequest,
1436
1815
  });
1816
+
1817
+ // WebSocket upgrades owned by the preview: the authenticated exec/control
1818
+ // channel plus same-origin helper/devtools proxy sockets.
1819
+ const handleProxyUpgrade = middleware.handleUpgrade;
1820
+ middleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
1821
+ if (handleExecUpgrade(req, socket, head)) return;
1822
+ handleProxyUpgrade(req, socket, head);
1823
+ };
1824
+ return middleware;
1437
1825
  }