serve-sim 0.1.43 → 0.1.44-beta.91.2
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 +20 -3
- package/bin/serve-sim-bin +0 -0
- package/dist/middleware.js +42 -28
- package/dist/serve-sim.js +84 -66
- package/package.json +2 -2
- package/src/middleware.ts +455 -67
package/src/middleware.ts
CHANGED
|
@@ -3,8 +3,14 @@ import { execSync, spawn, exec, execFile, type ChildProcess, type ExecException
|
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { createServer as createNetServer } from "net";
|
|
6
|
-
import { randomBytes, timingSafeEqual } from "crypto";
|
|
7
|
-
import type
|
|
6
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
7
|
+
import { request as httpRequest, type IncomingMessage, type ServerResponse } from "http";
|
|
8
|
+
import type { Socket } from "net";
|
|
9
|
+
// `ws` (kept external in the build) supplies a WebSocket *client* for the
|
|
10
|
+
// helper/devtools proxy. Node only exposes a global `WebSocket` on newer LTS
|
|
11
|
+
// lines, and `serve-sim/middleware` is embedded in third-party dev servers, so
|
|
12
|
+
// importing the dependency keeps the proxy working regardless of runtime.
|
|
13
|
+
import { WebSocket } from "ws";
|
|
8
14
|
import { createAxStreamerCache } from "./ax";
|
|
9
15
|
import { debugMw } from "./debug";
|
|
10
16
|
import {
|
|
@@ -19,6 +25,10 @@ import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-set
|
|
|
19
25
|
type SimReq = IncomingMessage;
|
|
20
26
|
type SimRes = ServerResponse;
|
|
21
27
|
type SimNext = (err?: unknown) => void;
|
|
28
|
+
export type SimMiddleware = {
|
|
29
|
+
(req: SimReq, res: SimRes, next?: SimNext): void;
|
|
30
|
+
handleUpgrade(req: SimReq, socket: Socket, head: Buffer): void;
|
|
31
|
+
};
|
|
22
32
|
|
|
23
33
|
// Injected at build time as a base64-encoded string via `define`
|
|
24
34
|
declare const __PREVIEW_HTML_B64__: string;
|
|
@@ -41,7 +51,7 @@ type WebKitBridgeTarget = {
|
|
|
41
51
|
inUseByOtherInspector?: boolean;
|
|
42
52
|
};
|
|
43
53
|
|
|
44
|
-
type WebKitBridge = {
|
|
54
|
+
export type WebKitBridge = {
|
|
45
55
|
port: number;
|
|
46
56
|
cdpUrl: string;
|
|
47
57
|
listTargets(): Promise<WebKitBridgeTarget[]>;
|
|
@@ -126,6 +136,10 @@ function isUserFacingBundle(bundleId: string): boolean {
|
|
|
126
136
|
return !NON_UI_BUNDLE_RE.test(bundleId);
|
|
127
137
|
}
|
|
128
138
|
|
|
139
|
+
function isSimulatorUdid(value: string): boolean {
|
|
140
|
+
return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(value);
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
export function parseForegroundAppLogMessage(message: string): { bundleId: string; pid: number } | null {
|
|
130
144
|
// e.g. "[app<com.apple.mobilesafari>:43117] Setting process visibility to: Foreground"
|
|
131
145
|
const match = /\[app<([^>]+)>:(\d+)\] Setting process visibility to: Foreground/.exec(message);
|
|
@@ -259,7 +273,7 @@ export function readServeSimStates(): ServeSimState[] {
|
|
|
259
273
|
// would accept connections yet never produce frames, leaving the
|
|
260
274
|
// preview stuck on "Connecting...". Recycle the stale state so the
|
|
261
275
|
// caller can spawn a fresh helper bound to whatever is booted.
|
|
262
|
-
if (booted && !booted.has(state.device)) {
|
|
276
|
+
if (booted && isSimulatorUdid(state.device) && !booted.has(state.device)) {
|
|
263
277
|
debugMw(
|
|
264
278
|
"recycling stale helper pid=%d (device %s no longer booted)",
|
|
265
279
|
state.pid,
|
|
@@ -291,41 +305,316 @@ function queryDevice(rawUrl: string): string | null {
|
|
|
291
305
|
return new URLSearchParams(rawUrl.slice(qIndex + 1)).get("device");
|
|
292
306
|
}
|
|
293
307
|
|
|
308
|
+
function hostForRequest(req: SimReq): string | undefined {
|
|
309
|
+
const host = req.headers?.host;
|
|
310
|
+
if (host) return host;
|
|
311
|
+
const port = req.socket.localPort;
|
|
312
|
+
return port ? `localhost:${port}` : undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
294
315
|
function endpoint(base: string, path: string, device: string): string {
|
|
295
316
|
const value = `${base}${path}`;
|
|
296
317
|
return `${value}?device=${encodeURIComponent(device)}`;
|
|
297
318
|
}
|
|
298
319
|
|
|
299
320
|
/**
|
|
300
|
-
* Rewrite the helper URLs in a state
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* the
|
|
321
|
+
* Rewrite the helper URLs in a state for the requesting browser.
|
|
322
|
+
*
|
|
323
|
+
* When `proxy` is set (standalone `serve-sim`, which owns its server and wires
|
|
324
|
+
* WebSocket upgrades), the URLs point at the preview's same-origin `/helper`
|
|
325
|
+
* proxy so remote viewers only need the one preview port. When it's off — the
|
|
326
|
+
* default for embedded `app.use(simMiddleware(...))` mounts, where the host's
|
|
327
|
+
* server doesn't forward `upgrade` events to `handleUpgrade` — the helper's
|
|
328
|
+
* loopback URLs are emitted directly (with `127.0.0.1` swapped for the request
|
|
329
|
+
* hostname so LAN/tunnel viewers can still reach the separate helper port).
|
|
305
330
|
*/
|
|
306
331
|
export function rewriteStateForRequestHost(
|
|
307
332
|
state: ServeSimState,
|
|
308
333
|
hostHeader: string | undefined,
|
|
334
|
+
base = "",
|
|
335
|
+
protocol: "http" | "https" = "http",
|
|
336
|
+
proxy = false,
|
|
309
337
|
): ServeSimState {
|
|
310
|
-
if (!hostHeader)
|
|
311
|
-
let hostname: string;
|
|
312
|
-
try {
|
|
313
|
-
hostname = new URL(`http://${hostHeader}`).hostname;
|
|
314
|
-
} catch {
|
|
338
|
+
if (!hostHeader) {
|
|
315
339
|
return state;
|
|
316
340
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
341
|
+
if (!proxy) {
|
|
342
|
+
let hostname: string;
|
|
343
|
+
try {
|
|
344
|
+
hostname = new URL(`http://${hostHeader}`).hostname;
|
|
345
|
+
} catch {
|
|
346
|
+
return state;
|
|
347
|
+
}
|
|
348
|
+
// `URL.hostname` keeps brackets around IPv6 literals, so the IPv6 loopback
|
|
349
|
+
// comparison is against the bracketed form rather than `::1`.
|
|
350
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") {
|
|
351
|
+
return state;
|
|
352
|
+
}
|
|
353
|
+
const rewrite = (s: string) => s.replace("127.0.0.1", hostname);
|
|
354
|
+
return {
|
|
355
|
+
...state,
|
|
356
|
+
url: rewrite(state.url),
|
|
357
|
+
streamUrl: rewrite(state.streamUrl),
|
|
358
|
+
wsUrl: rewrite(state.wsUrl),
|
|
359
|
+
};
|
|
321
360
|
}
|
|
322
|
-
const
|
|
361
|
+
const normalizedBase = base === "/" ? "" : base.replace(/\/+$/, "");
|
|
362
|
+
const helperBase = `${normalizedBase}/helper`;
|
|
363
|
+
const devicePath = `${helperBase}/${encodeURIComponent(state.device)}`;
|
|
364
|
+
// Match the request's scheme so an HTTPS-served preview doesn't hand the
|
|
365
|
+
// browser `http`/`ws` helper URLs (blocked as mixed content). Behind a proxy
|
|
366
|
+
// the original scheme arrives via `x-forwarded-proto`.
|
|
367
|
+
const origin = `${protocol}://${hostHeader}`;
|
|
368
|
+
const wsOrigin = `${protocol === "https" ? "wss" : "ws"}://${hostHeader}`;
|
|
323
369
|
return {
|
|
324
370
|
...state,
|
|
325
|
-
url:
|
|
326
|
-
streamUrl:
|
|
327
|
-
wsUrl:
|
|
371
|
+
url: `${origin}${devicePath}`,
|
|
372
|
+
streamUrl: `${origin}${devicePath}/stream.mjpeg`,
|
|
373
|
+
wsUrl: `${wsOrigin}${devicePath}/ws`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function helperProxyPrefix(base: string): string {
|
|
378
|
+
return `${base === "/" ? "" : base}/helper`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function devtoolsProxyPrefix(base: string): string {
|
|
382
|
+
return `${base === "/" ? "" : base}/devtools`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function devtoolsProxyTarget(rawUrl: string, prefix: string): { upstreamPath: string } | null {
|
|
386
|
+
const parsed = new URL(rawUrl, "http://serve-sim.local");
|
|
387
|
+
if (!parsed.pathname.startsWith(`${prefix}/page/`)) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
const suffix = parsed.pathname.slice(prefix.length);
|
|
391
|
+
return { upstreamPath: `/devtools${suffix}${parsed.search}` };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function helperProxyTarget(rawUrl: string, prefix: string): { device: string | null; upstreamPath: string } | null {
|
|
395
|
+
const parsed = new URL(rawUrl, "http://serve-sim.local");
|
|
396
|
+
if (parsed.pathname !== prefix && !parsed.pathname.startsWith(`${prefix}/`)) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const rawSuffix = parsed.pathname.slice(prefix.length);
|
|
400
|
+
const segments = rawSuffix.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
401
|
+
const directHelperEndpoints = new Set([
|
|
402
|
+
"ax",
|
|
403
|
+
"config",
|
|
404
|
+
"foreground",
|
|
405
|
+
"health",
|
|
406
|
+
"stream.avcc",
|
|
407
|
+
"stream.mjpeg",
|
|
408
|
+
"ws",
|
|
409
|
+
]);
|
|
410
|
+
let device = parsed.searchParams.get("device");
|
|
411
|
+
let upstreamSegments = segments;
|
|
412
|
+
if (segments[0] && !directHelperEndpoints.has(segments[0])) {
|
|
413
|
+
device = decodeURIComponent(segments[0]);
|
|
414
|
+
upstreamSegments = segments.slice(1);
|
|
415
|
+
}
|
|
416
|
+
const suffix = upstreamSegments.length > 0 ? `/${upstreamSegments.join("/")}` : "/";
|
|
417
|
+
parsed.searchParams.delete("device");
|
|
418
|
+
return { device, upstreamPath: `${suffix}${parsed.search}` };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function proxyHelperHttp(req: SimReq, res: SimRes, state: ServeSimState, upstreamPath: string): void {
|
|
422
|
+
const proxyReq = httpRequest(
|
|
423
|
+
{
|
|
424
|
+
host: "127.0.0.1",
|
|
425
|
+
port: state.port,
|
|
426
|
+
method: req.method,
|
|
427
|
+
path: upstreamPath,
|
|
428
|
+
headers: {
|
|
429
|
+
...req.headers,
|
|
430
|
+
host: `127.0.0.1:${state.port}`,
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
(proxyRes) => {
|
|
434
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
435
|
+
proxyRes.pipe(res);
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
proxyReq.on("error", (err) => {
|
|
439
|
+
if (res.headersSent) {
|
|
440
|
+
res.destroy(err);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
|
|
444
|
+
res.end(err instanceof Error ? err.message : "Helper proxy failed");
|
|
445
|
+
});
|
|
446
|
+
req.pipe(proxyReq);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const WS_ACCEPT_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
450
|
+
|
|
451
|
+
function websocketFrame(opcode: number, payload: Buffer<ArrayBufferLike>): Buffer {
|
|
452
|
+
const length = payload.length;
|
|
453
|
+
let header: Buffer;
|
|
454
|
+
if (length < 126) {
|
|
455
|
+
header = Buffer.from([0x80 | opcode, length]);
|
|
456
|
+
} else if (length <= 0xffff) {
|
|
457
|
+
header = Buffer.alloc(4);
|
|
458
|
+
header[0] = 0x80 | opcode;
|
|
459
|
+
header[1] = 126;
|
|
460
|
+
header.writeUInt16BE(length, 2);
|
|
461
|
+
} else {
|
|
462
|
+
header = Buffer.alloc(10);
|
|
463
|
+
header[0] = 0x80 | opcode;
|
|
464
|
+
header[1] = 127;
|
|
465
|
+
header.writeBigUInt64BE(BigInt(length), 2);
|
|
466
|
+
}
|
|
467
|
+
return Buffer.concat([header, payload]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
type ParsedWebSocketFrame = {
|
|
471
|
+
opcode: number;
|
|
472
|
+
payload: Buffer<ArrayBufferLike>;
|
|
473
|
+
consumed: number;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
function parseWebSocketFrame(buffer: Buffer): ParsedWebSocketFrame | null {
|
|
477
|
+
if (buffer.length < 2) return null;
|
|
478
|
+
const opcode = buffer[0]! & 0x0f;
|
|
479
|
+
const masked = (buffer[1]! & 0x80) !== 0;
|
|
480
|
+
let length = buffer[1]! & 0x7f;
|
|
481
|
+
let offset = 2;
|
|
482
|
+
if (length === 126) {
|
|
483
|
+
if (buffer.length < offset + 2) return null;
|
|
484
|
+
length = buffer.readUInt16BE(offset);
|
|
485
|
+
offset += 2;
|
|
486
|
+
} else if (length === 127) {
|
|
487
|
+
if (buffer.length < offset + 8) return null;
|
|
488
|
+
const bigLength = buffer.readBigUInt64BE(offset);
|
|
489
|
+
if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
490
|
+
throw new Error("WebSocket frame too large");
|
|
491
|
+
}
|
|
492
|
+
length = Number(bigLength);
|
|
493
|
+
offset += 8;
|
|
494
|
+
}
|
|
495
|
+
const maskOffset = offset;
|
|
496
|
+
if (masked) offset += 4;
|
|
497
|
+
if (buffer.length < offset + length) return null;
|
|
498
|
+
const payload = Buffer.from(buffer.subarray(offset, offset + length));
|
|
499
|
+
if (masked) {
|
|
500
|
+
const mask = buffer.subarray(maskOffset, maskOffset + 4);
|
|
501
|
+
for (let i = 0; i < payload.length; i++) {
|
|
502
|
+
payload[i] = payload[i]! ^ mask[i % 4]!;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return { opcode, payload, consumed: offset + length };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function sendBrowserFrame(socket: Socket, opcode: number, payload: Buffer<ArrayBufferLike> = Buffer.alloc(0)): void {
|
|
509
|
+
if (socket.destroyed || !socket.writable) return;
|
|
510
|
+
socket.write(websocketFrame(opcode, payload));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
type PendingWebSocketFrame = {
|
|
514
|
+
opcode: number;
|
|
515
|
+
payload: Buffer<ArrayBufferLike>;
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
function webSocketBinary(payload: Buffer<ArrayBufferLike>): Uint8Array<ArrayBuffer> {
|
|
519
|
+
const bytes = new Uint8Array(payload.length);
|
|
520
|
+
bytes.set(payload);
|
|
521
|
+
return bytes;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function bridgeWebSocketFrames(req: SimReq, socket: Socket, head: Buffer, upstreamUrl: string): void {
|
|
525
|
+
const key = req.headers["sec-websocket-key"];
|
|
526
|
+
if (typeof key !== "string") {
|
|
527
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const accept = createHash("sha1")
|
|
531
|
+
.update(key + WS_ACCEPT_GUID)
|
|
532
|
+
.digest("base64");
|
|
533
|
+
socket.write(
|
|
534
|
+
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
535
|
+
"Upgrade: websocket\r\n" +
|
|
536
|
+
"Connection: Upgrade\r\n" +
|
|
537
|
+
`Sec-WebSocket-Accept: ${accept}\r\n` +
|
|
538
|
+
"\r\n",
|
|
539
|
+
);
|
|
540
|
+
socket.resume();
|
|
541
|
+
|
|
542
|
+
const upstream = new WebSocket(upstreamUrl);
|
|
543
|
+
upstream.binaryType = "arraybuffer";
|
|
544
|
+
let upstreamOpen = false;
|
|
545
|
+
let closed = false;
|
|
546
|
+
let pendingToUpstream: PendingWebSocketFrame[] = [];
|
|
547
|
+
let buffered = Buffer.from(head);
|
|
548
|
+
|
|
549
|
+
const closeBoth = () => {
|
|
550
|
+
if (closed) return;
|
|
551
|
+
closed = true;
|
|
552
|
+
try { upstream.close(); } catch {}
|
|
553
|
+
try { socket.end(websocketFrame(0x8, Buffer.alloc(0))); } catch {}
|
|
554
|
+
try { socket.destroy(); } catch {}
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const sendToUpstream = (frame: PendingWebSocketFrame) => {
|
|
558
|
+
if (upstreamOpen && upstream.readyState === WebSocket.OPEN) {
|
|
559
|
+
upstream.send(frame.opcode === 0x1 ? frame.payload.toString("utf8") : webSocketBinary(frame.payload));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
pendingToUpstream.push({ opcode: frame.opcode, payload: Buffer.from(frame.payload) });
|
|
328
563
|
};
|
|
564
|
+
|
|
565
|
+
const drainFrames = () => {
|
|
566
|
+
try {
|
|
567
|
+
while (buffered.length > 0) {
|
|
568
|
+
const frame = parseWebSocketFrame(buffered);
|
|
569
|
+
if (!frame) break;
|
|
570
|
+
buffered = buffered.subarray(frame.consumed);
|
|
571
|
+
if (frame.opcode === 0x8) {
|
|
572
|
+
sendBrowserFrame(socket, 0x8, frame.payload);
|
|
573
|
+
closeBoth();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (frame.opcode === 0x9) {
|
|
577
|
+
sendBrowserFrame(socket, 0xA, frame.payload);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (frame.opcode === 0x1 || frame.opcode === 0x2) {
|
|
581
|
+
sendToUpstream({ opcode: frame.opcode, payload: frame.payload });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
} catch {
|
|
585
|
+
closeBoth();
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
upstream.onopen = () => {
|
|
590
|
+
upstreamOpen = true;
|
|
591
|
+
for (const frame of pendingToUpstream) {
|
|
592
|
+
upstream.send(frame.opcode === 0x1 ? frame.payload.toString("utf8") : webSocketBinary(frame.payload));
|
|
593
|
+
}
|
|
594
|
+
pendingToUpstream = [];
|
|
595
|
+
};
|
|
596
|
+
upstream.onmessage = (event) => {
|
|
597
|
+
const data = event.data;
|
|
598
|
+
const payload = typeof data === "string"
|
|
599
|
+
? Buffer.from(data)
|
|
600
|
+
: Buffer.from(data as ArrayBuffer);
|
|
601
|
+
sendBrowserFrame(socket, typeof data === "string" ? 0x1 : 0x2, payload);
|
|
602
|
+
};
|
|
603
|
+
upstream.onerror = closeBoth;
|
|
604
|
+
upstream.onclose = closeBoth;
|
|
605
|
+
|
|
606
|
+
socket.on("data", (chunk) => {
|
|
607
|
+
if (typeof chunk === "string") chunk = Buffer.from(chunk);
|
|
608
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
609
|
+
drainFrames();
|
|
610
|
+
});
|
|
611
|
+
socket.on("error", closeBoth);
|
|
612
|
+
socket.on("close", closeBoth);
|
|
613
|
+
drainFrames();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function bridgeHelperWebSocket(req: SimReq, socket: Socket, head: Buffer, state: ServeSimState): void {
|
|
617
|
+
bridgeWebSocketFrames(req, socket, head, state.wsUrl);
|
|
329
618
|
}
|
|
330
619
|
|
|
331
620
|
export function previewConfigForState(
|
|
@@ -333,6 +622,8 @@ export function previewConfigForState(
|
|
|
333
622
|
base: string,
|
|
334
623
|
serveSimBin: string,
|
|
335
624
|
execToken: string,
|
|
625
|
+
codec?: string,
|
|
626
|
+
proxyHelpers = false,
|
|
336
627
|
): ServeSimState & {
|
|
337
628
|
basePath: string;
|
|
338
629
|
appStateEndpoint: string;
|
|
@@ -345,6 +636,8 @@ export function previewConfigForState(
|
|
|
345
636
|
gridMemoryEndpoint: string;
|
|
346
637
|
previewEndpoint: string;
|
|
347
638
|
execToken: string;
|
|
639
|
+
codec?: string;
|
|
640
|
+
proxyHelpers?: boolean;
|
|
348
641
|
} {
|
|
349
642
|
const gridApiBase = (base === "" ? "" : base) + "/grid/api";
|
|
350
643
|
return {
|
|
@@ -360,6 +653,8 @@ export function previewConfigForState(
|
|
|
360
653
|
gridMemoryEndpoint: gridApiBase + "/memory",
|
|
361
654
|
previewEndpoint: base === "" ? "/" : base,
|
|
362
655
|
execToken,
|
|
656
|
+
...(codec ? { codec } : {}),
|
|
657
|
+
...(proxyHelpers ? { proxyHelpers: true } : {}),
|
|
363
658
|
};
|
|
364
659
|
}
|
|
365
660
|
|
|
@@ -430,10 +725,9 @@ async function ensureInspectWebKitBridge(): Promise<WebKitBridge> {
|
|
|
430
725
|
continue;
|
|
431
726
|
}
|
|
432
727
|
try {
|
|
433
|
-
// Bind explicitly to IPv4 127.0.0.1
|
|
434
|
-
//
|
|
435
|
-
// to ::1 first on some setups, which would leave the
|
|
436
|
-
// ws://127.0.0.1:9222 connection refused.
|
|
728
|
+
// Bind explicitly to IPv4 127.0.0.1 so the preview's DevTools
|
|
729
|
+
// websocket proxy has a stable loopback upstream. `localhost` resolves
|
|
730
|
+
// to ::1 first on some setups, which would leave the bridge unreachable.
|
|
437
731
|
const server = await startCdpServer({ host: "127.0.0.1", port }) as Awaited<ReturnType<typeof startCdpServer>> & {
|
|
438
732
|
highlightTarget?(targetId: string, on: boolean): Promise<void>;
|
|
439
733
|
releaseHighlight?(targetId?: string): void;
|
|
@@ -478,22 +772,34 @@ async function ensureInspectWebKitBridge(): Promise<WebKitBridge> {
|
|
|
478
772
|
return inspectWebKitBridge;
|
|
479
773
|
}
|
|
480
774
|
|
|
481
|
-
function
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
775
|
+
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
|
776
|
+
return Array.isArray(value) ? value[0] : value;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function forwardedProtoForRequest(req: SimReq): string | undefined {
|
|
780
|
+
return firstHeaderValue(req.headers["x-forwarded-proto"])
|
|
781
|
+
?.split(",", 1)[0]
|
|
782
|
+
?.trim()
|
|
783
|
+
.toLowerCase();
|
|
485
784
|
}
|
|
486
785
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
function
|
|
496
|
-
|
|
786
|
+
function websocketProtocolForRequest(req: SimReq): "ws" | "wss" {
|
|
787
|
+
return forwardedProtoForRequest(req) === "https" ? "wss" : "ws";
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function httpProtocolForRequest(req: SimReq): "http" | "https" {
|
|
791
|
+
return forwardedProtoForRequest(req) === "https" ? "https" : "http";
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function devtoolsFrontendUrl(
|
|
795
|
+
frontendBase: string,
|
|
796
|
+
wsParamName: "ws" | "wss",
|
|
797
|
+
wsTargetBase: string,
|
|
798
|
+
targetId: string,
|
|
799
|
+
): string {
|
|
800
|
+
const url = new URL(`${frontendBase}/inspector.html`, "http://serve-sim.local");
|
|
801
|
+
url.searchParams.set(wsParamName, `${wsTargetBase}/page/${encodeURIComponent(targetId)}`);
|
|
802
|
+
return `${url.pathname}${url.search}`;
|
|
497
803
|
}
|
|
498
804
|
|
|
499
805
|
let _html: string | null = null;
|
|
@@ -704,6 +1010,26 @@ export interface SimMiddlewareOptions {
|
|
|
704
1010
|
* cross-origin pages cannot read it.
|
|
705
1011
|
*/
|
|
706
1012
|
execToken?: string;
|
|
1013
|
+
/**
|
|
1014
|
+
* Pin the preview stream codec. `"mjpeg"` forces the software JPEG path for
|
|
1015
|
+
* hosts whose hardware can't encode H.264 (e.g. VMs without the high/low-
|
|
1016
|
+
* latency H.264 profiles); `"auto"`/undefined lets the browser pick H.264.
|
|
1017
|
+
* Reserved for future values such as `"hevc"`/`"av1"`.
|
|
1018
|
+
*/
|
|
1019
|
+
codec?: string;
|
|
1020
|
+
/**
|
|
1021
|
+
* Route the browser's helper stream/control and DevTools sockets through the
|
|
1022
|
+
* preview's same-origin `/helper` and `/devtools` proxies instead of the
|
|
1023
|
+
* helper's own loopback port — so a single exposed preview port is enough for
|
|
1024
|
+
* remote viewers. Requires the mounting server to forward WebSocket `upgrade`
|
|
1025
|
+
* events to {@link SimMiddleware.handleUpgrade}. Standalone `serve-sim`
|
|
1026
|
+
* enables this; plain `app.use(simMiddleware(...))` mounts leave it off (and
|
|
1027
|
+
* keep direct helper URLs) unless they also wire upgrades. See the README's
|
|
1028
|
+
* "Embed in your dev server" section.
|
|
1029
|
+
*/
|
|
1030
|
+
proxyHelpers?: boolean;
|
|
1031
|
+
/** Test hook for supplying a fake inspect-webkit bridge. */
|
|
1032
|
+
inspectWebKitBridge?: () => Promise<WebKitBridge>;
|
|
707
1033
|
}
|
|
708
1034
|
|
|
709
1035
|
function safeEqualString(a: string, b: string): boolean {
|
|
@@ -728,8 +1054,12 @@ function isJsonContentType(value: string | undefined): boolean {
|
|
|
728
1054
|
* GET {basePath}/api — serve-sim state JSON
|
|
729
1055
|
* GET {basePath}/ax — SSE stream of normalized accessibility snapshots
|
|
730
1056
|
*/
|
|
731
|
-
export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
1057
|
+
export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
|
|
732
1058
|
const base = (options?.basePath ?? "/.sim").replace(/\/+$/, "");
|
|
1059
|
+
const helperPrefix = helperProxyPrefix(base);
|
|
1060
|
+
const devtoolsPrefix = devtoolsProxyPrefix(base);
|
|
1061
|
+
const proxyHelpers = options?.proxyHelpers ?? false;
|
|
1062
|
+
const getInspectWebKitBridge = options?.inspectWebKitBridge ?? ensureInspectWebKitBridge;
|
|
733
1063
|
// Per-process random token. Anyone who can read the preview HTML same-origin
|
|
734
1064
|
// can call /exec; cross-origin pages and LAN clients cannot, because they
|
|
735
1065
|
// can't read this value (it's only injected into the preview page's config).
|
|
@@ -753,13 +1083,26 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
753
1083
|
return { ok: true };
|
|
754
1084
|
};
|
|
755
1085
|
|
|
756
|
-
const middleware = (req: SimReq, res: SimRes, next?: SimNext) => {
|
|
1086
|
+
const middleware = ((req: SimReq, res: SimRes, next?: SimNext) => {
|
|
757
1087
|
const rawUrl: string = req.url ?? "";
|
|
758
1088
|
const qIndex = rawUrl.indexOf("?");
|
|
759
1089
|
const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
|
760
1090
|
const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
|
|
761
1091
|
const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
|
|
762
1092
|
|
|
1093
|
+
const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
|
|
1094
|
+
if (helperTarget) {
|
|
1095
|
+
const states = readServeSimStates();
|
|
1096
|
+
const state = selectServeSimState(states, helperTarget.device ?? selectedDevice);
|
|
1097
|
+
if (!state) {
|
|
1098
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1099
|
+
res.end("No serve-sim device");
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
proxyHelperHttp(req, res, state, helperTarget.upstreamPath);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
763
1106
|
// Same-origin proxy for Chrome DevTools frontend assets. Loading the
|
|
764
1107
|
// appspot-hosted frontend directly works as a top-level tab, but is flaky
|
|
765
1108
|
// inside embedded browser iframes. Serving it from the preview origin keeps
|
|
@@ -812,8 +1155,8 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
812
1155
|
}
|
|
813
1156
|
|
|
814
1157
|
if (state) {
|
|
815
|
-
const remoteState = rewriteStateForRequestHost(state, req
|
|
816
|
-
const config = JSON.stringify(previewConfigForState(remoteState, base, serveSimBinPath(), execToken));
|
|
1158
|
+
const remoteState = rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers);
|
|
1159
|
+
const config = JSON.stringify(previewConfigForState(remoteState, base, serveSimBinPath(), execToken, options?.codec, proxyHelpers));
|
|
817
1160
|
const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
|
|
818
1161
|
html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
|
|
819
1162
|
}
|
|
@@ -853,7 +1196,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
853
1196
|
const sims = listAllSimulators();
|
|
854
1197
|
const devices = sims.map((d) => {
|
|
855
1198
|
const helper = helperByUdid.get(d.udid);
|
|
856
|
-
const remoteHelper = helper ? rewriteStateForRequestHost(helper, req
|
|
1199
|
+
const remoteHelper = helper ? rewriteStateForRequestHost(helper, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
|
|
857
1200
|
return {
|
|
858
1201
|
device: d.udid,
|
|
859
1202
|
name: d.name,
|
|
@@ -1017,17 +1360,23 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
1017
1360
|
return;
|
|
1018
1361
|
}
|
|
1019
1362
|
try {
|
|
1020
|
-
const bridge = await
|
|
1363
|
+
const bridge = await getInspectWebKitBridge();
|
|
1021
1364
|
const bridgeTargets = await bridge.listTargets();
|
|
1022
|
-
|
|
1365
|
+
// Proxy mode routes the inspector socket through the preview's
|
|
1366
|
+
// same-origin `/devtools` proxy; otherwise the browser talks to the
|
|
1367
|
+
// bridge's loopback port directly (the pre-proxy behavior).
|
|
1368
|
+
const wsProtocol = proxyHelpers ? websocketProtocolForRequest(req) : "ws";
|
|
1369
|
+
const wsTargetBase = proxyHelpers
|
|
1370
|
+
? `${hostForRequest(req) ?? `127.0.0.1:${bridge.port}`}${devtoolsPrefix}`
|
|
1371
|
+
: `127.0.0.1:${bridge.port}/devtools`;
|
|
1023
1372
|
// inspect-webkit@0.0.3 only exposes `sim:<webinspectord-pid>` for
|
|
1024
1373
|
// simulator targets, which can't be reconciled against a sim UDID.
|
|
1025
1374
|
// Surface every booted sim's targets (Safari Develop-menu behavior)
|
|
1026
1375
|
// until inspect-webkit grows a real UDID we can filter on.
|
|
1027
1376
|
const targets = bridgeTargets.map((target) => ({
|
|
1028
1377
|
...target,
|
|
1029
|
-
webSocketDebuggerUrl:
|
|
1030
|
-
devtoolsFrontendUrl: devtoolsFrontendUrl(devtoolsFrontendBase,
|
|
1378
|
+
webSocketDebuggerUrl: `${wsProtocol}://${wsTargetBase}/page/${encodeURIComponent(target.id)}`,
|
|
1379
|
+
devtoolsFrontendUrl: devtoolsFrontendUrl(devtoolsFrontendBase, wsProtocol, wsTargetBase, target.id),
|
|
1031
1380
|
}));
|
|
1032
1381
|
res.writeHead(200, {
|
|
1033
1382
|
"Content-Type": "application/json",
|
|
@@ -1056,7 +1405,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
1056
1405
|
req.on("end", async () => {
|
|
1057
1406
|
try {
|
|
1058
1407
|
const parsed: ReleaseRequestBody = body ? JSON.parse(body) : {};
|
|
1059
|
-
const bridge = await
|
|
1408
|
+
const bridge = await getInspectWebKitBridge();
|
|
1060
1409
|
bridge.releaseHighlight?.(parsed.targetId);
|
|
1061
1410
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1062
1411
|
res.end("{}");
|
|
@@ -1084,7 +1433,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
1084
1433
|
res.end(JSON.stringify({ error: "Missing targetId" }));
|
|
1085
1434
|
return;
|
|
1086
1435
|
}
|
|
1087
|
-
const bridge = await
|
|
1436
|
+
const bridge = await getInspectWebKitBridge();
|
|
1088
1437
|
if (!bridge.highlightTarget) {
|
|
1089
1438
|
res.writeHead(501, { "Content-Type": "application/json" });
|
|
1090
1439
|
res.end(JSON.stringify({ error: "highlightTarget not supported by inspect-webkit" }));
|
|
@@ -1126,8 +1475,8 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
1126
1475
|
"Content-Type": "application/json",
|
|
1127
1476
|
"Cache-Control": "no-store",
|
|
1128
1477
|
});
|
|
1129
|
-
const remoteState = state ? rewriteStateForRequestHost(state, req
|
|
1130
|
-
res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken) : null));
|
|
1478
|
+
const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
|
|
1479
|
+
res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, options?.codec, proxyHelpers) : null));
|
|
1131
1480
|
return;
|
|
1132
1481
|
}
|
|
1133
1482
|
|
|
@@ -1139,9 +1488,9 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
1139
1488
|
const computeConfig = (): string => {
|
|
1140
1489
|
const states = readServeSimStates();
|
|
1141
1490
|
const state = selectServeSimState(states, selectedDevice);
|
|
1142
|
-
const remoteState = state ? rewriteStateForRequestHost(state, req
|
|
1491
|
+
const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
|
|
1143
1492
|
return JSON.stringify(
|
|
1144
|
-
remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken) : null,
|
|
1493
|
+
remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, options?.codec, proxyHelpers) : null,
|
|
1145
1494
|
);
|
|
1146
1495
|
};
|
|
1147
1496
|
|
|
@@ -1414,24 +1763,63 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
|
|
|
1414
1763
|
|
|
1415
1764
|
// Not ours — pass through
|
|
1416
1765
|
if (next) next();
|
|
1766
|
+
}) as SimMiddleware;
|
|
1767
|
+
middleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
|
|
1768
|
+
const rawUrl = req.url ?? "";
|
|
1769
|
+
const selectedDevice = queryDevice(rawUrl) ?? options?.device ?? null;
|
|
1770
|
+
const helperTarget = helperProxyTarget(rawUrl, helperPrefix);
|
|
1771
|
+
const devtoolsTarget = devtoolsProxyTarget(rawUrl, devtoolsPrefix);
|
|
1772
|
+
if (devtoolsTarget) {
|
|
1773
|
+
(async () => {
|
|
1774
|
+
try {
|
|
1775
|
+
const bridge = await getInspectWebKitBridge();
|
|
1776
|
+
bridgeWebSocketFrames(req, socket, head, `ws://127.0.0.1:${bridge.port}${devtoolsTarget.upstreamPath}`);
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
const message = err instanceof Error ? err.message : "Failed to start inspect-webkit";
|
|
1779
|
+
socket.end(`HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${message}`);
|
|
1780
|
+
}
|
|
1781
|
+
})();
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (!helperTarget) {
|
|
1785
|
+
socket.destroy();
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
const states = readServeSimStates();
|
|
1789
|
+
const state = selectServeSimState(states, helperTarget.device ?? selectedDevice);
|
|
1790
|
+
if (!state) {
|
|
1791
|
+
socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
if (helperTarget.upstreamPath === "/ws") {
|
|
1795
|
+
bridgeHelperWebSocket(req, socket, head, state);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1417
1799
|
};
|
|
1418
|
-
|
|
1419
1800
|
// WebSocket exec channel — same auth/origin policy as POST /exec, but off
|
|
1420
1801
|
// the browser's per-origin HTTP connection pool so multiple preview tabs
|
|
1421
1802
|
// (each holding MJPEG + SSE streams) can't starve exec actions. Servers
|
|
1422
1803
|
// mounting this middleware should forward `upgrade` events here (the
|
|
1423
1804
|
// built-in preview server does); the client falls back to POST /exec when
|
|
1424
1805
|
// the upgrade never completes.
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
onUiRequest: handleUiRequest,
|
|
1435
|
-
}),
|
|
1806
|
+
const handleExecUpgrade = createExecUpgradeHandler({
|
|
1807
|
+
path: `${base}/exec-ws`,
|
|
1808
|
+
execToken,
|
|
1809
|
+
ssePrefixes: [
|
|
1810
|
+
`${base}/api/events`,
|
|
1811
|
+
`${base}/appstate`,
|
|
1812
|
+
`${base}/ax`,
|
|
1813
|
+
],
|
|
1814
|
+
onUiRequest: handleUiRequest,
|
|
1436
1815
|
});
|
|
1816
|
+
|
|
1817
|
+
// WebSocket upgrades owned by the preview: the authenticated exec/control
|
|
1818
|
+
// channel plus same-origin helper/devtools proxy sockets.
|
|
1819
|
+
const handleProxyUpgrade = middleware.handleUpgrade;
|
|
1820
|
+
middleware.handleUpgrade = (req: SimReq, socket: Socket, head: Buffer) => {
|
|
1821
|
+
if (handleExecUpgrade(req, socket, head)) return;
|
|
1822
|
+
handleProxyUpgrade(req, socket, head);
|
|
1823
|
+
};
|
|
1824
|
+
return middleware;
|
|
1437
1825
|
}
|