@expo/serve-sim 0.1.54 → 0.2.1

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/dist/state.js CHANGED
@@ -1 +1 @@
1
- import{tmpdir as Q}from"os";import{join as H}from"path";import{readdirSync as U,mkdirSync as V,writeFileSync as W,renameSync as X,readFileSync as Y,unlinkSync as Z}from"fs";var G=H(Q(),"serve-sim"),k=H(G,"server.json");function O(q){return H(G,`server-${q}.json`)}function x(q,z,B="/",C="127.0.0.1",K){let J=C==="0.0.0.0"||C==="::"?"127.0.0.1":C,M=B.replace(/^\/+/,"").replace(/\/+$/,""),N=M===""?"":`/${M}`;return{pid:process.pid,port:z,device:q,url:`http://${J}:${z}`,streamUrl:`http://${J}:${z}${N}/helper/${q}/stream.mjpeg`,wsUrl:`ws://${J}:${z}${N}/helper/${q}/ws`,...K?{streamSettings:K}:{}}}function P(q){V(G,{recursive:!0});let z=O(q.device),B=`${z}.${process.pid}.tmp`;W(B,JSON.stringify(q,null,2),{mode:384}),X(B,z)}function v(q,z){let B=O(q),C;try{C=JSON.parse(Y(B,"utf-8"))}catch{return}if(C.pid!==z)return;try{Z(B)}catch{}}function w(){try{return U(G).filter((q)=>q.startsWith("server-")&&q.endsWith(".json")).map((q)=>H(G,q))}catch{return[]}}export{P as writeServeSimState,O as stateFileForDevice,w as listStateFiles,x as inProcessServeSimState,v as clearServeSimState,k as STATE_FILE,G as STATE_DIR};
1
+ import{tmpdir as M}from"os";import{join as G}from"path";import{readdirSync as N,mkdirSync as O,writeFileSync as Q,renameSync as R,readFileSync as T,unlinkSync as U}from"fs";function H(){return process.env.SERVE_SIM_STATE_DIR||G(M(),"serve-sim")}function K(q){return G(H(),`server-${q}.json`)}function Y(q,z){let A=(C)=>({url:C.url,streamUrl:C.streamUrl,wsUrl:C.wsUrl,port:C.port,device:C.device}),B=q.length===1?A(q[0]):{devices:q.map(A)};return z?{...B,token:z}:B}function Z(q,z,A="/",B="127.0.0.1",C){let E=B==="0.0.0.0"||B==="::"?"127.0.0.1":B,I=A.replace(/^\/+/,"").replace(/\/+$/,""),J=I===""?"":`/${I}`;return{pid:process.pid,port:z,device:q,url:`http://${E}:${z}`,streamUrl:`http://${E}:${z}${J}/helper/${q}/stream.mjpeg`,wsUrl:`ws://${E}:${z}${J}/helper/${q}/ws`,...C?{streamSettings:C}:{}}}function _(q){O(H(),{recursive:!0});let z=K(q.device),A=`${z}.${process.pid}.tmp`;Q(A,JSON.stringify(q,null,2),{mode:384}),R(A,z)}function $(q,z){let A=K(q),B;try{B=JSON.parse(T(A,"utf-8"))}catch{return}if(B.pid!==z)return;try{U(A)}catch{}}function L(){try{let q=H();return N(q).filter((z)=>z.startsWith("server-")&&z.endsWith(".json")).map((z)=>G(q,z))}catch{return[]}}export{_ as writeServeSimState,K as stateFileForDevice,H as stateDir,Y as previewStartupPayload,L as listStateFiles,Z as inProcessServeSimState,$ as clearServeSimState};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/serve-sim",
3
- "version": "0.1.54",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -94,7 +94,8 @@
94
94
  "react-dom": "^19.0.0",
95
95
  "tailwindcss": "^4.1.7",
96
96
  "typescript": "^5.7.0",
97
- "werift": "^0.24.4"
97
+ "werift": "^0.24.4",
98
+ "zod": "^4.5.4"
98
99
  },
