dsh-moli 1.1.11 → 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,372 +0,0 @@
1
- /**
2
- * Interactive browser sessions, backed by `moli serve` and driven over CDP.
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
6
- * a search box, scroll a lazy list, or wait for a client-side route change, which
7
- * is most of what "use the web" means for an agent.
8
- *
9
- * `moli serve` already exposes Chrome DevTools Protocol, WebDriver Classic and
10
- * WebDriver BiDi; this module talks CDP over the loopback WebSocket that the
11
- * server publishes at `/json/list`. No dependency is added: Node's built-in
12
- * `WebSocket` (21+) does the transport, which is why this feature needs a newer
13
- * Node than the rest of the plugin.
14
- *
15
- * Two design constraints come from the plugin's history and shape everything here:
16
- *
17
- * - **A server is a long-lived child, unlike every other call in this plugin.**
18
- * `runOnce` spawns, waits and reaps. A serve process must outlive a single tool
19
- * call, so its lifecycle is explicit: started lazily on first use, held across
20
- * calls, and stopped by `close`. Nothing here may block the DSH host, so the
21
- * child is spawned asynchronously and every wait is cancellable.
22
- * - **The sandbox blocks named pipes, not loopback TCP.** The plugin writes child
23
- * stdout to real files for that reason. HTTP and WebSocket over 127.0.0.1 are
24
- * fine -- verified under the restricted sandbox -- so CDP works where an
25
- * inherited pipe would not.
26
- *
27
- * @module dsh-moli/lib/session
28
- */
29
- import { spawn } from 'node:child_process';
30
- import { closeSync, existsSync, mkdirSync, openSync, statSync, writeFileSync } from 'node:fs';
31
- import { createConnection } from 'node:net';
32
- import { tmpdir } from 'node:os';
33
- import { dirname, join } from 'node:path';
34
-
35
- /** CDP needs Node's built-in WebSocket, which arrived in 21. */
36
- export const MIN_NODE_FOR_SESSION = '21.0.0';
37
-
38
- /** Whether this Node can run a session at all. */
39
- export function sessionSupported() {
40
- return typeof globalThis.WebSocket === 'function';
41
- }
42
-
43
- /** The message used when it cannot, so callers do not have to phrase it. */
44
- export function sessionUnsupportedReason() {
45
- 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.`;
46
- }
47
-
48
- // ---------------------------------------------------------------------------
49
- // small helpers
50
- // ---------------------------------------------------------------------------
51
-
52
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
53
-
54
- /** Whether something is listening on host:port, used before spawning a server. */
55
- export function portListens(host, port, timeoutMs = 700) {
56
- return new Promise(resolve => {
57
- const socket = createConnection({ host, port });
58
- const settle = value => {
59
- socket.removeAllListeners();
60
- socket.destroy();
61
- resolve(value);
62
- };
63
- socket.setTimeout(timeoutMs);
64
- socket.once('connect', () => settle(true));
65
- socket.once('timeout', () => settle(false));
66
- socket.once('error', () => settle(false));
67
- });
68
- }
69
-
70
- /** GET a JSON document from the local server, with a deadline. */
71
- async function getJson(url, timeoutMs) {
72
- const controller = new AbortController();
73
- const timer = setTimeout(() => controller.abort(), timeoutMs);
74
- try {
75
- const response = await fetch(url, { signal: controller.signal });
76
- if (!response.ok) throw new Error(`HTTP ${String(response.status)} for ${url}`);
77
- return await response.json();
78
- } finally {
79
- clearTimeout(timer);
80
- }
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // CDP client
85
- // ---------------------------------------------------------------------------
86
-
87
- /**
88
- * A single WebSocket connection to one page target.
89
- *
90
- * `send` resolves with the CDP result or rejects with the protocol error, and
91
- * never rejects on a target that has gone away without a reply -- the socket
92
- * close path settles everything outstanding, so a caller cannot hang forever.
93
- */
94
- class CdpConnection {
95
- constructor(ws, targetId) {
96
- this.ws = ws;
97
- this.targetId = targetId;
98
- this.nextId = 1;
99
- this.pending = new Map();
100
- this.listeners = new Set();
101
- this.closed = false;
102
- this.lastError = undefined;
103
-
104
- ws.addEventListener('message', event => {
105
- let message;
106
- try { message = JSON.parse(String(event.data)); } catch { return; }
107
- if (message.id !== undefined && this.pending.has(message.id)) {
108
- const entry = this.pending.get(message.id);
109
- this.pending.delete(message.id);
110
- if (message.error !== undefined) entry.reject(new Error(`${entry.method}: ${String(message.error.message ?? JSON.stringify(message.error))}`));
111
- else entry.resolve(message.result ?? {});
112
- return;
113
- }
114
- if (message.method !== undefined) {
115
- for (const listener of this.listeners) {
116
- try { listener(message); } catch { /* a listener must not break the socket */ }
117
- }
118
- }
119
- });
120
-
121
- const failAll = reason => {
122
- this.closed = true;
123
- this.lastError = reason;
124
- for (const [, entry] of this.pending) entry.reject(new Error(reason));
125
- this.pending.clear();
126
- };
127
- ws.addEventListener('close', () => failAll('the CDP connection closed'));
128
- ws.addEventListener('error', () => failAll('the CDP connection errored'));
129
- }
130
-
131
- /** Subscribe to protocol events; returns an unsubscribe function. */
132
- onEvent(listener) {
133
- this.listeners.add(listener);
134
- return () => this.listeners.delete(listener);
135
- }
136
-
137
- /** Send one CDP command. */
138
- send(method, params = {}, timeoutMs = 30000) {
139
- if (this.closed) return Promise.reject(new Error(this.lastError ?? 'the CDP connection is closed'));
140
- const id = this.nextId++;
141
- return new Promise((resolve, reject) => {
142
- const timer = setTimeout(() => {
143
- this.pending.delete(id);
144
- reject(new Error(`${method} timed out after ${String(timeoutMs)}ms`));
145
- }, timeoutMs);
146
- this.pending.set(id, {
147
- method,
148
- resolve: value => { clearTimeout(timer); resolve(value); },
149
- reject: error => { clearTimeout(timer); reject(error); },
150
- });
151
- try {
152
- this.ws.send(JSON.stringify({ id, method, params }));
153
- } catch (error) {
154
- this.pending.delete(id);
155
- clearTimeout(timer);
156
- reject(error instanceof Error ? error : new Error(String(error)));
157
- }
158
- });
159
- }
160
-
161
- /**
162
- * Evaluate an expression in the page and return its value.
163
- *
164
- * A thrown expression is returned as `{ throws }` rather than rejected: the
165
- * caller is a model, and "your selector matched nothing" is information, not a
166
- * transport failure.
167
- */
168
- async evaluate(expression, { awaitPromise = false, timeoutMs = 30000 } = {}) {
169
- const result = await this.send('Runtime.evaluate', {
170
- expression,
171
- returnByValue: true,
172
- awaitPromise,
173
- userGesture: true,
174
- }, timeoutMs);
175
- if (result.exceptionDetails !== undefined) {
176
- const detail = result.exceptionDetails;
177
- const text = detail.exception?.description ?? detail.text ?? 'the expression threw';
178
- return { throws: String(text).split('\n')[0] };
179
- }
180
- return { value: result.result?.value };
181
- }
182
-
183
- close() {
184
- this.closed = true;
185
- try { this.ws.close(); } catch { /* already gone */ }
186
- }
187
- }
188
-
189
- // ---------------------------------------------------------------------------
190
- // server lifecycle
191
- // ---------------------------------------------------------------------------
192
-
193
- /**
194
- * Owns the `moli serve` child and the connection to it.
195
- *
196
- * One session per plugin instance. `ensure` is idempotent: it reuses a live
197
- * child, and if the port is already served by someone else's moli (the user's own
198
- * `moli serve`, another profile) it connects to that instead of failing to bind.
199
- */
200
- export class SessionManager {
201
- /**
202
- * @param options - resolved engine path, plugin config, and a logger.
203
- */
204
- constructor(options) {
205
- this.options = options;
206
- this.child = undefined;
207
- this.connection = undefined;
208
- this.port = options.port ?? 9222;
209
- this.host = options.host ?? '127.0.0.1';
210
- this.external = false;
211
- this.pid = undefined;
212
- }
213
-
214
- /** The running child, or undefined when nothing was started by us. */
215
- get started() {
216
- return this.child !== undefined && this.child.exitCode === null;
217
- }
218
-
219
- /**
220
- * Start the server if needed and return a live CDP connection.
221
- *
222
- * @returns the connection, plus whether the server was already running.
223
- */
224
- async ensure() {
225
- if (this.connection !== undefined && this.connection.closed === false) {
226
- return { connection: this.connection, external: this.external };
227
- }
228
- this.connection = undefined;
229
-
230
- // Someone else may already serve this port: the user's own moli serve, or a
231
- // previous run that outlived us. Connecting to it is friendlier than refusing
232
- // to bind, and it is detectable: the CDP endpoint identifies the browser.
233
- if (await portListens(this.host, this.port)) {
234
- const version = await this.#probeVersion();
235
- if (version !== undefined) {
236
- const connection = await this.#connect();
237
- this.external = true;
238
- this.connection = connection;
239
- return { connection, external: true, version };
240
- }
241
- throw new Error(
242
- `port ${String(this.port)} is in use but does not answer as a CDP endpoint. `
243
- + 'Pass a different `port`, or stop whatever is listening there.');
244
- }
245
-
246
- if (!this.options.exe) throw new Error('no moli engine is available to start a session');
247
-
248
- const args = ['serve', '--host', this.host, '--port', String(this.port)];
249
- // Layout is what makes geometry, hit-testing and screenshots real; without it
250
- // the engine keeps compatibility geometry and clicks land in the wrong place.
251
- args.push('--layout');
252
- if (this.options.insecureTls === true) args.push('--insecure-disable-tls-host-verification');
253
- if (this.options.proxy !== undefined && this.options.proxy !== '') args.push('--http-proxy', this.options.proxy);
254
- if (this.options.userAgent !== undefined && this.options.userAgent !== '') args.push('--user-agent', this.options.userAgent);
255
- if (this.options.profileDir !== undefined && this.options.profileDir !== '') args.push('--profile-dir', this.options.profileDir);
256
- if (this.options.resources === true) args.push('--resource');
257
- if (this.options.blockPrivateNetworks === true) args.push('--block-private-networks');
258
- for (const cidr of this.options.blockCidrs ?? []) args.push('--block-cidrs', cidr);
259
-
260
- // Child output goes to real files: the sandbox blocks inherited pipes, the
261
- // same reason lib/moli.js redirects every other child.
262
- const logPath = join(tmpdir(), `dsh-moli-serve-${String(process.pid)}.log`);
263
- const outFd = openSync(logPath, 'w');
264
- const child = spawn(this.options.exe, args, { stdio: ['ignore', outFd, outFd], windowsHide: true });
265
- closeSync(outFd);
266
- this.child = child;
267
- this.pid = child.pid;
268
- this.logPath = logPath;
269
-
270
- const exited = new Promise(resolve => child.once('exit', code => resolve(code)));
271
- // Wait for the port, or for the child to die trying.
272
- const started = Date.now();
273
- const deadline = started + (this.options.startTimeoutMs ?? 20000);
274
- for (;;) {
275
- if (child.exitCode !== null) {
276
- throw new Error(`moli serve exited with ${String(child.exitCode)} before listening; see ${logPath}`);
277
- }
278
- if (await portListens(this.host, this.port, 400)) break;
279
- if (Date.now() > deadline) {
280
- child.kill('SIGTERM');
281
- throw new Error(`moli serve did not listen on ${this.host}:${String(this.port)} within ${String(this.options.startTimeoutMs ?? 20000)}ms; see ${logPath}`);
282
- }
283
- await sleep(150);
284
- }
285
-
286
- // The socket can open a moment before the target is published.
287
- let connection;
288
- let lastError;
289
- for (let attempt = 0; attempt < 20; attempt++) {
290
- try { connection = await this.#connect(); break; }
291
- catch (error) { lastError = error; await sleep(150); }
292
- }
293
- if (connection === undefined) {
294
- child.kill('SIGTERM');
295
- throw new Error(`moli serve is listening on port ${String(this.port)} but no page target was reachable: ${String(lastError?.message ?? 'unknown')}`);
296
- }
297
- void exited;
298
- this.external = false;
299
- this.connection = connection;
300
- return { connection, external: false };
301
- }
302
-
303
- /** Ask the CDP endpoint who it is; undefined when it is not a CDP endpoint. */
304
- async #probeVersion() {
305
- try {
306
- const version = await getJson(`http://${this.host}:${String(this.port)}/json/version`, 3000);
307
- return typeof version.Browser === 'string' ? version : undefined;
308
- } catch {
309
- return undefined;
310
- }
311
- }
312
-
313
- /** Open a WebSocket to the first page target and enable the domains we use. */
314
- async #connect() {
315
- if (!sessionSupported()) throw new Error(sessionUnsupportedReason());
316
- const targets = await getJson(`http://${this.host}:${String(this.port)}/json/list`, 5000);
317
- const page = Array.isArray(targets) ? targets.find(t => t.type === 'page' || t.type === undefined) : undefined;
318
- if (page === undefined || typeof page.webSocketDebuggerUrl !== 'string') {
319
- throw new Error(`no page target on http://${this.host}:${String(this.port)}/json/list`);
320
- }
321
- const ws = new WebSocket(page.webSocketDebuggerUrl);
322
- await new Promise((resolve, reject) => {
323
- const timer = setTimeout(() => reject(new Error('the CDP WebSocket did not open in time')), 10000);
324
- ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
325
- ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('the CDP WebSocket failed to open')); }, { once: true });
326
- });
327
- const connection = new CdpConnection(ws, page.id);
328
- for (const domain of ['Page.enable', 'Runtime.enable', 'DOM.enable']) {
329
- await connection.send(domain, {}, 10000);
330
- }
331
- return connection;
332
- }
333
-
334
- /** Stop the server if we started it. A reused external server is left alone. */
335
- async close() {
336
- const connection = this.connection;
337
- this.connection = undefined;
338
- if (connection !== undefined) connection.close();
339
- const child = this.child;
340
- this.child = undefined;
341
- this.pid = undefined;
342
- const wasExternal = this.external;
343
- this.external = false;
344
- if (child === undefined || child.exitCode !== null) {
345
- return { stopped: false, external: wasExternal };
346
- }
347
- child.kill('SIGTERM');
348
- const deadline = Date.now() + 5000;
349
- while (child.exitCode === null && Date.now() < deadline) await sleep(100);
350
- if (child.exitCode === null) child.kill('SIGKILL');
351
- return { stopped: true, external: false, logPath: this.logPath };
352
- }
353
-
354
- /** Diagnostic view of the session, for the tool's `status` action. */
355
- async status() {
356
- const running = this.started || this.external;
357
- const base = {
358
- running,
359
- ownedByThisPlugin: this.started,
360
- external: this.external,
361
- pid: this.pid,
362
- endpoint: `http://${this.host}:${String(this.port)}`,
363
- logPath: this.logPath,
364
- };
365
- if (!running) return base;
366
- const connection = this.connection;
367
- if (connection === undefined) return base;
368
- const url = await connection.evaluate('location.href');
369
- const title = await connection.evaluate('document.title');
370
- return { ...base, url: url.value, title: title.value };
371
- }
372
- }