serve-sim 0.1.13 → 0.1.14
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/bin/serve-sim-bin +0 -0
- package/dist/middleware.js +44 -9
- package/dist/serve-sim.js +52 -17
- package/package.json +4 -1
- package/src/middleware.ts +291 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serve-sim",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Evan Bacon",
|
|
@@ -56,5 +56,8 @@
|
|
|
56
56
|
"react": "^19.0.0",
|
|
57
57
|
"react-dom": "^19.0.0",
|
|
58
58
|
"typescript": "^5.7.0"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"inspect-webkit": "^0.0.3"
|
|
59
62
|
}
|
|
60
63
|
}
|
package/src/middleware.ts
CHANGED
|
@@ -2,11 +2,34 @@ import { readdirSync, readFileSync, existsSync, unlinkSync } from "fs";
|
|
|
2
2
|
import { execSync, spawn, exec, execFile, type ChildProcess } from "child_process";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
+
import { createServer as createNetServer } from "net";
|
|
5
6
|
import { createAxStreamerCache } from "./ax";
|
|
6
7
|
|
|
7
8
|
// Injected at build time as a base64-encoded string via `define`
|
|
8
9
|
declare const __PREVIEW_HTML_B64__: string;
|
|
9
10
|
const STATE_DIR = join(tmpdir(), "serve-sim");
|
|
11
|
+
const DEVTOOLS_FRONTEND_REV = "854a02be78c7ffea104cb523636efa991bef5c5b";
|
|
12
|
+
const INSPECT_WEBKIT_START_PORT = 9222;
|
|
13
|
+
|
|
14
|
+
type WebKitBridgeTarget = {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
url: string;
|
|
18
|
+
type: string;
|
|
19
|
+
appName?: string;
|
|
20
|
+
bundleId?: string;
|
|
21
|
+
/** udid of the simulator hosting the target, when known. */
|
|
22
|
+
udid?: string;
|
|
23
|
+
inUseByOtherInspector?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type WebKitBridge = {
|
|
27
|
+
port: number;
|
|
28
|
+
cdpUrl: string;
|
|
29
|
+
listTargets(): Promise<WebKitBridgeTarget[]>;
|
|
30
|
+
highlightTarget?(targetId: string, on: boolean): Promise<void>;
|
|
31
|
+
releaseHighlight?(targetId?: string): void;
|
|
32
|
+
};
|
|
10
33
|
|
|
11
34
|
export interface ServeSimState {
|
|
12
35
|
pid: number;
|
|
@@ -18,6 +41,7 @@ export interface ServeSimState {
|
|
|
18
41
|
}
|
|
19
42
|
|
|
20
43
|
const axStreamerCache = createAxStreamerCache();
|
|
44
|
+
let inspectWebKitBridge: Promise<WebKitBridge> | null = null;
|
|
21
45
|
|
|
22
46
|
// Known bundle IDs that are always React Native shells (used as a fallback
|
|
23
47
|
// before the app-container path resolves, since simctl can lag after launch).
|
|
@@ -149,6 +173,133 @@ function endpoint(base: string, path: string, device: string): string {
|
|
|
149
173
|
return `${value}?device=${encodeURIComponent(device)}`;
|
|
150
174
|
}
|
|
151
175
|
|
|
176
|
+
async function isLocalPortFree(port: number): Promise<boolean> {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const server = createNetServer();
|
|
179
|
+
server.once("error", () => resolve(false));
|
|
180
|
+
server.once("listening", () => server.close(() => resolve(true)));
|
|
181
|
+
server.listen(port, "localhost");
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function existingInspectWebKitBridge(port: number): Promise<WebKitBridge | null> {
|
|
186
|
+
const cdpUrl = `http://localhost:${port}`;
|
|
187
|
+
try {
|
|
188
|
+
const versionRes = await fetch(`${cdpUrl}/json/version`);
|
|
189
|
+
if (!versionRes.ok) return null;
|
|
190
|
+
const version = await versionRes.json() as { Browser?: string };
|
|
191
|
+
if (version.Browser !== "Safari/inspect-webkit") return null;
|
|
192
|
+
return {
|
|
193
|
+
port,
|
|
194
|
+
cdpUrl,
|
|
195
|
+
async listTargets() {
|
|
196
|
+
// Hitting the bridge over HTTP loses the rich fields available to
|
|
197
|
+
// an in-process consumer (appName, inUseByOtherInspector). The id
|
|
198
|
+
// shape `sim:<udid>:<appId>:<pageId>` and the description string
|
|
199
|
+
// `<deviceLabel> (<bundleId>)` are all we have here.
|
|
200
|
+
const listRes = await fetch(`${cdpUrl}/json/list`);
|
|
201
|
+
const targets = await listRes.json() as Array<{
|
|
202
|
+
id: string;
|
|
203
|
+
title: string;
|
|
204
|
+
url: string;
|
|
205
|
+
type: string;
|
|
206
|
+
description?: string;
|
|
207
|
+
}>;
|
|
208
|
+
return targets
|
|
209
|
+
.filter((target) => target.id.startsWith("sim:"))
|
|
210
|
+
.map((target) => {
|
|
211
|
+
const idParts = target.id.split(":");
|
|
212
|
+
const udid = idParts[1];
|
|
213
|
+
const bundleId = target.description?.match(/\(([^)]+)\)/)?.[1];
|
|
214
|
+
return {
|
|
215
|
+
id: target.id,
|
|
216
|
+
title: target.title || target.url || "Untitled",
|
|
217
|
+
url: /^https?:/i.test(target.url) ? target.url : "about:blank",
|
|
218
|
+
type: target.type || "page",
|
|
219
|
+
udid,
|
|
220
|
+
bundleId,
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function ensureInspectWebKitBridge(): Promise<WebKitBridge> {
|
|
231
|
+
if (inspectWebKitBridge) {
|
|
232
|
+
try {
|
|
233
|
+
// Probe so a dead bridge gets retired instead of poisoning every call.
|
|
234
|
+
await (await inspectWebKitBridge).listTargets();
|
|
235
|
+
return inspectWebKitBridge;
|
|
236
|
+
} catch {
|
|
237
|
+
inspectWebKitBridge = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
inspectWebKitBridge = (async () => {
|
|
241
|
+
const { startCdpServer } = await import("inspect-webkit");
|
|
242
|
+
for (let port = INSPECT_WEBKIT_START_PORT; port < INSPECT_WEBKIT_START_PORT + 50; port++) {
|
|
243
|
+
if (!(await isLocalPortFree(port))) {
|
|
244
|
+
const existing = await existingInspectWebKitBridge(port);
|
|
245
|
+
if (existing) return existing;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const server = await startCdpServer({ host: "localhost", port });
|
|
250
|
+
return {
|
|
251
|
+
port,
|
|
252
|
+
cdpUrl: `http://localhost:${port}`,
|
|
253
|
+
async listTargets() {
|
|
254
|
+
return server.getTargets()
|
|
255
|
+
.filter((target: any) => target.source?.kind === "simulator")
|
|
256
|
+
.map((target: any) => ({
|
|
257
|
+
id: target.targetId,
|
|
258
|
+
title: target.title || target.appName || target.url || "Untitled",
|
|
259
|
+
url: /^https?:/i.test(target.url) ? target.url : "about:blank",
|
|
260
|
+
type: target.type || "page",
|
|
261
|
+
appName: target.appName,
|
|
262
|
+
bundleId: target.bundleId,
|
|
263
|
+
udid: target.source?.id,
|
|
264
|
+
inUseByOtherInspector: !!target.inUseByOtherInspector,
|
|
265
|
+
}));
|
|
266
|
+
},
|
|
267
|
+
highlightTarget: server.highlightTarget?.bind(server),
|
|
268
|
+
releaseHighlight: server.releaseHighlight?.bind(server),
|
|
269
|
+
};
|
|
270
|
+
} catch (err: any) {
|
|
271
|
+
if (err?.code === "EADDRINUSE") {
|
|
272
|
+
const existing = await existingInspectWebKitBridge(port);
|
|
273
|
+
if (existing) return existing;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
throw new Error(`No available inspect-webkit port found in ${INSPECT_WEBKIT_START_PORT}-${INSPECT_WEBKIT_START_PORT + 49}`);
|
|
280
|
+
})().catch((err) => {
|
|
281
|
+
inspectWebKitBridge = null;
|
|
282
|
+
throw err;
|
|
283
|
+
});
|
|
284
|
+
return inspectWebKitBridge;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function devtoolsFrontendUrl(frontendBase: string, wsHost: string, targetId: string): string {
|
|
288
|
+
const url = new URL(`${frontendBase}/inspector.html`, "http://serve-sim.local");
|
|
289
|
+
url.searchParams.set("ws", `${wsHost}/devtools/page/${targetId}`);
|
|
290
|
+
return `${url.pathname}${url.search}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The inspect-webkit bridge binds to localhost only. When the preview is
|
|
294
|
+
// loaded from a different hostname (e.g. another device on the LAN), strip
|
|
295
|
+
// to the request's hostname-less form `localhost:<port>` so the iframe at
|
|
296
|
+
// least tries the right port; document the limitation in the README.
|
|
297
|
+
function bridgeWsHost(reqHost: string | undefined, bridgePort: number): string {
|
|
298
|
+
const hostname = (reqHost ?? "localhost").split(":")[0];
|
|
299
|
+
const isLocal = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
300
|
+
return `${isLocal ? hostname : "localhost"}:${bridgePort}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
152
303
|
let _html: string | null = null;
|
|
153
304
|
function loadHtml(): string {
|
|
154
305
|
if (!_html) {
|
|
@@ -181,6 +332,41 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
181
332
|
const qIndex = rawUrl.indexOf("?");
|
|
182
333
|
const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
|
183
334
|
const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
|
|
335
|
+
const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
|
|
336
|
+
|
|
337
|
+
// Same-origin proxy for Chrome DevTools frontend assets. Loading the
|
|
338
|
+
// appspot-hosted frontend directly works as a top-level tab, but is flaky
|
|
339
|
+
// inside embedded browser iframes. Serving it from the preview origin keeps
|
|
340
|
+
// the frontend's relative assets and CSP on the local page.
|
|
341
|
+
if (url === devtoolsFrontendBase || url.startsWith(`${devtoolsFrontendBase}/`)) {
|
|
342
|
+
(async () => {
|
|
343
|
+
const assetPath = url === devtoolsFrontendBase
|
|
344
|
+
? "inspector.html"
|
|
345
|
+
: url.slice(devtoolsFrontendBase.length + 1);
|
|
346
|
+
// Reject path-traversal segments before they reach the upstream URL.
|
|
347
|
+
if (assetPath.split("/").some((seg) => seg === "..")) {
|
|
348
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
349
|
+
res.end("Invalid asset path");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const upstream = await fetch(
|
|
354
|
+
`https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${qIndex === -1 ? "" : rawUrl.slice(qIndex)}`,
|
|
355
|
+
);
|
|
356
|
+
const headers: Record<string, string> = {
|
|
357
|
+
"Cache-Control": "public, max-age=604800",
|
|
358
|
+
};
|
|
359
|
+
const contentType = upstream.headers.get("content-type");
|
|
360
|
+
if (contentType) headers["Content-Type"] = contentType;
|
|
361
|
+
res.writeHead(upstream.status, headers);
|
|
362
|
+
res.end(Buffer.from(await upstream.arrayBuffer()));
|
|
363
|
+
} catch (err) {
|
|
364
|
+
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
|
|
365
|
+
res.end(err instanceof Error ? err.message : "Failed to load DevTools frontend");
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
184
370
|
|
|
185
371
|
// Serve the preview page
|
|
186
372
|
if (url === base || url === base + "/") {
|
|
@@ -198,6 +384,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
198
384
|
logsEndpoint: endpoint(base, "/logs", state.device),
|
|
199
385
|
appStateEndpoint: endpoint(base, "/appstate", state.device),
|
|
200
386
|
axEndpoint: endpoint(base, "/ax", state.device),
|
|
387
|
+
devtoolsEndpoint: endpoint(base, "/devtools", state.device),
|
|
201
388
|
});
|
|
202
389
|
const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
|
|
203
390
|
html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
|
|
@@ -211,6 +398,110 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
211
398
|
return;
|
|
212
399
|
}
|
|
213
400
|
|
|
401
|
+
// JSON API: start the inspect-webkit CDP bridge and list WebKit targets
|
|
402
|
+
// for the selected simulator. The bridge itself serves /json/list and
|
|
403
|
+
// /devtools/page/:id on localhost; the preview adds iframe-safe frontend
|
|
404
|
+
// URLs so the browser UI can embed Chrome DevTools.
|
|
405
|
+
if (url === base + "/devtools") {
|
|
406
|
+
(async () => {
|
|
407
|
+
const states = readServeSimStates();
|
|
408
|
+
const state = selectServeSimState(states, selectedDevice);
|
|
409
|
+
if (!state) {
|
|
410
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
411
|
+
res.end(JSON.stringify({ error: "No serve-sim device" }));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const bridge = await ensureInspectWebKitBridge();
|
|
416
|
+
const bridgeTargets = await bridge.listTargets();
|
|
417
|
+
const wsHost = bridgeWsHost(req.headers?.host, bridge.port);
|
|
418
|
+
// The bridge enumerates targets across every booted simulator.
|
|
419
|
+
// Filter to the device this preview is pinned to so the picker
|
|
420
|
+
// doesn't surface webviews that belong to a different sim.
|
|
421
|
+
const scoped = bridgeTargets.filter((target) =>
|
|
422
|
+
// If we couldn't resolve a udid (e.g. degraded HTTP path), keep
|
|
423
|
+
// the target rather than dropping it silently.
|
|
424
|
+
!target.udid || target.udid === state.device,
|
|
425
|
+
);
|
|
426
|
+
const targets = scoped.map((target) => ({
|
|
427
|
+
...target,
|
|
428
|
+
webSocketDebuggerUrl: `ws://${wsHost}/devtools/page/${encodeURIComponent(target.id)}`,
|
|
429
|
+
devtoolsFrontendUrl: devtoolsFrontendUrl(devtoolsFrontendBase, wsHost, target.id),
|
|
430
|
+
}));
|
|
431
|
+
res.writeHead(200, {
|
|
432
|
+
"Content-Type": "application/json",
|
|
433
|
+
"Cache-Control": "no-store",
|
|
434
|
+
});
|
|
435
|
+
res.end(JSON.stringify({
|
|
436
|
+
port: bridge.port,
|
|
437
|
+
targets,
|
|
438
|
+
}));
|
|
439
|
+
} catch (err) {
|
|
440
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
441
|
+
res.end(JSON.stringify({
|
|
442
|
+
error: err instanceof Error ? err.message : "Failed to start inspect-webkit",
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
})();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// POST /devtools/release — drop hover-highlight CDP sessions so we don't
|
|
450
|
+
// sit on a WIR slot when the picker is dismissed (or the tab is closed).
|
|
451
|
+
// Optional body { targetId } releases just one; empty body releases all.
|
|
452
|
+
if (url === base + "/devtools/release" && req.method === "POST") {
|
|
453
|
+
let body = "";
|
|
454
|
+
req.on("data", (chunk) => (body += chunk));
|
|
455
|
+
req.on("end", async () => {
|
|
456
|
+
try {
|
|
457
|
+
const parsed = body ? JSON.parse(body) as { targetId?: string } : {};
|
|
458
|
+
const bridge = await ensureInspectWebKitBridge();
|
|
459
|
+
bridge.releaseHighlight?.(parsed.targetId);
|
|
460
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
461
|
+
res.end("{}");
|
|
462
|
+
} catch (err) {
|
|
463
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
464
|
+
res.end(JSON.stringify({
|
|
465
|
+
error: err instanceof Error ? err.message : "Failed to release",
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// POST /devtools/highlight — flash an inspectable target in the
|
|
473
|
+
// simulator the way Safari's Develop menu hover does. Body shape:
|
|
474
|
+
// { targetId: string, on: boolean }.
|
|
475
|
+
if (url === base + "/devtools/highlight" && req.method === "POST") {
|
|
476
|
+
let body = "";
|
|
477
|
+
req.on("data", (chunk) => (body += chunk));
|
|
478
|
+
req.on("end", async () => {
|
|
479
|
+
try {
|
|
480
|
+
const { targetId, on } = JSON.parse(body || "{}") as { targetId?: string; on?: boolean };
|
|
481
|
+
if (!targetId) {
|
|
482
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
483
|
+
res.end(JSON.stringify({ error: "Missing targetId" }));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const bridge = await ensureInspectWebKitBridge();
|
|
487
|
+
if (!bridge.highlightTarget) {
|
|
488
|
+
res.writeHead(501, { "Content-Type": "application/json" });
|
|
489
|
+
res.end(JSON.stringify({ error: "highlightTarget not supported by inspect-webkit" }));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
await bridge.highlightTarget(targetId, !!on);
|
|
493
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
494
|
+
res.end("{}");
|
|
495
|
+
} catch (err) {
|
|
496
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
497
|
+
res.end(JSON.stringify({
|
|
498
|
+
error: err instanceof Error ? err.message : "Failed to highlight target",
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
214
505
|
// JSON API: serve-sim state
|
|
215
506
|
if (url === base + "/api") {
|
|
216
507
|
const states = readServeSimStates();
|