99
100
  "dependencies": {
100
101
  "inspect-webkit": "^0.0.5",
@@ -1,9 +1,11 @@
1
1
  import { createHash } from "crypto";
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { STATE_DIR } from "./state";
4
+ import { stateDir } from "./state";
5
5
 
6
- export const CAMERA_STATE_DIR = join(STATE_DIR, "simcam");
6
+ export function cameraStateDir(): string {
7
+ return join(stateDir(), "simcam");
8
+ }
7
9
  const HELPER_TIMEOUT_MS = 3000;
8
10
 
9
11
  interface InjectedBundlesState {
@@ -28,11 +30,11 @@ export interface CameraStatusReply extends CameraHelperReply {
28
30
  }
29
31
 
30
32
  export function cameraHelperPidFile(udid: string): string {
31
- return join(CAMERA_STATE_DIR, `${udid}.pid`);
33
+ return join(cameraStateDir(), `${udid}.pid`);
32
34
  }
33
35
 
34
36
  export function cameraHelperBundlesFile(udid: string): string {
35
- return join(CAMERA_STATE_DIR, `${udid}.bundles.json`);
37
+ return join(cameraStateDir(), `${udid}.bundles.json`);
36
38
  }
37
39
 
38
40
  export function cameraHelperSocketFile(udid: string): string {
package/src/middleware.ts CHANGED
@@ -1,10 +1,10 @@
1
+ import { execFile, execSync, spawn, type ChildProcess } from "child_process";
1
2
  import { readdirSync, readFileSync, existsSync, unlinkSync, watch, type FSWatcher } from "fs";
2
3
  import { readFile, unlink } from "fs/promises";
3
- import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException } from "child_process";
4
4
  import { tmpdir } from "os";
5
5
  import { join } from "path";
6
6
  import { createServer as createNetServer } from "net";
7
- import { createHash, randomBytes, timingSafeEqual } from "crypto";
7
+ import { createHash, randomBytes } from "crypto";
8
8
  import type { IncomingMessage, ServerResponse } from "http";
9
9
  import type { Socket } from "net";
10
10
  // `ws` (kept external in the build) supplies a WebSocket *client* for the
@@ -16,7 +16,7 @@ import { createAxStreamerCache } from "./ax";
16
16
  import { readCameraStatus } from "./camera-helper";
17
17
  import { createMetricsSamplerCache, MetricsSampler, type MetricsSamplerCache } from "./metrics-sampler";
18
18
  import { foregroundTracker, type ForegroundApp, type ForegroundTrackerCache } from "./foreground-tracker";
19
- import { corsAllowOriginHeaders } from "./middleware-utils";
19
+ import { corsAllowOriginHeaders, frameAncestorsPolicy } from "./middleware-utils";
20
20
  import {
21
21
  closeDeviceSession,
22
22
  getDeviceSession,
@@ -24,13 +24,14 @@ import {
24
24
  sendCorsPreflight,
25
25
  type HidSocket,
26
26
  } from "./device-session";
27
+ import { assertPreviewAccess, assertUpgradeAccess } from "./session-auth";
27
28
  import {
28
- eventLogEventForCommand,
29
+ eventLogEventForAction,
29
30
  readEventLog,
30
31
  recordEventLogEvent,
31
32
  subscribeEventLog,
32
33
  } from "./event-log";
33
- import { inProcessServeSimState, writeServeSimState, type ServeSimDeviceState, type StreamSettings } from "./state";
34
+ import { inProcessServeSimState, stateDir, writeServeSimState, type ServeSimDeviceState, type StreamSettings } from "./state";
34
35
  import { debugMw } from "./debug";
35
36
  import {
36
37
  resolveDevicePlaceholderAsset,
@@ -54,7 +55,6 @@ export type SimMiddleware = WebMiddleware & {
54
55
 
55
56
  // Injected at build time as a base64-encoded string via `define`
56
57
  declare const __PREVIEW_HTML_B64__: string;
57
- const STATE_DIR = join(tmpdir(), "serve-sim");
58
58
  // Last logged result of a GET /api selection, used to suppress the
59
59
  // once-every-poll duplicate debugMw lines (the UI polls /api every ~2s).
60
60
  let lastApiLogKey: string | undefined;
@@ -117,8 +117,6 @@ type ShutdownRequestBody = { udid?: string };
117
117
  type StartRequestBody = { udid?: string };
118
118
  type ReleaseRequestBody = { targetId?: string };
119
119
  type HighlightRequestBody = { targetId?: string; on?: boolean };
120
- type ExecRequestBody = { command?: string };
121
-
122
120
  /** Re-exported alias for the canonical device-state record in `./state`. */
123
121
  export type ServeSimState = ServeSimDeviceState;
124
122
 
@@ -149,12 +147,16 @@ function eventLogSinceId(rawUrl: string): number | undefined {
149
147
  return Number.isFinite(since) ? since : undefined;
150
148
  }
151
149
 
152
- function recordCommandEvent(command: string, result: { exitCode?: number }): void {
150
+ function recordActionEvent(
151
+ action: string,
152
+ params: Record<string, unknown> | undefined,
153
+ result: { exitCode?: number },
154
+ ): void {
153
155
  try {
154
- const event = eventLogEventForCommand(command, result);
156
+ const event = eventLogEventForAction(action, params, result);
155
157
  if (event) recordEventLogEvent(event);
156
158
  } catch {
157
- // Event-log recording is diagnostic; it must never break the exec path.
159
+ // Event-log recording is diagnostic; it must never break the action path.
158
160
  }
159
161
  }
160
162
 
@@ -359,7 +361,7 @@ function getPreferredDeviceUdid(): string | null {
359
361
  export async function readServeSimStates(): Promise<ServeSimState[]> {
360
362
  let files: string[];
361
363
  try {
362
- files = readdirSync(STATE_DIR).filter(
364
+ files = readdirSync(stateDir()).filter(
363
365
  (f) => f.startsWith("server-") && f.endsWith(".json"),
364
366
  );
365
367
  } catch {
@@ -368,7 +370,7 @@ export async function readServeSimStates(): Promise<ServeSimState[]> {
368
370
  const booted = await getBootedUdids();
369
371
  const states: ServeSimState[] = [];
370
372
  for (const f of files) {
371
- const path = join(STATE_DIR, f);
373
+ const path = join(stateDir(), f);
372
374
  try {
373
375
  const state: ServeSimState = JSON.parse(readFileSync(path, "utf-8"));
374
376
  try {
@@ -847,11 +849,25 @@ function serveHelperInProcess(
847
849
  * preview server itself serves the device's /helper routes in-process. Resolves
848
850
  * to an error string on boot failure, or null on success.
849
851
  */
852
+ /** Carries the session token like the primary device's does, or its readers start failing. */
853
+ export function gridDeviceState(
854
+ udid: string,
855
+ port: number,
856
+ base: string,
857
+ streamSettings?: StreamSettings,
858
+ sessionToken?: string,
859
+ ): ServeSimDeviceState {
860
+ const state = inProcessServeSimState(udid, port, base, "127.0.0.1", streamSettings);
861
+ return sessionToken ? { ...state, token: sessionToken } : state;
862
+ }
863
+
850
864
  export async function startDeviceInProcess(
851
865
  udid: string,
852
866
  port: number,
853
867
  base: string,
854
868
  streamSettings?: StreamSettings,
869
+ /** Session token, when the server runs gated. */
870
+ sessionToken?: string,
855
871
  ): Promise<string | null> {
856
872
  // `simctl boot` errors when already booted — ignore and let bootstatus confirm.
857
873
  await new Promise<void>((resolve) => execFile("xcrun", ["simctl", "boot", udid], () => resolve()));
@@ -874,7 +890,7 @@ export async function startDeviceInProcess(
874
890
  });
875
891
  if (!booted) return `Device ${udid} failed to reach booted state`;
876
892
  }
877
- writeServeSimState(inProcessServeSimState(udid, port, base, "127.0.0.1", streamSettings));
893
+ writeServeSimState(gridDeviceState(udid, port, base, streamSettings, sessionToken));
878
894
  return null;
879
895
  }
880
896
 
@@ -958,7 +974,6 @@ function attachHidInProcess(
958
974
  export function previewConfigForState(
959
975
  state: ServeSimState,
960
976
  base: string,
961
- serveSimBin: string,
962
977
  execToken: string,
963
978
  streamSettingsOrCodec?: StreamSettings | string,
964
979
  proxyHelpers = false,
@@ -973,7 +988,6 @@ export function previewConfigForState(
973
988
  cameraStatusEndpoint: string;
974
989
  devtoolsEndpoint: string;
975
990
  streamSettingsEndpoint: string;
976
- serveSimBin: string;
977
991
  gridApiEndpoint: string;
978
992
  gridCatalogEndpoint: string;
979
993
  gridStatusEndpoint: string;
@@ -995,8 +1009,11 @@ export function previewConfigForState(
995
1009
  const streamSettings = typeof streamSettingsOrCodec === "object"
996
1010
  ? streamSettingsOrCodec
997
1011
  : httpStreamSettingsFromLegacyCodec(legacyCodec);
1012
+ // Every serve-sim's state file is readable here and ?device= is caller-supplied, so neither the
1013
+ // token nor the TURN credentials may ride along: that hands one instance's secrets to another.
1014
+ const { token: _sessionToken, streamSettings: _foreignStreamSettings, ...publicState } = state;
998
1015
  return {
999
- ...state,
1016
+ ...publicState,
1000
1017
  basePath: base,
1001
1018
  logsEndpoint: endpoint(base, "/logs", state.device),
1002
1019
  appStateEndpoint: endpoint(base, "/appstate", state.device),
@@ -1007,7 +1024,6 @@ export function previewConfigForState(
1007
1024
  cameraStatusEndpoint: `${base === "/" ? "" : base}/helper/${encodeURIComponent(state.device)}/camera/status`,
1008
1025
  devtoolsEndpoint: endpoint(base, "/devtools", state.device),
1009
1026
  streamSettingsEndpoint: streamSettingsEndpointFrom(state.streamUrl),
1010
- serveSimBin,
1011
1027
  gridApiEndpoint: gridApiBase,
1012
1028
  gridCatalogEndpoint: gridApiBase + "/catalog",
1013
1029
  gridStatusEndpoint: gridApiBase + "/status",
@@ -1475,6 +1491,8 @@ export interface SimMiddlewareOptions {
1475
1491
  * cross-origin pages cannot read it.
1476
1492
  */
1477
1493
  execToken?: string;
1494
+ /** Off by default: a loopback-only server is already reachable to whoever is on the machine. */
1495
+ requirePreviewToken?: boolean;
1478
1496
  /** Stream transport and codec settings for the preview. */
1479
1497
  streamSettings?: StreamSettings;
1480
1498
  /**
@@ -1483,6 +1501,7 @@ export interface SimMiddlewareOptions {
1483
1501
  * same-origin + token-gated regardless. Loopback is always allowed.
1484
1502
  */
1485
1503
  metricsCorsOrigins?: string[];
1504
+ frameAncestors?: string[];
1486
1505
  /** @deprecated Use `streamSettings: { transport: "http", codec }`. */
1487
1506
  codec?: string;
1488
1507
  /**
@@ -1507,20 +1526,6 @@ function httpStreamSettingsFromLegacyCodec(codec: string | undefined): StreamSet
1507
1526
  return undefined;
1508
1527
  }
1509
1528
 
1510
- function safeEqualString(a: string, b: string): boolean {
1511
- const ab = Buffer.from(a);
1512
- const bb = Buffer.from(b);
1513
- if (ab.length !== bb.length) return false;
1514
- return timingSafeEqual(ab, bb);
1515
- }
1516
-
1517
- function isJsonContentType(value: string | undefined): boolean {
1518
- if (!value) return false;
1519
- // `application/json; charset=utf-8` etc. — only the media type matters.
1520
- const mediaType = value.split(";", 1)[0]!.trim().toLowerCase();
1521
- return mediaType === "application/json";
1522
- }
1523
-
1524
1529
  /**
1525
1530
  * Connect-style middleware that serves the simulator preview UI.
1526
1531
  *
@@ -1580,23 +1585,36 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1580
1585
  // can call /exec; cross-origin pages and LAN clients cannot, because they
1581
1586
  // can't read this value (it's only injected into the preview page's config).
1582
1587
  const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
1588
+ const requirePreviewToken = options?.requirePreviewToken ?? false;
1583
1589
  const metricsCorsOrigins = options?.metricsCorsOrigins ?? [];
1590
+ const frameAncestors = options?.frameAncestors ?? [];
1584
1591
 
1585
1592
  // Simulator-settings requests run in-process (just the underlying simctl /
1586
1593
  // ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
1587
1594
  // sidebar interaction.
1595
+ /** Validation messages are the caller's own input. A child's failure carries host paths. */
1596
+ const sanitizeUiFailure = async <T,>(work: Promise<T>): Promise<T> => {
1597
+ try {
1598
+ return await work;
1599
+ } catch (err) {
1600
+ console.error("serve-sim ui request failed:", err);
1601
+ throw new Error("the simulator rejected this UI request");
1602
+ }
1603
+ };
1604
+
1588
1605
  const handleUiRequest: UiRequestHandler = async (payload) => {
1589
1606
  const p = (payload ?? {}) as { device?: string; option?: string; value?: string };
1590
- if (typeof p.device !== "string" || !/^[0-9A-Za-z-]+$/.test(p.device)) {
1607
+ // Must start alphanumeric and stay bounded: a leading "-" is parsed as a flag by simctl.
1608
+ if (typeof p.device !== "string" || !/^[0-9A-Za-z][0-9A-Za-z-]{0,255}$/.test(p.device)) {
1591
1609
  throw new Error("missing or invalid device udid");
1592
1610
  }
1593
1611
  if (p.option === undefined) {
1594
- return { status: await getUiStatus(p.device) };
1612
+ return { status: await sanitizeUiFailure(getUiStatus(p.device)) };
1595
1613
  }
1596
- if (!UI_OPTIONS[p.option]) throw new Error(`unknown option: ${p.option}`);
1614
+ if (!Object.hasOwn(UI_OPTIONS, p.option)) throw new Error(`unknown option: ${p.option}`);
1597
1615
  const value = typeof p.value === "string" ? normalizeUiValue(p.option, p.value) : null;
1598
1616
  if (value === null) throw new Error(`invalid value for ${p.option}: ${p.value}`);
1599
- await setUiOption(p.device, p.option, value);
1617
+ await sanitizeUiFailure(setUiOption(p.device, p.option, value));
1600
1618
  try {
1601
1619
  recordEventLogEvent({
1602
1620
  device: p.device,
@@ -1612,6 +1630,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1612
1630
  }
1613
1631
  return { ok: true };
1614
1632
  };
1633
+ /** Reachable without the session token: liveness probes cannot carry one. */
1634
+ const UNGATED_PATHS = ["/healthz", "/readyz"];
1615
1635
 
1616
1636
  const connectMiddleware = (async (req: SimReq, res: SimRes, next?: SimNext) => {
1617
1637
  const rawUrl: string = req.url ?? "";
@@ -1621,6 +1641,14 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1621
1641
  const selectedDevice = requestedDevice ?? options?.device ?? null;
1622
1642
  const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
1623
1643
 
1644
+ // Gated as a whole rather than per route, so a new route is protected by default.
1645
+ if (
1646
+ !UNGATED_PATHS.some((path) => url === base + path)
1647
+ && !assertPreviewAccess(req, res, execToken, { required: requirePreviewToken, basePath: base })
1648
+ ) {
1649
+ return;
1650
+ }
1651
+
1624
1652
  const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
1625
1653
  if (helperTarget) {
1626
1654
  const device = helperTarget.device ?? selectedDevice;
@@ -1647,8 +1675,12 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1647
1675
  return;
1648
1676
  }
1649
1677
  try {
1678
+ // The gate accepts `?token=` here, so forward the rest of the query but never that.
1679
+ const upstreamQuery = new URLSearchParams(qIndex === -1 ? "" : rawUrl.slice(qIndex + 1));
1680
+ upstreamQuery.delete("token");
1681
+ const upstreamSuffix = upstreamQuery.size === 0 ? "" : `?${upstreamQuery.toString()}`;
1650
1682
  const upstream = await fetch(
1651
- `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${qIndex === -1 ? "" : rawUrl.slice(qIndex)}`,
1683
+ `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${upstreamSuffix}`,
1652
1684
  );
1653
1685
  const headers: Record<string, string> = {
1654
1686
  "Cache-Control": "public, max-age=604800",
@@ -1683,7 +1715,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1683
1715
 
1684
1716
  if (state) {
1685
1717
  const remoteState = rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers);
1686
- const config = JSON.stringify(previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers));
1718
+ const config = JSON.stringify(previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers));
1687
1719
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
1688
1720
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
1689
1721
  }
@@ -1691,6 +1723,9 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1691
1723
  res.writeHead(200, {
1692
1724
  "Content-Type": "text/html; charset=utf-8",
1693
1725
  "Cache-Control": "no-store",
1726
+ ...(requirePreviewToken
1727
+ ? { "Content-Security-Policy": frameAncestorsPolicy(frameAncestors) }
1728
+ : {}),
1694
1729
  });
1695
1730
  res.end(html);
1696
1731
  return;
@@ -1828,7 +1863,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1828
1863
  watcherRetry = null;
1829
1864
  if (closed || res.writableEnded || watcher) return;
1830
1865
  try {
1831
- watcher = watch(STATE_DIR, onFsEvent);
1866
+ watcher = watch(stateDir(), onFsEvent);
1832
1867
  watcher.on("error", () => {
1833
1868
  watcher?.close();
1834
1869
  watcher = null;
@@ -1933,7 +1968,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1933
1968
  return;
1934
1969
  }
1935
1970
  const port = req.socket.localPort ?? 0;
1936
- void startDeviceInProcess(udid, port, base, streamSettings).then((error) => {
1971
+ void startDeviceInProcess(
1972
+ udid,
1973
+ port,
1974
+ base,
1975
+ streamSettings,
1976
+ requirePreviewToken ? execToken : undefined,
1977
+ ).then((error) => {
1937
1978
  if (res.writableEnded) return;
1938
1979
  if (error) {
1939
1980
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2112,7 +2153,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2112
2153
  "Cache-Control": "no-store",
2113
2154
  });
2114
2155
  const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
2115
- res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers) : null));
2156
+ res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers) : null));
2116
2157
  return;
2117
2158
  }
2118
2159
 
@@ -2248,7 +2289,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2248
2289
  const state = selectServeSimState(states, selectedDevice);
2249
2290
  const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
2250
2291
  return JSON.stringify(
2251
- remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, streamSettings, proxyHelpers) : null,
2292
+ remoteState ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers) : null,
2252
2293
  );
2253
2294
  };
2254
2295
 
@@ -2292,7 +2333,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2292
2333
  watcherRetry = null;
2293
2334
  if (closed || res.writableEnded || watcher) return;
2294
2335
  try {
2295
- watcher = watch(STATE_DIR, onFsEvent);
2336
+ watcher = watch(stateDir(), onFsEvent);
2296
2337
  watcher.on("error", () => {
2297
2338
  watcher?.close();
2298
2339
  watcher = null;
@@ -2403,84 +2444,6 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2403
2444
  return;
2404
2445
  }
2405
2446
 
2406
- // POST /exec — run a shell command on the host. Gated by a per-process
2407
- // bearer token injected only into the same-origin preview HTML, with
2408
- // Content-Type + Origin checks to block CORS-simple CSRF (a malicious
2409
- // page POSTing `text/plain` JSON to a dev server bound to a public iface)
2410
- // and LAN attackers who can reach the port but can't read the token.
2411
- if ((url === base + "/exec" || url === base + "/exec/") && req.method === "POST") {
2412
- // 1. Reject anything that isn't a JSON request, killing the
2413
- // `enctype="text/plain"` CORS-simple form-POST path.
2414
- if (!isJsonContentType(req.headers["content-type"])) {
2415
- res.writeHead(415, { "Content-Type": "application/json" });
2416
- res.end(JSON.stringify({ stdout: "", stderr: "Unsupported Media Type", exitCode: 1 }));
2417
- return;
2418
- }
2419
- // 2. If the browser supplied an Origin, require it match this server.
2420
- // Same-origin XHR from the preview page sets Origin to our own URL;
2421
- // a cross-origin page's Origin won't match.
2422
- const origin = req.headers.origin;
2423
- if (origin) {
2424
- try {
2425
- const originHost = new URL(origin).host;
2426
- if (originHost !== req.headers.host) {
2427
- res.writeHead(403, { "Content-Type": "application/json" });
2428
- res.end(JSON.stringify({ stdout: "", stderr: "Cross-origin request blocked", exitCode: 1 }));
2429
- return;
2430
- }
2431
- } catch {
2432
- res.writeHead(403, { "Content-Type": "application/json" });
2433
- res.end(JSON.stringify({ stdout: "", stderr: "Invalid Origin", exitCode: 1 }));
2434
- return;
2435
- }
2436
- }
2437
- // 3. Require the per-session bearer token. Cross-origin pages cannot
2438
- // read it from window.__SIM_PREVIEW__; non-browser callers must
2439
- // have copied it from the CLI output.
2440
- const authHeader = req.headers.authorization ?? "";
2441
- const match = /^Bearer\s+(.+)$/i.exec(authHeader);
2442
- if (!match || !safeEqualString(match[1]!.trim(), execToken)) {
2443
- res.writeHead(401, { "Content-Type": "application/json" });
2444
- res.end(JSON.stringify({ stdout: "", stderr: "Unauthorized", exitCode: 1 }));
2445
- return;
2446
- }
2447
- let body = "";
2448
- let aborted = false;
2449
- req.on("data", (chunk: Buffer | string) => {
2450
- body += typeof chunk === "string" ? chunk : chunk.toString();
2451
- // Cheap belt-and-braces cap so a runaway POST can't OOM the dev server.
2452
- if (body.length > 4 * 1024 * 1024) {
2453
- aborted = true;
2454
- res.writeHead(413, { "Content-Type": "application/json" });
2455
- res.end(JSON.stringify({ stdout: "", stderr: "Payload Too Large", exitCode: 1 }));
2456
- req.destroy();
2457
- }
2458
- });
2459
- req.on("end", () => {
2460
- if (aborted) return;
2461
- let command = "";
2462
- try {
2463
- command = (JSON.parse(body) as ExecRequestBody).command ?? "";
2464
- } catch {}
2465
- if (!command) {
2466
- res.writeHead(400, { "Content-Type": "application/json" });
2467
- res.end(JSON.stringify({ stdout: "", stderr: "Missing command", exitCode: 1 }));
2468
- return;
2469
- }
2470
- exec(command, { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
2471
- const exitCode = err ? (err as ExecException).code ?? 1 : 0;
2472
- recordCommandEvent(command, { exitCode });
2473
- res.writeHead(200, { "Content-Type": "application/json" });
2474
- res.end(JSON.stringify({
2475
- stdout: stdout.toString(),
2476
- stderr: stderr.toString(),
2477
- exitCode,
2478
- }));
2479
- });
2480
- });
2481
- return;
2482
- }
2483
-
2484
2447
  // SSE: foreground-app change stream. Emits `{bundleId, pid}` events
2485
2448
  // parsed from SpringBoard's "Setting process visibility to: Foreground"
2486
2449
  // log line. Filtering is done here (not in the browser) so the SSE stream
@@ -2531,6 +2494,24 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2531
2494
  if (next) return next();
2532
2495
  }) as ConnectMiddleware;
2533
2496
  connectMiddleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
2497
+ // Upgrades skip the HTTP request path, and the HID and devtools sockets carry no token of
2498
+ // their own, so gate them here too.
2499
+ if (
2500
+ !assertUpgradeAccess(
2501
+ {
2502
+ authorization: req.headers.authorization,
2503
+ cookie: req.headers.cookie,
2504
+ origin: req.headers.origin,
2505
+ host: req.headers.host,
2506
+ "sec-fetch-site": req.headers["sec-fetch-site"],
2507
+ },
2508
+ execToken,
2509
+ { required: requirePreviewToken },
2510
+ )
2511
+ ) {
2512
+ socket.destroy();
2513
+ return;
2514
+ }
2534
2515
  const rawUrl = req.url ?? "";
2535
2516
  const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
2536
2517
  const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
@@ -2560,12 +2541,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2560
2541
  }
2561
2542
  socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
2562
2543
  };
2563
- // WebSocket exec channel same auth/origin policy as POST /exec, but off
2564
- // the browser's per-origin HTTP connection pool so multiple preview tabs
2565
- // (each holding MJPEG + SSE streams) can't starve exec actions. Servers
2566
- // mounting this middleware should forward `upgrade` events here (the
2567
- // built-in preview server does); the client falls back to POST /exec when
2568
- // the upgrade never completes.
2544
+ // Off the browser's per-origin connection pool, so preview tabs holding MJPEG and SSE streams
2545
+ // can't starve actions. Hosts mounting this middleware forward `upgrade` events here.
2569
2546
  const fetchMiddleware = (async (request: Request) => {
2570
2547
  return connectToFetch(connectMiddleware, request);
2571
2548
  }) as SimMiddleware;
@@ -2583,16 +2560,36 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2583
2560
  `${base}/ax`,
2584
2561
  ],
2585
2562
  onUiRequest: handleUiRequest,
2586
- onCommandResult: (command, result) => recordCommandEvent(command, result),
2563
+ serveSimBinPath: serveSimBinPath(),
2564
+ onActionResult: (action, params, result) => recordActionEvent(action, params, result),
2587
2565
  onSseRequest(path, websocketRequest) {
2588
2566
  const url = new URL(path, websocketRequest.url);
2567
+ // The exec channel already authenticated, so its fan-out carries the token past the gate.
2589
2568
  return fetchMiddleware(new Request(url, {
2590
- headers: { accept: "text/event-stream" },
2569
+ headers: { accept: "text/event-stream", authorization: `Bearer ${execToken}` },
2591
2570
  }));
2592
2571
  },
2593
2572
  });
2594
2573
 
2595
2574
  fetchMiddleware.handleWebSocket = (request: Request, websocket: UpgradeHandlerWebSocket): boolean => {
2575
+ // Embedded hosts forward accepted sockets and bypass the request gate. The exec channel
2576
+ // re-checks the token in its first frame; the helper HID socket does not.
2577
+ if (
2578
+ !assertUpgradeAccess(
2579
+ {
2580
+ authorization: request.headers.get("authorization") ?? undefined,
2581
+ cookie: request.headers.get("cookie") ?? undefined,
2582
+ origin: request.headers.get("origin") ?? undefined,
2583
+ host: request.headers.get("host") ?? undefined,
2584
+ "sec-fetch-site": request.headers.get("sec-fetch-site") ?? undefined,
2585
+ },
2586
+ execToken,
2587
+ { required: requirePreviewToken },
2588
+ )
2589
+ ) {
2590
+ websocket.close();
2591
+ return true;
2592
+ }
2596
2593
  if (execWebSocketHandler(request, websocket)) return true;
2597
2594
  if (claimHelperHidSocket(request, websocket, {
2598
2595
  helperProxyTarget: (rawUrl) => helperProxyTarget(rawUrl, helperPrefix),
package/src/state.ts CHANGED
@@ -9,16 +9,13 @@ export type {
9
9
  WebRtcStreamCodec,
10
10
  } from "./stream-settings";
11
11
 
12
- /** Directory where serve-sim stores runtime state. */
13
- export const STATE_DIR = join(tmpdir(), "serve-sim");
14
-
15
- /** Path to the serve-sim server state file (JSON with pid, port, URLs).
16
- * @deprecated Use `stateFileForDevice(udid)` for multi-device support. Kept for backward compat. */
17
- export const STATE_FILE = join(STATE_DIR, "server.json");
12
+ /** Directory where serve-sim stores runtime state. Override with `SERVE_SIM_STATE_DIR`. */
13
+ export function stateDir(): string {
14
+ return process.env.SERVE_SIM_STATE_DIR || join(tmpdir(), "serve-sim");
15
+ }
18
16
 
19
- /** Per-device state file: `/tmp/serve-sim/server-{udid}.json` */
20
17
  export function stateFileForDevice(udid: string): string {
21
- return join(STATE_DIR, `server-${udid}.json`);
18
+ return join(stateDir(), `server-${udid}.json`);
22
19
  }
23
20
 
24
21
  /** Runtime record for a device streamed in-process by a preview server. */
@@ -30,6 +27,24 @@ export interface ServeSimDeviceState {
30
27
  streamUrl: string;
31
28
  wsUrl: string;
32
29
  streamSettings?: StreamSettings;
30
+ /** Present only under `--require-token`, so local subcommands can reach the gated socket. */
31
+ token?: string;
32
+ }
33
+
34
+ /** `--quiet` startup payload. Carries the session token only when the gate is on. */
35
+ export function previewStartupPayload(
36
+ states: ServeSimDeviceState[],
37
+ token?: string,
38
+ ): Record<string, unknown> {
39
+ const view = (s: ServeSimDeviceState) => ({
40
+ url: s.url,
41
+ streamUrl: s.streamUrl,
42
+ wsUrl: s.wsUrl,
43
+ port: s.port,
44
+ device: s.device,
45
+ });
46
+ const base = states.length === 1 ? view(states[0]!) : { devices: states.map(view) };
47
+ return token ? { ...base, token } : base;
33
48
  }
34
49
 
35
50
  /**
@@ -65,11 +80,10 @@ export function inProcessServeSimState(
65
80
  * Writes atomically (temp file + rename) so a concurrent reader never observes
66
81
  * a truncated or partially-written file. */
67
82
  export function writeServeSimState(state: ServeSimDeviceState): void {
68
- mkdirSync(STATE_DIR, { recursive: true });
83
+ mkdirSync(stateDir(), { recursive: true });
69
84
  const file = stateFileForDevice(state.device);
70
85
  const tmp = `${file}.${process.pid}.tmp`;
71
- // WebRTC state can contain short-lived TURN credentials. Keep the file
72
- // readable only by the account running serve-sim.
86
+ // Holds TURN credentials and, when gated, the session token.
73
87
  writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
74
88
  renameSync(tmp, file);
75
89
  }
@@ -89,9 +103,10 @@ export function clearServeSimState(udid: string, ownerPid: number): void {
89
103
  /** List all per-device state files in the state directory. */
90
104
  export function listStateFiles(): string[] {
91
105
  try {
92
- return readdirSync(STATE_DIR)
106
+ const dir = stateDir();
107
+ return readdirSync(dir)
93
108
  .filter((f) => f.startsWith("server-") && f.endsWith(".json"))
94
- .map((f) => join(STATE_DIR, f));
109
+ .map((f) => join(dir, f));
95
110
  } catch {
96
111
  return [];
97
112
  }