@orkestrel/browser 0.0.1
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/LICENSE +21 -0
- package/README.md +68 -0
- package/dist/src/core/index.cjs +1209 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +742 -0
- package/dist/src/core/index.d.ts +742 -0
- package/dist/src/core/index.js +1174 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +1172 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +567 -0
- package/dist/src/server/index.d.ts +567 -0
- package/dist/src/server/index.js +1120 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +99 -0
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
import { isRecord, isString } from "@orkestrel/contract";
|
|
2
|
+
import { Emitter } from "@orkestrel/emitter";
|
|
3
|
+
//#region src/core/constants.ts
|
|
4
|
+
/** Base64 alphabet, index-ordered, used to build {@link BASE64_LOOKUP}. */
|
|
5
|
+
var BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
6
|
+
/** Base64 character to 6-bit value lookup table, derived from {@link BASE64_CHARS}. */
|
|
7
|
+
var BASE64_LOOKUP = Object.freeze(Object.fromEntries(BASE64_CHARS.split("").map((char, index) => [char, index])));
|
|
8
|
+
/** Default timeout in milliseconds for browser connection, requests, and navigation. */
|
|
9
|
+
var BROWSER_DEFAULT_TIMEOUT_MS = 3e4;
|
|
10
|
+
/**
|
|
11
|
+
* Maximum serialized-character length for an `evaluate()`/`content()` result,
|
|
12
|
+
* enforced IN-PAGE before the result is returned to CDP.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* This counts UTF-16 STRING LENGTH (`String#length`), not transport BYTES —
|
|
16
|
+
* the actual CDP frame is UTF-8 encoded (up to 3 bytes/char for common
|
|
17
|
+
* multibyte content) and carries additional JSON/CDP framing overhead on top
|
|
18
|
+
* of the raw content. A `Runtime.evaluate` result above this size, once
|
|
19
|
+
* framed as a CDP JSON response, overflows the native WebSocket inbound frame
|
|
20
|
+
* limit and closes the whole CDP connection — a page-level failure with no
|
|
21
|
+
* clean error. The limit is set well under the observed ~3-4MB transport
|
|
22
|
+
* ceiling (rather than at it) to leave headroom for the length-vs-bytes gap
|
|
23
|
+
* and framing overhead. The cap is enforced by stringifying the candidate
|
|
24
|
+
* result in-page and throwing a recognizable sentinel error (see
|
|
25
|
+
* {@link BROWSER_RESULT_LIMIT_SENTINEL_PREFIX}) before the oversized frame is
|
|
26
|
+
* ever produced, so the caller gets a coded error instead of a dropped
|
|
27
|
+
* connection.
|
|
28
|
+
*/
|
|
29
|
+
var BROWSER_RESULT_LIMIT = 25e5;
|
|
30
|
+
/**
|
|
31
|
+
* Distinctive prefix for the in-page result-limit sentinel error, immediately
|
|
32
|
+
* followed by the serialized length.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* Deliberately unlikely to appear in a page's own thrown error text (unlike
|
|
36
|
+
* a plain `BROWSER_RESULT_LIMIT: ` label), so {@link BROWSER_RESULT_LIMIT_PATTERN}
|
|
37
|
+
* can distinguish the guard's own throw from a page error that merely
|
|
38
|
+
* mentions similar text.
|
|
39
|
+
*/
|
|
40
|
+
var BROWSER_RESULT_LIMIT_SENTINEL_PREFIX = "[[ORKESTREL_BROWSER_RESULT_LIMIT]]";
|
|
41
|
+
/**
|
|
42
|
+
* Matches the in-page result-limit sentinel error message, anchored
|
|
43
|
+
* immediately after the `Error: ` (optionally `Uncaught Error: `) prefix that
|
|
44
|
+
* Chromium prepends to a thrown error's description — so the guard's own
|
|
45
|
+
* throw is recognized only at the message START, not wherever the substring
|
|
46
|
+
* happens to occur.
|
|
47
|
+
*/
|
|
48
|
+
var BROWSER_RESULT_LIMIT_PATTERN = new RegExp(`(?:Uncaught )?Error: \\[\\[ORKESTREL_BROWSER_RESULT_LIMIT\\]\\](\\d+)`);
|
|
49
|
+
/** Poll interval in milliseconds while waiting for a selector to appear. */
|
|
50
|
+
var BROWSER_WAIT_POLL_INTERVAL_MS = 100;
|
|
51
|
+
/**
|
|
52
|
+
* Bound (in milliseconds) on the best-effort `Page.stopLoading` call issued
|
|
53
|
+
* after a failed `navigate()`.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* A wedged renderer can make the underlying CDP call hang for the full
|
|
57
|
+
* per-call timeout; capping it to a small fixed bound keeps a navigate
|
|
58
|
+
* failure's total latency close to the original timeout instead of doubling
|
|
59
|
+
* it. Best-effort only — never masks the original navigate error.
|
|
60
|
+
*/
|
|
61
|
+
var BROWSER_STOP_LOADING_TIMEOUT_MS = 1e3;
|
|
62
|
+
/** Default viewport width in pixels. */
|
|
63
|
+
var BROWSER_DEFAULT_VIEWPORT_WIDTH = 1280;
|
|
64
|
+
/** Default viewport height in pixels. */
|
|
65
|
+
var BROWSER_DEFAULT_VIEWPORT_HEIGHT = 720;
|
|
66
|
+
/** Name of the CDP runtime binding the codegen recorder script calls into. */
|
|
67
|
+
var BROWSER_CODEGEN_BINDING_NAME = "__orkestrelBrowserCodegen";
|
|
68
|
+
/**
|
|
69
|
+
* In-page recorder script injected via `Page.addScriptToEvaluateOnNewDocument`
|
|
70
|
+
* and `Runtime.evaluate`.
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* Attaches capturing-phase listeners for `click`, `input` (fill), and
|
|
74
|
+
* `change` (select) on `document`, builds a stable CSS selector for the
|
|
75
|
+
* target element, and forwards each action to the CDP binding
|
|
76
|
+
* ({@link BROWSER_CODEGEN_BINDING_NAME}) as a JSON string payload. Guarded to
|
|
77
|
+
* install exactly once per document (`window[name]` sentinel) so repeated
|
|
78
|
+
* injection on every new document is idempotent.
|
|
79
|
+
*/
|
|
80
|
+
var BROWSER_CODEGEN_SOURCE = `(() => {
|
|
81
|
+
const bindingName = ${JSON.stringify(BROWSER_CODEGEN_BINDING_NAME)}
|
|
82
|
+
if (window[bindingName + '__installed']) return
|
|
83
|
+
window[bindingName + '__installed'] = true
|
|
84
|
+
|
|
85
|
+
const selectorFor = (el) => {
|
|
86
|
+
if (el.id) return '#' + CSS.escape(el.id)
|
|
87
|
+
const parts = []
|
|
88
|
+
let node = el
|
|
89
|
+
while (node && node.nodeType === 1 && parts.length < 8) {
|
|
90
|
+
let part = node.tagName.toLowerCase()
|
|
91
|
+
if (node.classList && node.classList.length > 0) {
|
|
92
|
+
part += '.' + Array.from(node.classList).map((cls) => CSS.escape(cls)).join('.')
|
|
93
|
+
}
|
|
94
|
+
const parent = node.parentElement
|
|
95
|
+
if (parent) {
|
|
96
|
+
const siblings = Array.from(parent.children).filter((c) => c.tagName === node.tagName)
|
|
97
|
+
if (siblings.length > 1) {
|
|
98
|
+
part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
parts.unshift(part)
|
|
102
|
+
node = parent
|
|
103
|
+
}
|
|
104
|
+
return parts.join(' > ')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const send = (payload) => {
|
|
108
|
+
if (typeof window[bindingName] === 'function') {
|
|
109
|
+
window[bindingName](JSON.stringify(payload))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
document.addEventListener(
|
|
114
|
+
'click',
|
|
115
|
+
(event) => {
|
|
116
|
+
const target = event.target
|
|
117
|
+
if (!target || target.nodeType !== 1) return
|
|
118
|
+
send({ action: 'click', selector: selectorFor(target) })
|
|
119
|
+
},
|
|
120
|
+
true,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const fillableTypes = new Set([
|
|
124
|
+
'text',
|
|
125
|
+
'search',
|
|
126
|
+
'url',
|
|
127
|
+
'tel',
|
|
128
|
+
'email',
|
|
129
|
+
'password',
|
|
130
|
+
'number',
|
|
131
|
+
])
|
|
132
|
+
|
|
133
|
+
document.addEventListener(
|
|
134
|
+
'input',
|
|
135
|
+
(event) => {
|
|
136
|
+
const target = event.target
|
|
137
|
+
if (!target || target.nodeType !== 1) return
|
|
138
|
+
|
|
139
|
+
if (target.isContentEditable) {
|
|
140
|
+
send({ action: 'fill', selector: selectorFor(target), value: target.textContent || '' })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof target.value !== 'string') return
|
|
145
|
+
const tag = target.tagName
|
|
146
|
+
if (tag === 'TEXTAREA') {
|
|
147
|
+
send({ action: 'fill', selector: selectorFor(target), value: target.value })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (tag === 'INPUT' && fillableTypes.has((target.type || 'text').toLowerCase())) {
|
|
151
|
+
send({ action: 'fill', selector: selectorFor(target), value: target.value })
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
true,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
document.addEventListener(
|
|
158
|
+
'change',
|
|
159
|
+
(event) => {
|
|
160
|
+
const target = event.target
|
|
161
|
+
if (!target || target.tagName !== 'SELECT') return
|
|
162
|
+
const values = Array.from(target.selectedOptions || []).map((option) => option.value)
|
|
163
|
+
send({ action: 'select', selector: selectorFor(target), values })
|
|
164
|
+
},
|
|
165
|
+
true,
|
|
166
|
+
)
|
|
167
|
+
})()`;
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/core/errors.ts
|
|
170
|
+
/**
|
|
171
|
+
* Base error for all browser automation operations.
|
|
172
|
+
*
|
|
173
|
+
* @remarks
|
|
174
|
+
* Carries a machine-readable `code` and optional `context` (AGENTS §12) so
|
|
175
|
+
* callers can branch in a `catch` without parsing message strings.
|
|
176
|
+
*/
|
|
177
|
+
var BrowserError = class extends Error {
|
|
178
|
+
code;
|
|
179
|
+
context;
|
|
180
|
+
constructor(message, code = "BROWSER_ERROR", context) {
|
|
181
|
+
super(message);
|
|
182
|
+
this.name = "BrowserError";
|
|
183
|
+
this.code = code;
|
|
184
|
+
this.context = context;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* A selector-based lookup or wait timed out without the element appearing.
|
|
189
|
+
*/
|
|
190
|
+
var BrowserSelectorError = class extends BrowserError {
|
|
191
|
+
constructor(message, context) {
|
|
192
|
+
super(message, "BROWSER_SELECTOR_ERROR", context);
|
|
193
|
+
this.name = "BrowserSelectorError";
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* A CDP request received an error response from the remote endpoint.
|
|
198
|
+
*
|
|
199
|
+
* @remarks
|
|
200
|
+
* Carries the originating `method` plus the CDP error's own `code`,
|
|
201
|
+
* `message`, and `data` (when present) in `context`, so callers can branch
|
|
202
|
+
* on the protocol-level error instead of parsing the message string.
|
|
203
|
+
*/
|
|
204
|
+
var CDPError = class extends BrowserError {
|
|
205
|
+
constructor(message, context) {
|
|
206
|
+
super(message, "BROWSER_CDP_ERROR", context);
|
|
207
|
+
this.name = "CDPError";
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* A CDP request could not be sent or completed because the client was not in
|
|
212
|
+
* a connectable state (not connected, closed while connecting, or the
|
|
213
|
+
* connection dropped mid-request).
|
|
214
|
+
*/
|
|
215
|
+
var CDPConnectionError = class extends BrowserError {
|
|
216
|
+
constructor(message, context) {
|
|
217
|
+
super(message, "BROWSER_CDP_CONNECTION_ERROR", context);
|
|
218
|
+
this.name = "CDPConnectionError";
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* A pending CDP request was not answered within its timeout window.
|
|
223
|
+
*/
|
|
224
|
+
var CDPTimeoutError = class extends BrowserError {
|
|
225
|
+
constructor(message, context) {
|
|
226
|
+
super(message, "BROWSER_CDP_TIMEOUT_ERROR", context);
|
|
227
|
+
this.name = "CDPTimeoutError";
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* An `evaluate()`/`content()` result exceeded {@link BROWSER_RESULT_LIMIT}
|
|
232
|
+
* and was rejected in-page before it could overflow the CDP transport frame.
|
|
233
|
+
*/
|
|
234
|
+
var BrowserResultLimitError = class extends BrowserError {
|
|
235
|
+
constructor(message, context) {
|
|
236
|
+
super(message, "BROWSER_RESULT_LIMIT_ERROR", context);
|
|
237
|
+
this.name = "BrowserResultLimitError";
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
/**
|
|
241
|
+
* Narrow an unknown value to BrowserError.
|
|
242
|
+
*
|
|
243
|
+
* @param value - Value to check
|
|
244
|
+
* @returns True when value is a BrowserError instance
|
|
245
|
+
*/
|
|
246
|
+
function isBrowserError(value) {
|
|
247
|
+
return value instanceof BrowserError;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Narrow an unknown value to BrowserSelectorError.
|
|
251
|
+
*
|
|
252
|
+
* @param value - Value to check
|
|
253
|
+
* @returns True when value is a BrowserSelectorError instance
|
|
254
|
+
*/
|
|
255
|
+
function isBrowserSelectorError(value) {
|
|
256
|
+
return value instanceof BrowserSelectorError;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Narrow an unknown value to CDPError.
|
|
260
|
+
*
|
|
261
|
+
* @param value - Value to check
|
|
262
|
+
* @returns True when value is a CDPError instance
|
|
263
|
+
*/
|
|
264
|
+
function isCDPError(value) {
|
|
265
|
+
return value instanceof CDPError;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Narrow an unknown value to CDPConnectionError.
|
|
269
|
+
*
|
|
270
|
+
* @param value - Value to check
|
|
271
|
+
* @returns True when value is a CDPConnectionError instance
|
|
272
|
+
*/
|
|
273
|
+
function isCDPConnectionError(value) {
|
|
274
|
+
return value instanceof CDPConnectionError;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Narrow an unknown value to CDPTimeoutError.
|
|
278
|
+
*
|
|
279
|
+
* @param value - Value to check
|
|
280
|
+
* @returns True when value is a CDPTimeoutError instance
|
|
281
|
+
*/
|
|
282
|
+
function isCDPTimeoutError(value) {
|
|
283
|
+
return value instanceof CDPTimeoutError;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Narrow an unknown value to BrowserResultLimitError.
|
|
287
|
+
*
|
|
288
|
+
* @param value - Value to check
|
|
289
|
+
* @returns True when value is a BrowserResultLimitError instance
|
|
290
|
+
*/
|
|
291
|
+
function isBrowserResultLimitError(value) {
|
|
292
|
+
return value instanceof BrowserResultLimitError;
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/core/helpers.ts
|
|
296
|
+
/**
|
|
297
|
+
* Decode a base64-encoded string into raw bytes.
|
|
298
|
+
*
|
|
299
|
+
* @remarks
|
|
300
|
+
* Pure JS implementation — no `Buffer`, no `atob` (DOM-only) — so it runs
|
|
301
|
+
* identically in Node and browser environments. Whitespace and `=` padding
|
|
302
|
+
* are ignored; invalid characters are skipped.
|
|
303
|
+
*
|
|
304
|
+
* @param text - Base64-encoded input string
|
|
305
|
+
* @returns Decoded bytes
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```ts
|
|
309
|
+
* decodeBase64('aGVsbG8=') // Uint8Array [104, 101, 108, 108, 111]
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
function decodeBase64(text) {
|
|
313
|
+
const clean = text.replace(/[^A-Za-z0-9+/]/g, "");
|
|
314
|
+
const bytes = [];
|
|
315
|
+
let buffer = 0;
|
|
316
|
+
let bits = 0;
|
|
317
|
+
for (const char of clean) {
|
|
318
|
+
const value = BASE64_LOOKUP[char];
|
|
319
|
+
if (value === void 0) continue;
|
|
320
|
+
buffer = buffer << 6 | value;
|
|
321
|
+
bits += 6;
|
|
322
|
+
if (bits >= 8) {
|
|
323
|
+
bits -= 8;
|
|
324
|
+
bytes.push(buffer >> bits & 255);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return new Uint8Array(bytes);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Wrap a `Runtime.evaluate` expression so the IN-PAGE code stringifies its
|
|
331
|
+
* own result and throws a recognizable error before an oversized result
|
|
332
|
+
* would overflow the CDP transport frame.
|
|
333
|
+
*
|
|
334
|
+
* @remarks
|
|
335
|
+
* A result whose `JSON.stringify` length exceeds `limit` throws
|
|
336
|
+
* `Error('BROWSER_RESULT_LIMIT: <length>')` inside the page instead of being
|
|
337
|
+
* returned — the caller maps that sentinel to a coded
|
|
338
|
+
* {@link BrowserResultLimitError}. A non-serializable result (`undefined`,
|
|
339
|
+
* a function, a symbol) makes `JSON.stringify` return `undefined`, so the
|
|
340
|
+
* length check is skipped and today's undefined-passthrough behavior is
|
|
341
|
+
* unchanged.
|
|
342
|
+
*
|
|
343
|
+
* The expression is placed on its own line inside the wrapper (rather than
|
|
344
|
+
* inline with the guard code) so a trailing `//` line comment in the
|
|
345
|
+
* expression cannot swallow the closing guard syntax that follows it.
|
|
346
|
+
*
|
|
347
|
+
* @param expression - The candidate JavaScript expression to evaluate
|
|
348
|
+
* @param limit - Maximum serialized-character length (see {@link BROWSER_RESULT_LIMIT})
|
|
349
|
+
* @returns The wrapped, guarded expression
|
|
350
|
+
*/
|
|
351
|
+
function guardEvaluateExpression(expression, limit) {
|
|
352
|
+
return `(() => { const r = (
|
|
353
|
+
${expression}
|
|
354
|
+
); const s = JSON.stringify(r); if (typeof s === 'string' && s.length > ${limit}) throw new Error(${JSON.stringify(BROWSER_RESULT_LIMIT_SENTINEL_PREFIX)} + s.length); return r })()`;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Normalize a raw list of recorded codegen actions.
|
|
358
|
+
*
|
|
359
|
+
* @remarks
|
|
360
|
+
* Collapses consecutive `fill` actions on the same selector into the latest
|
|
361
|
+
* value (a text input fires one `input` event per keystroke) so the
|
|
362
|
+
* compiled script reflects the final typed value rather than every
|
|
363
|
+
* intermediate keystroke.
|
|
364
|
+
*
|
|
365
|
+
* @param actions - Raw recorded actions, in capture order
|
|
366
|
+
* @returns Normalized actions, in the same order
|
|
367
|
+
*/
|
|
368
|
+
function normalizeCodegenActions(actions) {
|
|
369
|
+
const result = [];
|
|
370
|
+
for (const action of actions) {
|
|
371
|
+
const previous = result[result.length - 1];
|
|
372
|
+
if (previous !== void 0 && previous.action === "fill" && action.action === "fill" && previous.selector === action.selector) {
|
|
373
|
+
result[result.length - 1] = action;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
result.push(action);
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Parse a codegen binding payload into a typed action.
|
|
382
|
+
*
|
|
383
|
+
* @remarks
|
|
384
|
+
* The in-page recorder script calls the CDP binding with a JSON string
|
|
385
|
+
* payload shaped like a {@link BrowserCodegenAction}. Returns `undefined`
|
|
386
|
+
* when the payload is not valid JSON or does not match a known action shape.
|
|
387
|
+
*
|
|
388
|
+
* @param payload - Raw binding call payload (the CDP `payload` param)
|
|
389
|
+
* @returns The parsed action, or `undefined` when the payload is malformed
|
|
390
|
+
*/
|
|
391
|
+
function parseCodegenActionPayload(payload) {
|
|
392
|
+
if (!isString(payload)) return void 0;
|
|
393
|
+
let parsed;
|
|
394
|
+
try {
|
|
395
|
+
parsed = JSON.parse(payload);
|
|
396
|
+
} catch {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (!isRecord(parsed)) return void 0;
|
|
400
|
+
const action = parsed["action"];
|
|
401
|
+
const selector = parsed["selector"];
|
|
402
|
+
if (action === "click" && isString(selector)) return {
|
|
403
|
+
action: "click",
|
|
404
|
+
selector
|
|
405
|
+
};
|
|
406
|
+
if (action === "fill" && isString(selector) && isString(parsed["value"])) return {
|
|
407
|
+
action: "fill",
|
|
408
|
+
selector,
|
|
409
|
+
value: parsed["value"]
|
|
410
|
+
};
|
|
411
|
+
if (action === "select" && isString(selector) && Array.isArray(parsed["values"])) {
|
|
412
|
+
const values = parsed["values"].filter(isString);
|
|
413
|
+
if (values.length === parsed["values"].length) return {
|
|
414
|
+
action: "select",
|
|
415
|
+
selector,
|
|
416
|
+
values
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Derive a `navigate` codegen action from a `Page.frameNavigated` CDP event.
|
|
422
|
+
*
|
|
423
|
+
* @remarks
|
|
424
|
+
* Only the top-level (main) frame's navigation is recorded — a frame
|
|
425
|
+
* carrying a `parentId` is a sub-frame and is ignored.
|
|
426
|
+
*
|
|
427
|
+
* @param params - The CDP `Page.frameNavigated` event params
|
|
428
|
+
* @returns A `navigate` action, or `undefined` when the event is not a
|
|
429
|
+
* top-level navigation with a resolvable URL
|
|
430
|
+
*/
|
|
431
|
+
function readCodegenNavigateAction(params) {
|
|
432
|
+
const frame = params["frame"];
|
|
433
|
+
if (!isRecord(frame)) return void 0;
|
|
434
|
+
if ("parentId" in frame) return void 0;
|
|
435
|
+
if (!isString(frame["url"])) return void 0;
|
|
436
|
+
return {
|
|
437
|
+
action: "navigate",
|
|
438
|
+
url: frame["url"]
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Compile recorded codegen actions into a replayable script.
|
|
443
|
+
*
|
|
444
|
+
* @remarks
|
|
445
|
+
* Emits one statement per action against a `page` object shaped like
|
|
446
|
+
* {@link BrowserPageInterface} (`page.navigate(...)`, `page.click(...)`, …).
|
|
447
|
+
* Both target languages emit an `async function run(page)` body whose
|
|
448
|
+
* statements are `await`-ed; `language` only toggles whether the `page`
|
|
449
|
+
* parameter carries a TypeScript type annotation (default `'javascript'`).
|
|
450
|
+
*
|
|
451
|
+
* @param actions - Normalized actions to compile
|
|
452
|
+
* @param options - Compilation options (target language)
|
|
453
|
+
* @returns The compiled script source
|
|
454
|
+
*/
|
|
455
|
+
function compileCodegenScript(actions, options) {
|
|
456
|
+
const language = options?.language ?? "javascript";
|
|
457
|
+
const lines = actions.map((action) => {
|
|
458
|
+
switch (action.action) {
|
|
459
|
+
case "navigate": return `await page.navigate(${JSON.stringify(action.url)})`;
|
|
460
|
+
case "click": return `await page.click(${JSON.stringify(action.selector)})`;
|
|
461
|
+
case "fill": return `await page.fill(${JSON.stringify(action.selector)}, ${JSON.stringify(action.value)})`;
|
|
462
|
+
case "select": return `await page.select(${JSON.stringify(action.selector)}, ${JSON.stringify(action.values)})`;
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
if (language === "typescript") return [
|
|
466
|
+
`async function run(page: import('@orkestrel/browser').BrowserPageInterface): Promise<void> {`,
|
|
467
|
+
...lines.map((line) => `\t${line}`),
|
|
468
|
+
`}`
|
|
469
|
+
].join("\n");
|
|
470
|
+
return [
|
|
471
|
+
`async function run(page) {`,
|
|
472
|
+
...lines.map((line) => `\t${line}`),
|
|
473
|
+
`}`
|
|
474
|
+
].join("\n");
|
|
475
|
+
}
|
|
476
|
+
//#endregion
|
|
477
|
+
//#region src/core/CDPClient.ts
|
|
478
|
+
/**
|
|
479
|
+
* Lightweight Chrome DevTools Protocol client over a {@link CDPTransportInterface}.
|
|
480
|
+
*
|
|
481
|
+
* @remarks
|
|
482
|
+
* Sends JSON-RPC messages over the injected transport and dispatches
|
|
483
|
+
* responses and events. The transport owns the connection itself
|
|
484
|
+
* (WebSocket, pipe, or any other duplex channel); this class owns only
|
|
485
|
+
* the JSON-RPC framing and id/session correlation.
|
|
486
|
+
*
|
|
487
|
+
* Supports session-scoped event subscriptions: when a sessionId is
|
|
488
|
+
* provided to subscribe/unsubscribe, the handler only fires for CDP
|
|
489
|
+
* events carrying that sessionId. Global subscriptions (no sessionId)
|
|
490
|
+
* continue to see ALL events for backwards compatibility.
|
|
491
|
+
*/
|
|
492
|
+
var CDPClient = class {
|
|
493
|
+
#transport;
|
|
494
|
+
#messageId = 0;
|
|
495
|
+
#pending = /* @__PURE__ */ new Map();
|
|
496
|
+
#subscriptions = /* @__PURE__ */ new Map();
|
|
497
|
+
#sessionSubscriptions = /* @__PURE__ */ new Map();
|
|
498
|
+
#connected = false;
|
|
499
|
+
#wired = false;
|
|
500
|
+
#timeout;
|
|
501
|
+
#connecting;
|
|
502
|
+
#closeRequested = false;
|
|
503
|
+
constructor(options) {
|
|
504
|
+
this.#transport = options.transport;
|
|
505
|
+
this.#timeout = options.timeout ?? 3e4;
|
|
506
|
+
}
|
|
507
|
+
get connected() {
|
|
508
|
+
return this.#connected;
|
|
509
|
+
}
|
|
510
|
+
async connect() {
|
|
511
|
+
if (this.#connected) return;
|
|
512
|
+
if (this.#connecting !== void 0) return this.#connecting;
|
|
513
|
+
this.#closeRequested = false;
|
|
514
|
+
const attempt = (async () => {
|
|
515
|
+
if (!this.#wired) {
|
|
516
|
+
this.#transport.emitter.on("message", (data) => this.#onMessage(data));
|
|
517
|
+
this.#transport.emitter.on("close", () => this.#onClose());
|
|
518
|
+
this.#transport.emitter.on("error", (error) => this.#onError(error));
|
|
519
|
+
this.#wired = true;
|
|
520
|
+
}
|
|
521
|
+
await this.#transport.start();
|
|
522
|
+
if (this.#closeRequested) {
|
|
523
|
+
this.#closeRequested = false;
|
|
524
|
+
await this.#transport.close();
|
|
525
|
+
throw new CDPConnectionError("CDP client was closed while connecting", { method: "connect" });
|
|
526
|
+
}
|
|
527
|
+
this.#connected = true;
|
|
528
|
+
})();
|
|
529
|
+
this.#connecting = attempt;
|
|
530
|
+
try {
|
|
531
|
+
await attempt;
|
|
532
|
+
} finally {
|
|
533
|
+
this.#connecting = void 0;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Close the transport and re-establish a fresh connection.
|
|
538
|
+
*
|
|
539
|
+
* @remarks
|
|
540
|
+
* Client-level subscriptions (`subscribe`/`unsubscribe` registrations)
|
|
541
|
+
* survive `close()` and remain active after the reconnect — only pending
|
|
542
|
+
* in-flight requests are rejected. This lets a caller reconnect a dropped
|
|
543
|
+
* transport without re-registering every handler.
|
|
544
|
+
*/
|
|
545
|
+
async reconnect() {
|
|
546
|
+
await this.close();
|
|
547
|
+
await this.connect();
|
|
548
|
+
}
|
|
549
|
+
async send(method, params, sessionId, timeout) {
|
|
550
|
+
if (!this.#connected) throw new CDPConnectionError("CDP client is not connected", { method });
|
|
551
|
+
const id = this.#nextId();
|
|
552
|
+
const message = {
|
|
553
|
+
id,
|
|
554
|
+
method
|
|
555
|
+
};
|
|
556
|
+
if (params !== void 0) message["params"] = params;
|
|
557
|
+
if (sessionId !== void 0) message["sessionId"] = sessionId;
|
|
558
|
+
const serialized = JSON.stringify(message);
|
|
559
|
+
const effectiveTimeout = timeout ?? this.#timeout;
|
|
560
|
+
return new Promise((resolve, reject) => {
|
|
561
|
+
const timer = setTimeout(() => {
|
|
562
|
+
this.#pending.delete(id);
|
|
563
|
+
reject(new CDPTimeoutError(`CDP request timed out: ${method}`, {
|
|
564
|
+
method,
|
|
565
|
+
timeout: effectiveTimeout
|
|
566
|
+
}));
|
|
567
|
+
}, effectiveTimeout);
|
|
568
|
+
this.#pending.set(id, {
|
|
569
|
+
method,
|
|
570
|
+
resolve,
|
|
571
|
+
reject,
|
|
572
|
+
timer
|
|
573
|
+
});
|
|
574
|
+
this.#transport.send(serialized).catch((thrown) => {
|
|
575
|
+
this.#pending.delete(id);
|
|
576
|
+
clearTimeout(timer);
|
|
577
|
+
reject(thrown);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
subscribe(method, handler, sessionId) {
|
|
582
|
+
if (sessionId !== void 0) {
|
|
583
|
+
let sessionMap = this.#sessionSubscriptions.get(sessionId);
|
|
584
|
+
if (sessionMap === void 0) {
|
|
585
|
+
sessionMap = /* @__PURE__ */ new Map();
|
|
586
|
+
this.#sessionSubscriptions.set(sessionId, sessionMap);
|
|
587
|
+
}
|
|
588
|
+
let handlers = sessionMap.get(method);
|
|
589
|
+
if (handlers === void 0) {
|
|
590
|
+
handlers = /* @__PURE__ */ new Set();
|
|
591
|
+
sessionMap.set(method, handlers);
|
|
592
|
+
}
|
|
593
|
+
handlers.add(handler);
|
|
594
|
+
} else {
|
|
595
|
+
let handlers = this.#subscriptions.get(method);
|
|
596
|
+
if (handlers === void 0) {
|
|
597
|
+
handlers = /* @__PURE__ */ new Set();
|
|
598
|
+
this.#subscriptions.set(method, handlers);
|
|
599
|
+
}
|
|
600
|
+
handlers.add(handler);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
unsubscribe(method, handler, sessionId) {
|
|
604
|
+
if (sessionId !== void 0) {
|
|
605
|
+
const sessionMap = this.#sessionSubscriptions.get(sessionId);
|
|
606
|
+
if (sessionMap !== void 0) {
|
|
607
|
+
const handlers = sessionMap.get(method);
|
|
608
|
+
if (handlers !== void 0) {
|
|
609
|
+
handlers.delete(handler);
|
|
610
|
+
if (handlers.size === 0) sessionMap.delete(method);
|
|
611
|
+
}
|
|
612
|
+
if (sessionMap.size === 0) this.#sessionSubscriptions.delete(sessionId);
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
const handlers = this.#subscriptions.get(method);
|
|
616
|
+
if (handlers !== void 0) {
|
|
617
|
+
handlers.delete(handler);
|
|
618
|
+
if (handlers.size === 0) this.#subscriptions.delete(method);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Close the transport and reject all pending requests.
|
|
624
|
+
*
|
|
625
|
+
* @remarks
|
|
626
|
+
* Subscriptions registered via `subscribe()` are client-level
|
|
627
|
+
* registrations, not connection-level state — they survive `close()`
|
|
628
|
+
* (and a subsequent `reconnect()`/`connect()`) and keep firing once the
|
|
629
|
+
* transport is active again. Only pending requests are rejected here.
|
|
630
|
+
* If `close()` is called while `connect()` is still awaiting
|
|
631
|
+
* `transport.start()`, the in-flight connect is rejected and the
|
|
632
|
+
* transport is closed deterministically once `start()` resolves.
|
|
633
|
+
*/
|
|
634
|
+
async close() {
|
|
635
|
+
if (this.#connecting !== void 0) {
|
|
636
|
+
this.#closeRequested = true;
|
|
637
|
+
await this.#connecting.catch(() => void 0);
|
|
638
|
+
}
|
|
639
|
+
if (!this.#connected) return;
|
|
640
|
+
this.#connected = false;
|
|
641
|
+
for (const [id, entry] of this.#pending) {
|
|
642
|
+
clearTimeout(entry.timer);
|
|
643
|
+
entry.reject(new CDPConnectionError("CDP connection closed", { method: entry.method }));
|
|
644
|
+
this.#pending.delete(id);
|
|
645
|
+
}
|
|
646
|
+
await this.#transport.close();
|
|
647
|
+
}
|
|
648
|
+
#nextId() {
|
|
649
|
+
this.#messageId += 1;
|
|
650
|
+
return this.#messageId;
|
|
651
|
+
}
|
|
652
|
+
#onClose() {
|
|
653
|
+
this.#connected = false;
|
|
654
|
+
for (const [id, entry] of this.#pending) {
|
|
655
|
+
clearTimeout(entry.timer);
|
|
656
|
+
entry.reject(new CDPConnectionError("CDP connection closed", { method: entry.method }));
|
|
657
|
+
this.#pending.delete(id);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
#onError(error) {
|
|
661
|
+
if (!this.#connected) for (const [id, entry] of this.#pending) {
|
|
662
|
+
clearTimeout(entry.timer);
|
|
663
|
+
entry.reject(new CDPConnectionError(`CDP connection failed: ${String(error)}`, {
|
|
664
|
+
method: entry.method,
|
|
665
|
+
error
|
|
666
|
+
}));
|
|
667
|
+
this.#pending.delete(id);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
#onMessage(data) {
|
|
671
|
+
let parsed;
|
|
672
|
+
try {
|
|
673
|
+
parsed = JSON.parse(data);
|
|
674
|
+
} catch {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (!isRecord(parsed)) return;
|
|
678
|
+
if (typeof parsed["id"] === "number") {
|
|
679
|
+
const id = parsed["id"];
|
|
680
|
+
const entry = this.#pending.get(id);
|
|
681
|
+
if (entry !== void 0) {
|
|
682
|
+
this.#pending.delete(id);
|
|
683
|
+
clearTimeout(entry.timer);
|
|
684
|
+
const errorValue = parsed["error"];
|
|
685
|
+
if (isRecord(errorValue)) {
|
|
686
|
+
const message = isString(errorValue["message"]) ? errorValue["message"] : "CDP error";
|
|
687
|
+
const context = { method: entry.method };
|
|
688
|
+
if ("code" in errorValue) context["code"] = errorValue["code"];
|
|
689
|
+
context["message"] = message;
|
|
690
|
+
if ("data" in errorValue) context["data"] = errorValue["data"];
|
|
691
|
+
entry.reject(new CDPError(message, context));
|
|
692
|
+
} else entry.resolve("result" in parsed ? parsed["result"] : void 0);
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (isString(parsed["method"])) {
|
|
697
|
+
const method = parsed["method"];
|
|
698
|
+
const rawParams = parsed["params"];
|
|
699
|
+
const params = isRecord(rawParams) ? rawParams : Object.freeze({});
|
|
700
|
+
const globalHandlers = this.#subscriptions.get(method);
|
|
701
|
+
if (globalHandlers !== void 0) for (const handler of [...globalHandlers]) handler(params);
|
|
702
|
+
const eventSessionId = parsed["sessionId"];
|
|
703
|
+
if (isString(eventSessionId)) {
|
|
704
|
+
const sessionMap = this.#sessionSubscriptions.get(eventSessionId);
|
|
705
|
+
if (sessionMap !== void 0) {
|
|
706
|
+
const sessionHandlers = sessionMap.get(method);
|
|
707
|
+
if (sessionHandlers !== void 0) for (const handler of [...sessionHandlers]) handler(params);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
//#endregion
|
|
714
|
+
//#region src/core/BrowserCodegen.ts
|
|
715
|
+
var BrowserCodegen = class {
|
|
716
|
+
#client;
|
|
717
|
+
#sessionId;
|
|
718
|
+
#actions = [];
|
|
719
|
+
#started = false;
|
|
720
|
+
#destroyed = false;
|
|
721
|
+
#emitter;
|
|
722
|
+
constructor(client, sessionId, options) {
|
|
723
|
+
this.#client = client;
|
|
724
|
+
this.#sessionId = sessionId;
|
|
725
|
+
this.#emitter = new Emitter({ on: options?.on });
|
|
726
|
+
}
|
|
727
|
+
get emitter() {
|
|
728
|
+
return this.#emitter;
|
|
729
|
+
}
|
|
730
|
+
get started() {
|
|
731
|
+
return this.#started;
|
|
732
|
+
}
|
|
733
|
+
async start() {
|
|
734
|
+
if (this.#destroyed) return;
|
|
735
|
+
if (this.#started) return;
|
|
736
|
+
this.#started = true;
|
|
737
|
+
this.#client.subscribe("Runtime.bindingCalled", this.#onBindingCalled, this.#sessionId);
|
|
738
|
+
this.#client.subscribe("Page.frameNavigated", this.#onFrameNavigated, this.#sessionId);
|
|
739
|
+
try {
|
|
740
|
+
await this.#client.send("Runtime.enable", void 0, this.#sessionId);
|
|
741
|
+
await this.#client.send("Runtime.addBinding", { name: BROWSER_CODEGEN_BINDING_NAME }, this.#sessionId);
|
|
742
|
+
await this.#client.send("Page.addScriptToEvaluateOnNewDocument", { source: BROWSER_CODEGEN_SOURCE }, this.#sessionId);
|
|
743
|
+
await this.#client.send("Runtime.evaluate", {
|
|
744
|
+
expression: BROWSER_CODEGEN_SOURCE,
|
|
745
|
+
awaitPromise: true
|
|
746
|
+
}, this.#sessionId);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
this.#client.unsubscribe("Runtime.bindingCalled", this.#onBindingCalled, this.#sessionId);
|
|
749
|
+
this.#client.unsubscribe("Page.frameNavigated", this.#onFrameNavigated, this.#sessionId);
|
|
750
|
+
this.#started = false;
|
|
751
|
+
throw error;
|
|
752
|
+
}
|
|
753
|
+
this.#emitter.emit("start");
|
|
754
|
+
}
|
|
755
|
+
async stop() {
|
|
756
|
+
if (!this.#started) return this.actions();
|
|
757
|
+
this.#client.unsubscribe("Runtime.bindingCalled", this.#onBindingCalled, this.#sessionId);
|
|
758
|
+
this.#client.unsubscribe("Page.frameNavigated", this.#onFrameNavigated, this.#sessionId);
|
|
759
|
+
this.#started = false;
|
|
760
|
+
const snapshot = this.actions();
|
|
761
|
+
this.#emitter.emit("stop", snapshot);
|
|
762
|
+
return snapshot;
|
|
763
|
+
}
|
|
764
|
+
actions() {
|
|
765
|
+
return normalizeCodegenActions(this.#actions);
|
|
766
|
+
}
|
|
767
|
+
script(options) {
|
|
768
|
+
return compileCodegenScript(this.actions(), options);
|
|
769
|
+
}
|
|
770
|
+
clear() {
|
|
771
|
+
this.#actions = [];
|
|
772
|
+
this.#emitter.emit("clear");
|
|
773
|
+
}
|
|
774
|
+
async destroy() {
|
|
775
|
+
if (this.#destroyed) return;
|
|
776
|
+
this.#destroyed = true;
|
|
777
|
+
try {
|
|
778
|
+
await this.stop();
|
|
779
|
+
} catch {}
|
|
780
|
+
this.#actions = [];
|
|
781
|
+
this.#emitter.destroy();
|
|
782
|
+
}
|
|
783
|
+
#onBindingCalled = (params) => {
|
|
784
|
+
if (params["name"] !== "__orkestrelBrowserCodegen") return;
|
|
785
|
+
const action = parseCodegenActionPayload(params["payload"]);
|
|
786
|
+
if (action === void 0) return;
|
|
787
|
+
this.#actions.push(action);
|
|
788
|
+
this.#emitter.emit("action", action);
|
|
789
|
+
};
|
|
790
|
+
#onFrameNavigated = (params) => {
|
|
791
|
+
const action = readCodegenNavigateAction(params);
|
|
792
|
+
if (action === void 0) return;
|
|
793
|
+
this.#actions.push(action);
|
|
794
|
+
this.#emitter.emit("action", action);
|
|
795
|
+
};
|
|
796
|
+
};
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/core/BrowserPage.ts
|
|
799
|
+
var BrowserPage = class {
|
|
800
|
+
#client;
|
|
801
|
+
#targetId;
|
|
802
|
+
#sessionId;
|
|
803
|
+
#writer;
|
|
804
|
+
#url = "about:blank";
|
|
805
|
+
#closed = false;
|
|
806
|
+
#codegen;
|
|
807
|
+
#destroyHandler;
|
|
808
|
+
constructor(client, targetId, sessionId, writer, url) {
|
|
809
|
+
this.#client = client;
|
|
810
|
+
this.#targetId = targetId;
|
|
811
|
+
this.#sessionId = sessionId;
|
|
812
|
+
this.#writer = writer;
|
|
813
|
+
if (url !== void 0) this.#url = url;
|
|
814
|
+
this.#destroyHandler = (params) => {
|
|
815
|
+
if (isString(params["targetId"]) && params["targetId"] === this.#targetId) this.#closed = true;
|
|
816
|
+
};
|
|
817
|
+
this.#client.subscribe("Target.targetDestroyed", this.#destroyHandler);
|
|
818
|
+
}
|
|
819
|
+
get url() {
|
|
820
|
+
return this.#url;
|
|
821
|
+
}
|
|
822
|
+
get closed() {
|
|
823
|
+
return this.#closed;
|
|
824
|
+
}
|
|
825
|
+
async title() {
|
|
826
|
+
const result = await this.#evaluate("document.title");
|
|
827
|
+
return typeof result === "string" ? result : "";
|
|
828
|
+
}
|
|
829
|
+
async navigate(url, options) {
|
|
830
|
+
const timeout = options?.timeout ?? 3e4;
|
|
831
|
+
const condition = options?.condition ?? "load";
|
|
832
|
+
const wait = this.#waitForLoadEvent(condition, timeout);
|
|
833
|
+
const waitOutcome = wait.promise.then(() => ({ ok: true }), (error) => ({
|
|
834
|
+
ok: false,
|
|
835
|
+
error
|
|
836
|
+
}));
|
|
837
|
+
try {
|
|
838
|
+
const result = await this.#client.send("Page.navigate", { url }, this.#sessionId, timeout);
|
|
839
|
+
if (isRecord(result) && isString(result["errorText"])) throw new BrowserError(`Navigation failed: ${result["errorText"]}`);
|
|
840
|
+
const outcome = await waitOutcome;
|
|
841
|
+
if (!outcome.ok) throw outcome.error;
|
|
842
|
+
} catch (error) {
|
|
843
|
+
wait.cancel();
|
|
844
|
+
await this.#stopLoading(Math.min(timeout, BROWSER_STOP_LOADING_TIMEOUT_MS));
|
|
845
|
+
throw error;
|
|
846
|
+
}
|
|
847
|
+
const currentUrl = await this.#evaluate("location.href");
|
|
848
|
+
this.#url = typeof currentUrl === "string" ? currentUrl : url;
|
|
849
|
+
}
|
|
850
|
+
async content() {
|
|
851
|
+
const [title, html, text, currentUrl] = await Promise.all([
|
|
852
|
+
this.#evaluate("document.title"),
|
|
853
|
+
this.#evaluate(guardEvaluateExpression("document.documentElement.outerHTML", BROWSER_RESULT_LIMIT)),
|
|
854
|
+
this.#evaluate(guardEvaluateExpression("document.body ? document.body.innerText : \"\"", BROWSER_RESULT_LIMIT)),
|
|
855
|
+
this.#evaluate("location.href")
|
|
856
|
+
]);
|
|
857
|
+
if (typeof currentUrl === "string") this.#url = currentUrl;
|
|
858
|
+
return {
|
|
859
|
+
url: this.#url,
|
|
860
|
+
title: typeof title === "string" ? title : "",
|
|
861
|
+
html: typeof html === "string" ? html : "",
|
|
862
|
+
text: typeof text === "string" ? text : ""
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
async screenshot(options) {
|
|
866
|
+
const format = options?.type ?? "png";
|
|
867
|
+
const params = { format };
|
|
868
|
+
if (options?.quality !== void 0 && format === "jpeg") params["quality"] = options.quality;
|
|
869
|
+
if (options?.full === true) {
|
|
870
|
+
const metrics = await this.#client.send("Page.getLayoutMetrics", void 0, this.#sessionId);
|
|
871
|
+
if (isRecord(metrics) && isRecord(metrics["contentSize"])) {
|
|
872
|
+
const contentSize = metrics["contentSize"];
|
|
873
|
+
const width = typeof contentSize["width"] === "number" ? contentSize["width"] : 0;
|
|
874
|
+
const height = typeof contentSize["height"] === "number" ? contentSize["height"] : 0;
|
|
875
|
+
if (width > 0 && height > 0) {
|
|
876
|
+
params["clip"] = {
|
|
877
|
+
x: 0,
|
|
878
|
+
y: 0,
|
|
879
|
+
width,
|
|
880
|
+
height,
|
|
881
|
+
scale: 1
|
|
882
|
+
};
|
|
883
|
+
params["captureBeyondViewport"] = true;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const result = await this.#client.send("Page.captureScreenshot", params, this.#sessionId);
|
|
888
|
+
if (!isRecord(result) || !isString(result["data"])) throw new BrowserError("Screenshot failed: no data returned");
|
|
889
|
+
const bytes = decodeBase64(result["data"]);
|
|
890
|
+
if (options?.path !== void 0 && this.#writer !== void 0) await this.#writer.write(options.path, bytes);
|
|
891
|
+
return {
|
|
892
|
+
bytes,
|
|
893
|
+
path: options?.path
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
async click(selector, options) {
|
|
897
|
+
const timeout = options?.timeout ?? 3e4;
|
|
898
|
+
await this.#waitForSelector(selector, timeout);
|
|
899
|
+
await this.#evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el) { el.scrollIntoView({ block: 'center' }); el.click(); } else { throw new Error('Element not found: ' + ${JSON.stringify(selector)}); } })()`);
|
|
900
|
+
}
|
|
901
|
+
async fill(selector, value, options) {
|
|
902
|
+
const timeout = options?.timeout ?? 3e4;
|
|
903
|
+
await this.#waitForSelector(selector, timeout);
|
|
904
|
+
await this.#evaluate(`(() => {
|
|
905
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
906
|
+
if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
|
|
907
|
+
el.focus();
|
|
908
|
+
if (el.isContentEditable) {
|
|
909
|
+
el.textContent = ${JSON.stringify(value)};
|
|
910
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
911
|
+
} else {
|
|
912
|
+
el.value = ${JSON.stringify(value)};
|
|
913
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
914
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
915
|
+
}
|
|
916
|
+
})()`);
|
|
917
|
+
}
|
|
918
|
+
async select(selector, values, options) {
|
|
919
|
+
const timeout = options?.timeout ?? 3e4;
|
|
920
|
+
await this.#waitForSelector(selector, timeout);
|
|
921
|
+
const valuesJson = JSON.stringify([...values]);
|
|
922
|
+
await this.#evaluate(`(() => {
|
|
923
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
924
|
+
if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
|
|
925
|
+
const vals = ${valuesJson};
|
|
926
|
+
for (const opt of el.options) {
|
|
927
|
+
opt.selected = vals.includes(opt.value);
|
|
928
|
+
}
|
|
929
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
930
|
+
})()`);
|
|
931
|
+
}
|
|
932
|
+
async evaluate(expression) {
|
|
933
|
+
return this.#evaluate(guardEvaluateExpression(expression, BROWSER_RESULT_LIMIT));
|
|
934
|
+
}
|
|
935
|
+
async wait(selector, options) {
|
|
936
|
+
const timeout = options?.timeout ?? 3e4;
|
|
937
|
+
await this.#waitForSelector(selector, timeout);
|
|
938
|
+
}
|
|
939
|
+
async frame(name) {
|
|
940
|
+
return (await this.frames()).find((frame) => frame.name === name || frame.url === name);
|
|
941
|
+
}
|
|
942
|
+
async frames() {
|
|
943
|
+
const result = await this.#client.send("Page.getFrameTree", void 0, this.#sessionId);
|
|
944
|
+
if (!isRecord(result) || !isRecord(result["frameTree"])) return [];
|
|
945
|
+
const frames = [];
|
|
946
|
+
this.#flattenFrameTree(result["frameTree"], frames);
|
|
947
|
+
return frames;
|
|
948
|
+
}
|
|
949
|
+
async codegen(options) {
|
|
950
|
+
if (this.#codegen !== void 0) return this.#codegen;
|
|
951
|
+
const codegen = new BrowserCodegen(this.#client, this.#sessionId, options);
|
|
952
|
+
await codegen.start();
|
|
953
|
+
this.#codegen = codegen;
|
|
954
|
+
return codegen;
|
|
955
|
+
}
|
|
956
|
+
async close() {
|
|
957
|
+
if (this.#closed) return;
|
|
958
|
+
this.#closed = true;
|
|
959
|
+
if (this.#codegen !== void 0) {
|
|
960
|
+
try {
|
|
961
|
+
await this.#codegen.destroy();
|
|
962
|
+
} catch {}
|
|
963
|
+
this.#codegen = void 0;
|
|
964
|
+
}
|
|
965
|
+
this.#client.unsubscribe("Target.targetDestroyed", this.#destroyHandler);
|
|
966
|
+
try {
|
|
967
|
+
await this.#client.send("Target.closeTarget", { targetId: this.#targetId });
|
|
968
|
+
} catch {}
|
|
969
|
+
}
|
|
970
|
+
#flattenFrameTree(node, out) {
|
|
971
|
+
const frame = node["frame"];
|
|
972
|
+
if (isRecord(frame) && isString(frame["id"]) && isString(frame["url"])) {
|
|
973
|
+
const parent = frame["parentId"];
|
|
974
|
+
const name = frame["name"];
|
|
975
|
+
out.push({
|
|
976
|
+
id: frame["id"],
|
|
977
|
+
...isString(parent) ? { parent } : {},
|
|
978
|
+
...isString(name) && name !== "" ? { name } : {},
|
|
979
|
+
url: frame["url"]
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
const children = node["childFrames"];
|
|
983
|
+
if (Array.isArray(children)) {
|
|
984
|
+
for (const child of children) if (isRecord(child)) this.#flattenFrameTree(child, out);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
async #stopLoading(timeout) {
|
|
988
|
+
try {
|
|
989
|
+
await this.#client.send("Page.stopLoading", void 0, this.#sessionId, timeout);
|
|
990
|
+
} catch {}
|
|
991
|
+
}
|
|
992
|
+
async #evaluate(expression) {
|
|
993
|
+
const result = await this.#client.send("Runtime.evaluate", {
|
|
994
|
+
expression,
|
|
995
|
+
returnByValue: true,
|
|
996
|
+
awaitPromise: true
|
|
997
|
+
}, this.#sessionId);
|
|
998
|
+
if (!isRecord(result)) return void 0;
|
|
999
|
+
if (isRecord(result["exceptionDetails"])) {
|
|
1000
|
+
const details = result["exceptionDetails"];
|
|
1001
|
+
if (isRecord(details["exception"]) && isString(details["exception"]["description"])) {
|
|
1002
|
+
const description = details["exception"]["description"];
|
|
1003
|
+
const limitMatch = BROWSER_RESULT_LIMIT_PATTERN.exec(description);
|
|
1004
|
+
if (limitMatch !== null) throw new BrowserResultLimitError("Evaluation result exceeds BROWSER_RESULT_LIMIT", {
|
|
1005
|
+
length: Number(limitMatch[1]),
|
|
1006
|
+
limit: BROWSER_RESULT_LIMIT
|
|
1007
|
+
});
|
|
1008
|
+
throw new BrowserError(description);
|
|
1009
|
+
}
|
|
1010
|
+
throw new BrowserError("JavaScript evaluation failed");
|
|
1011
|
+
}
|
|
1012
|
+
const remoteObject = result["result"];
|
|
1013
|
+
if (!isRecord(remoteObject)) return void 0;
|
|
1014
|
+
if ("value" in remoteObject) return remoteObject["value"];
|
|
1015
|
+
}
|
|
1016
|
+
async #waitForSelector(selector, timeout) {
|
|
1017
|
+
const deadline = Date.now() + timeout;
|
|
1018
|
+
const selectorJson = JSON.stringify(selector);
|
|
1019
|
+
while (Date.now() < deadline) {
|
|
1020
|
+
if (await this.#evaluate(`document.querySelector(${selectorJson}) !== null`) === true) return;
|
|
1021
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1022
|
+
}
|
|
1023
|
+
throw new BrowserSelectorError(`Timeout waiting for selector: ${selector}`);
|
|
1024
|
+
}
|
|
1025
|
+
#waitForLoadEvent(condition, timeout) {
|
|
1026
|
+
const eventName = condition === "domcontentloaded" ? "Page.domContentEventFired" : "Page.loadEventFired";
|
|
1027
|
+
let settled = false;
|
|
1028
|
+
let timer;
|
|
1029
|
+
let handler;
|
|
1030
|
+
const promise = new Promise((resolve, reject) => {
|
|
1031
|
+
timer = setTimeout(() => {
|
|
1032
|
+
if (settled) return;
|
|
1033
|
+
settled = true;
|
|
1034
|
+
this.#client.unsubscribe(eventName, handler, this.#sessionId);
|
|
1035
|
+
reject(new BrowserError(`Navigation timeout after ${timeout}ms`));
|
|
1036
|
+
}, timeout);
|
|
1037
|
+
handler = () => {
|
|
1038
|
+
if (settled) return;
|
|
1039
|
+
settled = true;
|
|
1040
|
+
clearTimeout(timer);
|
|
1041
|
+
this.#client.unsubscribe(eventName, handler, this.#sessionId);
|
|
1042
|
+
resolve();
|
|
1043
|
+
};
|
|
1044
|
+
this.#client.subscribe(eventName, handler, this.#sessionId);
|
|
1045
|
+
});
|
|
1046
|
+
const cancel = () => {
|
|
1047
|
+
if (settled) return;
|
|
1048
|
+
settled = true;
|
|
1049
|
+
clearTimeout(timer);
|
|
1050
|
+
this.#client.unsubscribe(eventName, handler, this.#sessionId);
|
|
1051
|
+
};
|
|
1052
|
+
return {
|
|
1053
|
+
promise,
|
|
1054
|
+
cancel
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
//#endregion
|
|
1059
|
+
//#region src/core/BrowserContext.ts
|
|
1060
|
+
var BrowserContext = class {
|
|
1061
|
+
#client;
|
|
1062
|
+
#id;
|
|
1063
|
+
#viewport;
|
|
1064
|
+
#writer;
|
|
1065
|
+
#pages = /* @__PURE__ */ new Map();
|
|
1066
|
+
constructor(client, id, viewport, writer) {
|
|
1067
|
+
this.#client = client;
|
|
1068
|
+
this.#id = id;
|
|
1069
|
+
this.#viewport = viewport;
|
|
1070
|
+
this.#writer = writer;
|
|
1071
|
+
}
|
|
1072
|
+
get id() {
|
|
1073
|
+
return this.#id;
|
|
1074
|
+
}
|
|
1075
|
+
page(index) {
|
|
1076
|
+
const i = index ?? 0;
|
|
1077
|
+
const values = [...this.#pages.values()];
|
|
1078
|
+
return i >= 0 && i < values.length ? values[i] : void 0;
|
|
1079
|
+
}
|
|
1080
|
+
pages() {
|
|
1081
|
+
return [...this.#pages.values()];
|
|
1082
|
+
}
|
|
1083
|
+
async create(options) {
|
|
1084
|
+
const createParams = { url: "about:blank" };
|
|
1085
|
+
if (this.#id !== void 0) createParams["browserContextId"] = this.#id;
|
|
1086
|
+
const result = await this.#client.send("Target.createTarget", createParams);
|
|
1087
|
+
if (!isRecord(result) || !isString(result["targetId"])) throw new Error("Failed to create new browser target");
|
|
1088
|
+
const targetId = result["targetId"];
|
|
1089
|
+
const attachResult = await this.#client.send("Target.attachToTarget", {
|
|
1090
|
+
targetId,
|
|
1091
|
+
flatten: true
|
|
1092
|
+
});
|
|
1093
|
+
if (!isRecord(attachResult) || !isString(attachResult["sessionId"])) throw new Error("Failed to attach to browser target");
|
|
1094
|
+
const sessionId = attachResult["sessionId"];
|
|
1095
|
+
await this.#client.send("Page.enable", void 0, sessionId);
|
|
1096
|
+
await this.#client.send("Runtime.enable", void 0, sessionId);
|
|
1097
|
+
const viewport = options?.viewport ?? this.#viewport;
|
|
1098
|
+
if (viewport !== void 0) await this.#client.send("Emulation.setDeviceMetricsOverride", {
|
|
1099
|
+
width: viewport.width,
|
|
1100
|
+
height: viewport.height,
|
|
1101
|
+
deviceScaleFactor: 1,
|
|
1102
|
+
mobile: false
|
|
1103
|
+
}, sessionId);
|
|
1104
|
+
const page = new BrowserPage(this.#client, targetId, sessionId, this.#writer, options?.url);
|
|
1105
|
+
if (options?.url !== void 0 && options.url !== "about:blank") await page.navigate(options.url, { timeout: options.timeout });
|
|
1106
|
+
this.#pages.set(targetId, page);
|
|
1107
|
+
return page;
|
|
1108
|
+
}
|
|
1109
|
+
async sync(targets) {
|
|
1110
|
+
const pageTargets = targets.filter((t) => t.type === "page");
|
|
1111
|
+
const targetIds = new Set(pageTargets.map((t) => t.id));
|
|
1112
|
+
for (const [id, page] of this.#pages) {
|
|
1113
|
+
if (targetIds.has(id)) continue;
|
|
1114
|
+
try {
|
|
1115
|
+
await page.close();
|
|
1116
|
+
} catch {}
|
|
1117
|
+
this.#pages.delete(id);
|
|
1118
|
+
}
|
|
1119
|
+
for (const target of pageTargets) {
|
|
1120
|
+
if (this.#pages.has(target.id)) continue;
|
|
1121
|
+
try {
|
|
1122
|
+
const attachResult = await this.#client.send("Target.attachToTarget", {
|
|
1123
|
+
targetId: target.id,
|
|
1124
|
+
flatten: true
|
|
1125
|
+
});
|
|
1126
|
+
if (!isRecord(attachResult) || !isString(attachResult["sessionId"])) continue;
|
|
1127
|
+
const sessionId = attachResult["sessionId"];
|
|
1128
|
+
await this.#client.send("Page.enable", void 0, sessionId);
|
|
1129
|
+
await this.#client.send("Runtime.enable", void 0, sessionId);
|
|
1130
|
+
if (this.#viewport !== void 0) try {
|
|
1131
|
+
await this.#client.send("Emulation.setDeviceMetricsOverride", {
|
|
1132
|
+
width: this.#viewport.width,
|
|
1133
|
+
height: this.#viewport.height,
|
|
1134
|
+
deviceScaleFactor: 1,
|
|
1135
|
+
mobile: false
|
|
1136
|
+
}, sessionId);
|
|
1137
|
+
} catch {}
|
|
1138
|
+
this.#pages.set(target.id, new BrowserPage(this.#client, target.id, sessionId, this.#writer, target.url));
|
|
1139
|
+
} catch {}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
async close() {
|
|
1143
|
+
for (const page of this.#pages.values()) try {
|
|
1144
|
+
await page.close();
|
|
1145
|
+
} catch {}
|
|
1146
|
+
this.#pages.clear();
|
|
1147
|
+
if (this.#id !== void 0) try {
|
|
1148
|
+
await this.#client.send("Target.disposeBrowserContext", { browserContextId: this.#id });
|
|
1149
|
+
} catch {}
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
//#endregion
|
|
1153
|
+
//#region src/core/factories.ts
|
|
1154
|
+
/**
|
|
1155
|
+
* Create a CDP client bound to the given transport.
|
|
1156
|
+
*
|
|
1157
|
+
* @param options - The transport (and optional timeout) the client uses
|
|
1158
|
+
* @returns A {@link CDPClientInterface}
|
|
1159
|
+
*
|
|
1160
|
+
* @example
|
|
1161
|
+
* ```ts
|
|
1162
|
+
* import { createCDPClient } from '@src/core'
|
|
1163
|
+
*
|
|
1164
|
+
* const client = createCDPClient({ transport })
|
|
1165
|
+
* await client.connect()
|
|
1166
|
+
* ```
|
|
1167
|
+
*/
|
|
1168
|
+
function createCDPClient(options) {
|
|
1169
|
+
return new CDPClient(options);
|
|
1170
|
+
}
|
|
1171
|
+
//#endregion
|
|
1172
|
+
export { BASE64_CHARS, BASE64_LOOKUP, BROWSER_CODEGEN_BINDING_NAME, BROWSER_CODEGEN_SOURCE, BROWSER_DEFAULT_TIMEOUT_MS, BROWSER_DEFAULT_VIEWPORT_HEIGHT, BROWSER_DEFAULT_VIEWPORT_WIDTH, BROWSER_RESULT_LIMIT, BROWSER_RESULT_LIMIT_PATTERN, BROWSER_RESULT_LIMIT_SENTINEL_PREFIX, BROWSER_STOP_LOADING_TIMEOUT_MS, BROWSER_WAIT_POLL_INTERVAL_MS, BrowserCodegen, BrowserContext, BrowserError, BrowserPage, BrowserResultLimitError, BrowserSelectorError, CDPClient, CDPConnectionError, CDPError, CDPTimeoutError, compileCodegenScript, createCDPClient, decodeBase64, guardEvaluateExpression, isBrowserError, isBrowserResultLimitError, isBrowserSelectorError, isCDPConnectionError, isCDPError, isCDPTimeoutError, normalizeCodegenActions, parseCodegenActionPayload, readCodegenNavigateAction };
|
|
1173
|
+
|
|
1174
|
+
//# sourceMappingURL=index.js.map
|