botprint 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cdp.ts ADDED
@@ -0,0 +1,267 @@
1
+ import { EXIT_CODES } from "./exit-codes";
2
+ import { BotprintError } from "./errors";
3
+ import { stringValue } from "./coerce";
4
+
5
+ type JsonObject = Record<string, unknown>;
6
+
7
+ const TEXT_DECODER = new TextDecoder();
8
+
9
+ type PendingRequest = {
10
+ resolve: (value: JsonObject) => void;
11
+ reject: (error: Error) => void;
12
+ timer: ReturnType<typeof setTimeout>;
13
+ };
14
+
15
+ const BunWebSocket = WebSocket as unknown as {
16
+ new (url: string, options?: Bun.WebSocketOptions): WebSocket;
17
+ };
18
+
19
+ type CdpResponse = {
20
+ id?: number;
21
+ result?: JsonObject;
22
+ error?: {
23
+ code: number;
24
+ message: string;
25
+ };
26
+ };
27
+
28
+ export type CdpClient = {
29
+ send(method: string, params?: JsonObject, sessionId?: string): Promise<JsonObject>;
30
+ close(): void;
31
+ };
32
+
33
+ export type BrowserVersion = {
34
+ product: string;
35
+ protocolVersion: string;
36
+ userAgent: string;
37
+ };
38
+
39
+ export type CdpTarget = {
40
+ targetId: string;
41
+ type: string;
42
+ title: string;
43
+ url: string;
44
+ attached: boolean;
45
+ browserContextId?: string;
46
+ };
47
+
48
+ export async function discoverWebSocketUrl(endpoint: string, headers: Record<string, string>): Promise<string> {
49
+ const url = new URL(endpoint);
50
+
51
+ if (url.protocol === "ws:" || url.protocol === "wss:") {
52
+ return endpoint;
53
+ }
54
+
55
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
56
+ throw new BotprintError(`Unsupported CDP endpoint protocol: ${url.protocol}`, EXIT_CODES.usage);
57
+ }
58
+
59
+ const versionUrl = new URL("/json/version", endpoint);
60
+ const response = await fetchCdp(versionUrl, headers, "CDP discovery");
61
+ if (!response.ok) {
62
+ throw new BotprintError(`CDP discovery failed at ${versionUrl.href}: HTTP ${response.status}`, EXIT_CODES.cdpConnectivity);
63
+ }
64
+
65
+ const body = (await readJson(response, "CDP discovery")) as { webSocketDebuggerUrl?: unknown };
66
+ if (typeof body.webSocketDebuggerUrl === "string") {
67
+ return body.webSocketDebuggerUrl;
68
+ }
69
+
70
+ const listUrl = new URL("/json/list", endpoint);
71
+ const listResponse = await fetchCdp(listUrl, headers, "CDP target discovery");
72
+ if (!listResponse.ok) {
73
+ throw new BotprintError(`CDP target discovery failed at ${listUrl.href}: HTTP ${listResponse.status}`, EXIT_CODES.cdpConnectivity);
74
+ }
75
+
76
+ const targets = (await readJson(listResponse, "CDP target discovery")) as Array<{ webSocketDebuggerUrl?: unknown }>;
77
+ const target = targets.find((candidate) => typeof candidate.webSocketDebuggerUrl === "string");
78
+ const targetWebSocketUrl = target?.webSocketDebuggerUrl;
79
+ if (typeof targetWebSocketUrl !== "string") {
80
+ throw new BotprintError("CDP endpoint did not expose a WebSocket debugger URL.", EXIT_CODES.cdpConnectivity);
81
+ }
82
+
83
+ return targetWebSocketUrl;
84
+ }
85
+
86
+ export function connectCdp(webSocketUrl: string, headers: Record<string, string>, timeoutMs: number): Promise<CdpClient> {
87
+ return new Promise((resolve, reject) => {
88
+ const pending = new Map<number, PendingRequest>();
89
+ let nextId = 1;
90
+ let settled = false;
91
+
92
+ let socket: WebSocket;
93
+ try {
94
+ socket = new BunWebSocket(webSocketUrl, { headers });
95
+ } catch (error) {
96
+ const detail = error instanceof Error ? error.message : String(error);
97
+ reject(new BotprintError(`Failed to construct CDP WebSocket for ${webSocketUrl}: ${detail}`, EXIT_CODES.cdpConnectivity));
98
+ return;
99
+ }
100
+
101
+ const connectTimer = setTimeout(() => {
102
+ if (!settled) {
103
+ settled = true;
104
+ socket.close();
105
+ reject(new BotprintError(`Timed out connecting to CDP WebSocket: ${webSocketUrl}`, EXIT_CODES.cdpConnectivity));
106
+ }
107
+ }, timeoutMs);
108
+
109
+ socket.addEventListener("open", () => {
110
+ if (settled) {
111
+ return;
112
+ }
113
+ settled = true;
114
+ clearTimeout(connectTimer);
115
+
116
+ resolve({
117
+ send(method: string, params: JsonObject = {}, sessionId?: string): Promise<JsonObject> {
118
+ if (socket.readyState !== WebSocket.OPEN) {
119
+ return Promise.reject(new BotprintError("CDP WebSocket is not open.", EXIT_CODES.cdpConnectivity));
120
+ }
121
+
122
+ const id = nextId;
123
+ nextId += 1;
124
+
125
+ const message: JsonObject = { id, method, params };
126
+ if (sessionId !== undefined) {
127
+ message.sessionId = sessionId;
128
+ }
129
+
130
+ return new Promise((requestResolve, requestReject) => {
131
+ const timer = setTimeout(() => {
132
+ pending.delete(id);
133
+ requestReject(new BotprintError(`Timed out waiting for CDP method ${method}.`, EXIT_CODES.cdpConnectivity));
134
+ }, timeoutMs);
135
+
136
+ pending.set(id, { resolve: requestResolve, reject: requestReject, timer });
137
+ socket.send(JSON.stringify(message));
138
+ });
139
+ },
140
+
141
+ close(): void {
142
+ for (const request of pending.values()) {
143
+ clearTimeout(request.timer);
144
+ request.reject(new BotprintError("CDP WebSocket closed.", EXIT_CODES.cdpConnectivity));
145
+ }
146
+ pending.clear();
147
+ socket.close();
148
+ },
149
+ });
150
+ });
151
+
152
+ socket.addEventListener("error", () => {
153
+ if (!settled) {
154
+ settled = true;
155
+ clearTimeout(connectTimer);
156
+ reject(new BotprintError(`Failed to connect to CDP WebSocket: ${webSocketUrl}`, EXIT_CODES.cdpConnectivity));
157
+ }
158
+ });
159
+
160
+ socket.addEventListener("close", () => {
161
+ if (!settled) {
162
+ settled = true;
163
+ clearTimeout(connectTimer);
164
+ reject(new BotprintError(`CDP WebSocket closed before connection opened: ${webSocketUrl}`, EXIT_CODES.cdpConnectivity));
165
+ return;
166
+ }
167
+ for (const request of pending.values()) {
168
+ clearTimeout(request.timer);
169
+ request.reject(new BotprintError("CDP WebSocket closed before a response arrived.", EXIT_CODES.cdpConnectivity));
170
+ }
171
+ pending.clear();
172
+ });
173
+
174
+ socket.addEventListener("message", async (event) => {
175
+ const response = parseResponse(await messageDataToString(event.data));
176
+ if (response.id === undefined) {
177
+ return;
178
+ }
179
+
180
+ const request = pending.get(response.id);
181
+ if (request === undefined) {
182
+ return;
183
+ }
184
+
185
+ pending.delete(response.id);
186
+ clearTimeout(request.timer);
187
+
188
+ if (response.error !== undefined) {
189
+ request.reject(new BotprintError(`CDP error ${response.error.code}: ${response.error.message}`, EXIT_CODES.cdpConnectivity));
190
+ return;
191
+ }
192
+
193
+ request.resolve(response.result ?? {});
194
+ });
195
+ });
196
+ }
197
+
198
+ export async function getBrowserVersion(client: CdpClient): Promise<BrowserVersion> {
199
+ const version = await client.send("Browser.getVersion");
200
+ return {
201
+ product: stringValue(version.product),
202
+ protocolVersion: stringValue(version.protocolVersion),
203
+ userAgent: stringValue(version.userAgent),
204
+ };
205
+ }
206
+
207
+ export async function getTargets(client: CdpClient): Promise<CdpTarget[]> {
208
+ const result = await client.send("Target.getTargets");
209
+ const rawTargets = Array.isArray(result.targetInfos) ? result.targetInfos : [];
210
+ return rawTargets.map(readTargetInfo);
211
+ }
212
+
213
+ function readTargetInfo(value: unknown): CdpTarget {
214
+ const target = typeof value === "object" && value !== null ? (value as JsonObject) : {};
215
+ const out: CdpTarget = {
216
+ targetId: stringValue(target.targetId),
217
+ type: stringValue(target.type),
218
+ title: stringValue(target.title),
219
+ url: stringValue(target.url),
220
+ attached: target.attached === true,
221
+ };
222
+ if (typeof target.browserContextId === "string" && target.browserContextId.length > 0) {
223
+ out.browserContextId = target.browserContextId;
224
+ }
225
+ return out;
226
+ }
227
+
228
+ async function messageDataToString(data: unknown): Promise<string> {
229
+ if (typeof data === "string") {
230
+ return data;
231
+ }
232
+ if (data instanceof Blob) {
233
+ return data.text();
234
+ }
235
+ if (data instanceof ArrayBuffer) {
236
+ return TEXT_DECODER.decode(data);
237
+ }
238
+ if (ArrayBuffer.isView(data)) {
239
+ return TEXT_DECODER.decode(data);
240
+ }
241
+ return String(data);
242
+ }
243
+
244
+ function parseResponse(raw: string): CdpResponse {
245
+ try {
246
+ return JSON.parse(raw) as CdpResponse;
247
+ } catch {
248
+ return {};
249
+ }
250
+ }
251
+
252
+ async function fetchCdp(url: URL, headers: Record<string, string>, operation: string): Promise<Response> {
253
+ try {
254
+ return await fetch(url, { headers });
255
+ } catch (error) {
256
+ const detail = error instanceof Error ? error.message : String(error);
257
+ throw new BotprintError(`${operation} failed at ${url.href}: ${detail}`, EXIT_CODES.cdpConnectivity);
258
+ }
259
+ }
260
+
261
+ async function readJson(response: Response, operation: string): Promise<unknown> {
262
+ try {
263
+ return await response.json();
264
+ } catch {
265
+ throw new BotprintError(`${operation} returned invalid JSON.`, EXIT_CODES.cdpConnectivity);
266
+ }
267
+ }