@expo/serve-sim 0.1.37 → 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/dist/middleware.js +53 -49
- package/dist/serve-sim.js +80 -76
- package/package.json +1 -1
- package/src/middleware.ts +125 -9
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" });
|
|
@@ -1833,6 +1867,78 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
1833
1867
|
return;
|
|
1834
1868
|
}
|
|
1835
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
|
+
|
|
1836
1942
|
// JSON API: recent simulator action log. This is intentionally in-memory and
|
|
1837
1943
|
// bounded; it is for live debugging/agent observability, not archival audit.
|
|
1838
1944
|
if (url === base + "/api/event-log") {
|
|
@@ -2215,7 +2321,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2215
2321
|
return connectToFetch(connectMiddleware, request);
|
|
2216
2322
|
}) as SimMiddleware;
|
|
2217
2323
|
|
|
2218
|
-
|
|
2324
|
+
const execWebSocketHandler = createExecWebSocketHandler({
|
|
2219
2325
|
path: `${base}/exec-ws`,
|
|
2220
2326
|
execToken,
|
|
2221
2327
|
ssePrefixes: [
|
|
@@ -2236,6 +2342,16 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
|
2236
2342
|
},
|
|
2237
2343
|
});
|
|
2238
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
|
+
|
|
2239
2355
|
// WebSocket upgrades owned by the preview: the authenticated exec/control
|
|
2240
2356
|
// channel plus same-origin helper/devtools proxy sockets.
|
|
2241
2357
|
fetchMiddleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
|