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/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
- }