344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
/**
|
|
* Minimal Chrome DevTools Protocol client for the eval runner.
|
|
*
|
|
* Zero dependencies: uses the global fetch + WebSocket available in Node 24+.
|
|
* Mirrors the pattern proven in apps/app/scripts/voice-cdp.mjs.
|
|
*/
|
|
|
|
export interface CdpTarget {
|
|
id: string;
|
|
type: string;
|
|
title: string;
|
|
url: string;
|
|
webSocketDebuggerUrl?: string;
|
|
}
|
|
|
|
export interface CdpClient {
|
|
targetId?: string | null;
|
|
webSocketDebuggerUrl?: string;
|
|
send(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<unknown>;
|
|
abort?(reason?: Error): void;
|
|
close(): void;
|
|
}
|
|
|
|
export interface CdpSendOptions {
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface CdpConnectOptions {
|
|
connectTimeoutMs?: number;
|
|
sendTimeoutMs?: number;
|
|
}
|
|
|
|
export interface EvaluateOptions {
|
|
awaitPromise?: boolean;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export type CdpFunctionArgument = string | number | boolean | null;
|
|
|
|
/** Cheap DOM/CDP probes should fail quickly enough for their caller to retry. */
|
|
export const DEFAULT_CDP_PROBE_TIMEOUT_MS = 8_000;
|
|
|
|
interface PendingCallbacks {
|
|
resolve(value: unknown): void;
|
|
reject(error: Error): void;
|
|
timer: NodeJS.Timeout | null;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function stringField(value: unknown): string {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function optionalStringField(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function parseTargets(payload: unknown): CdpTarget[] {
|
|
if (!Array.isArray(payload)) {
|
|
throw new Error("CDP target list response was not an array.");
|
|
}
|
|
return payload.map((entry, index) => {
|
|
if (!isRecord(entry)) {
|
|
return { id: String(index), type: "", title: "", url: "" };
|
|
}
|
|
return {
|
|
id: stringField(entry.id) || String(index),
|
|
type: stringField(entry.type),
|
|
title: stringField(entry.title),
|
|
url: stringField(entry.url),
|
|
webSocketDebuggerUrl: optionalStringField(entry.webSocketDebuggerUrl),
|
|
};
|
|
});
|
|
}
|
|
|
|
function messageText(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
export async function listTargets(
|
|
baseUrl: string,
|
|
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
|
): Promise<CdpTarget[]> {
|
|
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/json/list`, {
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Could not list CDP targets at ${baseUrl}: ${response.status}`);
|
|
}
|
|
return parseTargets(await response.json());
|
|
}
|
|
|
|
export async function pickAppTarget(
|
|
baseUrl: string,
|
|
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
|
): Promise<CdpTarget> {
|
|
const targets = await listTargets(baseUrl, { timeoutMs });
|
|
const pages = targets.filter((target) => target.type === "page" && target.webSocketDebuggerUrl);
|
|
const target =
|
|
pages.find((page) => page.title === "OpenWork") ??
|
|
pages.find(
|
|
(page) =>
|
|
page.url.includes("localhost") ||
|
|
page.url.includes("127.0.0.1") ||
|
|
page.url.includes("[::1]"),
|
|
) ??
|
|
pages[0];
|
|
if (!target) {
|
|
throw new Error(`No CDP page target found at ${baseUrl}.`);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Chromium reports webSocketDebuggerUrl with its own local host
|
|
* (e.g. ws://127.0.0.1:9825/devtools/page/<id>), which breaks when the
|
|
* endpoint is reached through a proxy (e.g. Daytona preview URLs).
|
|
* Rebuild the ws URL on the base URL's host and scheme.
|
|
*/
|
|
export function debuggerUrlFor(baseUrl: string, target: CdpTarget): string {
|
|
if (!target.webSocketDebuggerUrl) {
|
|
throw new Error(`CDP target ${target.id} has no webSocketDebuggerUrl.`);
|
|
}
|
|
const base = new URL(baseUrl);
|
|
const ws = new URL(target.webSocketDebuggerUrl);
|
|
ws.protocol = base.protocol === "https:" ? "wss:" : "ws:";
|
|
ws.hostname = base.hostname;
|
|
ws.port = base.port;
|
|
return ws.toString();
|
|
}
|
|
|
|
/**
|
|
* Probe a list of CDP base URL candidates and return the first that responds.
|
|
*/
|
|
export async function resolveCdpBaseUrl(candidates: string[]): Promise<string> {
|
|
const errors: string[] = [];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
await listTargets(candidate);
|
|
return candidate;
|
|
} catch (error) {
|
|
errors.push(`${candidate}: ${messageText(error)}`);
|
|
}
|
|
}
|
|
throw new Error(
|
|
`No CDP endpoint reachable. Tried:\n ${errors.join("\n ")}\n` +
|
|
"Start the app first (pnpm dev) or pass --cdp-url.",
|
|
);
|
|
}
|
|
|
|
// Default upper bound on a single CDP round trip (handshake or a send/reply).
|
|
// Without this, a stalled Daytona proxy WebSocket (seen intermittently — the
|
|
// handshake or a single message reply just never arrives) hangs the calling
|
|
// promise forever with no error, which hangs the whole flow silently.
|
|
const DEFAULT_CDP_TIMEOUT_MS = 20_000;
|
|
|
|
export function connect(
|
|
webSocketDebuggerUrl: string,
|
|
{ connectTimeoutMs = DEFAULT_CDP_TIMEOUT_MS, sendTimeoutMs = DEFAULT_CDP_TIMEOUT_MS }: CdpConnectOptions = {},
|
|
): Promise<CdpClient> {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = new WebSocket(webSocketDebuggerUrl);
|
|
let nextId = 1;
|
|
const pending = new Map<number, PendingCallbacks>();
|
|
let opened = false;
|
|
let settled = false;
|
|
let closed = false;
|
|
|
|
const connectTimer = setTimeout(() => {
|
|
if (opened) return;
|
|
settled = true;
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// Socket may already be in a closing state.
|
|
}
|
|
reject(new Error(`CDP connect timed out after ${connectTimeoutMs}ms: ${webSocketDebuggerUrl}`));
|
|
}, connectTimeoutMs);
|
|
|
|
const rejectPending = (error: Error) => {
|
|
for (const callbacks of pending.values()) {
|
|
if (callbacks.timer) clearTimeout(callbacks.timer);
|
|
callbacks.reject(error);
|
|
}
|
|
pending.clear();
|
|
};
|
|
|
|
const closeSocket = () => {
|
|
closed = true;
|
|
try {
|
|
socket.close();
|
|
} catch {
|
|
// Socket may already be in a closing state.
|
|
}
|
|
};
|
|
|
|
socket.addEventListener("open", () => {
|
|
if (settled) return;
|
|
opened = true;
|
|
if (connectTimer) clearTimeout(connectTimer);
|
|
resolve({
|
|
targetId: new URL(webSocketDebuggerUrl).pathname.split("/").pop() ?? null,
|
|
webSocketDebuggerUrl,
|
|
abort: (reason?: Error) => {
|
|
const detail = reason ? `: ${reason.message}` : ".";
|
|
rejectPending(new Error(`CDP transport stalled${detail}`, { cause: reason }));
|
|
closeSocket();
|
|
},
|
|
close: closeSocket,
|
|
send(method: string, params: Record<string, unknown> = {}, { timeoutMs = sendTimeoutMs }: CdpSendOptions = {}) {
|
|
if (closed || socket.readyState !== WebSocket.OPEN) {
|
|
return Promise.reject(new Error("CDP socket is not open."));
|
|
}
|
|
const id = nextId;
|
|
nextId += 1;
|
|
return new Promise((innerResolve, innerReject) => {
|
|
// Name WHAT timed out. The rejection fires from a timer, so the
|
|
// caller's stack is gone by then: "CDP call Runtime.evaluate timed
|
|
// out" alone cannot tell you which of a spec's dozens of
|
|
// evaluations blocked, which turns a one-line fix into a hunt.
|
|
const subject = typeof params.expression === "string"
|
|
? ` evaluating: ${params.expression.replace(/\s+/g, " ").trim().slice(0, 160)}`
|
|
: "";
|
|
const timer = setTimeout(() => {
|
|
pending.delete(id);
|
|
innerReject(new Error(`CDP call ${method} timed out after ${timeoutMs}ms.${subject}`));
|
|
}, timeoutMs);
|
|
pending.set(id, { resolve: innerResolve, reject: innerReject, timer });
|
|
try {
|
|
socket.send(JSON.stringify({ id, method, params }));
|
|
} catch (error) {
|
|
pending.delete(id);
|
|
if (timer) clearTimeout(timer);
|
|
innerReject(error);
|
|
}
|
|
});
|
|
},
|
|
});
|
|
});
|
|
socket.addEventListener("message", (event) => {
|
|
const parsed: unknown = JSON.parse(String(event.data));
|
|
if (!isRecord(parsed) || typeof parsed.id !== "number") return;
|
|
const callbacks = pending.get(parsed.id);
|
|
if (!callbacks) return;
|
|
pending.delete(parsed.id);
|
|
if (callbacks.timer) clearTimeout(callbacks.timer);
|
|
if (isRecord(parsed.error)) {
|
|
callbacks.reject(new Error(stringField(parsed.error.message) || "CDP call failed."));
|
|
} else {
|
|
callbacks.resolve(parsed.result);
|
|
}
|
|
});
|
|
socket.addEventListener("error", () => {
|
|
const error = new Error("CDP websocket failed.");
|
|
closed = true;
|
|
rejectPending(error);
|
|
if (!opened && !settled) {
|
|
settled = true;
|
|
if (connectTimer) clearTimeout(connectTimer);
|
|
reject(error);
|
|
}
|
|
});
|
|
socket.addEventListener("close", () => {
|
|
const error = new Error("CDP websocket closed.");
|
|
closed = true;
|
|
rejectPending(error);
|
|
if (!opened && !settled) {
|
|
settled = true;
|
|
if (connectTimer) clearTimeout(connectTimer);
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function evaluate(
|
|
client: CdpClient,
|
|
expression: string,
|
|
{ awaitPromise = false, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: EvaluateOptions = {},
|
|
): Promise<unknown> {
|
|
const payload = await client.send("Runtime.evaluate", {
|
|
expression,
|
|
awaitPromise,
|
|
returnByValue: true,
|
|
}, { timeoutMs });
|
|
return runtimeResultValue(payload);
|
|
}
|
|
|
|
function runtimeResultValue(payload: unknown): unknown {
|
|
if (!isRecord(payload)) return undefined;
|
|
if (isRecord(payload.exceptionDetails)) {
|
|
const exception = payload.exceptionDetails.exception;
|
|
throw new Error(
|
|
(isRecord(exception) && stringField(exception.description)) ||
|
|
stringField(payload.exceptionDetails.text) ||
|
|
"Evaluation failed.",
|
|
);
|
|
}
|
|
const result = payload.result;
|
|
return isRecord(result) ? result.value : undefined;
|
|
}
|
|
|
|
export async function callFunction(
|
|
client: CdpClient,
|
|
functionDeclaration: string,
|
|
args: readonly CdpFunctionArgument[] = [],
|
|
{ awaitPromise = false, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: EvaluateOptions = {},
|
|
): Promise<unknown> {
|
|
const receiverPayload = await client.send("Runtime.evaluate", {
|
|
expression: "globalThis",
|
|
returnByValue: false,
|
|
}, { timeoutMs });
|
|
if (!isRecord(receiverPayload) || !isRecord(receiverPayload.result)) {
|
|
throw new Error("CDP did not return the page global object.");
|
|
}
|
|
if (isRecord(receiverPayload.exceptionDetails)) runtimeResultValue(receiverPayload);
|
|
const objectId = stringField(receiverPayload.result.objectId);
|
|
if (!objectId) throw new Error("CDP did not identify the page global object.");
|
|
|
|
const payload = await client.send("Runtime.callFunctionOn", {
|
|
functionDeclaration,
|
|
objectId,
|
|
arguments: args.map((value) => ({ value })),
|
|
awaitPromise,
|
|
returnByValue: true,
|
|
}, { timeoutMs });
|
|
return runtimeResultValue(payload);
|
|
}
|
|
|
|
export async function navigate(client: CdpClient, url: string): Promise<void> {
|
|
await client.send("Page.navigate", { url });
|
|
}
|
|
|
|
export async function captureScreenshot(client: CdpClient): Promise<Buffer> {
|
|
const payload = await client.send("Page.captureScreenshot", { format: "png" });
|
|
if (!isRecord(payload) || typeof payload.data !== "string") {
|
|
throw new Error("Page.captureScreenshot did not return base64 PNG data.");
|
|
}
|
|
return Buffer.from(payload.data, "base64");
|
|
}
|