4a946e2cfc
- Serialized group and ungroup operations to prevent duplicate tab group creation races. - Queued and serially drained tab grouping requests in the relay bridge to prevent overlapping RPCs. - Mirrored tab group titles to session storage and healed duplicate groups during background service worker recovery. - Renamed run-cancellation utility to run-scope and updated corresponding module and test references.
142 lines
5.0 KiB
TypeScript
142 lines
5.0 KiB
TypeScript
/**
|
|
* HTTP + WebSocket server for the browser relay.
|
|
*
|
|
* Impersonates Chrome's CDP discovery endpoint so the omp browser tool (and
|
|
* any puppeteer client) can connect with a plain `browserURL`:
|
|
* - `GET /json/version` → 200 with `webSocketDebuggerUrl` once the extension
|
|
* is connected, 503 before that (clients like `waitForCdp` keep polling).
|
|
* - `GET /json` / `/json/list` → attachable page targets (debugging aid).
|
|
* - `WS /cdp` → downstream CDP clients (puppeteer).
|
|
* - `WS /ext` → the Chrome extension (token-gated when configured).
|
|
*
|
|
* Binds loopback only: anything that can reach this port can drive the
|
|
* user's logged-in browser.
|
|
*/
|
|
import { RelayBridge } from "./bridge";
|
|
|
|
/** Options for {@link startRelayServer}. */
|
|
export interface RelayServerOptions {
|
|
port: number;
|
|
/** Shared secret the extension must present as `?token=`; unset disables the check. */
|
|
token?: string;
|
|
/** Group tabs the agent actively drives under one per-window Chrome tab group (default on); `false` disables. */
|
|
group?: boolean | { title: string; color: string };
|
|
log?: (message: string, data?: Record<string, unknown>) => void;
|
|
}
|
|
|
|
/** A running relay server. */
|
|
export interface RelayServer {
|
|
bridge: RelayBridge;
|
|
port: number;
|
|
stop(): void;
|
|
}
|
|
|
|
interface SocketData {
|
|
role: "cdp" | "ext";
|
|
connId?: number;
|
|
}
|
|
|
|
type RelayWebSocket = Bun.ServerWebSocket<SocketData>;
|
|
|
|
const WS_KEEPALIVE_MS = 30_000;
|
|
/** Screenshots travel base64-encoded through both websocket legs. */
|
|
const MAX_PAYLOAD_BYTES = 256 * 1024 * 1024;
|
|
/** Default appearance of the omp tab group. */
|
|
const DEFAULT_GROUP = { title: "omp", color: "cyan" } as const;
|
|
|
|
/** Start the relay server on 127.0.0.1. Throws if the port is taken. */
|
|
export function startRelayServer(opts: RelayServerOptions): RelayServer {
|
|
const log = opts.log ?? (() => {});
|
|
const group =
|
|
opts.group === false ? null : opts.group === true || opts.group === undefined ? DEFAULT_GROUP : opts.group;
|
|
const bridge = new RelayBridge({ log, group });
|
|
const sockets = new Set<RelayWebSocket>();
|
|
|
|
const server = Bun.serve({
|
|
hostname: "127.0.0.1",
|
|
port: opts.port,
|
|
fetch(req, srv): Response | undefined {
|
|
const url = new URL(req.url);
|
|
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
if (path === "/cdp") {
|
|
// Browsers set Origin on websocket upgrades; native CDP clients
|
|
// don't. Reject any Origin so a web page can't drive the relay.
|
|
if (req.headers.get("origin")) return new Response("Forbidden", { status: 403 });
|
|
const data: SocketData = { role: "cdp" };
|
|
if (srv.upgrade(req, { data })) return undefined;
|
|
return new Response("websocket upgrade required", { status: 426 });
|
|
}
|
|
if (path === "/ext") {
|
|
const origin = req.headers.get("origin");
|
|
if (origin && !origin.startsWith("chrome-extension://")) {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
if (opts.token && url.searchParams.get("token") !== opts.token) {
|
|
return new Response("Unauthorized", { status: 401 });
|
|
}
|
|
const data: SocketData = { role: "ext" };
|
|
if (srv.upgrade(req, { data })) return undefined;
|
|
return new Response("websocket upgrade required", { status: 426 });
|
|
}
|
|
if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
|
|
if (path === "/json/version") {
|
|
if (!bridge.ready) {
|
|
return Response.json({ error: "relay extension is not connected" }, { status: 503 });
|
|
}
|
|
return Response.json(bridge.versionInfo(`ws://127.0.0.1:${opts.port}/cdp`));
|
|
}
|
|
if (path === "/json" || path === "/json/list") {
|
|
return Response.json(bridge.listTargets());
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
},
|
|
websocket: {
|
|
maxPayloadLength: MAX_PAYLOAD_BYTES,
|
|
// Disabled: Bun caps idleTimeout at 255s, and the keepalive pings
|
|
// below already detect dead peers via the websocket close path.
|
|
idleTimeout: 0,
|
|
open(ws: RelayWebSocket): void {
|
|
sockets.add(ws);
|
|
if (ws.data.role === "ext") {
|
|
bridge.extConnected(ws);
|
|
} else {
|
|
ws.data.connId = bridge.cdpConnected(ws);
|
|
}
|
|
},
|
|
message(ws: RelayWebSocket, message: string | Buffer): void {
|
|
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
if (ws.data.role === "ext") {
|
|
bridge.extMessage(ws, text);
|
|
} else if (ws.data.connId !== undefined) {
|
|
bridge.cdpMessage(ws.data.connId, text);
|
|
}
|
|
},
|
|
close(ws: RelayWebSocket): void {
|
|
sockets.delete(ws);
|
|
if (ws.data.role === "ext") {
|
|
bridge.extClosed(ws);
|
|
} else if (ws.data.connId !== undefined) {
|
|
bridge.cdpClosed(ws.data.connId);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
// Puppeteer connections go silent while the agent is idle; protocol-level
|
|
// pings count as activity and keep them under the idle timeout.
|
|
const keepalive = setInterval(() => {
|
|
for (const ws of sockets) ws.ping();
|
|
}, WS_KEEPALIVE_MS);
|
|
keepalive.unref();
|
|
|
|
log("relay listening", { port: opts.port });
|
|
return {
|
|
bridge,
|
|
port: opts.port,
|
|
stop() {
|
|
clearInterval(keepalive);
|
|
server.stop(true);
|
|
},
|
|
};
|
|
}
|