@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/middleware.js +119 -63
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +157 -101
- package/dist/state.js +1 -1
- package/package.json +3 -2
- package/src/camera-helper.ts +6 -4
- package/src/middleware.ts +126 -129
- package/src/state.ts +28 -13
package/dist/state.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{tmpdir as
|
|
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
|
|
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",
|
package/src/camera-helper.ts
CHANGED
|
@@ -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 {
|
|
4
|
+
import { stateDir } from "./state";
|
|
5
5
|
|
|
6
|
-
export
|
|
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(
|
|
33
|
+
return join(cameraStateDir(), `${udid}.pid`);
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function cameraHelperBundlesFile(udid: string): string {
|
|
35
|
-
return join(
|
|
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
|
|
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
|
-
|
|
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
|
|
150
|
+
function recordActionEvent(
|
|
151
|
+
action: string,
|
|
152
|
+
params: Record<string, unknown> | undefined,
|
|
153
|
+
result: { exitCode?: number },
|
|
154
|
+
): void {
|
|
153
155
|
try {
|
|
154
|
-
const event =
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
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}${
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
//
|
|
2564
|
-
//
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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(
|
|
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(
|
|
83
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
69
84
|
const file = stateFileForDevice(state.device);
|
|
70
85
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
71
|
-
//
|
|
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
|
-
|
|
106
|
+
const dir = stateDir();
|
|
107
|
+
return readdirSync(dir)
|
|
93
108
|
.filter((f) => f.startsWith("server-") && f.endsWith(".json"))
|
|
94
|
-
.map((f) => join(
|
|
109
|
+
.map((f) => join(dir, f));
|
|
95
110
|
} catch {
|
|
96
111
|
return [];
|
|
97
112
|
}
|