源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)

This commit is contained in:
agent-radar
2026-09-02 01:05:41 +08:00
commit 04bac1f6e2
2200 changed files with 489415 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@openwork/cdp",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./src/index.ts"
},
"./cdp": {
"types": "./src/cdp.ts",
"development": "./src/cdp.ts",
"default": "./src/cdp.ts"
},
"./targets": {
"types": "./src/targets.ts",
"development": "./src/targets.ts",
"default": "./src/targets.ts"
},
"./ports": {
"types": "./src/ports.ts",
"development": "./src/ports.ts",
"default": "./src/ports.ts"
},
"./surface": {
"types": "./src/surface.ts",
"development": "./src/surface.ts",
"default": "./src/surface.ts"
}
}
}
+129
View File
@@ -0,0 +1,129 @@
import { evaluate } from "./cdp.ts";
import type { CdpClient, EvaluateOptions } from "./cdp.ts";
/**
* Readiness of the OpenWork desktop is defined by the UI being *interactive*,
* not by the route matching an allowlist: a fresh profile with no workspace
* legitimately sits on `/session` offering "Create or connect a workspace",
* and a workspace can be selected while the panel still renders placeholders.
*
* This module is the single implementation of that predicate so the lifecycle
* layer (@openwork/hosts) and the behaviours specs call (@openwork/behaviors)
* cannot drift apart. It lives in @openwork/cdp because both already depend on
* it, which keeps the dependency graph acyclic.
*/
/** Copy the app renders while it is still settling (from apps/app/src/i18n/locales/en.ts). */
export const APP_TRANSITIONAL_TEXTS = [
"Preparing workspace",
"Connecting signed-in services",
"Connecting services",
"Loading available resources",
"Loading tasks",
"Pulling in the latest messages",
] as const;
export type AppSurfaceState = "welcome" | "workspace" | "no-workspace";
export interface AppStateProbe {
/** The automation control API is registered. */
controlReady: boolean;
/** The transitional message currently on screen, if any. */
transitional: string | null;
/** Which interactive surface is showing, or null while nothing is usable yet. */
surface: AppSurfaceState | null;
workspaceId: string | null;
route: string;
/** First 300 characters of visible text, for self-diagnosing failures. */
text: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function surfaceOrNull(value: unknown): AppSurfaceState | null {
return value === "welcome" || value === "workspace" || value === "no-workspace" ? value : null;
}
export function parseAppStateProbe(value: unknown): AppStateProbe {
if (!isRecord(value)) {
return { controlReady: false, transitional: null, surface: null, workspaceId: null, route: "", text: "" };
}
return {
controlReady: value.controlReady === true,
transitional: stringOrNull(value.transitional),
surface: surfaceOrNull(value.surface),
workspaceId: stringOrNull(value.workspaceId),
route: typeof value.route === "string" ? value.route : "",
text: typeof value.text === "string" ? value.text : "",
};
}
/**
* The app is usable: control registered and a surface a user can act on.
*
* Transitional copy only disqualifies the workspace surfaces. The welcome screen
* legitimately shows progress ("Preparing workspace") for a background step
* while its own buttons remain perfectly clickable, and treating that as
* not-ready waits forever for something that is not blocking the user.
*/
export function isInteractive(probe: AppStateProbe): boolean {
if (!probe.controlReady || probe.surface === null) return false;
if (probe.surface === "welcome") return true;
return probe.transitional === null;
}
export function describeAppState(probe: AppStateProbe): string {
const blocker = probe.transitional ? `still showing ${JSON.stringify(probe.transitional)}` : "no interactive surface";
return `route ${probe.route || "<unknown>"}, ${probe.controlReady ? "control ready" : "control missing"}, ${blocker}. Visible text: ${JSON.stringify(probe.text)}`;
}
const PROBE_EXPRESSION = `(() => {
const text = document.body?.innerText ?? "";
const route = window.location.hash.replace(/^#/, "") || window.location.pathname;
const transitional = ${JSON.stringify([...APP_TRANSITIONAL_TEXTS])}
.find((message) => text.includes(message)) ?? null;
const buttonLabels = [...document.querySelectorAll("button")].map((button) => (button.textContent ?? "").trim());
const taskUi = text.includes("What do you need done?") || buttonLabels.includes("Run task");
const needsWorkspace = text.includes("Create or connect a workspace");
const welcome = text.includes("Welcome to OpenWork");
// The product's own active-workspace state; the route is only a fallback
// because a selected workspace does not always appear in the hash.
const stored = localStorage.getItem("openwork.react.activeWorkspace");
const routeMatch = (route.match(/\\/workspace\\/([^/?#]+)/) ?? [])[1] ?? null;
const workspaceId = (stored && stored.length > 0 ? stored : null) ?? routeMatch;
// Any settings/extensions surface inside a workspace is interactive too.
const settingsSurface = /\\/workspace\\/[^/?#]+\\/(settings|extensions)/.test(route)
&& (text.includes("Extensions") || text.includes("Preferences") || text.includes("Permissions"));
const surface = welcome
? "welcome"
: (taskUi || settingsSurface) && workspaceId && !needsWorkspace
? "workspace"
: taskUi || needsWorkspace
? "no-workspace"
: null;
return {
controlReady: Boolean(window.__openworkControl),
transitional,
surface,
// Report the id whenever the app knows one, even while the welcome surface
// is showing: onboarding selects a workspace before it leaves that screen.
workspaceId,
route,
text: text.slice(0, 300),
};
})()`;
export async function probeAppState(client: CdpClient, opts: EvaluateOptions = {}): Promise<AppStateProbe> {
return parseAppStateProbe(await evaluate(client, PROBE_EXPRESSION, opts));
}
/** The product's active workspace id, read from its own state. */
export async function readActiveWorkspaceId(client: CdpClient, opts: EvaluateOptions = {}): Promise<string | null> {
return (await probeAppState(client, opts)).workspaceId;
}
+343
View File
@@ -0,0 +1,343 @@
/**
* 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");
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./app-state.ts";
export * from "./cdp.ts";
export * from "./ports.ts";
export * from "./screen.ts";
export * from "./screen-state.ts";
export * from "./surface.ts";
export * from "./targets.ts";
+54
View File
@@ -0,0 +1,54 @@
import { createServer } from "node:net";
import type { AddressInfo, Server } from "node:net";
function portFromAddress(address: string | AddressInfo | null): number {
if (typeof address === "object" && address !== null) return address.port;
throw new Error("Allocated port server did not expose a TCP address.");
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
});
}
function openPortServer(): Promise<{ server: Server; port: number }> {
return new Promise((resolve, reject) => {
const server = createServer();
let settled = false;
server.once("error", (error) => {
if (settled) return;
settled = true;
reject(error);
});
server.listen(0, "127.0.0.1", () => {
if (settled) return;
settled = true;
resolve({ server, port: portFromAddress(server.address()) });
});
});
}
export async function allocateFreePort(): Promise<number> {
const { server, port } = await openPortServer();
await closeServer(server);
return port;
}
export async function allocateFreePorts(count: number): Promise<number[]> {
if (!Number.isInteger(count) || count < 0) {
throw new Error(`Port count must be a non-negative integer, got ${count}.`);
}
const opened: { server: Server; port: number }[] = [];
try {
for (let index = 0; index < count; index += 1) {
opened.push(await openPortServer());
}
return opened.map((entry) => entry.port);
} finally {
await Promise.all(opened.map((entry) => closeServer(entry.server).catch(() => undefined)));
}
}
+57
View File
@@ -0,0 +1,57 @@
import { evaluate } from "./cdp.ts";
import type { Surface } from "./surface.ts";
const SCREEN_DUMP_TIMEOUT_MS = 8_000;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringField(value: Record<string, unknown>, key: string): string {
return typeof value[key] === "string" ? value[key] : "";
}
function stringArrayField(value: Record<string, unknown>, key: string): string[] {
const entries = value[key];
return Array.isArray(entries) ? entries.filter((entry): entry is string => typeof entry === "string").slice(0, 30) : [];
}
const SCREEN_DUMP_EXPRESSION = `(() => {
const hash = window.location.hash;
return {
hash,
route: hash.replace(/^#/, "") || window.location.pathname,
title: document.title,
buttons: [...document.querySelectorAll("button")]
.map((button) => (button.textContent ?? "").replace(/\\s+/g, " ").trim())
.filter(Boolean)
.slice(0, 30),
body: (document.body?.innerText ?? "").slice(0, 500),
};
})()`;
/** A short, best-effort snapshot suitable for appending to timeout errors. */
export async function dumpScreenState(surface: Surface): Promise<string> {
let timer: NodeJS.Timeout | undefined;
try {
const timedOut = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error("screen dump timed out")), SCREEN_DUMP_TIMEOUT_MS);
});
const value = await Promise.race([
evaluate(surface.client, SCREEN_DUMP_EXPRESSION, { timeoutMs: SCREEN_DUMP_TIMEOUT_MS }),
timedOut,
]);
if (!isRecord(value)) return "unavailable";
return JSON.stringify({
hash: stringField(value, "hash"),
route: stringField(value, "route"),
title: stringField(value, "title"),
buttons: stringArrayField(value, "buttons"),
body: stringField(value, "body").slice(0, 500),
});
} catch {
return "unavailable";
} finally {
if (timer) clearTimeout(timer);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { evaluate } from "./cdp.ts";
import type { Surface } from "./surface.ts";
export interface Viewport {
width: number;
height: number;
deviceScaleFactor: number;
}
export async function setViewport(surface: Surface, viewport: Viewport): Promise<void> {
await surface.client.send("Emulation.setDeviceMetricsOverride", {
width: viewport.width,
height: viewport.height,
deviceScaleFactor: viewport.deviceScaleFactor,
mobile: false,
});
}
export async function emulateFocus(surface: Surface): Promise<void> {
await surface.client.send("Emulation.setFocusEmulationEnabled", { enabled: true });
}
export async function paintBackdrop(surface: Surface, color: string): Promise<void> {
await evaluate(surface.client, `(() => {
if (!document.getElementById("docs-shots-backdrop")) {
const style = document.createElement("style");
style.id = "docs-shots-backdrop";
style.textContent = "html { background-color: ${color} !important; }";
document.head.appendChild(style);
}
return true;
})()`);
}
/** Stop CSS motion and text carets so two clean frames can be pixel-identical. */
export async function freezeMotion(surface: Surface): Promise<void> {
await evaluate(surface.client, `(() => {
if (!document.getElementById("docs-shots-freeze")) {
const style = document.createElement("style");
style.id = "docs-shots-freeze";
style.textContent = "*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; scroll-behavior: auto !important; }";
document.head.appendChild(style);
}
return true;
})()`);
}
+166
View File
@@ -0,0 +1,166 @@
import { probeAppState } from "./app-state.ts";
import { callFunction, DEFAULT_CDP_PROBE_TIMEOUT_MS, connect, debuggerUrlFor, evaluate, pickAppTarget } from "./cdp.ts";
import { firstPageTarget, targetById, waitForCdp } from "./targets.ts";
import type { AppStateProbe } from "./app-state.ts";
import type { CdpClient, CdpFunctionArgument, CdpTarget, EvaluateOptions } from "./cdp.ts";
export type SurfaceKind = "electron" | "chrome";
export interface SurfaceHandle {
name: string;
kind: SurfaceKind;
hostKind: string;
cdpUrl: string;
pid?: number;
profileDir?: string;
sandboxId?: string;
meta?: Record<string, string>;
}
export interface Surface {
handle: SurfaceHandle;
client: CdpClient;
}
export interface AttachedSurface extends Surface, AsyncDisposable {
stop(): Promise<void>;
}
const REATTACH_FLOOR_MS = 8_000;
const HTTP_LIVENESS_TIMEOUT_MS = 4_000;
async function connectToAppTarget(handle: SurfaceHandle, timeoutMs = 30_000, preferredTargetId?: string): Promise<CdpClient> {
const startedAt = Date.now();
const remaining = () => Math.max(1, timeoutMs - (Date.now() - startedAt));
const fallbackTarget = () => handle.kind === "electron"
? pickAppTarget(handle.cdpUrl, { timeoutMs: remaining() })
: firstPageTarget(handle.cdpUrl, { timeoutMs: remaining() });
const target: CdpTarget = preferredTargetId
? await targetById(handle.cdpUrl, preferredTargetId, { timeoutMs: remaining() }).catch(fallbackTarget)
: await fallbackTarget();
const client = await connect(debuggerUrlFor(handle.cdpUrl, target), {
connectTimeoutMs: remaining(),
sendTimeoutMs: remaining(),
});
await client.send("Page.enable", {}, { timeoutMs: remaining() }).catch(() => undefined);
return client;
}
export async function attachSurface(handle: SurfaceHandle, opts: { timeoutMs?: number } = {}): Promise<AttachedSurface> {
const timeoutMs = opts.timeoutMs ?? 30_000;
const startedAt = Date.now();
await waitForCdp(handle.cdpUrl, { timeoutMs });
const client = await connectToAppTarget(handle, Math.max(1, timeoutMs - (Date.now() - startedAt)));
const surface: AttachedSurface = {
handle,
client,
stop: async () => surface.client.close(),
[Symbol.asyncDispose]: async () => surface.client.close(),
};
return surface;
}
/**
* Re-attach to the app's CURRENT page target.
*
* The desktop recreates its page target during some transitions (finishing
* onboarding, for example). Evaluations against the old target then hang rather
* than fail, which looks exactly like a blocked renderer. The legacy runner had
* the same escape hatch as `ctx.reconnect()`.
*/
export async function reattachSurface(surface: Surface, opts: { timeoutMs?: number } = {}): Promise<void> {
const targetId = surface.client.targetId ?? undefined;
try {
surface.client.close();
} catch {
// The old client is already gone; that is the case we are recovering from.
}
const client = await connectToAppTarget(surface.handle, Math.max(1, opts.timeoutMs ?? 30_000), targetId);
surface.client = client;
}
function isRecoverableTransportError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return /timed out|timeout|CDP socket is not open|CDP transport stalled|CDP websocket (failed|closed)/i.test(error.message);
}
async function isCdpHttpAlive(cdpUrl: string): Promise<boolean> {
try {
const response = await fetch(`${cdpUrl.replace(/\/$/, "")}/json/version`, {
signal: AbortSignal.timeout(HTTP_LIVENESS_TIMEOUT_MS),
});
return response.ok;
} catch {
return false;
}
}
async function readOnSurface<T>(
surface: Surface,
read: (client: CdpClient, timeoutMs: number) => Promise<T>,
{ timeoutMs, reattachAttempts }: { timeoutMs: number; reattachAttempts: number },
): Promise<T> {
const startedAt = Date.now();
const remaining = () => Math.max(0, timeoutMs - (Date.now() - startedAt));
let lastError: unknown = null;
for (let attempt = 0; attempt <= reattachAttempts; attempt += 1) {
try {
const attemptTimeoutMs = attempt === 0 ? Math.max(1, remaining()) : Math.max(remaining(), REATTACH_FLOOR_MS);
return await read(surface.client, attemptTimeoutMs);
} catch (error) {
lastError = error;
if (attempt === reattachAttempts || !isRecoverableTransportError(error)) break;
if (!await isCdpHttpAlive(surface.handle.cdpUrl)) throw error;
surface.client.abort?.(error instanceof Error ? error : undefined);
await reattachSurface(surface, { timeoutMs: Math.max(remaining(), REATTACH_FLOOR_MS) }).catch(() => undefined);
}
}
throw lastError instanceof Error ? lastError : new Error("Surface read failed.");
}
/**
* Evaluate against a surface, healing a replaced page target.
*
* The desktop swaps its page target during transitions; evaluations against the
* old one hang until they time out, which reads like a blocked renderer. Owning
* that here means callers — behaviours, specs, the readiness gate — never carry
* re-attach bookkeeping.
*/
export async function evaluateOnSurface(
surface: Surface,
expression: string,
opts: EvaluateOptions & { reattachAttempts?: number } = {},
): Promise<unknown> {
const { reattachAttempts = 1, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS, ...evaluateOptions } = opts;
return readOnSurface(
surface,
(client, attemptTimeoutMs) => evaluate(client, expression, { ...evaluateOptions, timeoutMs: attemptTimeoutMs }),
{ timeoutMs: Math.max(1, timeoutMs), reattachAttempts },
);
}
export async function callFunctionOnSurface(
surface: Surface,
functionDeclaration: string,
args: readonly CdpFunctionArgument[] = [],
opts: EvaluateOptions & { reattachAttempts?: number } = {},
): Promise<unknown> {
const { reattachAttempts = 1, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS, ...callOptions } = opts;
return readOnSurface(
surface,
(client, attemptTimeoutMs) => callFunction(client, functionDeclaration, args, { ...callOptions, timeoutMs: attemptTimeoutMs }),
{ timeoutMs: Math.max(1, timeoutMs), reattachAttempts },
);
}
export async function probeAppStateOnSurface(
surface: Surface,
opts: EvaluateOptions & { reattachAttempts?: number } = {},
): Promise<AppStateProbe> {
const { reattachAttempts = 1, timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS, ...probeOptions } = opts;
return readOnSurface(
surface,
(client, attemptTimeoutMs) => probeAppState(client, { ...probeOptions, timeoutMs: attemptTimeoutMs }),
{ timeoutMs: Math.max(1, timeoutMs), reattachAttempts },
);
}
+135
View File
@@ -0,0 +1,135 @@
import { DEFAULT_CDP_PROBE_TIMEOUT_MS, listTargets } from "./cdp.ts";
import type { CdpTarget } from "./cdp.ts";
const DEFAULT_CDP_WAIT_MS = 20_000;
const POLL_INTERVAL_MS = 250;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function cleanBaseUrl(cdpBaseUrl: string): string {
let end = cdpBaseUrl.length;
while (end > 0 && cdpBaseUrl[end - 1] === "/") end -= 1;
return cdpBaseUrl.slice(0, end);
}
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 targetFromUnknown(value: unknown): CdpTarget | null {
if (!isRecord(value)) return null;
if (value.type !== "page" || typeof value.webSocketDebuggerUrl !== "string" || !value.webSocketDebuggerUrl) return null;
return {
id: stringField(value.id),
type: "page",
title: stringField(value.title),
url: stringField(value.url),
webSocketDebuggerUrl: value.webSocketDebuggerUrl,
};
}
function messageText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function firstPageTarget(
cdpBaseUrl: string,
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
): Promise<CdpTarget> {
const startedAt = Date.now();
const existing = await listTargets(cdpBaseUrl, { timeoutMs });
const page = existing.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
if (page) return page;
return newPageTarget(cdpBaseUrl, "about:blank", { timeoutMs: Math.max(0, timeoutMs - (Date.now() - startedAt)) });
}
export async function targetById(
cdpBaseUrl: string,
targetId: string,
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
): Promise<CdpTarget> {
const targets = await listTargets(cdpBaseUrl, { timeoutMs });
const target = targets.find((entry) => entry.id === targetId && entry.type === "page" && entry.webSocketDebuggerUrl);
if (!target) {
throw new Error(`No page target ${targetId} available at ${cdpBaseUrl}.`);
}
return target;
}
export async function newPageTarget(
cdpBaseUrl: string,
url = "about:blank",
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
): Promise<CdpTarget> {
const base = cleanBaseUrl(cdpBaseUrl);
const startedAt = Date.now();
const remaining = () => Math.max(0, timeoutMs - (Date.now() - startedAt));
let response = await fetch(`${base}/json/new?${encodeURIComponent(url)}`, {
method: "PUT",
signal: AbortSignal.timeout(remaining()),
});
if (!response.ok) {
response = await fetch(`${base}/json/new?${encodeURIComponent(url)}`, {
signal: AbortSignal.timeout(remaining()),
});
}
if (!response.ok) {
throw new Error(`Could not create a page target at ${cdpBaseUrl}: ${response.status}`);
}
const created = targetFromUnknown(await response.json());
if (created) return created;
const targets = await listTargets(cdpBaseUrl, { timeoutMs: remaining() });
const nextPage = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
if (!nextPage) {
throw new Error(`No page target available at ${cdpBaseUrl}.`);
}
return nextPage;
}
export async function activateTarget(
cdpBaseUrl: string,
targetId: string,
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
): Promise<void> {
if (!targetId) return;
const base = cleanBaseUrl(cdpBaseUrl);
await fetch(`${base}/json/activate/${encodeURIComponent(targetId)}`, {
signal: AbortSignal.timeout(timeoutMs),
}).catch(() => undefined);
}
export async function closeTarget(
cdpBaseUrl: string,
targetId: string,
{ timeoutMs = DEFAULT_CDP_PROBE_TIMEOUT_MS }: { timeoutMs?: number } = {},
): Promise<void> {
if (!targetId) return;
const base = cleanBaseUrl(cdpBaseUrl);
await fetch(`${base}/json/close/${encodeURIComponent(targetId)}`, {
signal: AbortSignal.timeout(timeoutMs),
}).catch(() => undefined);
}
export async function waitForCdp(cdpBaseUrl: string, { timeoutMs = DEFAULT_CDP_WAIT_MS }: { timeoutMs?: number } = {}): Promise<void> {
const startedAt = Date.now();
let lastError: unknown = null;
while (Date.now() - startedAt < timeoutMs) {
try {
await listTargets(cdpBaseUrl, {
timeoutMs: Math.min(DEFAULT_CDP_PROBE_TIMEOUT_MS, timeoutMs - (Date.now() - startedAt)),
});
return;
} catch (error) {
lastError = error;
await sleep(Math.min(POLL_INTERVAL_MS, Math.max(0, timeoutMs - (Date.now() - startedAt))));
}
}
throw new Error(
`Timed out after ${timeoutMs}ms waiting for CDP at ${cdpBaseUrl}` +
(lastError ? ` (last error: ${messageText(lastError)})` : ""),
);
}
+185
View File
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import test from "node:test";
import type { Server } from "node:http";
import type { Duplex } from "node:stream";
import { callFunction, connect, evaluate } from "../src/cdp.ts";
import { evaluateOnSurface } from "../src/surface.ts";
import type { Surface } from "../src/surface.ts";
const TARGET_ID = "page-1";
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function portFromServer(server: Server): number {
const address = server.address();
if (typeof address === "object" && address !== null) return address.port;
throw new Error("Test server did not expose a TCP port.");
}
function websocketFrame(payload: string): Buffer {
const body = Buffer.from(payload);
if (body.length < 126) return Buffer.concat([Buffer.from([0x81, body.length]), body]);
const header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(body.length, 2);
return Buffer.concat([header, body]);
}
function handleClientFrames(socket: Duplex): void {
let buffered = Buffer.alloc(0);
socket.on("data", (chunk: Buffer) => {
buffered = Buffer.concat([buffered, chunk]);
while (buffered.length >= 2) {
const lengthCode = buffered[1] & 0x7f;
const lengthBytes = lengthCode === 126 ? 2 : 0;
if (lengthCode === 127) throw new Error("Test websocket frame is unexpectedly large.");
const headerLength = 2 + lengthBytes + 4;
if (buffered.length < headerLength) return;
const payloadLength = lengthCode === 126 ? buffered.readUInt16BE(2) : lengthCode;
if (buffered.length < headerLength + payloadLength) return;
const maskOffset = 2 + lengthBytes;
const payload = Buffer.alloc(payloadLength);
for (let index = 0; index < payloadLength; index += 1) {
payload[index] = buffered[headerLength + index] ^ buffered[maskOffset + (index % 4)];
}
buffered = buffered.subarray(headerLength + payloadLength);
const message: unknown = JSON.parse(payload.toString());
if (!isRecord(message) || typeof message.id !== "number") continue;
let result = {};
if (message.method === "Runtime.evaluate") {
const params = isRecord(message.params) ? message.params : {};
result = params.expression === "globalThis"
? { result: { objectId: "global-object" } }
: { result: { value: "recovered" } };
} else if (message.method === "Runtime.callFunctionOn") {
const params = isRecord(message.params) ? message.params : {};
const args = Array.isArray(params.arguments) ? params.arguments : [];
const first = args[0];
result = { result: { value: isRecord(first) ? first.value : undefined } };
}
socket.write(websocketFrame(JSON.stringify({ id: message.id, result })));
}
});
}
async function startCdpServer(stalledConnections: number): Promise<{
baseUrl: string;
websocketUrl: string;
connections(): number;
close(): Promise<void>;
}> {
const sockets = new Set<Duplex>();
let connectionCount = 0;
const server = createServer((request, response) => {
if (request.url === "/json/version") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ Browser: "test" }));
return;
}
if (request.url === "/json/list") {
const port = portFromServer(server);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify([{
id: TARGET_ID,
type: "page",
title: "OpenWork",
url: "http://127.0.0.1/app",
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/${TARGET_ID}`,
}]));
return;
}
response.writeHead(404);
response.end("not found");
});
server.on("upgrade", (request, socket) => {
const key = request.headers["sec-websocket-key"];
if (typeof key !== "string") {
socket.destroy();
return;
}
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
const accept = createHash("sha1").update(`${key}${WEBSOCKET_GUID}`).digest("base64");
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${accept}`,
"",
"",
].join("\r\n"));
connectionCount += 1;
if (connectionCount > stalledConnections) handleClientFrames(socket);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const port = portFromServer(server);
return {
baseUrl: `http://127.0.0.1:${port}`,
websocketUrl: `ws://127.0.0.1:${port}/devtools/page/${TARGET_ID}`,
connections: () => connectionCount,
close: async () => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
},
};
}
test("a stalled websocket can be condemned without awaiting close", async () => {
const server = await startCdpServer(1);
try {
const client = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
await assert.rejects(evaluate(client, "1", { timeoutMs: 30 }), /CDP call Runtime\.evaluate timed out/);
const pending = evaluate(client, "2", { timeoutMs: 500 });
if (!client.abort) throw new Error("Connected CDP client did not expose abort().");
const startedAt = Date.now();
client.abort(new Error("probe timed out"));
assert.ok(Date.now() - startedAt < 100, "abort() waited for the peer to complete websocket close");
await assert.rejects(pending, /CDP transport stalled: probe timed out/);
await assert.rejects(evaluate(client, "3", { timeoutMs: 500 }), /CDP socket is not open/);
} finally {
await server.close();
}
});
test("function calls pass dynamic values as structured CDP arguments", async () => {
const server = await startCdpServer(0);
const client = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
try {
const dynamicValue = `"); throw new Error("must stay data"); //`;
const value = await callFunction(client, "function (value) { return value; }", [dynamicValue]);
assert.equal(value, dynamicValue);
} finally {
client.close();
await server.close();
}
});
test("surface evaluation heals after the first websocket consumes its budget", async () => {
const server = await startCdpServer(1);
try {
const firstClient = await connect(server.websocketUrl, { connectTimeoutMs: 500, sendTimeoutMs: 500 });
const surface: Surface = {
handle: { name: "test", kind: "electron", hostKind: "test", cdpUrl: server.baseUrl },
client: firstClient,
};
const value = await evaluateOnSurface(surface, "42", { timeoutMs: 30 });
assert.equal(value, "recovered");
assert.notEqual(surface.client, firstClient);
assert.equal(server.connections(), 2);
} finally {
await server.close();
}
});