mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Move swipe screen logic into hooks/services and extract state-specific UI components to keep files small while preserving existing behavior. Reference: US-002
732 lines
21 KiB
TypeScript
732 lines
21 KiB
TypeScript
/**
|
|
* CDP Relay Server for Chrome Extension mode
|
|
*
|
|
* This server acts as a bridge between Playwright clients and a Chrome extension.
|
|
* Instead of launching a browser, it waits for the extension to connect and
|
|
* forwards CDP commands/events between them.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { serve } from "@hono/node-server";
|
|
import { createNodeWebSocket } from "@hono/node-ws";
|
|
import type { WSContext } from "hono/ws";
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
export interface RelayOptions {
|
|
port?: number;
|
|
host?: string;
|
|
}
|
|
|
|
export interface RelayServer {
|
|
wsEndpoint: string;
|
|
port: number;
|
|
stop(): Promise<void>;
|
|
}
|
|
|
|
interface TargetInfo {
|
|
targetId: string;
|
|
type: string;
|
|
title: string;
|
|
url: string;
|
|
attached: boolean;
|
|
}
|
|
|
|
interface ConnectedTarget {
|
|
sessionId: string;
|
|
targetId: string;
|
|
targetInfo: TargetInfo;
|
|
}
|
|
|
|
interface PlaywrightClient {
|
|
id: string;
|
|
ws: WSContext;
|
|
knownTargets: Set<string>; // targetIds this client has received attachedToTarget for
|
|
}
|
|
|
|
// Message types for extension communication
|
|
interface ExtensionCommandMessage {
|
|
id: number;
|
|
method: "forwardCDPCommand";
|
|
params: {
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
sessionId?: string;
|
|
};
|
|
}
|
|
|
|
interface ExtensionResponseMessage {
|
|
id: number;
|
|
result?: unknown;
|
|
error?: string;
|
|
}
|
|
|
|
interface ExtensionEventMessage {
|
|
method: "forwardCDPEvent";
|
|
params: {
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
sessionId?: string;
|
|
};
|
|
}
|
|
|
|
type ExtensionMessage =
|
|
| ExtensionResponseMessage
|
|
| ExtensionEventMessage
|
|
| { method: "log"; params: { level: string; args: string[] } };
|
|
|
|
// CDP message types
|
|
interface CDPCommand {
|
|
id: number;
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
sessionId?: string;
|
|
}
|
|
|
|
interface CDPResponse {
|
|
id: number;
|
|
sessionId?: string;
|
|
result?: unknown;
|
|
error?: { message: string };
|
|
}
|
|
|
|
interface CDPEvent {
|
|
method: string;
|
|
sessionId?: string;
|
|
params?: Record<string, unknown>;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Relay Server Implementation
|
|
// ============================================================================
|
|
|
|
export async function serveRelay(options: RelayOptions = {}): Promise<RelayServer> {
|
|
const port = options.port ?? 9222;
|
|
const host = options.host ?? "127.0.0.1";
|
|
|
|
// State
|
|
const connectedTargets = new Map<string, ConnectedTarget>();
|
|
const namedPages = new Map<string, string>(); // name -> sessionId
|
|
const playwrightClients = new Map<string, PlaywrightClient>();
|
|
let extensionWs: WSContext | null = null;
|
|
|
|
// Pending requests to extension
|
|
const extensionPendingRequests = new Map<
|
|
number,
|
|
{
|
|
resolve: (result: unknown) => void;
|
|
reject: (error: Error) => void;
|
|
}
|
|
>();
|
|
let extensionMessageId = 0;
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
function log(...args: unknown[]) {
|
|
console.log("[relay]", ...args);
|
|
}
|
|
|
|
function sendToPlaywright(message: CDPResponse | CDPEvent, clientId?: string) {
|
|
const messageStr = JSON.stringify(message);
|
|
|
|
if (clientId) {
|
|
const client = playwrightClients.get(clientId);
|
|
if (client) {
|
|
client.ws.send(messageStr);
|
|
}
|
|
} else {
|
|
// Broadcast to all clients
|
|
for (const client of playwrightClients.values()) {
|
|
client.ws.send(messageStr);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send Target.attachedToTarget event with deduplication.
|
|
* Tracks which targets each client has seen to prevent "Duplicate target" errors.
|
|
*/
|
|
function sendAttachedToTarget(
|
|
target: ConnectedTarget,
|
|
clientId?: string,
|
|
waitingForDebugger = false
|
|
) {
|
|
const event: CDPEvent = {
|
|
method: "Target.attachedToTarget",
|
|
params: {
|
|
sessionId: target.sessionId,
|
|
targetInfo: { ...target.targetInfo, attached: true },
|
|
waitingForDebugger,
|
|
},
|
|
};
|
|
|
|
if (clientId) {
|
|
const client = playwrightClients.get(clientId);
|
|
if (client && !client.knownTargets.has(target.targetId)) {
|
|
client.knownTargets.add(target.targetId);
|
|
client.ws.send(JSON.stringify(event));
|
|
}
|
|
} else {
|
|
// Broadcast to all clients that don't know about this target yet
|
|
for (const client of playwrightClients.values()) {
|
|
if (!client.knownTargets.has(target.targetId)) {
|
|
client.knownTargets.add(target.targetId);
|
|
client.ws.send(JSON.stringify(event));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendToExtension({
|
|
method,
|
|
params,
|
|
timeout = 30000,
|
|
}: {
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
timeout?: number;
|
|
}): Promise<unknown> {
|
|
if (!extensionWs) {
|
|
throw new Error("Extension not connected");
|
|
}
|
|
|
|
const id = ++extensionMessageId;
|
|
const message = { id, method, params };
|
|
|
|
extensionWs.send(JSON.stringify(message));
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timeoutId = setTimeout(() => {
|
|
extensionPendingRequests.delete(id);
|
|
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
|
|
}, timeout);
|
|
|
|
extensionPendingRequests.set(id, {
|
|
resolve: (result) => {
|
|
clearTimeout(timeoutId);
|
|
resolve(result);
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timeoutId);
|
|
reject(error);
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
async function routeCdpCommand({
|
|
method,
|
|
params,
|
|
sessionId,
|
|
}: {
|
|
method: string;
|
|
params?: Record<string, unknown>;
|
|
sessionId?: string;
|
|
}): Promise<unknown> {
|
|
// Handle some CDP commands locally
|
|
switch (method) {
|
|
case "Browser.getVersion":
|
|
return {
|
|
protocolVersion: "1.3",
|
|
product: "Chrome/Extension-Bridge",
|
|
revision: "1.0.0",
|
|
userAgent: "dev-browser-relay/1.0.0",
|
|
jsVersion: "V8",
|
|
};
|
|
|
|
case "Browser.setDownloadBehavior":
|
|
return {};
|
|
|
|
case "Target.setAutoAttach":
|
|
if (sessionId) {
|
|
break; // Forward to extension for child frames
|
|
}
|
|
return {};
|
|
|
|
case "Target.setDiscoverTargets":
|
|
return {};
|
|
|
|
case "Target.attachToBrowserTarget":
|
|
// Browser-level session - return a fake session since we only proxy tabs
|
|
return { sessionId: "browser" };
|
|
|
|
case "Target.detachFromTarget":
|
|
// If detaching from our fake "browser" session, just return success
|
|
if (sessionId === "browser" || params?.sessionId === "browser") {
|
|
return {};
|
|
}
|
|
// Otherwise forward to extension
|
|
break;
|
|
|
|
case "Target.attachToTarget": {
|
|
const targetId = params?.targetId as string;
|
|
if (!targetId) {
|
|
throw new Error("targetId is required for Target.attachToTarget");
|
|
}
|
|
|
|
for (const target of connectedTargets.values()) {
|
|
if (target.targetId === targetId) {
|
|
return { sessionId: target.sessionId };
|
|
}
|
|
}
|
|
|
|
throw new Error(`Target ${targetId} not found in connected targets`);
|
|
}
|
|
|
|
case "Target.getTargetInfo": {
|
|
const targetId = params?.targetId as string;
|
|
|
|
if (targetId) {
|
|
for (const target of connectedTargets.values()) {
|
|
if (target.targetId === targetId) {
|
|
return { targetInfo: target.targetInfo };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (sessionId) {
|
|
const target = connectedTargets.get(sessionId);
|
|
if (target) {
|
|
return { targetInfo: target.targetInfo };
|
|
}
|
|
}
|
|
|
|
// Return first target if no specific one requested
|
|
const firstTarget = Array.from(connectedTargets.values())[0];
|
|
return { targetInfo: firstTarget?.targetInfo };
|
|
}
|
|
|
|
case "Target.getTargets":
|
|
return {
|
|
targetInfos: Array.from(connectedTargets.values()).map((t) => ({
|
|
...t.targetInfo,
|
|
attached: true,
|
|
})),
|
|
};
|
|
|
|
case "Target.createTarget":
|
|
case "Target.closeTarget":
|
|
// Forward to extension
|
|
return await sendToExtension({
|
|
method: "forwardCDPCommand",
|
|
params: { method, params },
|
|
});
|
|
}
|
|
|
|
// Forward all other commands to extension
|
|
return await sendToExtension({
|
|
method: "forwardCDPCommand",
|
|
params: { sessionId, method, params },
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// HTTP/WebSocket Server
|
|
// ============================================================================
|
|
|
|
const app = new Hono();
|
|
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
|
|
|
// Health check / server info
|
|
app.get("/", (c) => {
|
|
return c.json({
|
|
wsEndpoint: `ws://${host}:${port}/cdp`,
|
|
extensionConnected: extensionWs !== null,
|
|
mode: "extension",
|
|
});
|
|
});
|
|
|
|
// List named pages
|
|
app.get("/pages", (c) => {
|
|
return c.json({
|
|
pages: Array.from(namedPages.keys()),
|
|
});
|
|
});
|
|
|
|
// Get or create a named page
|
|
app.post("/pages", async (c) => {
|
|
const body = await c.req.json();
|
|
const name = body.name as string;
|
|
|
|
if (!name) {
|
|
return c.json({ error: "name is required" }, 400);
|
|
}
|
|
|
|
// Check if page already exists by name
|
|
const existingSessionId = namedPages.get(name);
|
|
if (existingSessionId) {
|
|
const target = connectedTargets.get(existingSessionId);
|
|
if (target) {
|
|
// Activate the tab so it becomes the active tab
|
|
await sendToExtension({
|
|
method: "forwardCDPCommand",
|
|
params: {
|
|
method: "Target.activateTarget",
|
|
params: { targetId: target.targetId },
|
|
},
|
|
});
|
|
return c.json({
|
|
wsEndpoint: `ws://${host}:${port}/cdp`,
|
|
name,
|
|
targetId: target.targetId,
|
|
url: target.targetInfo.url,
|
|
});
|
|
}
|
|
// Session no longer valid, remove it
|
|
namedPages.delete(name);
|
|
}
|
|
|
|
// Create a new tab
|
|
if (!extensionWs) {
|
|
return c.json({ error: "Extension not connected" }, 503);
|
|
}
|
|
|
|
try {
|
|
const result = (await sendToExtension({
|
|
method: "forwardCDPCommand",
|
|
params: { method: "Target.createTarget", params: { url: "about:blank" } },
|
|
})) as { targetId: string };
|
|
|
|
// Wait for Target.attachedToTarget event to register the new target
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
// Find and name the new target
|
|
for (const [sessionId, target] of connectedTargets) {
|
|
if (target.targetId === result.targetId) {
|
|
namedPages.set(name, sessionId);
|
|
// Activate the tab so it becomes the active tab
|
|
await sendToExtension({
|
|
method: "forwardCDPCommand",
|
|
params: {
|
|
method: "Target.activateTarget",
|
|
params: { targetId: target.targetId },
|
|
},
|
|
});
|
|
return c.json({
|
|
wsEndpoint: `ws://${host}:${port}/cdp`,
|
|
name,
|
|
targetId: target.targetId,
|
|
url: target.targetInfo.url,
|
|
});
|
|
}
|
|
}
|
|
|
|
throw new Error("Target created but not found in registry");
|
|
} catch (err) {
|
|
log("Error creating tab:", err);
|
|
return c.json({ error: (err as Error).message }, 500);
|
|
}
|
|
});
|
|
|
|
// Delete a named page (removes the name, doesn't close the tab)
|
|
app.delete("/pages/:name", (c) => {
|
|
const name = c.req.param("name");
|
|
const deleted = namedPages.delete(name);
|
|
return c.json({ success: deleted });
|
|
});
|
|
|
|
// ============================================================================
|
|
// Playwright Client WebSocket
|
|
// ============================================================================
|
|
|
|
app.get(
|
|
"/cdp/:clientId?",
|
|
upgradeWebSocket((c) => {
|
|
const clientId =
|
|
c.req.param("clientId") || `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
return {
|
|
onOpen(_event, ws) {
|
|
if (playwrightClients.has(clientId)) {
|
|
log(`Rejecting duplicate client ID: ${clientId}`);
|
|
ws.close(1000, "Client ID already connected");
|
|
return;
|
|
}
|
|
|
|
playwrightClients.set(clientId, { id: clientId, ws, knownTargets: new Set() });
|
|
log(`Playwright client connected: ${clientId}`);
|
|
},
|
|
|
|
async onMessage(event, _ws) {
|
|
let message: CDPCommand;
|
|
|
|
try {
|
|
message = JSON.parse(event.data.toString());
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const { id, sessionId, method, params } = message;
|
|
|
|
if (!extensionWs) {
|
|
sendToPlaywright(
|
|
{
|
|
id,
|
|
sessionId,
|
|
error: { message: "Extension not connected" },
|
|
},
|
|
clientId
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await routeCdpCommand({ method, params, sessionId });
|
|
|
|
// After Target.setAutoAttach, send attachedToTarget for existing targets
|
|
// Uses deduplication to prevent "Duplicate target" errors
|
|
if (method === "Target.setAutoAttach" && !sessionId) {
|
|
for (const target of connectedTargets.values()) {
|
|
sendAttachedToTarget(target, clientId);
|
|
}
|
|
}
|
|
|
|
// After Target.setDiscoverTargets, send targetCreated events
|
|
if (
|
|
method === "Target.setDiscoverTargets" &&
|
|
(params as { discover?: boolean })?.discover
|
|
) {
|
|
for (const target of connectedTargets.values()) {
|
|
sendToPlaywright(
|
|
{
|
|
method: "Target.targetCreated",
|
|
params: {
|
|
targetInfo: { ...target.targetInfo, attached: true },
|
|
},
|
|
},
|
|
clientId
|
|
);
|
|
}
|
|
}
|
|
|
|
// After Target.attachToTarget, send attachedToTarget event (with deduplication)
|
|
if (
|
|
method === "Target.attachToTarget" &&
|
|
(result as { sessionId?: string })?.sessionId
|
|
) {
|
|
const targetId = params?.targetId as string;
|
|
const target = Array.from(connectedTargets.values()).find(
|
|
(t) => t.targetId === targetId
|
|
);
|
|
if (target) {
|
|
sendAttachedToTarget(target, clientId);
|
|
}
|
|
}
|
|
|
|
sendToPlaywright({ id, sessionId, result }, clientId);
|
|
} catch (e) {
|
|
log("Error handling CDP command:", method, e);
|
|
sendToPlaywright(
|
|
{
|
|
id,
|
|
sessionId,
|
|
error: { message: (e as Error).message },
|
|
},
|
|
clientId
|
|
);
|
|
}
|
|
},
|
|
|
|
onClose() {
|
|
playwrightClients.delete(clientId);
|
|
log(`Playwright client disconnected: ${clientId}`);
|
|
},
|
|
|
|
onError(event) {
|
|
log(`Playwright WebSocket error [${clientId}]:`, event);
|
|
},
|
|
};
|
|
})
|
|
);
|
|
|
|
// ============================================================================
|
|
// Extension WebSocket
|
|
// ============================================================================
|
|
|
|
app.get(
|
|
"/extension",
|
|
upgradeWebSocket(() => {
|
|
return {
|
|
onOpen(_event, ws) {
|
|
if (extensionWs) {
|
|
log("Closing existing extension connection");
|
|
extensionWs.close(4001, "Extension Replaced");
|
|
|
|
// Clear state
|
|
connectedTargets.clear();
|
|
namedPages.clear();
|
|
for (const pending of extensionPendingRequests.values()) {
|
|
pending.reject(new Error("Extension connection replaced"));
|
|
}
|
|
extensionPendingRequests.clear();
|
|
}
|
|
|
|
extensionWs = ws;
|
|
log("Extension connected");
|
|
},
|
|
|
|
async onMessage(event, ws) {
|
|
let message: ExtensionMessage;
|
|
|
|
try {
|
|
message = JSON.parse(event.data.toString());
|
|
} catch {
|
|
ws.close(1000, "Invalid JSON");
|
|
return;
|
|
}
|
|
|
|
// Handle response to our request
|
|
if ("id" in message && typeof message.id === "number") {
|
|
const pending = extensionPendingRequests.get(message.id);
|
|
if (!pending) {
|
|
log("Unexpected response with id:", message.id);
|
|
return;
|
|
}
|
|
|
|
extensionPendingRequests.delete(message.id);
|
|
|
|
if ((message as ExtensionResponseMessage).error) {
|
|
pending.reject(new Error((message as ExtensionResponseMessage).error));
|
|
} else {
|
|
pending.resolve((message as ExtensionResponseMessage).result);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle log messages
|
|
if ("method" in message && message.method === "log") {
|
|
const { level, args } = message.params;
|
|
console.log(`[extension:${level}]`, ...args);
|
|
return;
|
|
}
|
|
|
|
// Handle CDP events from extension
|
|
if ("method" in message && message.method === "forwardCDPEvent") {
|
|
const eventMsg = message as ExtensionEventMessage;
|
|
const { method, params, sessionId } = eventMsg.params;
|
|
|
|
// Handle target lifecycle events
|
|
if (method === "Target.attachedToTarget") {
|
|
const targetParams = params as {
|
|
sessionId: string;
|
|
targetInfo: TargetInfo;
|
|
};
|
|
|
|
const target: ConnectedTarget = {
|
|
sessionId: targetParams.sessionId,
|
|
targetId: targetParams.targetInfo.targetId,
|
|
targetInfo: targetParams.targetInfo,
|
|
};
|
|
connectedTargets.set(targetParams.sessionId, target);
|
|
|
|
log(`Target attached: ${targetParams.targetInfo.url} (${targetParams.sessionId})`);
|
|
|
|
// Use deduplication helper - only sends to clients that don't know about this target
|
|
sendAttachedToTarget(target);
|
|
} else if (method === "Target.detachedFromTarget") {
|
|
const detachParams = params as { sessionId: string };
|
|
connectedTargets.delete(detachParams.sessionId);
|
|
|
|
// Also remove any name mapping
|
|
for (const [name, sid] of namedPages) {
|
|
if (sid === detachParams.sessionId) {
|
|
namedPages.delete(name);
|
|
break;
|
|
}
|
|
}
|
|
|
|
log(`Target detached: ${detachParams.sessionId}`);
|
|
|
|
sendToPlaywright({
|
|
method: "Target.detachedFromTarget",
|
|
params: detachParams,
|
|
});
|
|
} else if (method === "Target.targetInfoChanged") {
|
|
const infoParams = params as { targetInfo: TargetInfo };
|
|
for (const target of connectedTargets.values()) {
|
|
if (target.targetId === infoParams.targetInfo.targetId) {
|
|
target.targetInfo = infoParams.targetInfo;
|
|
break;
|
|
}
|
|
}
|
|
|
|
sendToPlaywright({
|
|
method: "Target.targetInfoChanged",
|
|
params: infoParams,
|
|
});
|
|
} else {
|
|
// Forward other CDP events to Playwright
|
|
sendToPlaywright({
|
|
sessionId,
|
|
method,
|
|
params,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
onClose(_event, ws) {
|
|
if (extensionWs && extensionWs !== ws) {
|
|
log("Old extension connection closed");
|
|
return;
|
|
}
|
|
|
|
log("Extension disconnected");
|
|
|
|
for (const pending of extensionPendingRequests.values()) {
|
|
pending.reject(new Error("Extension connection closed"));
|
|
}
|
|
extensionPendingRequests.clear();
|
|
|
|
extensionWs = null;
|
|
connectedTargets.clear();
|
|
namedPages.clear();
|
|
|
|
// Close all Playwright clients
|
|
for (const client of playwrightClients.values()) {
|
|
client.ws.close(1000, "Extension disconnected");
|
|
}
|
|
playwrightClients.clear();
|
|
},
|
|
|
|
onError(event) {
|
|
log("Extension WebSocket error:", event);
|
|
},
|
|
};
|
|
})
|
|
);
|
|
|
|
// ============================================================================
|
|
// Start Server
|
|
// ============================================================================
|
|
|
|
const server = serve({ fetch: app.fetch, port, hostname: host });
|
|
injectWebSocket(server);
|
|
|
|
const wsEndpoint = `ws://${host}:${port}/cdp`;
|
|
|
|
log("CDP relay server started");
|
|
log(` HTTP: http://${host}:${port}`);
|
|
log(` CDP endpoint: ${wsEndpoint}`);
|
|
log(` Extension endpoint: ws://${host}:${port}/extension`);
|
|
log("");
|
|
log("Waiting for extension to connect...");
|
|
|
|
return {
|
|
wsEndpoint,
|
|
port,
|
|
async stop() {
|
|
for (const client of playwrightClients.values()) {
|
|
client.ws.close(1000, "Server stopped");
|
|
}
|
|
playwrightClients.clear();
|
|
extensionWs?.close(1000, "Server stopped");
|
|
server.close();
|
|
},
|
|
};
|
|
}
|