@digital-gravy/etch-connector 0.1.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 +26 -0
- package/README.md +253 -0
- package/dist/bridge-server.js +242 -0
- package/dist/cdp-client.js +285 -0
- package/dist/cli.js +452 -0
- package/dist/control-server.js +108 -0
- package/dist/index.js +11 -0
- package/dist/manual.js +2 -0
- package/dist/protocol.js +38 -0
- package/package.json +70 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP backend — the out-of-band counterpart to {@link BridgeServer}.
|
|
3
|
+
*
|
|
4
|
+
* Where the bridge waits for a tab to dial *in* (and runs page-sandboxed,
|
|
5
|
+
* safe/unsafe scripts), this connects *out* to a Chrome started with
|
|
6
|
+
* `--remote-debugging-port=<n>` and drives the tab over the Chrome DevTools
|
|
7
|
+
* Protocol. That runs at DevTools privilege, so it unlocks what the in-page
|
|
8
|
+
* sandbox can't: full-`window` eval, `Page.captureScreenshot`, rendered
|
|
9
|
+
* `outerHTML`, and computed styles.
|
|
10
|
+
*
|
|
11
|
+
* Chrome already hosts the persistent endpoint, so this is a stateless client —
|
|
12
|
+
* no daemon. It speaks just enough CDP (Runtime + Page) to do the job; HTML and
|
|
13
|
+
* computed style ride on `Runtime.evaluate` rather than the DOM/CSS domains, to
|
|
14
|
+
* keep the surface small.
|
|
15
|
+
*/
|
|
16
|
+
import { WebSocket } from 'ws';
|
|
17
|
+
/** A script evaluated over CDP threw. Distinct from operational failures. */
|
|
18
|
+
export class CdpEvalError extends Error {
|
|
19
|
+
/** The full page-side exception text (description + stack), if any. */
|
|
20
|
+
details;
|
|
21
|
+
constructor(message, details) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'CdpEvalError';
|
|
24
|
+
this.details = details;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Operational CDP failure (no Chrome, no/ambiguous target, timeout). */
|
|
28
|
+
export class CdpError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'CdpError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const DEFAULT_PORT = 9222;
|
|
35
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
37
|
+
export class CdpClient {
|
|
38
|
+
host;
|
|
39
|
+
port;
|
|
40
|
+
ws = null;
|
|
41
|
+
nextId = 0;
|
|
42
|
+
pending = new Map();
|
|
43
|
+
target = null;
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.host = options.host ?? DEFAULT_HOST;
|
|
46
|
+
this.port = options.port ?? DEFAULT_PORT;
|
|
47
|
+
}
|
|
48
|
+
/** List page targets from a Chrome remote-debugging endpoint. */
|
|
49
|
+
static async listTargets(options = {}) {
|
|
50
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
51
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
52
|
+
let list;
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(`http://${host}:${port}/json/list`);
|
|
55
|
+
list = (await res.json());
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
throw new CdpError(`cannot reach Chrome remote debugging at ${host}:${port} — start Chrome with --remote-debugging-port=${port} (${String(err)})`);
|
|
59
|
+
}
|
|
60
|
+
return list.filter((t) => t.type === 'page' && !t.url.startsWith('devtools://'));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Pick a target (by url/title substring, case-insensitive) and attach.
|
|
64
|
+
* With no match string, requires exactly one page target.
|
|
65
|
+
*/
|
|
66
|
+
async connect(match) {
|
|
67
|
+
const pages = await CdpClient.listTargets({
|
|
68
|
+
host: this.host,
|
|
69
|
+
port: this.port
|
|
70
|
+
});
|
|
71
|
+
const chosen = this.pickTarget(pages, match);
|
|
72
|
+
await new Promise((resolve, reject) => {
|
|
73
|
+
const ws = new WebSocket(chosen.webSocketDebuggerUrl, {
|
|
74
|
+
perMessageDeflate: false
|
|
75
|
+
});
|
|
76
|
+
ws.on('open', () => resolve());
|
|
77
|
+
ws.on('error', (err) => reject(new CdpError(`failed to attach to target: ${String(err)}`)));
|
|
78
|
+
ws.on('message', (data) => this.onMessage(data.toString()));
|
|
79
|
+
ws.on('close', () => this.failAll(new CdpError('CDP connection closed')));
|
|
80
|
+
this.ws = ws;
|
|
81
|
+
});
|
|
82
|
+
this.target = chosen;
|
|
83
|
+
await this.send('Runtime.enable');
|
|
84
|
+
await this.send('Page.enable');
|
|
85
|
+
return chosen;
|
|
86
|
+
}
|
|
87
|
+
pickTarget(pages, match) {
|
|
88
|
+
if (match !== undefined) {
|
|
89
|
+
const m = match.toLowerCase();
|
|
90
|
+
const hits = pages.filter((t) => t.url.toLowerCase().includes(m) ||
|
|
91
|
+
(t.title ?? '').toLowerCase().includes(m));
|
|
92
|
+
if (hits.length === 0)
|
|
93
|
+
throw new CdpError(`no page target matches "${match}"`);
|
|
94
|
+
if (hits.length > 1) {
|
|
95
|
+
throw new CdpError(`"${match}" matches ${hits.length} targets: ${hits.map((t) => t.url).join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
return hits[0];
|
|
98
|
+
}
|
|
99
|
+
if (pages.length === 0)
|
|
100
|
+
throw new CdpError('no page targets open in Chrome');
|
|
101
|
+
if (pages.length > 1) {
|
|
102
|
+
throw new CdpError(`${pages.length} page targets open; pass -t <url-or-title substring>: ${pages
|
|
103
|
+
.map((t) => t.title || t.url)
|
|
104
|
+
.join(' | ')}`);
|
|
105
|
+
}
|
|
106
|
+
return pages[0];
|
|
107
|
+
}
|
|
108
|
+
async close() {
|
|
109
|
+
this.failAll(new CdpError('client closing'));
|
|
110
|
+
try {
|
|
111
|
+
this.ws?.close();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* already gone */
|
|
115
|
+
}
|
|
116
|
+
this.ws = null;
|
|
117
|
+
}
|
|
118
|
+
/** Send a CDP command and await its result. */
|
|
119
|
+
send(method, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
120
|
+
if (!this.ws)
|
|
121
|
+
return Promise.reject(new CdpError('not connected'));
|
|
122
|
+
const id = ++this.nextId;
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
this.pending.delete(id);
|
|
126
|
+
reject(new CdpError(`CDP "${method}" timed out after ${timeoutMs}ms`));
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
this.pending.set(id, {
|
|
129
|
+
resolve: resolve,
|
|
130
|
+
reject,
|
|
131
|
+
timer
|
|
132
|
+
});
|
|
133
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
onMessage(raw) {
|
|
137
|
+
let msg;
|
|
138
|
+
try {
|
|
139
|
+
msg = JSON.parse(raw);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (typeof msg.id !== 'number')
|
|
145
|
+
return; // an event, not a command reply
|
|
146
|
+
const pending = this.pending.get(msg.id);
|
|
147
|
+
if (!pending)
|
|
148
|
+
return;
|
|
149
|
+
this.pending.delete(msg.id);
|
|
150
|
+
clearTimeout(pending.timer);
|
|
151
|
+
if (msg.error)
|
|
152
|
+
pending.reject(new CdpError(`CDP error: ${msg.error.message ?? JSON.stringify(msg.error)}`));
|
|
153
|
+
else
|
|
154
|
+
pending.resolve(msg.result);
|
|
155
|
+
}
|
|
156
|
+
failAll(error) {
|
|
157
|
+
for (const [id, p] of this.pending) {
|
|
158
|
+
clearTimeout(p.timer);
|
|
159
|
+
this.pending.delete(id);
|
|
160
|
+
p.reject(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Evaluate a raw expression, returning its (JSON-safe) value. */
|
|
164
|
+
async evaluate(expression, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
165
|
+
const result = await this.send('Runtime.evaluate', {
|
|
166
|
+
expression,
|
|
167
|
+
awaitPromise: true,
|
|
168
|
+
returnByValue: true,
|
|
169
|
+
userGesture: true
|
|
170
|
+
}, timeoutMs);
|
|
171
|
+
if (result.exceptionDetails) {
|
|
172
|
+
const ex = result.exceptionDetails.exception;
|
|
173
|
+
const message = ex?.description ??
|
|
174
|
+
ex?.value ??
|
|
175
|
+
result.exceptionDetails.text ??
|
|
176
|
+
'evaluation failed';
|
|
177
|
+
throw new CdpEvalError(String(message).split('\n')[0], String(message));
|
|
178
|
+
}
|
|
179
|
+
return result.result?.value;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Run `code` as an async function body with full page access (it may `return`
|
|
183
|
+
* a value and use top-level `await`). Returns the JSON-safe return value.
|
|
184
|
+
*/
|
|
185
|
+
eval(code, options = {}) {
|
|
186
|
+
return this.evaluate(`(async () => { ${code} })()`, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
187
|
+
}
|
|
188
|
+
/** Rendered `outerHTML` of the first element matching `selector`. */
|
|
189
|
+
async outerHtml(selector) {
|
|
190
|
+
const v = await this.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? el.outerHTML : null; })()`);
|
|
191
|
+
if (v === null)
|
|
192
|
+
throw new CdpError(`no element matches "${selector}"`);
|
|
193
|
+
return v;
|
|
194
|
+
}
|
|
195
|
+
/** Computed style of `selector` as a plain object (all properties, or a subset). */
|
|
196
|
+
async computedStyle(selector, props) {
|
|
197
|
+
const keys = props ? JSON.stringify(props) : 'Array.from(cs)';
|
|
198
|
+
const expr = `(() => {
|
|
199
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
200
|
+
if (!el) return null;
|
|
201
|
+
const cs = getComputedStyle(el);
|
|
202
|
+
const out = {};
|
|
203
|
+
for (const k of ${keys}) out[k] = cs.getPropertyValue(k);
|
|
204
|
+
return out;
|
|
205
|
+
})()`;
|
|
206
|
+
const v = await this.evaluate(expr);
|
|
207
|
+
if (v === null)
|
|
208
|
+
throw new CdpError(`no element matches "${selector}"`);
|
|
209
|
+
return v;
|
|
210
|
+
}
|
|
211
|
+
/** Capture a PNG/JPEG of the viewport, a selector's box, or the full page. */
|
|
212
|
+
async screenshot(options = {}) {
|
|
213
|
+
const format = options.format ?? 'png';
|
|
214
|
+
// Freeze first, so the clip rect is measured on the same frame we capture.
|
|
215
|
+
if (options.freeze)
|
|
216
|
+
await this.freezeAnimations();
|
|
217
|
+
try {
|
|
218
|
+
let clip;
|
|
219
|
+
let captureBeyondViewport = false;
|
|
220
|
+
if (options.selector) {
|
|
221
|
+
const rect = (await this.evaluate(`(() => {
|
|
222
|
+
const el = document.querySelector(${JSON.stringify(options.selector)});
|
|
223
|
+
if (!el) return null;
|
|
224
|
+
const b = el.getBoundingClientRect();
|
|
225
|
+
return { x: b.left + scrollX, y: b.top + scrollY, width: b.width, height: b.height };
|
|
226
|
+
})()`));
|
|
227
|
+
if (!rect)
|
|
228
|
+
throw new CdpError(`no element matches "${options.selector}"`);
|
|
229
|
+
clip = { ...rect, scale: 1 };
|
|
230
|
+
captureBeyondViewport = true;
|
|
231
|
+
}
|
|
232
|
+
else if (options.full) {
|
|
233
|
+
const metrics = await this.send('Page.getLayoutMetrics');
|
|
234
|
+
const size = metrics.cssContentSize ?? metrics.contentSize;
|
|
235
|
+
clip = {
|
|
236
|
+
x: 0,
|
|
237
|
+
y: 0,
|
|
238
|
+
width: size.width,
|
|
239
|
+
height: size.height,
|
|
240
|
+
scale: 1
|
|
241
|
+
};
|
|
242
|
+
captureBeyondViewport = true;
|
|
243
|
+
}
|
|
244
|
+
const { data } = await this.send('Page.captureScreenshot', {
|
|
245
|
+
format,
|
|
246
|
+
...(clip ? { clip } : {}),
|
|
247
|
+
captureBeyondViewport
|
|
248
|
+
});
|
|
249
|
+
return Buffer.from(data, 'base64');
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
if (options.freeze)
|
|
253
|
+
await this.restoreAnimations();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Pause every animation/transition and seek it to a deterministic frame. */
|
|
257
|
+
async freezeAnimations() {
|
|
258
|
+
await this.evaluate(`(() => {
|
|
259
|
+
let s = document.getElementById('__etch_freeze__');
|
|
260
|
+
if (!s) {
|
|
261
|
+
s = document.createElement('style');
|
|
262
|
+
s.id = '__etch_freeze__';
|
|
263
|
+
s.textContent = '*,*::before,*::after{transition:none!important}';
|
|
264
|
+
(document.head || document.documentElement).appendChild(s);
|
|
265
|
+
}
|
|
266
|
+
for (const a of document.getAnimations()) {
|
|
267
|
+
try {
|
|
268
|
+
a.pause();
|
|
269
|
+
const end = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming().endTime : 0;
|
|
270
|
+
a.currentTime = (typeof end === 'number' && isFinite(end)) ? end : 0;
|
|
271
|
+
} catch (e) { /* leave this one as-is */ }
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
274
|
+
})()`);
|
|
275
|
+
}
|
|
276
|
+
/** Undo {@link freezeAnimations}: resume playback and remove the helper style. */
|
|
277
|
+
async restoreAnimations() {
|
|
278
|
+
await this.evaluate(`(() => {
|
|
279
|
+
const s = document.getElementById('__etch_freeze__');
|
|
280
|
+
if (s) s.remove();
|
|
281
|
+
for (const a of document.getAnimations()) { try { a.play(); } catch (e) {} }
|
|
282
|
+
return true;
|
|
283
|
+
})()`);
|
|
284
|
+
}
|
|
285
|
+
}
|