Spaces:
Paused
Paused
| import WebSocket from "ws"; | |
| import { rawDataToString } from "../infra/ws.js"; | |
| import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; | |
| type CdpResponse = { | |
| id: number; | |
| result?: unknown; | |
| error?: { message?: string }; | |
| }; | |
| type Pending = { | |
| resolve: (value: unknown) => void; | |
| reject: (err: Error) => void; | |
| }; | |
| export type CdpSendFn = (method: string, params?: Record<string, unknown>) => Promise<unknown>; | |
| export function isLoopbackHost(host: string) { | |
| const h = host.trim().toLowerCase(); | |
| return ( | |
| h === "localhost" || | |
| h === "127.0.0.1" || | |
| h === "0.0.0.0" || | |
| h === "[::1]" || | |
| h === "::1" || | |
| h === "[::]" || | |
| h === "::" | |
| ); | |
| } | |
| export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) { | |
| const relayHeaders = getChromeExtensionRelayAuthHeaders(url); | |
| const mergedHeaders = { ...relayHeaders, ...headers }; | |
| try { | |
| const parsed = new URL(url); | |
| const hasAuthHeader = Object.keys(mergedHeaders).some( | |
| (key) => key.toLowerCase() === "authorization", | |
| ); | |
| if (hasAuthHeader) { | |
| return mergedHeaders; | |
| } | |
| if (parsed.username || parsed.password) { | |
| const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64"); | |
| return { ...mergedHeaders, Authorization: `Basic ${auth}` }; | |
| } | |
| } catch { | |
| // ignore | |
| } | |
| return mergedHeaders; | |
| } | |
| export function appendCdpPath(cdpUrl: string, path: string): string { | |
| const url = new URL(cdpUrl); | |
| const basePath = url.pathname.replace(/\/$/, ""); | |
| const suffix = path.startsWith("/") ? path : `/${path}`; | |
| url.pathname = `${basePath}${suffix}`; | |
| return url.toString(); | |
| } | |
| function createCdpSender(ws: WebSocket) { | |
| let nextId = 1; | |
| const pending = new Map<number, Pending>(); | |
| const send: CdpSendFn = (method: string, params?: Record<string, unknown>) => { | |
| const id = nextId++; | |
| const msg = { id, method, params }; | |
| ws.send(JSON.stringify(msg)); | |
| return new Promise<unknown>((resolve, reject) => { | |
| pending.set(id, { resolve, reject }); | |
| }); | |
| }; | |
| const closeWithError = (err: Error) => { | |
| for (const [, p] of pending) { | |
| p.reject(err); | |
| } | |
| pending.clear(); | |
| try { | |
| ws.close(); | |
| } catch { | |
| // ignore | |
| } | |
| }; | |
| ws.on("message", (data) => { | |
| try { | |
| const parsed = JSON.parse(rawDataToString(data)) as CdpResponse; | |
| if (typeof parsed.id !== "number") { | |
| return; | |
| } | |
| const p = pending.get(parsed.id); | |
| if (!p) { | |
| return; | |
| } | |
| pending.delete(parsed.id); | |
| if (parsed.error?.message) { | |
| p.reject(new Error(parsed.error.message)); | |
| return; | |
| } | |
| p.resolve(parsed.result); | |
| } catch { | |
| // ignore | |
| } | |
| }); | |
| ws.on("close", () => { | |
| closeWithError(new Error("CDP socket closed")); | |
| }); | |
| return { send, closeWithError }; | |
| } | |
| export async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit): Promise<T> { | |
| const ctrl = new AbortController(); | |
| const t = setTimeout(() => ctrl.abort(), timeoutMs); | |
| try { | |
| const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {}); | |
| const res = await fetch(url, { ...init, headers, signal: ctrl.signal }); | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}`); | |
| } | |
| return (await res.json()) as T; | |
| } finally { | |
| clearTimeout(t); | |
| } | |
| } | |
| export async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promise<void> { | |
| const ctrl = new AbortController(); | |
| const t = setTimeout(() => ctrl.abort(), timeoutMs); | |
| try { | |
| const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {}); | |
| const res = await fetch(url, { ...init, headers, signal: ctrl.signal }); | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}`); | |
| } | |
| } finally { | |
| clearTimeout(t); | |
| } | |
| } | |
| export async function withCdpSocket<T>( | |
| wsUrl: string, | |
| fn: (send: CdpSendFn) => Promise<T>, | |
| opts?: { headers?: Record<string, string> }, | |
| ): Promise<T> { | |
| const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {}); | |
| const ws = new WebSocket(wsUrl, { | |
| handshakeTimeout: 5000, | |
| ...(Object.keys(headers).length ? { headers } : {}), | |
| }); | |
| const { send, closeWithError } = createCdpSender(ws); | |
| const openPromise = new Promise<void>((resolve, reject) => { | |
| ws.once("open", () => resolve()); | |
| ws.once("error", (err) => reject(err)); | |
| }); | |
| await openPromise; | |
| try { | |
| return await fn(send); | |
| } catch (err) { | |
| closeWithError(err instanceof Error ? err : new Error(String(err))); | |
| throw err; | |
| } finally { | |
| try { | |
| ws.close(); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| } | |