dsh-moli 1.1.12 → 1.1.13
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/README.md +46 -125
- package/README.zh.md +41 -111
- package/index.js +5 -299
- package/lib/moli.js +62 -1
- package/package.json +1 -1
- package/lib/actions.js +0 -460
- package/lib/session.js +0 -632
package/lib/session.js
DELETED
|
@@ -1,632 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Interactive browser sessions, driven over the Chrome DevTools Protocol.
|
|
3
|
-
*
|
|
4
|
-
* Why this exists next to the fetch tool: `moli_fetch` is one shot -- it loads a
|
|
5
|
-
* page and prints it. That covers reading. It cannot click a cookie banner, fill a
|
|
6
|
-
* search box, scroll a lazy list, or wait for a client-side route change, which is
|
|
7
|
-
* most of what "use the web" means for an agent.
|
|
8
|
-
*
|
|
9
|
-
* ## Two backends, chosen explicitly
|
|
10
|
-
*
|
|
11
|
-
* `moli serve` publishes CDP, WebDriver Classic and WebDriver BiDi on a loopback
|
|
12
|
-
* port, and so does any Chromium browser with `--remote-debugging-port`. CDP is a
|
|
13
|
-
* protocol, so the client below does not care which one answers -- only two things
|
|
14
|
-
* differ, and both are isolated here:
|
|
15
|
-
*
|
|
16
|
-
* 1. **How the process is started.** `moli serve ...` versus `chrome --headless=new
|
|
17
|
-
* --remote-debugging-port=...`.
|
|
18
|
-
* 2. **What element geometry means.** This is the reason a second backend exists at
|
|
19
|
-
* all. Moli's `getBoundingClientRect()` returns document-absolute coordinates
|
|
20
|
-
* that do not follow scrolling, and its hit test stops answering past the
|
|
21
|
-
* viewport height; measured against moli 1.1.16, an element at document y=1008
|
|
22
|
-
* reports y=1008 at scrollY 0, 400 and 800. A real Chromium is standards
|
|
23
|
-
* compliant: the same element reports 1008, 608, 208. Any automation written for
|
|
24
|
-
* one is wrong on the other, silently, so {@link ./actions.js} is told which
|
|
25
|
-
* frame it is working in and adapts.
|
|
26
|
-
*
|
|
27
|
-
* There is deliberately **no automatic fallback**. Switching engines because a
|
|
28
|
-
* click failed would make the tool's timing and behaviour depend on which
|
|
29
|
-
* operation happened to fail, and the caller could not tell which browser it was
|
|
30
|
-
* talking to. `browser: moli | chromium` says what you get.
|
|
31
|
-
*
|
|
32
|
-
* ## Lifecycle
|
|
33
|
-
*
|
|
34
|
-
* A server is a long-lived child, unlike every other call in this plugin: `runOnce`
|
|
35
|
-
* spawns, waits and reaps, while this process has to outlive a single tool call.
|
|
36
|
-
* So the lifecycle is explicit -- started lazily on first use, held across calls,
|
|
37
|
-
* stopped by `close` or when the plugin is disposed. Nothing blocks the DSH host:
|
|
38
|
-
* the child is spawned asynchronously and every wait has a deadline.
|
|
39
|
-
*
|
|
40
|
-
* The sandbox blocks inherited pipes but not loopback TCP, so CDP works where a
|
|
41
|
-
* piped child would not; child output goes to real files for the same reason.
|
|
42
|
-
*
|
|
43
|
-
* @module dsh-moli/lib/session
|
|
44
|
-
*/
|
|
45
|
-
import { spawn } from 'node:child_process';
|
|
46
|
-
import { closeSync, existsSync, mkdirSync, openSync, rmSync } from 'node:fs';
|
|
47
|
-
import { createConnection } from 'node:net';
|
|
48
|
-
import { homedir, tmpdir } from 'node:os';
|
|
49
|
-
import { join } from 'node:path';
|
|
50
|
-
|
|
51
|
-
/** CDP needs Node's built-in WebSocket, which arrived in 21. */
|
|
52
|
-
export const MIN_NODE_FOR_SESSION = '21.0.0';
|
|
53
|
-
|
|
54
|
-
/** Whether this Node can run a session at all. */
|
|
55
|
-
export function sessionSupported() {
|
|
56
|
-
return typeof globalThis.WebSocket === 'function';
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The message used when it cannot, so callers do not have to phrase it. */
|
|
60
|
-
export function sessionUnsupportedReason() {
|
|
61
|
-
return `moli_session needs Node >= ${MIN_NODE_FOR_SESSION} for the built-in WebSocket; this is Node ${process.versions.node}. moli_fetch and moli_probe work on older versions.`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Geometry conventions, per backend. */
|
|
65
|
-
export const GEOMETRY = {
|
|
66
|
-
/** Standard: the rectangle is viewport-relative and follows the scroll. */
|
|
67
|
-
viewport: 'viewport',
|
|
68
|
-
/** Moli: the rectangle is document-absolute and the scroll does not move it. */
|
|
69
|
-
absolute: 'absolute',
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
// helpers
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
|
|
76
|
-
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
77
|
-
|
|
78
|
-
/** Whether something is listening on host:port. */
|
|
79
|
-
export function portListens(host, port, timeoutMs = 700) {
|
|
80
|
-
return new Promise(resolve => {
|
|
81
|
-
const socket = createConnection({ host, port });
|
|
82
|
-
const settle = value => {
|
|
83
|
-
socket.removeAllListeners();
|
|
84
|
-
socket.destroy();
|
|
85
|
-
resolve(value);
|
|
86
|
-
};
|
|
87
|
-
socket.setTimeout(timeoutMs);
|
|
88
|
-
socket.once('connect', () => settle(true));
|
|
89
|
-
socket.once('timeout', () => settle(false));
|
|
90
|
-
socket.once('error', () => settle(false));
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function getJson(url, timeoutMs) {
|
|
95
|
-
const controller = new AbortController();
|
|
96
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
97
|
-
try {
|
|
98
|
-
const response = await fetch(url, { signal: controller.signal });
|
|
99
|
-
if (!response.ok) throw new Error(`HTTP ${String(response.status)} for ${url}`);
|
|
100
|
-
return await response.json();
|
|
101
|
-
} finally {
|
|
102
|
-
clearTimeout(timer);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** A writable directory for logs and browser profiles, outside the plugin tree. */
|
|
107
|
-
export function dataDir() {
|
|
108
|
-
const base = process.env.LOCALAPPDATA ?? process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share');
|
|
109
|
-
const dir = join(base, 'dsh-moli');
|
|
110
|
-
try { mkdirSync(dir, { recursive: true }); } catch { return tmpdir(); }
|
|
111
|
-
return dir;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Find a Chromium-family browser to drive.
|
|
116
|
-
*
|
|
117
|
-
* Node has no portable way to ask the OS for the default browser, so the known
|
|
118
|
-
* install locations are probed. `browserPath` overrides it, which is also the way
|
|
119
|
-
* to point at a build this list does not know about.
|
|
120
|
-
*/
|
|
121
|
-
export function findChromium(override) {
|
|
122
|
-
const candidates = [];
|
|
123
|
-
if (typeof override === 'string' && override !== '') candidates.push(override);
|
|
124
|
-
if (process.platform === 'win32') {
|
|
125
|
-
const pf = process.env['ProgramFiles'] ?? 'C:\\Program Files';
|
|
126
|
-
const pf86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
|
|
127
|
-
const local = process.env.LOCALAPPDATA ?? '';
|
|
128
|
-
candidates.push(
|
|
129
|
-
join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
130
|
-
join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
131
|
-
join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
132
|
-
join(pf, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
133
|
-
join(pf86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
134
|
-
join(pf, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),
|
|
135
|
-
);
|
|
136
|
-
} else if (process.platform === 'darwin') {
|
|
137
|
-
candidates.push(
|
|
138
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
139
|
-
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
140
|
-
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
141
|
-
);
|
|
142
|
-
} else {
|
|
143
|
-
candidates.push(
|
|
144
|
-
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium',
|
|
145
|
-
'/usr/bin/chromium-browser', '/usr/bin/microsoft-edge', '/snap/bin/chromium',
|
|
146
|
-
'/usr/bin/brave-browser',
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
return candidates.find(p => p !== '' && existsSync(p));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
// CDP client
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* One WebSocket connection to a page target.
|
|
158
|
-
*
|
|
159
|
-
* `send` rejects on a protocol error and on a closed socket, so a caller can never
|
|
160
|
-
* wait forever for a target that has gone away.
|
|
161
|
-
*/
|
|
162
|
-
class CdpConnection {
|
|
163
|
-
constructor(ws, targetId) {
|
|
164
|
-
this.ws = ws;
|
|
165
|
-
this.targetId = targetId;
|
|
166
|
-
this.nextId = 1;
|
|
167
|
-
this.pending = new Map();
|
|
168
|
-
this.listeners = new Set();
|
|
169
|
-
this.closed = false;
|
|
170
|
-
this.lastError = undefined;
|
|
171
|
-
|
|
172
|
-
ws.addEventListener('message', event => {
|
|
173
|
-
let message;
|
|
174
|
-
try { message = JSON.parse(String(event.data)); } catch { return; }
|
|
175
|
-
if (message.id !== undefined && this.pending.has(message.id)) {
|
|
176
|
-
const entry = this.pending.get(message.id);
|
|
177
|
-
this.pending.delete(message.id);
|
|
178
|
-
if (message.error !== undefined) entry.reject(new Error(`${entry.method}: ${String(message.error.message ?? JSON.stringify(message.error))}`));
|
|
179
|
-
else entry.resolve(message.result ?? {});
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (message.method !== undefined) {
|
|
183
|
-
for (const listener of this.listeners) {
|
|
184
|
-
try { listener(message); } catch { /* a listener must not break the socket */ }
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
const failAll = reason => {
|
|
190
|
-
this.closed = true;
|
|
191
|
-
this.lastError = reason;
|
|
192
|
-
for (const [, entry] of this.pending) entry.reject(new Error(reason));
|
|
193
|
-
this.pending.clear();
|
|
194
|
-
};
|
|
195
|
-
ws.addEventListener('close', () => failAll('the CDP connection closed'));
|
|
196
|
-
ws.addEventListener('error', () => failAll('the CDP connection errored'));
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
onEvent(listener) {
|
|
200
|
-
this.listeners.add(listener);
|
|
201
|
-
return () => this.listeners.delete(listener);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Send one CDP command. */
|
|
205
|
-
send(method, params = {}, timeoutMs = 30000) {
|
|
206
|
-
if (this.closed) return Promise.reject(new Error(this.lastError ?? 'the CDP connection is closed'));
|
|
207
|
-
const id = this.nextId++;
|
|
208
|
-
return new Promise((resolve, reject) => {
|
|
209
|
-
const timer = setTimeout(() => {
|
|
210
|
-
this.pending.delete(id);
|
|
211
|
-
reject(new Error(`${method} timed out after ${String(timeoutMs)}ms`));
|
|
212
|
-
}, timeoutMs);
|
|
213
|
-
this.pending.set(id, {
|
|
214
|
-
method,
|
|
215
|
-
resolve: value => { clearTimeout(timer); resolve(value); },
|
|
216
|
-
reject: error => { clearTimeout(timer); reject(error); },
|
|
217
|
-
});
|
|
218
|
-
try {
|
|
219
|
-
this.ws.send(JSON.stringify({ id, method, params }));
|
|
220
|
-
} catch (error) {
|
|
221
|
-
this.pending.delete(id);
|
|
222
|
-
clearTimeout(timer);
|
|
223
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Evaluate an expression in the page and return its value.
|
|
230
|
-
*
|
|
231
|
-
* A thrown expression comes back as `{ throws }` rather than a rejection: the
|
|
232
|
-
* caller is a model, and "your selector matched nothing" is information, not a
|
|
233
|
-
* transport failure.
|
|
234
|
-
*/
|
|
235
|
-
async evaluate(expression, { awaitPromise = false, timeoutMs = 30000 } = {}) {
|
|
236
|
-
const result = await this.send('Runtime.evaluate', {
|
|
237
|
-
expression, returnByValue: true, awaitPromise, userGesture: true,
|
|
238
|
-
}, timeoutMs);
|
|
239
|
-
if (result.exceptionDetails !== undefined) {
|
|
240
|
-
const detail = result.exceptionDetails;
|
|
241
|
-
const text = detail.exception?.description ?? detail.text ?? 'the expression threw';
|
|
242
|
-
return { throws: String(text).split('\n')[0] };
|
|
243
|
-
}
|
|
244
|
-
return { value: result.result?.value };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
close() {
|
|
248
|
-
this.closed = true;
|
|
249
|
-
try { this.ws.close(); } catch { /* already gone */ }
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ---------------------------------------------------------------------------
|
|
254
|
-
// server lifecycle
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Owns the browser process and the CDP connection to it.
|
|
259
|
-
*
|
|
260
|
-
* `ensure` is idempotent and reuses an external server when one is already
|
|
261
|
-
* listening, so a browser the user started themselves can be driven without
|
|
262
|
-
* fighting over the port -- and is left running when this plugin disposes.
|
|
263
|
-
*/
|
|
264
|
-
export class SessionManager {
|
|
265
|
-
/**
|
|
266
|
-
* @param options - engine path, backend choice, plugin config, and a logger.
|
|
267
|
-
*/
|
|
268
|
-
constructor(options) {
|
|
269
|
-
this.options = options;
|
|
270
|
-
this.child = undefined;
|
|
271
|
-
this.connection = undefined;
|
|
272
|
-
/** Host and port of the browser this plugin manages. Never a per-call value. */
|
|
273
|
-
this.host = options.host ?? '127.0.0.1';
|
|
274
|
-
this.port = options.port ?? 9222;
|
|
275
|
-
/**
|
|
276
|
-
* A CDP URL to drive for the current call only. Kept apart from `host`/`port`
|
|
277
|
-
* because it is transient: an earlier version overwrote `this.port` while
|
|
278
|
-
* resolving it and never put it back, so one call against `...:9/` left the
|
|
279
|
-
* session pointing at port 9 for the rest of the process.
|
|
280
|
-
*/
|
|
281
|
-
this.overrideEndpoint = undefined;
|
|
282
|
-
this.external = false;
|
|
283
|
-
this.backend = undefined;
|
|
284
|
-
this.geometry = GEOMETRY.absolute;
|
|
285
|
-
this.pid = undefined;
|
|
286
|
-
this.profileDir = undefined;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
get started() {
|
|
290
|
-
return this.child !== undefined && this.child.exitCode === null;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** The host and port CDP is reached on right now. */
|
|
294
|
-
get target() {
|
|
295
|
-
if (this.overrideEndpoint === undefined) return { host: this.host, port: this.port };
|
|
296
|
-
const parsed = new URL(this.overrideEndpoint);
|
|
297
|
-
return {
|
|
298
|
-
host: parsed.hostname,
|
|
299
|
-
port: Number(parsed.port === '' ? (parsed.protocol === 'https:' ? 443 : 80) : parsed.port),
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Which browser this session talks to, for `status`. */
|
|
304
|
-
describe() {
|
|
305
|
-
const { host, port } = this.target;
|
|
306
|
-
return {
|
|
307
|
-
backend: this.backend,
|
|
308
|
-
geometry: this.geometry,
|
|
309
|
-
endpoint: this.overrideEndpoint ?? `http://${host}:${String(port)}`,
|
|
310
|
-
external: this.external,
|
|
311
|
-
ownedByThisPlugin: this.started,
|
|
312
|
-
pid: this.pid,
|
|
313
|
-
logPath: this.logPath,
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Identify which geometry frame an endpoint's input APIs use.
|
|
319
|
-
*
|
|
320
|
-
* A name check cannot do this: moli answers `/json/version` with
|
|
321
|
-
* `{"Browser":"Chrome/145.0.0.0"}`, so testing the product string classifies moli
|
|
322
|
-
* as Chromium and picks the wrong frame -- the exact failure this mechanism
|
|
323
|
-
* exists to prevent. It does, however, put its own name in the debugger URL
|
|
324
|
-
* (`ws://127.0.0.1:9222/devtools/browser/moli-browser`), which no other browser
|
|
325
|
-
* does, so that is the marker used.
|
|
326
|
-
*
|
|
327
|
-
* A behavioural probe was tried first and is not reliable: moli updates element
|
|
328
|
-
* geometry lazily, so scrolling and re-measuring inside one evaluation shows the
|
|
329
|
-
* rectangle moving, and only a later read reveals that it snapped back to
|
|
330
|
-
* document-absolute. A hint that costs no round trip and cannot be mistimed is
|
|
331
|
-
* better than a measurement that has to guess when to look.
|
|
332
|
-
*/
|
|
333
|
-
#detectGeometry(version) {
|
|
334
|
-
const product = String(version?.Browser ?? '');
|
|
335
|
-
const socket = String(version?.webSocketDebuggerUrl ?? '');
|
|
336
|
-
if (/moli|lexmount/iu.test(product) || /moli/iu.test(socket)) {
|
|
337
|
-
return { backend: 'moli', geometry: GEOMETRY.absolute };
|
|
338
|
-
}
|
|
339
|
-
return { backend: 'chromium', geometry: GEOMETRY.viewport };
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Ensure a browser is running and return a live connection.
|
|
344
|
-
*
|
|
345
|
-
* @returns `{ connection, backend, geometry, external }`.
|
|
346
|
-
*/
|
|
347
|
-
async ensure() {
|
|
348
|
-
if (this.connection !== undefined && this.connection.closed === false) {
|
|
349
|
-
return { connection: this.connection, backend: this.backend, geometry: this.geometry, external: this.external };
|
|
350
|
-
}
|
|
351
|
-
this.connection = undefined;
|
|
352
|
-
const { host, port } = this.target;
|
|
353
|
-
|
|
354
|
-
// An explicit endpoint wins over everything: the caller has a browser.
|
|
355
|
-
if (this.overrideEndpoint !== undefined) {
|
|
356
|
-
const version = await this.#probeVersion(host, port);
|
|
357
|
-
if (version === undefined) throw new Error(`no CDP endpoint answers at ${this.overrideEndpoint}`);
|
|
358
|
-
const connection = await this.#connect(version, host, port);
|
|
359
|
-
this.external = true;
|
|
360
|
-
Object.assign(this, this.#detectGeometry(version));
|
|
361
|
-
this.connection = connection;
|
|
362
|
-
return { connection, backend: this.backend, geometry: this.geometry, external: true };
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Something already listening on the port? Use it rather than refuse to bind.
|
|
366
|
-
if (await portListens(host, port)) {
|
|
367
|
-
const version = await this.#probeVersion(host, port);
|
|
368
|
-
if (version !== undefined) {
|
|
369
|
-
const connection = await this.#connect(version, host, port);
|
|
370
|
-
this.external = true;
|
|
371
|
-
// A foreign server is not identifiable by name -- see #detectGeometry.
|
|
372
|
-
Object.assign(this, this.#detectGeometry(version));
|
|
373
|
-
this.connection = connection;
|
|
374
|
-
return { connection, backend: this.backend, geometry: this.geometry, external: true };
|
|
375
|
-
}
|
|
376
|
-
throw new Error(`port ${String(port)} is in use but does not answer as a CDP endpoint. Pass a different port, or stop whatever is listening there.`);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const wanted = this.options.browser ?? 'moli';
|
|
380
|
-
if (wanted === 'chromium') await this.#spawnChromium();
|
|
381
|
-
else if (wanted === 'moli') await this.#spawnMoli();
|
|
382
|
-
else throw new Error(`unknown browser ${JSON.stringify(String(wanted))}; use "moli" or "chromium"`);
|
|
383
|
-
|
|
384
|
-
const connection = await this.#connectAfterStart();
|
|
385
|
-
this.connection = connection;
|
|
386
|
-
return { connection, backend: this.backend, geometry: this.geometry, external: false };
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** Start `moli serve`, which already speaks CDP. */
|
|
390
|
-
async #spawnMoli() {
|
|
391
|
-
const exe = this.options.exe;
|
|
392
|
-
if (exe === undefined) throw new Error('no moli engine was found, so a moli session cannot be started');
|
|
393
|
-
const args = ['serve', '--host', this.host, '--port', String(this.port), '--layout'];
|
|
394
|
-
if (this.options.insecureTls === true) args.push('--insecure-disable-tls-host-verification');
|
|
395
|
-
if (this.options.proxy !== undefined && this.options.proxy !== '') args.push('--http-proxy', this.options.proxy);
|
|
396
|
-
if (this.options.userAgent !== undefined && this.options.userAgent !== '') args.push('--user-agent', this.options.userAgent);
|
|
397
|
-
if (this.options.profileDir !== undefined && this.options.profileDir !== '') args.push('--profile-dir', this.options.profileDir);
|
|
398
|
-
if (this.options.resources === true) args.push('--resource');
|
|
399
|
-
if (this.options.blockPrivateNetworks === true) args.push('--block-private-networks');
|
|
400
|
-
for (const cidr of this.options.blockCidrs ?? []) args.push('--block-cidrs', cidr);
|
|
401
|
-
this.backend = 'moli';
|
|
402
|
-
this.geometry = GEOMETRY.absolute;
|
|
403
|
-
await this.#spawn(exe, args, `dsh-moli-serve-${String(process.pid)}.log`);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** Start a Chromium-family browser with remote debugging enabled. */
|
|
407
|
-
async #spawnChromium() {
|
|
408
|
-
const exe = findChromium(this.options.browserPath);
|
|
409
|
-
if (exe === undefined) {
|
|
410
|
-
throw new Error('no Chromium-family browser was found (Chrome, Edge or Brave). Set `browserPath` to the executable, or use browser: "moli".');
|
|
411
|
-
}
|
|
412
|
-
// A persistent profile, so logins and cookies survive `close`: an ephemeral
|
|
413
|
-
// one is thrown away each time and makes every session start logged out.
|
|
414
|
-
this.profileDir = this.options.profileDir !== undefined && this.options.profileDir !== ''
|
|
415
|
-
? this.options.profileDir
|
|
416
|
-
: join(dataDir(), 'chromium-profile');
|
|
417
|
-
try { mkdirSync(this.profileDir, { recursive: true }); } catch { /* the browser will report it */ }
|
|
418
|
-
|
|
419
|
-
const args = [
|
|
420
|
-
`--remote-debugging-port=${String(this.port)}`,
|
|
421
|
-
'--remote-debugging-address=127.0.0.1',
|
|
422
|
-
`--user-data-dir=${this.profileDir}`,
|
|
423
|
-
// `--headless=new` is the modern headless that shares the real browser's
|
|
424
|
-
// behaviour; older builds only understand bare `--headless`.
|
|
425
|
-
'--headless=new',
|
|
426
|
-
'--no-first-run',
|
|
427
|
-
'--no-default-browser-check',
|
|
428
|
-
'--disable-gpu',
|
|
429
|
-
'--disable-background-networking',
|
|
430
|
-
'--disable-sync',
|
|
431
|
-
'--disable-extensions',
|
|
432
|
-
'--disable-features=Translate,MediaRouter',
|
|
433
|
-
'--hide-crash-restore-bubble',
|
|
434
|
-
'--window-size=1280,900',
|
|
435
|
-
];
|
|
436
|
-
if (this.options.userAgent !== undefined && this.options.userAgent !== '') args.push(`--user-agent=${this.options.userAgent}`);
|
|
437
|
-
if (this.options.proxy !== undefined && this.options.proxy !== '') args.push(`--proxy-server=${this.options.proxy}`);
|
|
438
|
-
if (this.options.insecureTls === true) args.push('--ignore-certificate-errors');
|
|
439
|
-
args.push('about:blank');
|
|
440
|
-
|
|
441
|
-
this.backend = 'chromium';
|
|
442
|
-
this.geometry = GEOMETRY.viewport;
|
|
443
|
-
await this.#spawn(exe, args, `dsh-moli-chromium-${String(process.pid)}.log`);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** Spawn a browser child and wait for its debugging port. */
|
|
447
|
-
async #spawn(exe, args, logName) {
|
|
448
|
-
const logPath = join(dataDir(), logName);
|
|
449
|
-
let outFd;
|
|
450
|
-
try { outFd = openSync(logPath, 'w'); } catch { outFd = undefined; }
|
|
451
|
-
const child = outFd === undefined
|
|
452
|
-
? spawn(exe, args, { stdio: 'ignore', windowsHide: true })
|
|
453
|
-
: spawn(exe, args, { stdio: ['ignore', outFd, outFd], windowsHide: true });
|
|
454
|
-
if (outFd !== undefined) closeSync(outFd);
|
|
455
|
-
this.child = child;
|
|
456
|
-
this.pid = child.pid;
|
|
457
|
-
this.logPath = logPath;
|
|
458
|
-
|
|
459
|
-
const timeout = this.options.startTimeoutMs ?? 25000;
|
|
460
|
-
const deadline = Date.now() + timeout;
|
|
461
|
-
for (;;) {
|
|
462
|
-
if (child.exitCode !== null) {
|
|
463
|
-
throw new Error(`${this.backend} exited with ${String(child.exitCode)} before listening; see ${logPath}`);
|
|
464
|
-
}
|
|
465
|
-
if (await portListens(this.host, this.port, 400)) return;
|
|
466
|
-
if (Date.now() > deadline) {
|
|
467
|
-
child.kill('SIGTERM');
|
|
468
|
-
throw new Error(`${this.backend} did not listen on ${this.host}:${String(this.port)} within ${String(timeout)}ms; see ${logPath}`);
|
|
469
|
-
}
|
|
470
|
-
await sleep(150);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Wait for a usable page target after the port opens, then confirm the frame.
|
|
476
|
-
*
|
|
477
|
-
* The backend is already known here -- we spawned it -- so the frame it was
|
|
478
|
-
* started with is authoritative and no probe is needed. Verifying it anyway cost
|
|
479
|
-
* a round trip and could disagree with the process we are holding, so it does not.
|
|
480
|
-
*/
|
|
481
|
-
async #connectAfterStart() {
|
|
482
|
-
let lastError;
|
|
483
|
-
for (let attempt = 0; attempt < 40; attempt++) {
|
|
484
|
-
try {
|
|
485
|
-
const version = await this.#probeVersion(this.host, this.port);
|
|
486
|
-
if (version !== undefined) return await this.#connect(version, this.host, this.port);
|
|
487
|
-
} catch (error) { lastError = error; }
|
|
488
|
-
await sleep(200);
|
|
489
|
-
}
|
|
490
|
-
this.child?.kill('SIGTERM');
|
|
491
|
-
throw new Error(`${String(this.backend)} is listening on port ${String(this.port)} but no page target was reachable: ${String(lastError?.message ?? 'unknown')}`);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
async #probeVersion(host, port) {
|
|
495
|
-
try {
|
|
496
|
-
const version = await getJson(`http://${host}:${String(port)}/json/version`, 3000);
|
|
497
|
-
return typeof version.Browser === 'string' ? version : undefined;
|
|
498
|
-
} catch {
|
|
499
|
-
return undefined;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/** Open a WebSocket to the first page target and enable the domains we use. */
|
|
504
|
-
async #connect(version, host, port) {
|
|
505
|
-
if (!sessionSupported()) throw new Error(sessionUnsupportedReason());
|
|
506
|
-
const targets = await getJson(`http://${host}:${String(port)}/json/list`, 5000);
|
|
507
|
-
const page = Array.isArray(targets) ? targets.find(t => t.type === 'page') : undefined;
|
|
508
|
-
if (page === undefined || typeof page.webSocketDebuggerUrl !== 'string') {
|
|
509
|
-
throw new Error(`no page target on http://${host}:${String(port)}/json/list`);
|
|
510
|
-
}
|
|
511
|
-
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
512
|
-
await new Promise((resolve, reject) => {
|
|
513
|
-
const timer = setTimeout(() => reject(new Error('the CDP WebSocket did not open in time')), 15000);
|
|
514
|
-
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
515
|
-
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('the CDP WebSocket failed to open')); }, { once: true });
|
|
516
|
-
});
|
|
517
|
-
const connection = new CdpConnection(ws, page.id);
|
|
518
|
-
for (const domain of ['Page.enable', 'Runtime.enable', 'DOM.enable']) {
|
|
519
|
-
await connection.send(domain, {}, 10000);
|
|
520
|
-
}
|
|
521
|
-
this.browserVersion = version?.Browser;
|
|
522
|
-
return connection;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Run something against a specific CDP endpoint, without latching onto it.
|
|
527
|
-
*
|
|
528
|
-
* A per-call endpoint has to be scoped to the call. Latching it permanently --
|
|
529
|
-
* which an earlier version did by assigning `this.endpoint` -- meant a single
|
|
530
|
-
* call that named a bad or dead endpoint poisoned every later call, and a call
|
|
531
|
-
* that named a good one silently redirected the whole session. The previous
|
|
532
|
-
* endpoint is restored afterwards, including when the call throws.
|
|
533
|
-
*
|
|
534
|
-
* This is also how a browser the user already has open, with their cookies and
|
|
535
|
-
* logins, gets driven: start it with `--remote-debugging-port`, pass that URL,
|
|
536
|
-
* and nothing is spawned or stopped on its behalf.
|
|
537
|
-
*/
|
|
538
|
-
async withEndpoint(endpoint, work) {
|
|
539
|
-
// Already attached to exactly this endpoint: reuse it rather than reconnect.
|
|
540
|
-
if (this.overrideEndpoint === endpoint && this.connection !== undefined && this.connection.closed === false) {
|
|
541
|
-
return work({ connection: this.connection, backend: this.backend, geometry: this.geometry, external: true });
|
|
542
|
-
}
|
|
543
|
-
// Save the managed browser's own state. Clearing it instead -- which an earlier
|
|
544
|
-
// version did -- meant a `status` or `close` that named an endpoint discarded
|
|
545
|
-
// the connection to the browser this plugin manages, and the following `close`
|
|
546
|
-
// then stopped that browser on the caller's behalf.
|
|
547
|
-
const saved = {
|
|
548
|
-
connection: this.connection,
|
|
549
|
-
backend: this.backend,
|
|
550
|
-
geometry: this.geometry,
|
|
551
|
-
external: this.external,
|
|
552
|
-
browserVersion: this.browserVersion,
|
|
553
|
-
};
|
|
554
|
-
if (this.connection !== undefined && this.connection.closed === false) this.connection.close();
|
|
555
|
-
this.connection = undefined;
|
|
556
|
-
this.overrideEndpoint = endpoint;
|
|
557
|
-
try {
|
|
558
|
-
return await work(await this.ensure());
|
|
559
|
-
} finally {
|
|
560
|
-
// A connection opened for this call is not kept: the caller asked for one call
|
|
561
|
-
// against that browser, not for the plugin to keep talking to it. The managed
|
|
562
|
-
// host and port were never touched, so restoring the saved state is enough.
|
|
563
|
-
// The saved connection may have been closed during the call -- `close` does
|
|
564
|
-
// exactly that -- so it is only restored while it is still usable.
|
|
565
|
-
this.connection = saved.connection !== undefined && saved.connection.closed === false
|
|
566
|
-
? saved.connection
|
|
567
|
-
: undefined;
|
|
568
|
-
this.backend = saved.backend;
|
|
569
|
-
this.geometry = saved.geometry;
|
|
570
|
-
this.external = saved.external;
|
|
571
|
-
this.browserVersion = saved.browserVersion;
|
|
572
|
-
this.overrideEndpoint = undefined;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Stop the browser this plugin started. A browser it merely attached to -- one the
|
|
578
|
-
* caller named, or one already listening on the port -- is left running.
|
|
579
|
-
*
|
|
580
|
-
* When an override endpoint is active, only that connection is dropped: the
|
|
581
|
-
* managed browser is not the subject of this call and must not be stopped by it.
|
|
582
|
-
*/
|
|
583
|
-
async close() {
|
|
584
|
-
const connection = this.connection;
|
|
585
|
-
this.connection = undefined;
|
|
586
|
-
if (connection !== undefined) connection.close();
|
|
587
|
-
|
|
588
|
-
if (this.overrideEndpoint !== undefined) {
|
|
589
|
-
return { stopped: false, external: true, scope: this.overrideEndpoint };
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
const child = this.child;
|
|
593
|
-
this.child = undefined;
|
|
594
|
-
const pid = this.pid;
|
|
595
|
-
this.pid = undefined;
|
|
596
|
-
const wasExternal = this.external;
|
|
597
|
-
this.external = false;
|
|
598
|
-
if (child === undefined || child.exitCode !== null) {
|
|
599
|
-
return { stopped: false, external: wasExternal };
|
|
600
|
-
}
|
|
601
|
-
child.kill('SIGTERM');
|
|
602
|
-
const deadline = Date.now() + 6000;
|
|
603
|
-
while (child.exitCode === null && Date.now() < deadline) await sleep(120);
|
|
604
|
-
if (child.exitCode === null) child.kill('SIGKILL');
|
|
605
|
-
return { stopped: true, external: false, pid, backend: this.backend, logPath: this.logPath };
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/** Remove a Chromium profile this plugin created. */
|
|
609
|
-
forgetProfile() {
|
|
610
|
-
if (this.backend !== 'chromium' || this.profileDir === undefined) return undefined;
|
|
611
|
-
const dir = this.profileDir;
|
|
612
|
-
this.profileDir = undefined;
|
|
613
|
-
try { rmSync(dir, { recursive: true, force: true }); return dir; } catch { return undefined; }
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/** Diagnostic view for the tool's `status` action. */
|
|
617
|
-
async status() {
|
|
618
|
-
const base = { running: this.started || this.external, ...this.describe(), browserVersion: this.browserVersion, logPath: this.logPath };
|
|
619
|
-
const connection = this.connection;
|
|
620
|
-
if (connection === undefined || connection.closed === true) return base;
|
|
621
|
-
// Asking a browser that has gone away for its current page must not turn a
|
|
622
|
-
// status report into an exception; report what is known and drop the socket.
|
|
623
|
-
try {
|
|
624
|
-
const url = await connection.evaluate('location.href');
|
|
625
|
-
const title = await connection.evaluate('document.title');
|
|
626
|
-
return { ...base, url: url.value, title: title.value };
|
|
627
|
-
} catch {
|
|
628
|
-
this.connection = undefined;
|
|
629
|
-
return { ...base, running: this.started, pageUnavailable: true };
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
}
|