opera-browser-cli 0.1.44 → 0.1.46
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 +98 -16
- package/SKILL.md +31 -2
- package/dist/src/bridge.d.ts +69 -1
- package/dist/src/bridge.js +261 -24
- package/dist/src/bridge.js.map +1 -1
- package/dist/src/browser-target.d.ts +84 -0
- package/dist/src/browser-target.js +161 -0
- package/dist/src/browser-target.js.map +1 -0
- package/dist/src/cli.d.ts +72 -2
- package/dist/src/cli.js +947 -213
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +75 -8
- package/dist/src/client.js +671 -119
- package/dist/src/client.js.map +1 -1
- package/dist/src/config.d.ts +58 -0
- package/dist/src/config.js +171 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/detect.d.ts +21 -0
- package/dist/src/detect.js +76 -0
- package/dist/src/detect.js.map +1 -0
- package/dist/src/identity.d.ts +61 -0
- package/dist/src/identity.js +87 -0
- package/dist/src/identity.js.map +1 -0
- package/dist/src/profile.d.ts +80 -0
- package/dist/src/profile.js +187 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/version.d.ts +10 -0
- package/dist/src/version.js +34 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +2 -2
- package/dist/bin/opera-cli-bridge.d.ts +0 -2
- package/dist/bin/opera-cli-bridge.js +0 -7
- package/dist/bin/opera-cli-bridge.js.map +0 -1
- package/dist/bin/opera-cli.d.ts +0 -2
- package/dist/bin/opera-cli.js +0 -4
- package/dist/bin/opera-cli.js.map +0 -1
- package/openclaw/.claude/settings.local.json +0 -8
package/dist/src/client.js
CHANGED
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP client for the opera-browser-cli bridge + bridge lifecycle management.
|
|
3
|
+
*
|
|
4
|
+
* The lifecycle rules, in one place:
|
|
5
|
+
*
|
|
6
|
+
* - A process is only ever signalled once it has been positively identified
|
|
7
|
+
* as our bridge — by answering /health, or by matching a PID file entry
|
|
8
|
+
* recorded on this same boot. A recycled PID after a reboot must never be
|
|
9
|
+
* mistaken for ours.
|
|
10
|
+
* - A bridge running a different package version is unusable, however
|
|
11
|
+
* healthy it looks: it is serving pre-upgrade code from memory.
|
|
12
|
+
* - Exactly one process starts a bridge at a time (an exclusive lock), and
|
|
13
|
+
* if the port it wants is taken it moves to the next one.
|
|
14
|
+
* - A connection lost mid-command is recovered transparently, except for the
|
|
15
|
+
* expensive Opera AI tools, which are never silently replayed.
|
|
3
16
|
*/
|
|
4
17
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { mkdirSync, openSync, readFileSync,
|
|
18
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
6
19
|
import { join } from "node:path";
|
|
7
20
|
import { homedir } from "node:os";
|
|
8
21
|
import { request } from "node:http";
|
|
9
22
|
import { AxiError } from "axi-sdk-js";
|
|
10
|
-
import {
|
|
23
|
+
import { resolveBridgeLauncher, } from "./bridge.js";
|
|
24
|
+
import { computeBootMinute, isOurBridge, isUsableBridge, parseHealth, sameBoot, } from "./identity.js";
|
|
25
|
+
import { getPackageVersion } from "./version.js";
|
|
11
26
|
const STATE_DIR = join(homedir(), ".opera-browser-cli");
|
|
12
27
|
const PID_FILE = join(STATE_DIR, "bridge.pid");
|
|
13
28
|
const CONFIG_FILE = join(STATE_DIR, "config");
|
|
14
29
|
const LOG_FILE = join(STATE_DIR, "bridge.log");
|
|
30
|
+
const LOCK_FILE = join(STATE_DIR, "bridge.lock");
|
|
15
31
|
const DEFAULT_PORT = 9225;
|
|
32
|
+
/** How many consecutive ports to try before giving up. */
|
|
33
|
+
const PORT_SCAN_COUNT = 10;
|
|
34
|
+
/** Budget for a single bridge process to reach READY (Chrome launch is slow). */
|
|
35
|
+
const START_TIMEOUT_MS = 30_000;
|
|
36
|
+
/** A start lock older than this is assumed abandoned. */
|
|
37
|
+
const LOCK_STALE_MS = 60_000;
|
|
38
|
+
/** Grace period for a SIGTERMed bridge before escalating to SIGKILL. */
|
|
39
|
+
const STOP_GRACE_MS = 5_000;
|
|
40
|
+
/** Rotate the bridge log past this size so it cannot grow without bound. */
|
|
41
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
42
|
+
/** Lines of bridge.log to quote back when a startup fails. */
|
|
43
|
+
const LOG_TAIL_LINES = 20;
|
|
16
44
|
export function getLogFile() {
|
|
17
45
|
return LOG_FILE;
|
|
18
46
|
}
|
|
@@ -80,6 +108,14 @@ function readPidFile() {
|
|
|
80
108
|
return null;
|
|
81
109
|
}
|
|
82
110
|
}
|
|
111
|
+
function removePidFile() {
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(PID_FILE);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Already gone — fine
|
|
117
|
+
}
|
|
118
|
+
}
|
|
83
119
|
/** Read the bridge's per-instance auth token from the PID file, if present. */
|
|
84
120
|
function readBridgeToken() {
|
|
85
121
|
return readPidFile()?.token ?? null;
|
|
@@ -93,6 +129,37 @@ function isProcessAlive(pid) {
|
|
|
93
129
|
return false;
|
|
94
130
|
}
|
|
95
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether a PID file entry may be signalled.
|
|
134
|
+
*
|
|
135
|
+
* Requires the entry to record the boot it was written on, and that boot to be
|
|
136
|
+
* the current one. Entries without a boot stamp (pre-0.1.46) are only
|
|
137
|
+
* trustworthy when something has *also* identified the port as ours — see
|
|
138
|
+
* `resolveSignalablePid`.
|
|
139
|
+
*/
|
|
140
|
+
function pidFileIsFromThisBoot(info) {
|
|
141
|
+
return (typeof info.bootMinute === "number" &&
|
|
142
|
+
sameBoot(info.bootMinute, computeBootMinute()));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Work out which PID, if any, it is safe to signal for the bridge on `port`.
|
|
146
|
+
*
|
|
147
|
+
* `health.pid` is authoritative — that process just told us who it is. Older
|
|
148
|
+
* bridges do not report a PID; for those we fall back to the PID file, but only
|
|
149
|
+
* when it names the same port, which means the file was written by whatever is
|
|
150
|
+
* answering there now.
|
|
151
|
+
*/
|
|
152
|
+
function resolveSignalablePid(port, health) {
|
|
153
|
+
if (health.pid > 0)
|
|
154
|
+
return health.pid;
|
|
155
|
+
const info = readPidFile();
|
|
156
|
+
if (info && info.port === port && isProcessAlive(info.pid))
|
|
157
|
+
return info.pid;
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// HTTP
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
96
163
|
function httpGet(port, path, timeoutMs = 2000, token) {
|
|
97
164
|
return new Promise((resolve, reject) => {
|
|
98
165
|
const req = request({
|
|
@@ -181,103 +248,456 @@ function httpPost(port, path, body, timeoutMs = 120_000, onLog, token) {
|
|
|
181
248
|
req.end();
|
|
182
249
|
});
|
|
183
250
|
}
|
|
184
|
-
|
|
251
|
+
function sleep(ms) {
|
|
252
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
253
|
+
}
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Discovery
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
/** The ports a bridge may live on, in preference order. */
|
|
258
|
+
export function candidatePorts() {
|
|
259
|
+
const base = Number.parseInt(process.env.OPERA_CLI_PORT ?? String(DEFAULT_PORT), 10);
|
|
260
|
+
const start = Number.isFinite(base) ? base : DEFAULT_PORT;
|
|
261
|
+
return Array.from({ length: PORT_SCAN_COUNT }, (_, i) => start + i);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Ask what is listening on a port.
|
|
265
|
+
*
|
|
266
|
+
* Returns the identity if it is one of our bridges (of any version), and null
|
|
267
|
+
* for everything else: nothing listening, a foreign server, or a response we
|
|
268
|
+
* cannot parse. A foreign server is deliberately indistinguishable from an
|
|
269
|
+
* empty port here — the caller handles both the same way, by moving on and
|
|
270
|
+
* letting the bridge's own EADDRINUSE handling sort out the collision.
|
|
271
|
+
*/
|
|
272
|
+
async function probeHealth(port) {
|
|
185
273
|
try {
|
|
186
|
-
|
|
187
|
-
const data = JSON.parse(resp);
|
|
188
|
-
return data.status === "ok";
|
|
274
|
+
return parseHealth(await httpGet(port, "/health", 2000));
|
|
189
275
|
}
|
|
190
276
|
catch {
|
|
191
|
-
return
|
|
277
|
+
return null;
|
|
192
278
|
}
|
|
193
279
|
}
|
|
280
|
+
async function probeAll(ports) {
|
|
281
|
+
return Promise.all(ports.map(async (port) => ({ port, health: await probeHealth(port) })));
|
|
282
|
+
}
|
|
194
283
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
284
|
+
* Find a bridge we can use, cleaning up any of our own that we cannot.
|
|
285
|
+
*
|
|
286
|
+
* Stale-version bridges are shut down rather than left running: they hold a
|
|
287
|
+
* port, they will never become usable, and leaving them behind is how a machine
|
|
288
|
+
* accumulates zombies across upgrades.
|
|
198
289
|
*/
|
|
199
|
-
async function
|
|
290
|
+
export async function findUsableBridge(ports) {
|
|
291
|
+
const version = getPackageVersion();
|
|
292
|
+
// Fast path: the port in the PID file is nearly always the answer, and
|
|
293
|
+
// checking it alone keeps the common case to a single round trip.
|
|
294
|
+
const preferred = readPidFile()?.port;
|
|
295
|
+
if (preferred !== undefined && ports.includes(preferred)) {
|
|
296
|
+
const health = await probeHealth(preferred);
|
|
297
|
+
if (isUsableBridge(health, version))
|
|
298
|
+
return preferred;
|
|
299
|
+
if (isOurBridge(health))
|
|
300
|
+
await shutdownBridgeOnPort(preferred, health);
|
|
301
|
+
}
|
|
302
|
+
const probes = await probeAll(ports.filter((p) => p !== preferred));
|
|
303
|
+
for (const { port, health } of probes) {
|
|
304
|
+
if (isUsableBridge(health, version))
|
|
305
|
+
return port;
|
|
306
|
+
}
|
|
307
|
+
for (const { port, health } of probes) {
|
|
308
|
+
if (isOurBridge(health))
|
|
309
|
+
await shutdownBridgeOnPort(port, health);
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
/** Poll for a bridge someone else is starting. */
|
|
314
|
+
async function waitForUsableBridge(ports, timeoutMs) {
|
|
315
|
+
const deadline = Date.now() + timeoutMs;
|
|
316
|
+
while (Date.now() < deadline) {
|
|
317
|
+
const port = await findUsableBridge(ports);
|
|
318
|
+
if (port !== null)
|
|
319
|
+
return port;
|
|
320
|
+
await sleep(250);
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Shutdown
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
/** SIGTERM, wait, SIGKILL. Returns true if the process is gone afterwards. */
|
|
328
|
+
async function terminateProcess(pid) {
|
|
200
329
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
330
|
+
process.kill(pid, "SIGTERM");
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return { gone: true, forced: false };
|
|
334
|
+
}
|
|
335
|
+
const deadline = Date.now() + STOP_GRACE_MS;
|
|
336
|
+
while (Date.now() < deadline) {
|
|
337
|
+
if (!isProcessAlive(pid))
|
|
338
|
+
return { gone: true, forced: false };
|
|
339
|
+
await sleep(100);
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
process.kill(pid, "SIGKILL");
|
|
207
343
|
}
|
|
208
344
|
catch {
|
|
209
|
-
return
|
|
345
|
+
return { gone: true, forced: true };
|
|
210
346
|
}
|
|
347
|
+
await sleep(200);
|
|
348
|
+
return { gone: !isProcessAlive(pid), forced: true };
|
|
211
349
|
}
|
|
212
|
-
|
|
213
|
-
|
|
350
|
+
/** Shut down a bridge we have positively identified on `port`. */
|
|
351
|
+
async function shutdownBridgeOnPort(port, health) {
|
|
352
|
+
const pid = resolveSignalablePid(port, health);
|
|
353
|
+
if (pid === null)
|
|
354
|
+
return;
|
|
355
|
+
await terminateProcess(pid);
|
|
356
|
+
if (readPidFile()?.pid === pid)
|
|
357
|
+
removePidFile();
|
|
214
358
|
}
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
359
|
+
/** Shut down every bridge of ours across the candidate ports. */
|
|
360
|
+
async function shutdownOurBridges(ports) {
|
|
361
|
+
for (const { port, health } of await probeAll(ports)) {
|
|
362
|
+
if (isOurBridge(health))
|
|
363
|
+
await shutdownBridgeOnPort(port, health);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
let holdingLock = false;
|
|
367
|
+
function readLock() {
|
|
368
|
+
try {
|
|
369
|
+
const data = JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
|
|
370
|
+
if (typeof data.pid !== "number" || typeof data.startedAt !== "number") {
|
|
371
|
+
return null;
|
|
225
372
|
}
|
|
373
|
+
return { pid: data.pid, startedAt: data.startedAt };
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function acquireStartLock() {
|
|
380
|
+
try {
|
|
381
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
382
|
+
const fd = openSync(LOCK_FILE, "wx");
|
|
226
383
|
try {
|
|
227
|
-
|
|
384
|
+
writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
|
|
228
385
|
}
|
|
229
|
-
|
|
230
|
-
|
|
386
|
+
finally {
|
|
387
|
+
closeSync(fd);
|
|
231
388
|
}
|
|
389
|
+
holdingLock = true;
|
|
390
|
+
// Held only while we hold the lock, so an interrupted start still releases
|
|
391
|
+
// it and we never accumulate listeners across repeated acquisitions.
|
|
392
|
+
process.on("exit", releaseStartLock);
|
|
393
|
+
return true;
|
|
232
394
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (portStatus === "ok") {
|
|
236
|
-
// A healthy bridge is already running (no PID file or stale PID).
|
|
237
|
-
return port;
|
|
395
|
+
catch {
|
|
396
|
+
return false;
|
|
238
397
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
398
|
+
}
|
|
399
|
+
function releaseStartLock() {
|
|
400
|
+
if (!holdingLock)
|
|
401
|
+
return;
|
|
402
|
+
holdingLock = false;
|
|
403
|
+
process.off("exit", releaseStartLock);
|
|
404
|
+
try {
|
|
405
|
+
unlinkSync(LOCK_FILE);
|
|
243
406
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
407
|
+
catch {
|
|
408
|
+
// Someone else cleaned it up — fine
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/** True when the lock is held by a dead process or has simply been there too long. */
|
|
412
|
+
function startLockIsStale() {
|
|
413
|
+
const lock = readLock();
|
|
414
|
+
if (lock === null)
|
|
415
|
+
return true; // unreadable or malformed
|
|
416
|
+
if (!isProcessAlive(lock.pid))
|
|
417
|
+
return true;
|
|
418
|
+
return Date.now() - lock.startedAt > LOCK_STALE_MS;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Remove an abandoned lock and take it.
|
|
422
|
+
*
|
|
423
|
+
* Two processes can both decide a lock is stale and both end up believing they
|
|
424
|
+
* hold it. That is tolerable: the loser's bridge fails with EADDRINUSE and
|
|
425
|
+
* retries the next port, which is exactly the path the port scan already
|
|
426
|
+
* handles. The lock removes the common case; the port scan is the real backstop.
|
|
427
|
+
*/
|
|
428
|
+
function stealStartLock() {
|
|
429
|
+
try {
|
|
430
|
+
unlinkSync(LOCK_FILE);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
// Already gone
|
|
434
|
+
}
|
|
435
|
+
return acquireStartLock();
|
|
436
|
+
}
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Logging
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
/** Rotate the bridge log now, whatever its size. Used by `doctor --fix`. */
|
|
441
|
+
export function rotateBridgeLog() {
|
|
442
|
+
try {
|
|
443
|
+
renameSync(LOG_FILE, `${LOG_FILE}.1`);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function rotateLogIfLarge() {
|
|
451
|
+
try {
|
|
452
|
+
if (statSync(LOG_FILE).size < MAX_LOG_BYTES)
|
|
453
|
+
return;
|
|
454
|
+
renameSync(LOG_FILE, `${LOG_FILE}.1`);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// No log yet, or rotation is not possible — never block a start over it.
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** The tail of the bridge log, for quoting back when a start fails. */
|
|
461
|
+
function readLogTail(lines = LOG_TAIL_LINES) {
|
|
462
|
+
try {
|
|
463
|
+
const all = readFileSync(LOG_FILE, "utf-8").split("\n").filter(Boolean);
|
|
464
|
+
return all.slice(-lines).join("\n");
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return "";
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function openLogFd() {
|
|
254
471
|
try {
|
|
255
472
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
256
|
-
|
|
257
|
-
|
|
473
|
+
rotateLogIfLarge();
|
|
474
|
+
return openSync(LOG_FILE, "a");
|
|
258
475
|
}
|
|
259
476
|
catch {
|
|
260
|
-
// Log directory unwritable — bridge still runs, just
|
|
477
|
+
// Log directory unwritable — the bridge still runs, just without logs.
|
|
478
|
+
return null;
|
|
261
479
|
}
|
|
262
|
-
|
|
263
|
-
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Start one bridge process on one port and wait for its handshake.
|
|
483
|
+
*
|
|
484
|
+
* The bridge reports READY or FAILED on stdout, so a dead child is detected in
|
|
485
|
+
* milliseconds instead of costing the full startup budget. Its stderr goes to
|
|
486
|
+
* the log file, whose tail is folded into the failure detail.
|
|
487
|
+
*/
|
|
488
|
+
async function spawnBridge(port) {
|
|
489
|
+
const launcher = resolveBridgeLauncher(import.meta.dirname);
|
|
490
|
+
if (!launcher.ok)
|
|
491
|
+
return { ok: false, reason: launcher.reason };
|
|
492
|
+
const logFd = openLogFd();
|
|
493
|
+
const child = spawn(launcher.command, launcher.args, {
|
|
494
|
+
stdio: ["ignore", "pipe", logFd ?? "ignore"],
|
|
264
495
|
env: { ...process.env, OPERA_CLI_PORT: String(port) },
|
|
265
496
|
detached: true,
|
|
266
497
|
});
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
498
|
+
return new Promise((resolve) => {
|
|
499
|
+
let settled = false;
|
|
500
|
+
let buffer = "";
|
|
501
|
+
const finish = (outcome) => {
|
|
502
|
+
if (settled)
|
|
503
|
+
return;
|
|
504
|
+
settled = true;
|
|
505
|
+
clearTimeout(timer);
|
|
506
|
+
child.removeAllListeners("exit");
|
|
507
|
+
child.removeAllListeners("error");
|
|
508
|
+
child.stdout?.removeAllListeners("data");
|
|
509
|
+
// Release the pipe so this process can exit; the bridge writes nothing
|
|
510
|
+
// to stdout after the handshake and guards against EPIPE regardless.
|
|
511
|
+
child.stdout?.destroy();
|
|
512
|
+
child.unref();
|
|
513
|
+
if (logFd !== null) {
|
|
514
|
+
try {
|
|
515
|
+
closeSync(logFd);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// Already closed
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
resolve(outcome);
|
|
522
|
+
};
|
|
523
|
+
const timer = setTimeout(() => finish({ ok: false, reason: "timeout", detail: readLogTail() }), START_TIMEOUT_MS);
|
|
524
|
+
child.stdout?.setEncoding("utf-8");
|
|
525
|
+
child.stdout?.on("data", (chunk) => {
|
|
526
|
+
buffer += chunk;
|
|
527
|
+
let newline;
|
|
528
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
529
|
+
const line = buffer.slice(0, newline).trim();
|
|
530
|
+
buffer = buffer.slice(newline + 1);
|
|
531
|
+
if (line === "READY") {
|
|
532
|
+
finish({ ok: true });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (line.startsWith("FAILED ")) {
|
|
536
|
+
const rest = line.slice("FAILED ".length);
|
|
537
|
+
const spaceAt = rest.indexOf(" ");
|
|
538
|
+
finish({
|
|
539
|
+
ok: false,
|
|
540
|
+
reason: spaceAt === -1 ? rest : rest.slice(0, spaceAt),
|
|
541
|
+
detail: spaceAt === -1 ? undefined : rest.slice(spaceAt + 1),
|
|
542
|
+
});
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
child.on("error", (error) => finish({ ok: false, reason: "spawn-failed", detail: error.message }));
|
|
548
|
+
child.on("exit", (code) => finish({
|
|
549
|
+
ok: false,
|
|
550
|
+
reason: code === 75 ? "port-in-use" : "exited",
|
|
551
|
+
detail: `bridge exited with code ${code}\n${readLogTail()}`,
|
|
552
|
+
}));
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function startFailureError(outcome, ports) {
|
|
556
|
+
const detail = outcome.detail ? `\n${outcome.detail}` : "";
|
|
557
|
+
switch (outcome.reason) {
|
|
558
|
+
case "mcp-connect":
|
|
559
|
+
return new CdpError(`Bridge could not connect to opera-devtools-mcp.${detail}`, "BRIDGE_NOT_READY", [
|
|
560
|
+
"Check that opera-devtools-mcp is installed: `npx opera-devtools-mcp@latest --help`",
|
|
561
|
+
"For local dev: set OPERA_CLI_MCP_BIN to the linked binary",
|
|
562
|
+
"Run `opera-browser-cli logs` for the full bridge output",
|
|
563
|
+
]);
|
|
564
|
+
case "state-dir-unwritable":
|
|
565
|
+
return new CdpError(`Bridge cannot write to its state directory (${outcome.detail ?? STATE_DIR}).`, "BRIDGE_NOT_READY", [
|
|
566
|
+
`Check ownership: \`ls -ld ${outcome.detail ?? STATE_DIR}\``,
|
|
567
|
+
`If it is root-owned from an earlier sudo run: \`sudo chown -R "$(whoami)" ${outcome.detail ?? STATE_DIR}\``,
|
|
568
|
+
]);
|
|
569
|
+
case "tsx-not-installed":
|
|
570
|
+
return new CdpError("Bridge cannot run from TypeScript source — tsx is not installed.", "BRIDGE_NOT_READY", [
|
|
571
|
+
"Run `npm install` in the opera-browser-cli checkout",
|
|
572
|
+
"Or build first: `npm run build`",
|
|
573
|
+
]);
|
|
574
|
+
case "bridge-not-built":
|
|
575
|
+
return new CdpError("Bridge entrypoint not found — the package looks unbuilt.", "BRIDGE_NOT_READY", ["Run `npm run build` in the opera-browser-cli checkout"]);
|
|
576
|
+
case "port-in-use":
|
|
577
|
+
return new CdpError(`Ports ${ports[0]}-${ports[ports.length - 1]} are all in use by other servers.`, "BRIDGE_NOT_READY", [
|
|
578
|
+
"Free one of those ports, or set OPERA_CLI_PORT to a different base port",
|
|
579
|
+
]);
|
|
580
|
+
case "timeout":
|
|
581
|
+
return new CdpError(`Bridge did not become ready within ${START_TIMEOUT_MS / 1000}s.${detail}`, "BRIDGE_NOT_READY", [
|
|
582
|
+
"Run `opera-browser-cli logs` to see what the bridge was doing",
|
|
583
|
+
"Run `opera-browser-cli doctor` to check the configuration",
|
|
584
|
+
]);
|
|
585
|
+
default:
|
|
586
|
+
return new CdpError(`Bridge failed to start.${detail}`, "BRIDGE_NOT_READY", [
|
|
587
|
+
"Run `opera-browser-cli logs` for the full bridge output",
|
|
588
|
+
"Run `opera-browser-cli doctor` to check the configuration",
|
|
589
|
+
]);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Take the start lock and bring a bridge up, walking the port range.
|
|
594
|
+
*
|
|
595
|
+
* If another process holds the lock we wait for its bridge instead of racing
|
|
596
|
+
* it; only an abandoned lock is stolen.
|
|
597
|
+
*/
|
|
598
|
+
async function startBridge(ports, attempt = 0) {
|
|
599
|
+
if (!acquireStartLock()) {
|
|
600
|
+
const port = await waitForUsableBridge(ports, START_TIMEOUT_MS);
|
|
601
|
+
if (port !== null)
|
|
272
602
|
return port;
|
|
603
|
+
if (attempt >= 1 || !startLockIsStale() || !stealStartLock()) {
|
|
604
|
+
throw new CdpError("Timed out waiting for another opera-browser-cli process to start the bridge", "BRIDGE_NOT_READY", [
|
|
605
|
+
"Run `opera-browser-cli logs` to see what the other process was doing",
|
|
606
|
+
"Run `opera-browser-cli restart` to force a clean start",
|
|
607
|
+
]);
|
|
273
608
|
}
|
|
274
|
-
|
|
609
|
+
return startBridge(ports, attempt + 1);
|
|
275
610
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
611
|
+
try {
|
|
612
|
+
let lastOutcome = { ok: false, reason: "port-in-use" };
|
|
613
|
+
for (const port of ports) {
|
|
614
|
+
lastOutcome = await spawnBridge(port);
|
|
615
|
+
if (lastOutcome.ok)
|
|
616
|
+
return port;
|
|
617
|
+
// Only a port collision is worth trying the next port for; anything else
|
|
618
|
+
// will fail the same way everywhere, so surface it immediately.
|
|
619
|
+
if (lastOutcome.reason !== "port-in-use")
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
throw startFailureError(lastOutcome, ports);
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
releaseStartLock();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Ensure a bridge running our version is up. Returns the port it is on.
|
|
630
|
+
*/
|
|
631
|
+
export async function ensureBridge(options = {}) {
|
|
632
|
+
const ports = candidatePorts();
|
|
633
|
+
if (options.forceRestart) {
|
|
634
|
+
await shutdownOurBridges(ports);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
const existing = await findUsableBridge(ports);
|
|
638
|
+
if (existing !== null)
|
|
639
|
+
return existing;
|
|
640
|
+
}
|
|
641
|
+
return startBridge(ports);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Stop the bridge.
|
|
645
|
+
*
|
|
646
|
+
* Looks past the PID file: if the file is missing or stale but a bridge of ours
|
|
647
|
+
* is answering on one of the candidate ports, that one is stopped too. Escalates
|
|
648
|
+
* to SIGKILL rather than reporting success against a process that ignored the
|
|
649
|
+
* signal, and always leaves the PID file cleaned up.
|
|
650
|
+
*/
|
|
651
|
+
export async function stopBridge() {
|
|
652
|
+
const result = {
|
|
653
|
+
stopped: false,
|
|
654
|
+
stale: false,
|
|
655
|
+
forced: false,
|
|
656
|
+
pid: null,
|
|
657
|
+
port: null,
|
|
658
|
+
};
|
|
659
|
+
// Prefer a live, identified bridge — that is the one actually holding a port.
|
|
660
|
+
for (const { port, health } of await probeAll(candidatePorts())) {
|
|
661
|
+
if (!isOurBridge(health))
|
|
662
|
+
continue;
|
|
663
|
+
const pid = resolveSignalablePid(port, health);
|
|
664
|
+
if (pid === null)
|
|
665
|
+
continue;
|
|
666
|
+
const outcome = await terminateProcess(pid);
|
|
667
|
+
result.stopped ||= outcome.gone;
|
|
668
|
+
result.forced ||= outcome.forced;
|
|
669
|
+
result.pid ??= pid;
|
|
670
|
+
result.port ??= port;
|
|
671
|
+
}
|
|
672
|
+
const info = readPidFile();
|
|
673
|
+
if (info) {
|
|
674
|
+
if (!result.stopped) {
|
|
675
|
+
// Nothing answered. Only signal the recorded PID if the file is provably
|
|
676
|
+
// from this boot — otherwise the PID may belong to a stranger.
|
|
677
|
+
if (pidFileIsFromThisBoot(info) && isProcessAlive(info.pid)) {
|
|
678
|
+
const outcome = await terminateProcess(info.pid);
|
|
679
|
+
result.stopped = outcome.gone;
|
|
680
|
+
result.forced = outcome.forced;
|
|
681
|
+
result.pid = info.pid;
|
|
682
|
+
result.port = info.port;
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
result.stale = true;
|
|
686
|
+
result.pid = info.pid;
|
|
687
|
+
result.port = info.port;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
removePidFile();
|
|
691
|
+
}
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
/** Stop whatever is running and bring a fresh bridge up. Returns the port. */
|
|
695
|
+
export async function restartBridge() {
|
|
696
|
+
return ensureBridge({ forceRestart: true });
|
|
280
697
|
}
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// Tool calls
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
281
701
|
const OPERA_AI_TIMEOUT = 1_200_000; // 20 minutes
|
|
282
702
|
const OPERA_AI_TOOLS = new Set([
|
|
283
703
|
"opera_chat",
|
|
@@ -286,33 +706,142 @@ const OPERA_AI_TOOLS = new Set([
|
|
|
286
706
|
"opera_make",
|
|
287
707
|
]);
|
|
288
708
|
/**
|
|
289
|
-
*
|
|
709
|
+
* Tools that must never be replayed after a dropped connection.
|
|
710
|
+
*
|
|
711
|
+
* All four Opera AI tools are long-running, billable, and may have already
|
|
712
|
+
* acted on the page before the bridge went away. A silent second run could
|
|
713
|
+
* double a booking as easily as it could double a bill.
|
|
290
714
|
*/
|
|
291
|
-
|
|
292
|
-
|
|
715
|
+
const NON_REPLAYABLE_TOOLS = OPERA_AI_TOOLS;
|
|
716
|
+
function errorMessageOf(error) {
|
|
717
|
+
return error instanceof Error ? error.message : String(error);
|
|
718
|
+
}
|
|
719
|
+
/** A dropped or rejected bridge connection, as opposed to a tool-level failure. */
|
|
720
|
+
function isTransportFailure(message) {
|
|
721
|
+
return (/ECONNREFUSED|ECONNRESET|EPIPE|socket hang up|MCP transport disconnected/i.test(message) || isAuthFailure(message));
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* The bridge's own 401. Matched exactly: page content and tool output routinely
|
|
725
|
+
* contain the word "unauthorized" and must not trigger a restart.
|
|
726
|
+
*/
|
|
727
|
+
function isAuthFailure(message) {
|
|
728
|
+
return message.trim().toLowerCase() === "unauthorized";
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* A page-state race rather than a real failure: the DOM moved under us while
|
|
732
|
+
* the call was in flight. Common during navigation, and almost always gone by
|
|
733
|
+
* the time we ask again.
|
|
734
|
+
*/
|
|
735
|
+
function isTransientPageFailure(message) {
|
|
736
|
+
return /detached|execution context was destroyed|cannot find context|no node with given id|target closed/i.test(message);
|
|
737
|
+
}
|
|
738
|
+
async function callToolOnce(name, args, options) {
|
|
739
|
+
const port = await ensureBridge(options);
|
|
293
740
|
const isStreaming = OPERA_AI_TOOLS.has(name);
|
|
294
741
|
const timeoutMs = isStreaming ? OPERA_AI_TIMEOUT : undefined;
|
|
295
742
|
const onLog = isStreaming
|
|
296
743
|
? (msg) => process.stderr.write(msg + "\n")
|
|
297
744
|
: undefined;
|
|
745
|
+
const resp = await httpPost(port, "/call", { name, args }, timeoutMs, onLog, readBridgeToken());
|
|
746
|
+
const data = JSON.parse(resp);
|
|
747
|
+
if (data.error)
|
|
748
|
+
throw new Error(data.error);
|
|
749
|
+
return data.result ?? "";
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Call an MCP tool via the bridge. Returns the text result.
|
|
753
|
+
*
|
|
754
|
+
* A connection lost mid-call is recovered once: the bridge is restarted and the
|
|
755
|
+
* call replayed. The Opera AI tools are exempt from *that* recovery — they are
|
|
756
|
+
* reported instead, so the user decides whether to pay for a second run.
|
|
757
|
+
*
|
|
758
|
+
* A second, distinct failure is also repaired: the bridge answers, but the
|
|
759
|
+
* browser it was told to drive is unreachable (a dead attach URL, or a managed
|
|
760
|
+
* launch that never produced a browser). devtools-mcp reports that as a tool
|
|
761
|
+
* *result*, not an error, so it would otherwise look like success. We rebuild
|
|
762
|
+
* the bridge against the current target and retry once — safe for every tool,
|
|
763
|
+
* because nothing could have acted on a browser that was never reached.
|
|
764
|
+
*/
|
|
765
|
+
export async function callTool(name, args = {}) {
|
|
766
|
+
let result;
|
|
298
767
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
768
|
+
result = await callToolOnce(name, args, {});
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
return recoverFailedCall(name, args, error);
|
|
772
|
+
}
|
|
773
|
+
// The bridge answered, but the browser it was told to drive is unreachable
|
|
774
|
+
// (a dead attach URL, or a managed launch that never produced a browser).
|
|
775
|
+
// Rebuild the bridge against the current target and retry once — safe for
|
|
776
|
+
// every tool, because nothing could have acted on a browser that was never
|
|
777
|
+
// reached. A persistent failure is a real error, not a fake success.
|
|
778
|
+
if (isBrowserUnreachableResult(result)) {
|
|
779
|
+
try {
|
|
780
|
+
const recovered = await callToolOnce(name, args, { forceRestart: true });
|
|
781
|
+
if (!isBrowserUnreachableResult(recovered))
|
|
782
|
+
return recovered;
|
|
783
|
+
}
|
|
784
|
+
catch (retryError) {
|
|
785
|
+
return recoverFailedCall(name, args, retryError);
|
|
786
|
+
}
|
|
787
|
+
throw browserUnreachableError();
|
|
788
|
+
}
|
|
789
|
+
return result;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Handle an exception thrown by the bridge: recover what is worth recovering
|
|
793
|
+
* (transient page races, dropped transport), and map the rest to an error code.
|
|
794
|
+
*/
|
|
795
|
+
async function recoverFailedCall(name, args, error) {
|
|
796
|
+
const message = errorMessageOf(error);
|
|
797
|
+
// A page-state race is worth one immediate retry against the same bridge —
|
|
798
|
+
// no restart, no user-visible failure.
|
|
799
|
+
if (isTransientPageFailure(message) && !NON_REPLAYABLE_TOOLS.has(name)) {
|
|
800
|
+
await sleep(250);
|
|
801
|
+
try {
|
|
802
|
+
return await callToolOnce(name, args, {});
|
|
803
|
+
}
|
|
804
|
+
catch (retryError) {
|
|
805
|
+
throw mapErrorMessage(errorMessageOf(retryError));
|
|
304
806
|
}
|
|
305
|
-
return data.result ?? "";
|
|
306
807
|
}
|
|
307
|
-
|
|
308
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
808
|
+
if (!isTransportFailure(message))
|
|
309
809
|
throw mapErrorMessage(message);
|
|
810
|
+
if (NON_REPLAYABLE_TOOLS.has(name)) {
|
|
811
|
+
throw new CdpError(`The bridge connection dropped while running ${name}, and the command was not retried automatically because it may already have taken effect.`, "BRIDGE_NOT_READY", [
|
|
812
|
+
"Re-run the command — the bridge restarts automatically",
|
|
813
|
+
"Run `opera-browser-cli logs` to see why the bridge dropped",
|
|
814
|
+
]);
|
|
815
|
+
}
|
|
816
|
+
try {
|
|
817
|
+
return await callToolOnce(name, args, { forceRestart: true });
|
|
818
|
+
}
|
|
819
|
+
catch (retryError) {
|
|
820
|
+
throw mapErrorMessage(errorMessageOf(retryError));
|
|
310
821
|
}
|
|
311
822
|
}
|
|
823
|
+
/** devtools-mcp's "I have no browser to talk to" result text. */
|
|
824
|
+
function isBrowserUnreachableResult(result) {
|
|
825
|
+
return /could not connect to chrome|failed to fetch browser websocket url/i.test(result);
|
|
826
|
+
}
|
|
827
|
+
function browserUnreachableError() {
|
|
828
|
+
return new CdpError("The browser is not reachable. It may be running without a debugging port, or the bridge is pointing at a browser that has closed.", "BROWSER_ERROR", [
|
|
829
|
+
"Run `opera-browser-cli doctor` to check the profile and bridge state",
|
|
830
|
+
"Restart the running browser with a debug port: `opera-browser-cli open <url> --takeover`",
|
|
831
|
+
"Or use a separate profile (no flag) if the browser cannot be restarted",
|
|
832
|
+
]);
|
|
833
|
+
}
|
|
312
834
|
export function mapErrorMessage(message) {
|
|
835
|
+
if (isAuthFailure(message)) {
|
|
836
|
+
return new CdpError("Bridge rejected the auth token", "BRIDGE_NOT_READY", [
|
|
837
|
+
"Run `opera-browser-cli restart` to issue a fresh token",
|
|
838
|
+
"Run `opera-browser-cli doctor` to inspect the bridge state",
|
|
839
|
+
]);
|
|
840
|
+
}
|
|
313
841
|
if (message.includes("ECONNREFUSED") || message.includes("ECONNRESET")) {
|
|
314
842
|
return new CdpError("Bridge is not running", "BRIDGE_NOT_READY", [
|
|
315
843
|
"Run `opera-browser-cli open <url>` — the bridge starts automatically",
|
|
844
|
+
"Run `opera-browser-cli restart` if it keeps failing",
|
|
316
845
|
]);
|
|
317
846
|
}
|
|
318
847
|
if ((message.includes("uid") || message.includes("element")) &&
|
|
@@ -335,9 +864,9 @@ export function mapErrorMessage(message) {
|
|
|
335
864
|
if (message.includes("User is not signed in") ||
|
|
336
865
|
(message.includes("Opera.dispatchAction") &&
|
|
337
866
|
message.includes("not signed in"))) {
|
|
338
|
-
return new CdpError("Opera: user is not signed in", "
|
|
339
|
-
"
|
|
340
|
-
"Run `opera-browser-cli
|
|
867
|
+
return new CdpError("Opera: user is not signed in", "AUTH_REQUIRED", [
|
|
868
|
+
"Run `opera-browser-cli login` to sign in to your Opera account",
|
|
869
|
+
"Run `opera-browser-cli doctor` to inspect the current configuration",
|
|
341
870
|
]);
|
|
342
871
|
}
|
|
343
872
|
// Try to parse JSON error
|
|
@@ -355,55 +884,92 @@ export function mapErrorMessage(message) {
|
|
|
355
884
|
return new CdpError(message, "UNKNOWN");
|
|
356
885
|
}
|
|
357
886
|
/**
|
|
358
|
-
* Inspect the bridge without starting it. Used by `
|
|
887
|
+
* Inspect the bridge without starting it. Used by `doctor` and `status`.
|
|
359
888
|
*/
|
|
360
889
|
export async function getBridgeStatus() {
|
|
361
|
-
const
|
|
362
|
-
|
|
890
|
+
const expectedVersion = getPackageVersion();
|
|
891
|
+
const base = {
|
|
892
|
+
pidFileExists: false,
|
|
893
|
+
processAlive: false,
|
|
894
|
+
healthy: false,
|
|
895
|
+
port: null,
|
|
896
|
+
pid: null,
|
|
897
|
+
runningVersion: null,
|
|
898
|
+
expectedVersion,
|
|
899
|
+
versionSkew: false,
|
|
900
|
+
stalePidFile: false,
|
|
901
|
+
};
|
|
902
|
+
// A live bridge is the best source of truth, wherever its port came from.
|
|
903
|
+
for (const { port, health } of await probeAll(candidatePorts())) {
|
|
904
|
+
if (!isOurBridge(health))
|
|
905
|
+
continue;
|
|
363
906
|
return {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
907
|
+
...base,
|
|
908
|
+
pidFileExists: existsSync(PID_FILE),
|
|
909
|
+
processAlive: true,
|
|
910
|
+
healthy: isUsableBridge(health, expectedVersion),
|
|
911
|
+
port,
|
|
912
|
+
pid: health.pid > 0 ? health.pid : (readPidFile()?.pid ?? null),
|
|
913
|
+
runningVersion: health.version,
|
|
914
|
+
versionSkew: health.version !== expectedVersion,
|
|
369
915
|
};
|
|
370
916
|
}
|
|
371
|
-
const
|
|
372
|
-
|
|
917
|
+
const info = readPidFile();
|
|
918
|
+
if (!info)
|
|
919
|
+
return base;
|
|
920
|
+
const fromThisBoot = pidFileIsFromThisBoot(info);
|
|
921
|
+
const alive = fromThisBoot && isProcessAlive(info.pid);
|
|
373
922
|
return {
|
|
923
|
+
...base,
|
|
374
924
|
pidFileExists: true,
|
|
375
925
|
processAlive: alive,
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
926
|
+
port: info.port,
|
|
927
|
+
pid: info.pid,
|
|
928
|
+
// Nothing answered on any port, so a PID file that survives is stale
|
|
929
|
+
// whether its process is gone or merely wedged.
|
|
930
|
+
stalePidFile: !alive || !fromThisBoot,
|
|
379
931
|
};
|
|
380
932
|
}
|
|
933
|
+
/** The bridge to read from, without starting one. */
|
|
934
|
+
async function activeBridge() {
|
|
935
|
+
const info = readPidFile();
|
|
936
|
+
if (info) {
|
|
937
|
+
const health = await probeHealth(info.port);
|
|
938
|
+
if (isUsableBridge(health, getPackageVersion())) {
|
|
939
|
+
return { port: info.port, token: info.token ?? null };
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const port = await findUsableBridge(candidatePorts());
|
|
943
|
+
if (port === null)
|
|
944
|
+
return null;
|
|
945
|
+
return { port, token: readBridgeToken() };
|
|
946
|
+
}
|
|
381
947
|
/** Retrieve the most recent snapshot the bridge has cached, without triggering a new one. */
|
|
382
948
|
export async function getLastSnapshot() {
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
949
|
+
const bridge = await activeBridge();
|
|
950
|
+
if (bridge === null)
|
|
385
951
|
return null;
|
|
386
952
|
try {
|
|
387
|
-
const resp = await httpGet(
|
|
953
|
+
const resp = await httpGet(bridge.port, "/last-snapshot", 2000, bridge.token);
|
|
388
954
|
const data = JSON.parse(resp);
|
|
389
955
|
if (data.error || !data.raw)
|
|
390
956
|
return null;
|
|
391
|
-
return {
|
|
957
|
+
return {
|
|
958
|
+
raw: data.raw,
|
|
959
|
+
pageUrl: data.pageUrl ?? null,
|
|
960
|
+
capturedAt: data.capturedAt ?? 0,
|
|
961
|
+
};
|
|
392
962
|
}
|
|
393
963
|
catch {
|
|
394
964
|
return null;
|
|
395
965
|
}
|
|
396
966
|
}
|
|
397
967
|
export async function getSessionSnapshotIfRunning() {
|
|
398
|
-
const
|
|
399
|
-
if (
|
|
400
|
-
return null;
|
|
401
|
-
}
|
|
402
|
-
if (!(await isBridgeHealthy(pidInfo.port))) {
|
|
968
|
+
const bridge = await activeBridge();
|
|
969
|
+
if (bridge === null)
|
|
403
970
|
return null;
|
|
404
|
-
}
|
|
405
971
|
try {
|
|
406
|
-
const resp = await httpPost(
|
|
972
|
+
const resp = await httpPost(bridge.port, "/call", { name: "take_snapshot", args: {} }, 5000, undefined, bridge.token);
|
|
407
973
|
const data = JSON.parse(resp);
|
|
408
974
|
if (data.error)
|
|
409
975
|
return null;
|
|
@@ -413,18 +979,4 @@ export async function getSessionSnapshotIfRunning() {
|
|
|
413
979
|
return null;
|
|
414
980
|
}
|
|
415
981
|
}
|
|
416
|
-
/**
|
|
417
|
-
* Stop the bridge process.
|
|
418
|
-
*/
|
|
419
|
-
export function stopBridge() {
|
|
420
|
-
const pidInfo = readPidFile();
|
|
421
|
-
if (!pidInfo) {
|
|
422
|
-
return false;
|
|
423
|
-
}
|
|
424
|
-
if (isProcessAlive(pidInfo.pid)) {
|
|
425
|
-
process.kill(pidInfo.pid, "SIGTERM");
|
|
426
|
-
return true;
|
|
427
|
-
}
|
|
428
|
-
return false;
|
|
429
|
-
}
|
|
430
982
|
//# sourceMappingURL=client.js.map
|