@expo/serve-sim 0.1.54 → 0.2.0
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/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 +120 -128
- 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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
|
@@ -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
|
/**
|
|
@@ -1507,20 +1525,6 @@ function httpStreamSettingsFromLegacyCodec(codec: string | undefined): StreamSet
|
|
|
1507
1525
|
return undefined;
|
|
1508
1526
|
}
|
|
1509
1527
|
|
|
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
1528
|
/**
|
|
1525
1529
|
* Connect-style middleware that serves the simulator preview UI.
|
|
1526
1530
|
*
|
|
@@ -1580,23 +1584,35 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1580
1584
|
// can call /exec; cross-origin pages and LAN clients cannot, because they
|
|
1581
1585
|
// can't read this value (it's only injected into the preview page's config).
|
|
1582
1586
|
const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
|
|
1587
|
+
const requirePreviewToken = options?.requirePreviewToken ?? false;
|
|
1583
1588
|
const metricsCorsOrigins = options?.metricsCorsOrigins ?? [];
|
|
1584
1589
|
|
|
1585
1590
|
// Simulator-settings requests run in-process (just the underlying simctl /
|
|
1586
1591
|
// ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
|
|
1587
1592
|
// sidebar interaction.
|
|
1593
|
+
/** Validation messages are the caller's own input. A child's failure carries host paths. */
|
|
1594
|
+
const sanitizeUiFailure = async <T,>(work: Promise<T>): Promise<T> => {
|
|
1595
|
+
try {
|
|
1596
|
+
return await work;
|
|
1597
|
+
} catch (err) {
|
|
1598
|
+
console.error("serve-sim ui request failed:", err);
|
|
1599
|
+
throw new Error("the simulator rejected this UI request");
|
|
1600
|
+
}
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1588
1603
|
const handleUiRequest: UiRequestHandler = async (payload) => {
|
|
1589
1604
|
const p = (payload ?? {}) as { device?: string; option?: string; value?: string };
|
|
1590
|
-
|
|
1605
|
+
// Must start alphanumeric and stay bounded: a leading "-" is parsed as a flag by simctl.
|
|
1606
|
+
if (typeof p.device !== "string" || !/^[0-9A-Za-z][0-9A-Za-z-]{0,255}$/.test(p.device)) {
|
|
1591
1607
|
throw new Error("missing or invalid device udid");
|
|
1592
1608
|
}
|
|
1593
1609
|
if (p.option === undefined) {
|
|
1594
|
-
return { status: await getUiStatus(p.device) };
|
|
1610
|
+
return { status: await sanitizeUiFailure(getUiStatus(p.device)) };
|
|
1595
1611
|
}
|
|
1596
|
-
if (!UI_OPTIONS
|
|
1612
|
+
if (!Object.hasOwn(UI_OPTIONS, p.option)) throw new Error(`unknown option: ${p.option}`);
|
|
1597
1613
|
const value = typeof p.value === "string" ? normalizeUiValue(p.option, p.value) : null;
|
|
1598
1614
|
if (value === null) throw new Error(`invalid value for ${p.option}: ${p.value}`);
|
|
1599
|
-
await setUiOption(p.device, p.option, value);
|
|
1615
|
+
await sanitizeUiFailure(setUiOption(p.device, p.option, value));
|
|
1600
1616
|
try {
|
|
1601
1617
|
recordEventLogEvent({
|
|
1602
1618
|
device: p.device,
|
|
@@ -1612,6 +1628,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1612
1628
|
}
|
|
1613
1629
|
return { ok: true };
|
|
1614
1630
|
};
|
|
1631
|
+
/** Reachable without the session token: liveness probes cannot carry one. */
|
|
1632
|
+
const UNGATED_PATHS = ["/healthz", "/readyz"];
|
|
1615
1633
|
|
|
1616
1634
|
const connectMiddleware = (async (req: SimReq, res: SimRes, next?: SimNext) => {
|
|
1617
1635
|
const rawUrl: string = req.url ?? "";
|
|
@@ -1621,6 +1639,14 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1621
1639
|
const selectedDevice = requestedDevice ?? options?.device ?? null;
|
|
1622
1640
|
const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
|
|
1623
1641
|
|
|
1642
|
+
// Gated as a whole rather than per route, so a new route is protected by default.
|
|
1643
|
+
if (
|
|
1644
|
+
!UNGATED_PATHS.some((path) => url === base + path)
|
|
1645
|
+
&& !assertPreviewAccess(req, res, execToken, { required: requirePreviewToken, basePath: base })
|
|
1646
|
+
) {
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1624
1650
|
const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
|
|
1625
1651
|
if (helperTarget) {
|
|
1626
1652
|
const device = helperTarget.device ?? selectedDevice;
|
|
@@ -1647,8 +1673,12 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1647
1673
|
return;
|
|
1648
1674
|
}
|
|
1649
1675
|
try {
|
|
1676
|
+
// The gate accepts `?token=` here, so forward the rest of the query but never that.
|
|
1677
|
+
const upstreamQuery = new URLSearchParams(qIndex === -1 ? "" : rawUrl.slice(qIndex + 1));
|
|
1678
|
+
upstreamQuery.delete("token");
|
|
1679
|
+
const upstreamSuffix = upstreamQuery.size === 0 ? "" : `?${upstreamQuery.toString()}`;
|
|
1650
1680
|
const upstream = await fetch(
|
|
1651
|
-
`https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${
|
|
1681
|
+
`https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${upstreamSuffix}`,
|
|
1652
1682
|
);
|
|
1653
1683
|
const headers: Record<string, string> = {
|
|
1654
1684
|
"Cache-Control": "public, max-age=604800",
|
|
@@ -1683,7 +1713,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1683
1713
|
|
|
1684
1714
|
if (state) {
|
|
1685
1715
|
const remoteState = rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers);
|
|
1686
|
-
const config = JSON.stringify(previewConfigForState(remoteState, base,
|
|
1716
|
+
const config = JSON.stringify(previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers));
|
|
1687
1717
|
const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
|
|
1688
1718
|
html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
|
|
1689
1719
|
}
|
|
@@ -1828,7 +1858,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1828
1858
|
watcherRetry = null;
|
|
1829
1859
|
if (closed || res.writableEnded || watcher) return;
|
|
1830
1860
|
try {
|
|
1831
|
-
watcher = watch(
|
|
1861
|
+
watcher = watch(stateDir(), onFsEvent);
|
|
1832
1862
|
watcher.on("error", () => {
|
|
1833
1863
|
watcher?.close();
|
|
1834
1864
|
watcher = null;
|
|
@@ -1933,7 +1963,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1933
1963
|
return;
|
|
1934
1964
|
}
|
|
1935
1965
|
const port = req.socket.localPort ?? 0;
|
|
1936
|
-
void startDeviceInProcess(
|
|
1966
|
+
void startDeviceInProcess(
|
|
1967
|
+
udid,
|
|
1968
|
+
port,
|
|
1969
|
+
base,
|
|
1970
|
+
streamSettings,
|
|
1971
|
+
requirePreviewToken ? execToken : undefined,
|
|
1972
|
+
).then((error) => {
|
|
1937
1973
|
if (res.writableEnded) return;
|
|
1938
1974
|
if (error) {
|
|
1939
1975
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -2112,7 +2148,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2112
2148
|
"Cache-Control": "no-store",
|
|
2113
2149
|
});
|
|
2114
2150
|
const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
|
|
2115
|
-
res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base,
|
|
2151
|
+
res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers) : null));
|
|
2116
2152
|
return;
|
|
2117
2153
|
}
|
|
2118
2154
|
|
|
@@ -2248,7 +2284,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2248
2284
|
const state = selectServeSimState(states, selectedDevice);
|
|
2249
2285
|
const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
|
|
2250
2286
|
return JSON.stringify(
|
|
2251
|
-
remoteState ? previewConfigForState(remoteState, base,
|
|
2287
|
+
remoteState ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers) : null,
|
|
2252
2288
|
);
|
|
2253
2289
|
};
|
|
2254
2290
|
|
|
@@ -2292,7 +2328,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2292
2328
|
watcherRetry = null;
|
|
2293
2329
|
if (closed || res.writableEnded || watcher) return;
|
|
2294
2330
|
try {
|
|
2295
|
-
watcher = watch(
|
|
2331
|
+
watcher = watch(stateDir(), onFsEvent);
|
|
2296
2332
|
watcher.on("error", () => {
|
|
2297
2333
|
watcher?.close();
|
|
2298
2334
|
watcher = null;
|
|
@@ -2403,84 +2439,6 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2403
2439
|
return;
|
|
2404
2440
|
}
|
|
2405
2441
|
|
|
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
2442
|
// SSE: foreground-app change stream. Emits `{bundleId, pid}` events
|
|
2485
2443
|
// parsed from SpringBoard's "Setting process visibility to: Foreground"
|
|
2486
2444
|
// log line. Filtering is done here (not in the browser) so the SSE stream
|
|
@@ -2531,6 +2489,24 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2531
2489
|
if (next) return next();
|
|
2532
2490
|
}) as ConnectMiddleware;
|
|
2533
2491
|
connectMiddleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
|
|
2492
|
+
// Upgrades skip the HTTP request path, and the HID and devtools sockets carry no token of
|
|
2493
|
+
// their own, so gate them here too.
|
|
2494
|
+
if (
|
|
2495
|
+
!assertUpgradeAccess(
|
|
2496
|
+
{
|
|
2497
|
+
authorization: req.headers.authorization,
|
|
2498
|
+
cookie: req.headers.cookie,
|
|
2499
|
+
origin: req.headers.origin,
|
|
2500
|
+
host: req.headers.host,
|
|
2501
|
+
"sec-fetch-site": req.headers["sec-fetch-site"],
|
|
2502
|
+
},
|
|
2503
|
+
execToken,
|
|
2504
|
+
{ required: requirePreviewToken },
|
|
2505
|
+
)
|
|
2506
|
+
) {
|
|
2507
|
+
socket.destroy();
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2534
2510
|
const rawUrl = req.url ?? "";
|
|
2535
2511
|
const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
|
|
2536
2512
|
const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
|
|
@@ -2560,12 +2536,8 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2560
2536
|
}
|
|
2561
2537
|
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
2562
2538
|
};
|
|
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.
|
|
2539
|
+
// Off the browser's per-origin connection pool, so preview tabs holding MJPEG and SSE streams
|
|
2540
|
+
// can't starve actions. Hosts mounting this middleware forward `upgrade` events here.
|
|
2569
2541
|
const fetchMiddleware = (async (request: Request) => {
|
|
2570
2542
|
return connectToFetch(connectMiddleware, request);
|
|
2571
2543
|
}) as SimMiddleware;
|
|
@@ -2583,16 +2555,36 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2583
2555
|
`${base}/ax`,
|
|
2584
2556
|
],
|
|
2585
2557
|
onUiRequest: handleUiRequest,
|
|
2586
|
-
|
|
2558
|
+
serveSimBinPath: serveSimBinPath(),
|
|
2559
|
+
onActionResult: (action, params, result) => recordActionEvent(action, params, result),
|
|
2587
2560
|
onSseRequest(path, websocketRequest) {
|
|
2588
2561
|
const url = new URL(path, websocketRequest.url);
|
|
2562
|
+
// The exec channel already authenticated, so its fan-out carries the token past the gate.
|
|
2589
2563
|
return fetchMiddleware(new Request(url, {
|
|
2590
|
-
headers: { accept: "text/event-stream" },
|
|
2564
|
+
headers: { accept: "text/event-stream", authorization: `Bearer ${execToken}` },
|
|
2591
2565
|
}));
|
|
2592
2566
|
},
|
|
2593
2567
|
});
|
|
2594
2568
|
|
|
2595
2569
|
fetchMiddleware.handleWebSocket = (request: Request, websocket: UpgradeHandlerWebSocket): boolean => {
|
|
2570
|
+
// Embedded hosts forward accepted sockets and bypass the request gate. The exec channel
|
|
2571
|
+
// re-checks the token in its first frame; the helper HID socket does not.
|
|
2572
|
+
if (
|
|
2573
|
+
!assertUpgradeAccess(
|
|
2574
|
+
{
|
|
2575
|
+
authorization: request.headers.get("authorization") ?? undefined,
|
|
2576
|
+
cookie: request.headers.get("cookie") ?? undefined,
|
|
2577
|
+
origin: request.headers.get("origin") ?? undefined,
|
|
2578
|
+
host: request.headers.get("host") ?? undefined,
|
|
2579
|
+
"sec-fetch-site": request.headers.get("sec-fetch-site") ?? undefined,
|
|
2580
|
+
},
|
|
2581
|
+
execToken,
|
|
2582
|
+
{ required: requirePreviewToken },
|
|
2583
|
+
)
|
|
2584
|
+
) {
|
|
2585
|
+
websocket.close();
|
|
2586
|
+
return true;
|
|
2587
|
+
}
|
|
2596
2588
|
if (execWebSocketHandler(request, websocket)) return true;
|
|
2597
2589
|
if (claimHelperHidSocket(request, websocket, {
|
|
2598
2590
|
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
|
}
|