@expo/serve-sim 0.1.36 → 0.1.38
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/README.md +14 -0
- package/dist/middleware.js +53 -49
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +80 -76
- package/package.json +1 -1
- package/src/middleware.ts +162 -9
- package/src/stream-settings.ts +16 -2
package/package.json
CHANGED
package/src/middleware.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync, unlinkSync, watch, type FSWatcher } from "fs";
|
|
2
|
+
import { readFile, unlink } from "fs/promises";
|
|
2
3
|
import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException } from "child_process";
|
|
3
4
|
import { tmpdir } from "os";
|
|
4
5
|
import { join } from "path";
|
|
@@ -13,7 +14,7 @@ import type { Socket } from "net";
|
|
|
13
14
|
import { WebSocket } from "ws";
|
|
14
15
|
import { createAxStreamerCache } from "./ax";
|
|
15
16
|
import { readCameraStatus } from "./camera-helper";
|
|
16
|
-
import { createMetricsSamplerCache, type MetricsSamplerCache } from "./
|
|
17
|
+
import { createMetricsSamplerCache, MetricsSampler, type MetricsSamplerCache } from "./metrics-sampler";
|
|
17
18
|
import { foregroundTracker, type ForegroundApp, type ForegroundTrackerCache } from "./foreground-tracker";
|
|
18
19
|
import { corsAllowOriginHeaders } from "./middleware-utils";
|
|
19
20
|
import { closeDeviceSession, getDeviceSession, sendCorsPreflight, type HidSocket } from "./device-session";
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
serveDevicePlaceholderAsset,
|
|
33
34
|
} from "./devicekit-chrome";
|
|
34
35
|
import { createExecWebSocketHandler, type UiRequestHandler } from "./exec-ws";
|
|
36
|
+
import { claimHelperHidSocket, type UpgradeHandlerWebSocket } from "./middleware-utils";
|
|
35
37
|
import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings";
|
|
36
38
|
import { type WebMiddleware } from "./runtime-utils";
|
|
37
39
|
import { connectToFetch, type ConnectMiddleware } from "./connect-to-fetch";
|
|
@@ -94,7 +96,7 @@ type CdpHttpListEntry = {
|
|
|
94
96
|
type CdpHttpVersion = { Browser?: string };
|
|
95
97
|
|
|
96
98
|
type SimctlBootedList = {
|
|
97
|
-
devices: Record<string, Array<{ udid: string; state: string }>>;
|
|
99
|
+
devices: Record<string, Array<{ udid: string; state: string; name: string }>>;
|
|
98
100
|
};
|
|
99
101
|
|
|
100
102
|
type SimctlAllList = {
|
|
@@ -111,8 +113,11 @@ type ExecRequestBody = { command?: string };
|
|
|
111
113
|
export type ServeSimState = ServeSimDeviceState;
|
|
112
114
|
|
|
113
115
|
const axStreamerCache = createAxStreamerCache();
|
|
114
|
-
// One shared cpu/mem sampler per udid; every /metrics viewer subscribes.
|
|
115
|
-
|
|
116
|
+
// One shared cpu/mem sampler per udid; every /metrics viewer subscribes. Stamp the device name
|
|
117
|
+
// (from the last booted-device snapshot) into the sampler's meta frame when we know it.
|
|
118
|
+
const metricsSamplerCache = createMetricsSamplerCache(
|
|
119
|
+
(udid) => new MetricsSampler({ udid, deviceName: bootedDeviceName(udid) }),
|
|
120
|
+
);
|
|
116
121
|
|
|
117
122
|
// Hard cap on the SSE line-assembly buffer for child-process stdout.
|
|
118
123
|
// A malformed log entry without a newline can't grow this beyond 1 MB;
|
|
@@ -162,6 +167,11 @@ function isSimulatorUdid(value: string): boolean {
|
|
|
162
167
|
return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(value);
|
|
163
168
|
}
|
|
164
169
|
|
|
170
|
+
const SCREENSHOT_RESPONSE_HEADERS = {
|
|
171
|
+
"Access-Control-Allow-Origin": "*",
|
|
172
|
+
"Cache-Control": "no-store",
|
|
173
|
+
};
|
|
174
|
+
|
|
165
175
|
/** What to do with a persisted device state when reaping during a grid poll. */
|
|
166
176
|
type StaleStateAction = "keep" | "recycle-self" | "recycle-helper";
|
|
167
177
|
|
|
@@ -237,7 +247,11 @@ export function matchInstalledAppByDisplayName(
|
|
|
237
247
|
// Cache simctl's booted-device set briefly so per-request cost stays bounded.
|
|
238
248
|
// The middleware runs inside the user's dev server (Metro etc.) and
|
|
239
249
|
// readServeSimStates() is called on every /api and every page load.
|
|
240
|
-
let bootedSnapshot: { at: number; booted: Set<string> | null
|
|
250
|
+
let bootedSnapshot: { at: number; booted: Set<string> | null; names: Map<string, string> } = {
|
|
251
|
+
at: 0,
|
|
252
|
+
booted: null,
|
|
253
|
+
names: new Map(),
|
|
254
|
+
};
|
|
241
255
|
async function getBootedUdids(): Promise<Set<string> | null> {
|
|
242
256
|
const now = Date.now();
|
|
243
257
|
if (bootedSnapshot.booted && now - bootedSnapshot.at < 1500) {
|
|
@@ -260,18 +274,38 @@ async function getBootedUdids(): Promise<Set<string> | null> {
|
|
|
260
274
|
});
|
|
261
275
|
const data = JSON.parse(stdout) as SimctlBootedList;
|
|
262
276
|
const booted = new Set<string>();
|
|
277
|
+
const names = new Map<string, string>();
|
|
263
278
|
for (const runtime of Object.values(data.devices)) {
|
|
264
279
|
for (const device of runtime) {
|
|
265
|
-
if (device.state === "Booted")
|
|
280
|
+
if (device.state === "Booted") {
|
|
281
|
+
// simctl's JSON is uppercase; canonicalize so Map/Set lookups stay case-insensitive.
|
|
282
|
+
const udid = device.udid.toUpperCase();
|
|
283
|
+
booted.add(udid);
|
|
284
|
+
names.set(udid, device.name);
|
|
285
|
+
}
|
|
266
286
|
}
|
|
267
287
|
}
|
|
268
|
-
bootedSnapshot = { at: now, booted };
|
|
288
|
+
bootedSnapshot = { at: now, booted, names };
|
|
269
289
|
return booted;
|
|
270
290
|
} catch {
|
|
271
291
|
return null;
|
|
272
292
|
}
|
|
273
293
|
}
|
|
274
294
|
|
|
295
|
+
/** Look up a display name in a simctl udid→name map. Keys are stored uppercase. */
|
|
296
|
+
export function deviceNameFromBootedNames(
|
|
297
|
+
names: Map<string, string>,
|
|
298
|
+
udid: string,
|
|
299
|
+
): string | undefined {
|
|
300
|
+
return names.get(udid.toUpperCase());
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Display name for a booted udid, from the last simctl snapshot (refreshed on grid polls).
|
|
304
|
+
// Undefined until the first snapshot lands or if the device isn't booted.
|
|
305
|
+
function bootedDeviceName(udid: string): string | undefined {
|
|
306
|
+
return deviceNameFromBootedNames(bootedSnapshot.names, udid);
|
|
307
|
+
}
|
|
308
|
+
|
|
275
309
|
// The device the user most recently opened in Simulator.app, regardless of
|
|
276
310
|
// which tool launched it. Simulator.app persists this as CurrentDeviceUDID, so
|
|
277
311
|
// it's the best signal for "the device this user actually cares about" — we
|
|
@@ -1617,7 +1651,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1617
1651
|
closeDeviceSession(udid);
|
|
1618
1652
|
// Drop the snapshot so the next /grid/api call re-queries simctl
|
|
1619
1653
|
// and prunes any helper bound to this now-shutdown device.
|
|
1620
|
-
bootedSnapshot = { at: 0, booted: null };
|
|
1654
|
+
bootedSnapshot = { at: 0, booted: null, names: new Map() };
|
|
1621
1655
|
execFile("xcrun", ["simctl", "shutdown", udid], { timeout: 30_000 }, (err, _stdout, stderr) => {
|
|
1622
1656
|
if (err) {
|
|
1623
1657
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -1768,6 +1802,43 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1768
1802
|
return;
|
|
1769
1803
|
}
|
|
1770
1804
|
|
|
1805
|
+
if (url === base + "/healthz") {
|
|
1806
|
+
res.writeHead(200, {
|
|
1807
|
+
"Content-Type": "application/json",
|
|
1808
|
+
"Cache-Control": "no-store",
|
|
1809
|
+
});
|
|
1810
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
if (url === base + "/readyz") {
|
|
1815
|
+
const states = await readServeSimStates();
|
|
1816
|
+
const state = selectServeSimState(states, selectedDevice);
|
|
1817
|
+
if (!state) {
|
|
1818
|
+
res.writeHead(503, {
|
|
1819
|
+
"Content-Type": "application/json",
|
|
1820
|
+
"Cache-Control": "no-store",
|
|
1821
|
+
});
|
|
1822
|
+
res.end(JSON.stringify({ status: "starting" }));
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
try {
|
|
1826
|
+
await getDeviceSession(state.device, streamSettings).start();
|
|
1827
|
+
res.writeHead(200, {
|
|
1828
|
+
"Content-Type": "application/json",
|
|
1829
|
+
"Cache-Control": "no-store",
|
|
1830
|
+
});
|
|
1831
|
+
res.end(JSON.stringify({ status: "ready", device: state.device }));
|
|
1832
|
+
} catch {
|
|
1833
|
+
res.writeHead(503, {
|
|
1834
|
+
"Content-Type": "application/json",
|
|
1835
|
+
"Cache-Control": "no-store",
|
|
1836
|
+
});
|
|
1837
|
+
res.end(JSON.stringify({ status: "starting" }));
|
|
1838
|
+
}
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1771
1842
|
// JSON API: serve-sim state
|
|
1772
1843
|
if (url === base + "/api") {
|
|
1773
1844
|
const states = await readServeSimStates();
|
|
@@ -1796,6 +1867,78 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1796
1867
|
return;
|
|
1797
1868
|
}
|
|
1798
1869
|
|
|
1870
|
+
// Still-PNG capture via `simctl io <udid> screenshot`. Consumed by the
|
|
1871
|
+
// Expo Device Hub dashboard's save-screenshot action (the serve-sim web UI
|
|
1872
|
+
// shells out over exec-ws instead, so it never hits this route). Uses the
|
|
1873
|
+
// ?device= selection with a booted-simulator fallback.
|
|
1874
|
+
if (url === base + "/api/screenshot") {
|
|
1875
|
+
if (req.method !== "POST") {
|
|
1876
|
+
res.writeHead(405, {
|
|
1877
|
+
...SCREENSHOT_RESPONSE_HEADERS,
|
|
1878
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
1879
|
+
});
|
|
1880
|
+
res.end("method not allowed");
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
let udid = selectedDevice;
|
|
1884
|
+
if (udid && !isSimulatorUdid(udid)) {
|
|
1885
|
+
res.writeHead(400, {
|
|
1886
|
+
...SCREENSHOT_RESPONSE_HEADERS,
|
|
1887
|
+
"Content-Type": "application/json",
|
|
1888
|
+
});
|
|
1889
|
+
res.end(JSON.stringify({ ok: false, error: "Invalid simulator device ID" }));
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
if (!udid) {
|
|
1893
|
+
const booted = await getBootedUdids();
|
|
1894
|
+
udid = (booted && [...booted][0]) ?? null;
|
|
1895
|
+
}
|
|
1896
|
+
if (!udid) {
|
|
1897
|
+
res.writeHead(400, {
|
|
1898
|
+
...SCREENSHOT_RESPONSE_HEADERS,
|
|
1899
|
+
"Content-Type": "application/json",
|
|
1900
|
+
});
|
|
1901
|
+
res.end(JSON.stringify({ ok: false, error: "No booted simulator to screenshot" }));
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
// simctl only writes to a file, so round-trip through a private tmp path
|
|
1905
|
+
// instead of streaming; captures are a few MB at most.
|
|
1906
|
+
const file = join(tmpdir(), `serve-sim-screenshot-${randomBytes(8).toString("hex")}.png`);
|
|
1907
|
+
try {
|
|
1908
|
+
await new Promise<void>((resolve, reject) => {
|
|
1909
|
+
execFile(
|
|
1910
|
+
"xcrun",
|
|
1911
|
+
["simctl", "io", udid, "screenshot", file],
|
|
1912
|
+
{ timeout: 5_000 },
|
|
1913
|
+
(err, _stdout, stderr) => {
|
|
1914
|
+
if (err) reject(Object.assign(err, { stderr: stderr?.toString() }));
|
|
1915
|
+
else resolve();
|
|
1916
|
+
},
|
|
1917
|
+
);
|
|
1918
|
+
});
|
|
1919
|
+
const png = await readFile(file);
|
|
1920
|
+
res.writeHead(200, {
|
|
1921
|
+
...SCREENSHOT_RESPONSE_HEADERS,
|
|
1922
|
+
"Content-Type": "image/png",
|
|
1923
|
+
});
|
|
1924
|
+
res.end(png);
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
const stderr = (err as { stderr?: unknown }).stderr;
|
|
1927
|
+
const message =
|
|
1928
|
+
(typeof stderr === "string" && stderr.trim()) ||
|
|
1929
|
+
(err instanceof Error ? err.message : String(err));
|
|
1930
|
+
res.writeHead(500, {
|
|
1931
|
+
...SCREENSHOT_RESPONSE_HEADERS,
|
|
1932
|
+
"Content-Type": "application/json",
|
|
1933
|
+
});
|
|
1934
|
+
res.end(JSON.stringify({ ok: false, error: message }));
|
|
1935
|
+
} finally {
|
|
1936
|
+
// Best-effort cleanup; the PNG is already in memory by now.
|
|
1937
|
+
await unlink(file).catch(() => {});
|
|
1938
|
+
}
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1799
1942
|
// JSON API: recent simulator action log. This is intentionally in-memory and
|
|
1800
1943
|
// bounded; it is for live debugging/agent observability, not archival audit.
|
|
1801
1944
|
if (url === base + "/api/event-log") {
|
|
@@ -2178,7 +2321,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2178
2321
|
return connectToFetch(connectMiddleware, request);
|
|
2179
2322
|
}) as SimMiddleware;
|
|
2180
2323
|
|
|
2181
|
-
|
|
2324
|
+
const execWebSocketHandler = createExecWebSocketHandler({
|
|
2182
2325
|
path: `${base}/exec-ws`,
|
|
2183
2326
|
execToken,
|
|
2184
2327
|
ssePrefixes: [
|
|
@@ -2199,6 +2342,16 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2199
2342
|
},
|
|
2200
2343
|
});
|
|
2201
2344
|
|
|
2345
|
+
fetchMiddleware.handleWebSocket = (request: Request, websocket: UpgradeHandlerWebSocket): boolean => {
|
|
2346
|
+
if (execWebSocketHandler(request, websocket)) return true;
|
|
2347
|
+
if (claimHelperHidSocket(request, websocket, {
|
|
2348
|
+
helperProxyTarget: (rawUrl) => helperProxyTarget(rawUrl, helperPrefix),
|
|
2349
|
+
fallbackDevice: options?.device ?? null,
|
|
2350
|
+
resolveSession: (device) => getDeviceSession(device, streamSettings),
|
|
2351
|
+
})) return true;
|
|
2352
|
+
return false;
|
|
2353
|
+
};
|
|
2354
|
+
|
|
2202
2355
|
// WebSocket upgrades owned by the preview: the authenticated exec/control
|
|
2203
2356
|
// channel plus same-origin helper/devtools proxy sockets.
|
|
2204
2357
|
fetchMiddleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
|
package/src/stream-settings.ts
CHANGED
|
@@ -2,9 +2,10 @@ export type HttpStreamCodec = "auto" | "mjpeg" | "h264";
|
|
|
2
2
|
export type WebRtcStreamCodec = "vp8" | "vp9" | "h264";
|
|
3
3
|
export type WebRtcIceServer = { urls: string[]; username?: string; credential?: string };
|
|
4
4
|
|
|
5
|
-
export type StreamSettings =
|
|
5
|
+
export type StreamSettings = (
|
|
6
6
|
| { transport: "http"; codec?: HttpStreamCodec }
|
|
7
|
-
| { transport: "webrtc"; codec: WebRtcStreamCodec; iceServers?: WebRtcIceServer[] }
|
|
7
|
+
| { transport: "webrtc"; codec: WebRtcStreamCodec; iceServers?: WebRtcIceServer[] }
|
|
8
|
+
) & Partial<StreamEncoderSettings>;
|
|
8
9
|
|
|
9
10
|
export interface StreamPlaybackSettings {
|
|
10
11
|
transport: "http" | "webrtc";
|
|
@@ -17,7 +18,9 @@ export interface StreamEncoderSettings {
|
|
|
17
18
|
mjpegFps: number;
|
|
18
19
|
mjpegQuality: number;
|
|
19
20
|
maxDimension: number;
|
|
21
|
+
/** Shared target bitrate for H.264/AVCC and WebRTC video. */
|
|
20
22
|
h264Bitrate: number;
|
|
23
|
+
/** Shared target frame rate for H.264/AVCC and WebRTC video. */
|
|
21
24
|
h264Fps: number;
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -162,16 +165,27 @@ export function streamEncoderSettingsFrom(
|
|
|
162
165
|
export function streamControlSettingsFrom(
|
|
163
166
|
settings: StreamSettings | undefined,
|
|
164
167
|
): StreamControlSettings {
|
|
168
|
+
const encoderSettings = settings
|
|
169
|
+
? {
|
|
170
|
+
mjpegFps: settings.mjpegFps,
|
|
171
|
+
mjpegQuality: settings.mjpegQuality,
|
|
172
|
+
maxDimension: settings.maxDimension,
|
|
173
|
+
h264Bitrate: settings.h264Bitrate,
|
|
174
|
+
h264Fps: settings.h264Fps,
|
|
175
|
+
}
|
|
176
|
+
: {};
|
|
165
177
|
if (settings?.transport === "webrtc") {
|
|
166
178
|
return normalizeStreamControlSettings({
|
|
167
179
|
transport: "webrtc",
|
|
168
180
|
webRtcCodec: settings.codec,
|
|
169
181
|
iceServers: settings.iceServers,
|
|
182
|
+
...encoderSettings,
|
|
170
183
|
});
|
|
171
184
|
}
|
|
172
185
|
return normalizeStreamControlSettings({
|
|
173
186
|
transport: "http",
|
|
174
187
|
httpCodec: settings?.codec ?? "auto",
|
|
188
|
+
...encoderSettings,
|
|
175
189
|
});
|
|
176
190
|
}
|
|
177
191
|
|