@orkestrel/browser 0.0.18 → 0.0.20
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 +47 -28
- package/dist/bin/main.js +35 -0
- package/dist/bin/main.js.map +1 -0
- package/dist/src/browser/index.d.ts +841 -0
- package/dist/src/browser/index.js +1640 -0
- package/dist/src/browser/index.js.map +1 -0
- package/dist/src/core/index.cjs +8372 -2494
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +4410 -2110
- package/dist/src/core/index.d.ts +4410 -2110
- package/dist/src/core/index.js +8256 -2473
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1013 -92
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +288 -39
- package/dist/src/server/index.d.ts +288 -39
- package/dist/src/server/index.js +975 -69
- package/dist/src/server/index.js.map +1 -1
- package/package.json +35 -12
|
@@ -6,13 +6,15 @@ let node_fs_promises = require("node:fs/promises");
|
|
|
6
6
|
let node_os = require("node:os");
|
|
7
7
|
let node_path = require("node:path");
|
|
8
8
|
let node_child_process = require("node:child_process");
|
|
9
|
-
let node_timers_promises = require("node:timers/promises");
|
|
10
9
|
let node_crypto = require("node:crypto");
|
|
11
10
|
let node_http = require("node:http");
|
|
12
11
|
let node_https = require("node:https");
|
|
13
12
|
let _orkestrel_emitter = require("@orkestrel/emitter");
|
|
14
13
|
let _orkestrel_websocket = require("@orkestrel/websocket");
|
|
15
14
|
let node_events = require("node:events");
|
|
15
|
+
let _orkestrel_mcp = require("@orkestrel/mcp");
|
|
16
|
+
let _orkestrel_mcp_server = require("@orkestrel/mcp/server");
|
|
17
|
+
let _orkestrel_tool = require("@orkestrel/tool");
|
|
16
18
|
//#region src/server/constants.ts
|
|
17
19
|
/**
|
|
18
20
|
* Sets the default CDP port probed for an existing browser and used for launches, `9222`.
|
|
@@ -58,9 +60,28 @@ var BROWSER_KILL_GRACE_MS = 3e3;
|
|
|
58
60
|
*/
|
|
59
61
|
var BROWSER_PORT_PROBE_TIMEOUT_MS = 200;
|
|
60
62
|
/**
|
|
63
|
+
* Matches the stderr line Chromium prints when its CDP endpoint accepts connections, and
|
|
64
|
+
* captures the `ws://` endpoint the line names.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Chromium prints the line only when launched with `--remote-debugging-port`, which is why an
|
|
68
|
+
* owned launch always passes that flag.
|
|
69
|
+
*/
|
|
70
|
+
var BROWSER_DEVTOOLS_PATTERN = /DevTools listening on (ws:\/\/\S+)/;
|
|
71
|
+
/**
|
|
72
|
+
* Sets the interval in milliseconds between liveness probes of a terminated browser process
|
|
73
|
+
* group.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* Node raises an exit event for the direct child only. Neither the rest of a POSIX process
|
|
77
|
+
* group nor a process a launcher handed the endpoint to raises one, so the bounded drain in
|
|
78
|
+
* `Browser` probes on this interval until the group is gone or the grace period ends.
|
|
79
|
+
*/
|
|
80
|
+
var BROWSER_DRAIN_INTERVAL_MS = 100;
|
|
81
|
+
/**
|
|
61
82
|
* Defers once for `50` milliseconds when a transport loss is observed on an owned process,
|
|
62
|
-
*
|
|
63
|
-
* socket close,
|
|
83
|
+
* so a near-simultaneous process-exit event, which libuv might reap slightly later than the
|
|
84
|
+
* socket close, decides the diagnosis first.
|
|
64
85
|
*/
|
|
65
86
|
var BROWSER_TRANSPORT_LOSS_DEFER_MS = 50;
|
|
66
87
|
/**
|
|
@@ -177,19 +198,23 @@ var BROWSER_STORE_GLOBS = Object.freeze({
|
|
|
177
198
|
darwin: "chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium",
|
|
178
199
|
win32: "chromium-*/chrome-win*/chrome.exe"
|
|
179
200
|
});
|
|
201
|
+
/** Names the persisted journey snapshot. */
|
|
202
|
+
var BROWSER_JOURNEY_SNAPSHOT_FILE = "journey.json";
|
|
203
|
+
/** Names the retained journey revision counter. */
|
|
204
|
+
var BROWSER_JOURNEY_REVISION_FILE = "revision";
|
|
205
|
+
/** Names the exclusive journey write lock directory. */
|
|
206
|
+
var BROWSER_JOURNEY_LOCK_DIRECTORY = "journey.lock";
|
|
207
|
+
/** Bounds attempts to acquire a journey lock after concurrent recovery. */
|
|
208
|
+
var BROWSER_JOURNEY_LOCK_ATTEMPTS = 8;
|
|
209
|
+
/** Names the persisted run snapshot. */
|
|
210
|
+
var BROWSER_RUN_FILE = "run.json";
|
|
211
|
+
/** Names the journey directory holding its runs. */
|
|
212
|
+
var BROWSER_RUN_DIRECTORY = "runs";
|
|
213
|
+
/** Bounds a file-store listing page by default. */
|
|
214
|
+
var BROWSER_FILE_STORE_LIMIT = 100;
|
|
180
215
|
//#endregion
|
|
181
216
|
//#region src/server/errors.ts
|
|
182
217
|
/**
|
|
183
|
-
* Reports that a CDP connection, discovery, or launch attempt failed, under the code
|
|
184
|
-
* `BROWSER_CONNECTION_ERROR`.
|
|
185
|
-
*/
|
|
186
|
-
var BrowserConnectionError = class extends _src_core.BrowserError {
|
|
187
|
-
constructor(message, context) {
|
|
188
|
-
super(message, "BROWSER_CONNECTION_ERROR", context);
|
|
189
|
-
this.name = "BrowserConnectionError";
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
/**
|
|
193
218
|
* Reports that an operation requiring an active connection was attempted while disconnected,
|
|
194
219
|
* under the code `BROWSER_NOT_CONNECTED_ERROR`.
|
|
195
220
|
*/
|
|
@@ -210,15 +235,6 @@ var BrowserDestroyedError = class extends _src_core.BrowserError {
|
|
|
210
235
|
}
|
|
211
236
|
};
|
|
212
237
|
/**
|
|
213
|
-
* Narrows an unknown value to a `BrowserConnectionError`.
|
|
214
|
-
*
|
|
215
|
-
* @param value - Value to check
|
|
216
|
-
* @returns True if value is a BrowserConnectionError instance; false otherwise
|
|
217
|
-
*/
|
|
218
|
-
function isBrowserConnectionError(value) {
|
|
219
|
-
return (0, _orkestrel_contract.isInstance)(value, BrowserConnectionError);
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
238
|
* Narrows an unknown value to a `BrowserNotConnectedError`.
|
|
223
239
|
*
|
|
224
240
|
* @param value - Value to check
|
|
@@ -239,6 +255,30 @@ function isBrowserDestroyedError(value) {
|
|
|
239
255
|
//#endregion
|
|
240
256
|
//#region src/server/helpers.ts
|
|
241
257
|
/**
|
|
258
|
+
* Formats a process identifier and UUID token as a lock entry name.
|
|
259
|
+
* @param pid - Positive safe integer identifying the holder process
|
|
260
|
+
* @param token - Lowercase UUID token identifying this acquisition
|
|
261
|
+
* @returns The entry name
|
|
262
|
+
* @example
|
|
263
|
+
* formatBrowserLockEntry(123, '11111111-1111-4111-8111-111111111111')
|
|
264
|
+
*/
|
|
265
|
+
function formatBrowserLockEntry(pid, token) {
|
|
266
|
+
return `${pid}-${token}`;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Parses the holder process identifier from a lock entry name.
|
|
270
|
+
* @remarks Requires a positive safe integer followed by a lowercase UUID token.
|
|
271
|
+
* @param name - Directory entry name
|
|
272
|
+
* @returns The process identifier, or undefined when the entry is malformed
|
|
273
|
+
* @example
|
|
274
|
+
* parseBrowserLockEntry('123-11111111-1111-4111-8111-111111111111') // 123
|
|
275
|
+
*/
|
|
276
|
+
function parseBrowserLockEntry(name) {
|
|
277
|
+
const match = /^([1-9]\d*)-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.exec(name);
|
|
278
|
+
const pid = Number(match?.[1]);
|
|
279
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : void 0;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
242
282
|
* Enumerates every Chrome/Chromium/Edge executable discoverable on this
|
|
243
283
|
* machine, deduplicated by normalized absolute path.
|
|
244
284
|
*
|
|
@@ -468,57 +508,69 @@ function findStorePaths(base, platform) {
|
|
|
468
508
|
* subprocesses with it, so that single signal drains the tree.
|
|
469
509
|
*
|
|
470
510
|
* @param executable - Absolute path to the browser executable
|
|
471
|
-
* @param port - Port the browser exposes its CDP endpoint on
|
|
511
|
+
* @param port - Port the browser exposes its CDP endpoint on, or undefined for a port the operating system picks
|
|
472
512
|
* @param headless - Whether to launch in headless mode
|
|
473
513
|
* @param profile - Optional user-data-dir for a persistent profile
|
|
474
514
|
* @param extra - Additional command-line flags
|
|
475
|
-
* @returns The spawned ChildProcess
|
|
515
|
+
* @returns The spawned ChildProcess, whose standard error carries the endpoint line `readBrowserEndpoint` reads
|
|
476
516
|
*/
|
|
477
517
|
function launchBrowserProcess(executable, port, headless, profile, extra) {
|
|
478
518
|
const args = [];
|
|
479
519
|
if (extra !== void 0) args.push(...extra);
|
|
480
|
-
args.push(`--remote-debugging-port=${port}`, ...BROWSER_LAUNCH_ARGS);
|
|
520
|
+
args.push(`--remote-debugging-port=${port ?? 0}`, ...BROWSER_LAUNCH_ARGS);
|
|
481
521
|
if (headless) args.push(BROWSER_HEADLESS_ARG);
|
|
482
522
|
if (profile !== void 0) args.push(`--user-data-dir=${profile}`);
|
|
483
523
|
return (0, node_child_process.spawn)(executable, args, {
|
|
484
|
-
stdio:
|
|
524
|
+
stdio: [
|
|
525
|
+
"ignore",
|
|
526
|
+
"ignore",
|
|
527
|
+
"pipe"
|
|
528
|
+
],
|
|
485
529
|
detached: process.platform !== "win32"
|
|
486
530
|
});
|
|
487
531
|
}
|
|
488
532
|
/**
|
|
489
|
-
*
|
|
533
|
+
* Reads the CDP endpoint a launched browser announces on its standard error.
|
|
490
534
|
*
|
|
491
|
-
* @
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
535
|
+
* @remarks
|
|
536
|
+
* Chromium prints `DevTools listening on ws://HOST:PORT/devtools/browser/ID` after it binds the
|
|
537
|
+
* debugging port. The read splits arriving text on line breaks across chunks, so a line split
|
|
538
|
+
* between two chunks and a line ending `\r\n` both resolve. After resolving, the stream keeps
|
|
539
|
+
* draining until it closes so the browser's logging never fills the pipe.
|
|
540
|
+
*
|
|
541
|
+
* @param stream - The browser's standard error
|
|
542
|
+
* @param signal - Abort that rejects the read with its reason
|
|
543
|
+
* @returns The `ws://` endpoint the browser announced
|
|
496
544
|
*
|
|
497
|
-
* @throws When the
|
|
545
|
+
* @throws When the stream closes without the line, or the signal aborts first
|
|
498
546
|
*/
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (
|
|
547
|
+
function readBrowserEndpoint(stream, signal) {
|
|
548
|
+
const read = Promise.withResolvers();
|
|
549
|
+
let pending = "";
|
|
550
|
+
let found = false;
|
|
551
|
+
if (signal.aborted) read.reject(signal.reason);
|
|
552
|
+
signal.addEventListener("abort", () => read.reject(signal.reason), { once: true });
|
|
553
|
+
stream.on("error", read.reject);
|
|
554
|
+
stream.setEncoding("utf8");
|
|
555
|
+
stream.on("data", (chunk) => {
|
|
556
|
+
if (found || !(0, _orkestrel_contract.isString)(chunk)) return;
|
|
557
|
+
const lines = (pending + chunk).split(/\r\n|\n/);
|
|
558
|
+
pending = lines.pop() ?? "";
|
|
559
|
+
for (const line of lines) {
|
|
560
|
+
const endpoint = BROWSER_DEVTOOLS_PATTERN.exec(line)?.[1];
|
|
561
|
+
if (endpoint === void 0) continue;
|
|
562
|
+
found = true;
|
|
563
|
+
pending = "";
|
|
564
|
+
read.resolve(endpoint);
|
|
565
|
+
return;
|
|
514
566
|
}
|
|
515
|
-
const delay = Math.min(_src_core.BROWSER_WAIT_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now()));
|
|
516
|
-
if (delay > 0) await (0, node_timers_promises.setTimeout)(delay, void 0, { signal });
|
|
517
|
-
}
|
|
518
|
-
throw new BrowserConnectionError(`CDP endpoint on port ${port} did not become ready within ${timeout}ms`, {
|
|
519
|
-
port,
|
|
520
|
-
timeout
|
|
521
567
|
});
|
|
568
|
+
stream.on("close", () => {
|
|
569
|
+
const endpoint = found ? void 0 : BROWSER_DEVTOOLS_PATTERN.exec(pending)?.[1];
|
|
570
|
+
if (endpoint !== void 0) read.resolve(endpoint);
|
|
571
|
+
else read.reject(new _src_core.BrowserConnectionError("Browser closed its standard error before reporting a CDP endpoint"));
|
|
572
|
+
});
|
|
573
|
+
return read.promise;
|
|
522
574
|
}
|
|
523
575
|
/**
|
|
524
576
|
* Fetches the current CDP target list from a browser's `/json/list` endpoint, as a `Result`
|
|
@@ -552,7 +604,7 @@ async function fetchCDPTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
|
|
|
552
604
|
const response = await fetch(url, { signal: controller.signal });
|
|
553
605
|
if (!response.ok) return {
|
|
554
606
|
success: false,
|
|
555
|
-
error: new BrowserConnectionError("CDP target list request was refused", {
|
|
607
|
+
error: new _src_core.BrowserConnectionError("CDP target list request was refused", {
|
|
556
608
|
url,
|
|
557
609
|
status: response.status
|
|
558
610
|
})
|
|
@@ -560,7 +612,7 @@ async function fetchCDPTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
|
|
|
560
612
|
const list = await response.json();
|
|
561
613
|
if (!(0, _orkestrel_contract.isArray)(list)) return {
|
|
562
614
|
success: false,
|
|
563
|
-
error: new BrowserConnectionError("CDP target list is not a JSON array", { url })
|
|
615
|
+
error: new _src_core.BrowserConnectionError("CDP target list is not a JSON array", { url })
|
|
564
616
|
};
|
|
565
617
|
const targets = [];
|
|
566
618
|
for (const entry of list) {
|
|
@@ -580,7 +632,7 @@ async function fetchCDPTargets(port, timeout, host = BROWSER_DEFAULT_HOST) {
|
|
|
580
632
|
} catch (error) {
|
|
581
633
|
return {
|
|
582
634
|
success: false,
|
|
583
|
-
error: new BrowserConnectionError("CDP target list is unreachable", {
|
|
635
|
+
error: new _src_core.BrowserConnectionError("CDP target list is unreachable", {
|
|
584
636
|
url,
|
|
585
637
|
error
|
|
586
638
|
})
|
|
@@ -648,7 +700,7 @@ var WebSocketCDPTransport = class {
|
|
|
648
700
|
}
|
|
649
701
|
async send(data) {
|
|
650
702
|
const socket = this.#socket;
|
|
651
|
-
if (socket === void 0 || socket.readyState !== _orkestrel_websocket.WEBSOCKET_READY_OPEN) throw new BrowserConnectionError("WebSocket CDP transport is not open", { url: this.#url });
|
|
703
|
+
if (socket === void 0 || socket.readyState !== _orkestrel_websocket.WEBSOCKET_READY_OPEN) throw new _src_core.BrowserConnectionError("WebSocket CDP transport is not open", { url: this.#url });
|
|
652
704
|
socket.send(data);
|
|
653
705
|
}
|
|
654
706
|
async close() {
|
|
@@ -656,12 +708,12 @@ var WebSocketCDPTransport = class {
|
|
|
656
708
|
}
|
|
657
709
|
async #start() {
|
|
658
710
|
const parsed = (0, _orkestrel_contract.attempt)(() => new URL(this.#url));
|
|
659
|
-
if (!parsed.success) throw new BrowserConnectionError(`WebSocket CDP URL is invalid: ${this.#url}`, {
|
|
711
|
+
if (!parsed.success) throw new _src_core.BrowserConnectionError(`WebSocket CDP URL is invalid: ${this.#url}`, {
|
|
660
712
|
url: this.#url,
|
|
661
713
|
error: parsed.error
|
|
662
714
|
});
|
|
663
715
|
const url = parsed.value;
|
|
664
|
-
if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new BrowserConnectionError(`WebSocket CDP connection requires a ws: or wss: URL: ${this.#url}`, { url: this.#url });
|
|
716
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new _src_core.BrowserConnectionError(`WebSocket CDP connection requires a ws: or wss: URL: ${this.#url}`, { url: this.#url });
|
|
665
717
|
const key = (0, node_crypto.randomBytes)(16).toString("base64");
|
|
666
718
|
const deferred = Promise.withResolvers();
|
|
667
719
|
const options = {
|
|
@@ -693,7 +745,7 @@ var WebSocketCDPTransport = class {
|
|
|
693
745
|
async #close() {
|
|
694
746
|
const request = this.#request;
|
|
695
747
|
if (request !== void 0) {
|
|
696
|
-
this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} was closed before it finished connecting`, { url: this.#url }));
|
|
748
|
+
this.#rejectRequest(request, new _src_core.BrowserConnectionError(`WebSocket CDP connection to ${this.#url} was closed before it finished connecting`, { url: this.#url }));
|
|
697
749
|
request.destroy();
|
|
698
750
|
}
|
|
699
751
|
await this.#starting.pending?.catch(() => void 0);
|
|
@@ -722,7 +774,7 @@ var WebSocketCDPTransport = class {
|
|
|
722
774
|
const accept = response.headers["sec-websocket-accept"];
|
|
723
775
|
if (!(0, _orkestrel_contract.isString)(accept) || accept !== (0, _orkestrel_websocket.computeWebSocketAccept)(key)) {
|
|
724
776
|
socket.destroy();
|
|
725
|
-
this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: Sec-WebSocket-Accept mismatch`, { url: this.#url }));
|
|
777
|
+
this.#rejectRequest(request, new _src_core.BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: Sec-WebSocket-Accept mismatch`, { url: this.#url }));
|
|
726
778
|
return;
|
|
727
779
|
}
|
|
728
780
|
try {
|
|
@@ -738,13 +790,13 @@ var WebSocketCDPTransport = class {
|
|
|
738
790
|
#decline(request, response) {
|
|
739
791
|
response.resume();
|
|
740
792
|
const reason = response.statusCode === void 0 ? "upgrade was declined without a status code" : `upgrade declined with status ${response.statusCode}`;
|
|
741
|
-
this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${reason}`, { url: this.#url }));
|
|
793
|
+
this.#rejectRequest(request, new _src_core.BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${reason}`, { url: this.#url }));
|
|
742
794
|
}
|
|
743
795
|
#fail(request, error) {
|
|
744
|
-
this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${error.message}`, { url: this.#url }));
|
|
796
|
+
this.#rejectRequest(request, new _src_core.BrowserConnectionError(`WebSocket CDP connection to ${this.#url} failed: ${error.message}`, { url: this.#url }));
|
|
745
797
|
}
|
|
746
798
|
#expire(request) {
|
|
747
|
-
this.#rejectRequest(request, new BrowserConnectionError(`WebSocket CDP connection to ${this.#url} timed out after ${this.#timeout}ms`, {
|
|
799
|
+
this.#rejectRequest(request, new _src_core.BrowserConnectionError(`WebSocket CDP connection to ${this.#url} timed out after ${this.#timeout}ms`, {
|
|
748
800
|
url: this.#url,
|
|
749
801
|
timeout: this.#timeout
|
|
750
802
|
}));
|
|
@@ -815,6 +867,790 @@ var FileBrowserWriter = class {
|
|
|
815
867
|
}
|
|
816
868
|
};
|
|
817
869
|
//#endregion
|
|
870
|
+
//#region package.json
|
|
871
|
+
var version = "0.0.20";
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/server/BrowserMCPServer.ts
|
|
874
|
+
/**
|
|
875
|
+
* Implements `BrowserMCPServerInterface`: serves the browser vocabulary and the journey tools over
|
|
876
|
+
* the Model Context Protocol on stdio, and launches Chromium on the first tool call.
|
|
877
|
+
*
|
|
878
|
+
* @remarks
|
|
879
|
+
* The server's own manager holds one dispatcher per name of the vocabulary — `look`, `read`,
|
|
880
|
+
* `click`, `type`, `press`, `navigate`, `wait`, `dialog`, `tabs`, `switch`, `record`, `save`,
|
|
881
|
+
* `journeys`, `edit`, and `replay` — each carrying the description, parameters, and annotations
|
|
882
|
+
* `BROWSER_TOOL_COPY` gives, so `tools/list` answers before Chromium starts and with Chromium
|
|
883
|
+
* absent. After the launch, a tool the toolset's manager adds under another name, such as a page
|
|
884
|
+
* tool it adopts, is mirrored as a dispatcher with that tool's definition, and the mirror is
|
|
885
|
+
* removed when the toolset withdraws the tool, so the server's tool list changes and a subscribed
|
|
886
|
+
* client is notified. The first call of any dispatcher launches Chromium one time: concurrent callers await
|
|
887
|
+
* the same launch, a failed launch rejects every one of them, and the next call launches again.
|
|
888
|
+
* A launch creates `ROOT/.profiles/ID/` exclusively, connects a browser that never attaches to an
|
|
889
|
+
* existing endpoint, opens one page in an isolated context, and constructs the page toolset with
|
|
890
|
+
* that context and the file journey and run stores under the root, on a manager the toolset owns.
|
|
891
|
+
* A dispatcher forwards its arguments and the call's context, signal included, to the toolset's
|
|
892
|
+
* manager and returns that manager's value, or rejects with its failure message; a text value
|
|
893
|
+
* reaches the client as one text block rather than as its JSON text.
|
|
894
|
+
*
|
|
895
|
+
* `start()` serves stdio and destroys the server at the end of its input, on `SIGINT`, and on
|
|
896
|
+
* `SIGTERM`. `destroy()` stops reading requests and removes every dispatcher, then destroys the
|
|
897
|
+
* toolset, which aborts the active replay, then the browser it launched, then removes the profile.
|
|
898
|
+
*
|
|
899
|
+
* Chromium on Linux refuses to start as the root user unless its sandbox is disabled, so a server
|
|
900
|
+
* running as root on Linux launches Chromium with `--no-sandbox`; a launch that could run
|
|
901
|
+
* sandboxed is never changed.
|
|
902
|
+
*
|
|
903
|
+
* @example
|
|
904
|
+
* ```ts
|
|
905
|
+
* import { BrowserMCPServer } from '@orkestrel/browser/server'
|
|
906
|
+
*
|
|
907
|
+
* const server = new BrowserMCPServer({ root: 'tmp/browsers', readonly: true })
|
|
908
|
+
* await server.start()
|
|
909
|
+
* await server.destroy()
|
|
910
|
+
* ```
|
|
911
|
+
*/
|
|
912
|
+
var BrowserMCPServer = class {
|
|
913
|
+
#root;
|
|
914
|
+
#headless;
|
|
915
|
+
#executable;
|
|
916
|
+
#readonly;
|
|
917
|
+
#launcher;
|
|
918
|
+
#input;
|
|
919
|
+
#tools;
|
|
920
|
+
#transport;
|
|
921
|
+
#abort = new AbortController();
|
|
922
|
+
#signal;
|
|
923
|
+
#vocabulary;
|
|
924
|
+
#added;
|
|
925
|
+
#removed;
|
|
926
|
+
#cleared;
|
|
927
|
+
#mirrored;
|
|
928
|
+
#session;
|
|
929
|
+
#browser;
|
|
930
|
+
#profile;
|
|
931
|
+
#started = false;
|
|
932
|
+
#closing;
|
|
933
|
+
/**
|
|
934
|
+
* Registers the dispatchers and binds the protocol to the stdio streams without reading them.
|
|
935
|
+
*
|
|
936
|
+
* @param options - The root, the launch, the journeys' read-only switch, and the streams
|
|
937
|
+
*/
|
|
938
|
+
constructor(options) {
|
|
939
|
+
this.#root = (0, node_path.resolve)(options?.root ?? "tmp/browsers");
|
|
940
|
+
this.#headless = options?.headless ?? true;
|
|
941
|
+
this.#executable = options?.executable;
|
|
942
|
+
this.#readonly = options?.readonly ?? false;
|
|
943
|
+
this.#launcher = options?.launch ?? createBrowser;
|
|
944
|
+
this.#input = options?.stdio?.input ?? process.stdin;
|
|
945
|
+
this.#tools = (0, _orkestrel_tool.createToolManager)();
|
|
946
|
+
const names = [..._src_core.BROWSER_TOOL_NAMES, ..._src_core.BROWSER_JOURNEY_TOOL_NAMES];
|
|
947
|
+
this.#vocabulary = new Set(names);
|
|
948
|
+
for (const name of names) this.#tools.add((0, _orkestrel_tool.createTool)({
|
|
949
|
+
..._src_core.BROWSER_TOOL_COPY[name],
|
|
950
|
+
execute: this.#forward.bind(this, name)
|
|
951
|
+
}));
|
|
952
|
+
const server = (0, _orkestrel_mcp.createMCPServer)({
|
|
953
|
+
identity: {
|
|
954
|
+
name: "browse",
|
|
955
|
+
version
|
|
956
|
+
},
|
|
957
|
+
tools: this.#tools,
|
|
958
|
+
execution: this.#execute.bind(this)
|
|
959
|
+
});
|
|
960
|
+
this.#transport = (0, _orkestrel_mcp_server.createStdioServer)((0, _orkestrel_mcp.createMCPLegacy)(server), {
|
|
961
|
+
input: this.#input,
|
|
962
|
+
...options?.stdio?.output === void 0 ? {} : { output: options.stdio.output }
|
|
963
|
+
});
|
|
964
|
+
this.#signal = this.#end.bind(this);
|
|
965
|
+
this.#added = this.#mirror.bind(this);
|
|
966
|
+
this.#removed = this.#withdraw.bind(this);
|
|
967
|
+
this.#cleared = this.#clear.bind(this);
|
|
968
|
+
}
|
|
969
|
+
async start() {
|
|
970
|
+
if (this.#closing !== void 0) throw this.#ended();
|
|
971
|
+
if (this.#started) return;
|
|
972
|
+
this.#started = true;
|
|
973
|
+
this.#transport.start();
|
|
974
|
+
this.#input.on("end", this.#signal);
|
|
975
|
+
process.on("SIGINT", this.#signal);
|
|
976
|
+
process.on("SIGTERM", this.#signal);
|
|
977
|
+
}
|
|
978
|
+
destroy() {
|
|
979
|
+
this.#closing ??= this.#destroy();
|
|
980
|
+
return this.#closing;
|
|
981
|
+
}
|
|
982
|
+
async #destroy() {
|
|
983
|
+
process.removeListener("SIGINT", this.#signal);
|
|
984
|
+
process.removeListener("SIGTERM", this.#signal);
|
|
985
|
+
this.#input.removeListener("end", this.#signal);
|
|
986
|
+
this.#transport.stop();
|
|
987
|
+
for (const tool of this.#tools.tools()) this.#tools.remove(tool.name);
|
|
988
|
+
this.#unmirror();
|
|
989
|
+
this.#abort.abort();
|
|
990
|
+
const failures = [];
|
|
991
|
+
await (await this.#session?.catch(() => void 0))?.destroy().catch((error) => failures.push(error));
|
|
992
|
+
await this.#browser?.destroy().catch((error) => failures.push(error));
|
|
993
|
+
this.#browser = void 0;
|
|
994
|
+
const profile = this.#profile;
|
|
995
|
+
this.#profile = void 0;
|
|
996
|
+
if (profile !== void 0) await (0, node_fs_promises.rm)(profile, {
|
|
997
|
+
recursive: true,
|
|
998
|
+
force: true,
|
|
999
|
+
maxRetries: 5
|
|
1000
|
+
});
|
|
1001
|
+
if (failures.length > 0) throw new AggregateError(failures, "The browse server teardown failed");
|
|
1002
|
+
}
|
|
1003
|
+
async #execute(context) {
|
|
1004
|
+
const result = await context.tools.execute(context.call, {
|
|
1005
|
+
signal: context.signal,
|
|
1006
|
+
...context.caller === void 0 ? {} : { caller: context.caller }
|
|
1007
|
+
});
|
|
1008
|
+
if (!result.success || typeof result.value !== "string") return result;
|
|
1009
|
+
return {
|
|
1010
|
+
resultType: "complete",
|
|
1011
|
+
content: [{
|
|
1012
|
+
type: "text",
|
|
1013
|
+
text: result.value
|
|
1014
|
+
}]
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
#end() {
|
|
1018
|
+
this.destroy();
|
|
1019
|
+
}
|
|
1020
|
+
async #forward(name, args, context) {
|
|
1021
|
+
const result = await (await this.#open()).tools.execute({
|
|
1022
|
+
id: name,
|
|
1023
|
+
name,
|
|
1024
|
+
arguments: args
|
|
1025
|
+
}, context);
|
|
1026
|
+
if (!result.success) throw new _src_core.BrowserError(result.error);
|
|
1027
|
+
return result.value;
|
|
1028
|
+
}
|
|
1029
|
+
#open() {
|
|
1030
|
+
if (this.#closing !== void 0) return Promise.reject(this.#ended());
|
|
1031
|
+
const current = this.#session;
|
|
1032
|
+
if (current !== void 0) return current;
|
|
1033
|
+
const session = this.#launch();
|
|
1034
|
+
this.#session = session;
|
|
1035
|
+
session.catch(() => {
|
|
1036
|
+
if (this.#session === session) this.#session = void 0;
|
|
1037
|
+
});
|
|
1038
|
+
return session;
|
|
1039
|
+
}
|
|
1040
|
+
async #launch() {
|
|
1041
|
+
await (0, node_fs_promises.mkdir)(this.#root, { recursive: true });
|
|
1042
|
+
const profiles = (0, node_path.join)(this.#root, ".profiles");
|
|
1043
|
+
await (0, node_fs_promises.mkdir)(profiles, { recursive: true });
|
|
1044
|
+
const profile = (0, node_path.join)(profiles, (0, node_crypto.randomUUID)());
|
|
1045
|
+
await (0, node_fs_promises.mkdir)(profile);
|
|
1046
|
+
this.#profile = profile;
|
|
1047
|
+
let browser;
|
|
1048
|
+
let toolset;
|
|
1049
|
+
try {
|
|
1050
|
+
browser = this.#launcher({
|
|
1051
|
+
headless: this.#headless,
|
|
1052
|
+
profile,
|
|
1053
|
+
cdp: { discover: false },
|
|
1054
|
+
signal: this.#abort.signal,
|
|
1055
|
+
...this.#executable === void 0 ? {} : { executable: this.#executable },
|
|
1056
|
+
...process.platform === "linux" && process.getuid?.() === 0 ? { args: ["--no-sandbox"] } : {}
|
|
1057
|
+
});
|
|
1058
|
+
this.#browser = browser;
|
|
1059
|
+
await browser.connect();
|
|
1060
|
+
const context = await browser.isolate();
|
|
1061
|
+
const page = await context.create();
|
|
1062
|
+
toolset = (0, _src_core.createBrowserToolset)(page, {
|
|
1063
|
+
context,
|
|
1064
|
+
journeys: {
|
|
1065
|
+
store: createFileBrowserJourneyStore({ root: this.#root }),
|
|
1066
|
+
runs: createFileBrowserRunStore({ root: this.#root }),
|
|
1067
|
+
readonly: this.#readonly
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
this.#mirrored = toolset.tools;
|
|
1071
|
+
toolset.tools.emitter.on("add", this.#added);
|
|
1072
|
+
toolset.tools.emitter.on("remove", this.#removed);
|
|
1073
|
+
toolset.tools.emitter.on("clear", this.#cleared);
|
|
1074
|
+
await toolset.start();
|
|
1075
|
+
return toolset;
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
this.#unmirror();
|
|
1078
|
+
if (this.#browser === browser) this.#browser = void 0;
|
|
1079
|
+
if (this.#profile === profile) this.#profile = void 0;
|
|
1080
|
+
await toolset?.destroy().catch(() => void 0);
|
|
1081
|
+
await browser?.destroy().catch(() => void 0);
|
|
1082
|
+
await (0, node_fs_promises.rm)(profile, {
|
|
1083
|
+
recursive: true,
|
|
1084
|
+
force: true,
|
|
1085
|
+
maxRetries: 5
|
|
1086
|
+
});
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
#mirror(tool) {
|
|
1091
|
+
if (this.#vocabulary.has(tool.name)) return;
|
|
1092
|
+
this.#tools.add((0, _orkestrel_tool.createTool)({
|
|
1093
|
+
...(0, _orkestrel_tool.toolToDefinition)(tool),
|
|
1094
|
+
execute: this.#forward.bind(this, tool.name)
|
|
1095
|
+
}));
|
|
1096
|
+
}
|
|
1097
|
+
#withdraw(tool) {
|
|
1098
|
+
if (this.#vocabulary.has(tool.name)) return;
|
|
1099
|
+
const current = this.#mirrored?.tool(tool.name);
|
|
1100
|
+
if (current === void 0) this.#tools.remove(tool.name);
|
|
1101
|
+
else this.#mirror(current);
|
|
1102
|
+
}
|
|
1103
|
+
#clear(tools) {
|
|
1104
|
+
for (const tool of tools) this.#withdraw(tool);
|
|
1105
|
+
}
|
|
1106
|
+
#unmirror() {
|
|
1107
|
+
const mirrored = this.#mirrored;
|
|
1108
|
+
if (mirrored === void 0) return;
|
|
1109
|
+
this.#mirrored = void 0;
|
|
1110
|
+
mirrored.emitter.off("add", this.#added);
|
|
1111
|
+
mirrored.emitter.off("remove", this.#removed);
|
|
1112
|
+
mirrored.emitter.off("clear", this.#cleared);
|
|
1113
|
+
}
|
|
1114
|
+
#ended() {
|
|
1115
|
+
return new _src_core.BrowserError("the browser session ended", "BROWSER_TOOLSET_ENDED");
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
//#endregion
|
|
1119
|
+
//#region src/server/stores/FileBrowserStore.ts
|
|
1120
|
+
/**
|
|
1121
|
+
* Shares confined filesystem operations between the journey and run stores.
|
|
1122
|
+
* @remarks Requires an existing root directory. Links present during a component check are refused;
|
|
1123
|
+
* replacement between a check and its use by another filesystem writer is outside this boundary.
|
|
1124
|
+
* @example
|
|
1125
|
+
* const files = new FileBrowserStore({ root: directory })
|
|
1126
|
+
* files.validateName('check-ready')
|
|
1127
|
+
*/
|
|
1128
|
+
var FileBrowserStore = class {
|
|
1129
|
+
#root;
|
|
1130
|
+
#limit;
|
|
1131
|
+
constructor(options) {
|
|
1132
|
+
this.#limit = options.limit ?? 100;
|
|
1133
|
+
if (!Number.isSafeInteger(this.#limit) || this.#limit < 1) throw new _src_core.BrowserError("The listing cap must be a positive integer", "BROWSER_JOURNEY_ARGUMENT");
|
|
1134
|
+
try {
|
|
1135
|
+
this.#root = (0, node_fs.realpathSync)(options.root);
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") throw new _src_core.BrowserError(`Missing root: ${options.root}`, "BROWSER_JOURNEY_PATH", { path: options.root });
|
|
1138
|
+
throw this.translateError(options.root, error);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
/** Checks a journey name before filesystem access. @param name - Journey name */
|
|
1142
|
+
validateName(name) {
|
|
1143
|
+
if (!_src_core.BROWSER_JOURNEY_NAME_PATTERN.test(name)) throw new _src_core.BrowserError(`Refused journey name: ${name}`, "BROWSER_JOURNEY_PATH");
|
|
1144
|
+
}
|
|
1145
|
+
/** Checks a run id before filesystem access. @param id - Producer id */
|
|
1146
|
+
validateId(id) {
|
|
1147
|
+
if (!_src_core.BROWSER_RUN_ID_PATTERN.test(id)) throw new _src_core.BrowserError(`Refused run id: ${id}`, "BROWSER_JOURNEY_PATH");
|
|
1148
|
+
}
|
|
1149
|
+
/** Resolves a confined target. @param parts - Relative components @returns Absolute target */
|
|
1150
|
+
resolvePath(...parts) {
|
|
1151
|
+
const path = (0, node_path.resolve)(this.#root, ...parts);
|
|
1152
|
+
if (path !== this.#root && !path.startsWith(this.#root.endsWith(node_path.sep) ? this.#root : this.#root + node_path.sep)) throw new _src_core.BrowserError(`Refused path: ${path}`, "BROWSER_JOURNEY_PATH");
|
|
1153
|
+
return path;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Checks every existing component including the root and target.
|
|
1157
|
+
* @param path - Absolute target
|
|
1158
|
+
* @param options - Cancellation options
|
|
1159
|
+
* @returns True if the target exists; false if a component is missing
|
|
1160
|
+
*/
|
|
1161
|
+
async check(path, options) {
|
|
1162
|
+
this.resolvePath(path);
|
|
1163
|
+
let current = this.#root;
|
|
1164
|
+
const parts = (0, node_path.relative)(this.#root, path).split(node_path.sep).filter(Boolean);
|
|
1165
|
+
for (let index = 0; index <= parts.length; index += 1) {
|
|
1166
|
+
options?.signal?.throwIfAborted();
|
|
1167
|
+
try {
|
|
1168
|
+
const status = await (0, node_fs_promises.lstat)(current);
|
|
1169
|
+
options?.signal?.throwIfAborted();
|
|
1170
|
+
if (status.isSymbolicLink()) throw new _src_core.BrowserError(`Refused symbolic link: ${current}`, "BROWSER_JOURNEY_PATH");
|
|
1171
|
+
if (index < parts.length && !status.isDirectory()) throw new _src_core.BrowserError(`Not a directory: ${current}`, "BROWSER_JOURNEY_PATH");
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
options?.signal?.throwIfAborted();
|
|
1174
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
1175
|
+
throw this.translateError(current, error);
|
|
1176
|
+
}
|
|
1177
|
+
const part = parts[index];
|
|
1178
|
+
if (part !== void 0) current = this.resolvePath(current, part);
|
|
1179
|
+
}
|
|
1180
|
+
return true;
|
|
1181
|
+
}
|
|
1182
|
+
/** Creates checked directories one component at a time. @param path - Directory @param options - Cancellation options */
|
|
1183
|
+
async createDirectory(path, options) {
|
|
1184
|
+
if (await this.check(path, options)) return;
|
|
1185
|
+
if (path === this.#root) throw new _src_core.BrowserError(`Missing root: ${path}`, "BROWSER_JOURNEY_PATH");
|
|
1186
|
+
await this.createDirectory((0, node_path.dirname)(path), options);
|
|
1187
|
+
options?.signal?.throwIfAborted();
|
|
1188
|
+
try {
|
|
1189
|
+
await (0, node_fs_promises.mkdir)(path);
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw this.translateError(path, error);
|
|
1192
|
+
}
|
|
1193
|
+
await this.check(path, options);
|
|
1194
|
+
}
|
|
1195
|
+
/** Reads an existing file without collapsing a fault to absence. @param path - File @param options - Cancellation options @returns Bytes as UTF-8, or absence */
|
|
1196
|
+
async read(path, options) {
|
|
1197
|
+
if (!await this.check(path, options)) return void 0;
|
|
1198
|
+
try {
|
|
1199
|
+
options?.signal?.throwIfAborted();
|
|
1200
|
+
const value = await (0, node_fs_promises.readFile)(path, {
|
|
1201
|
+
encoding: "utf8",
|
|
1202
|
+
signal: options?.signal
|
|
1203
|
+
});
|
|
1204
|
+
options?.signal?.throwIfAborted();
|
|
1205
|
+
return value;
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
options?.signal?.throwIfAborted();
|
|
1208
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return void 0;
|
|
1209
|
+
throw this.translateError(path, error);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
/** Atomically replaces a file and cleans its owned temporary file on failure. @param path - Destination @param value - Bytes or UTF-8 @param options - Cancellation options */
|
|
1213
|
+
async write(path, value, options) {
|
|
1214
|
+
const temporary = this.resolvePath((0, node_path.dirname)(path), `.${(0, node_crypto.randomUUID)()}.tmp`);
|
|
1215
|
+
let owned = false;
|
|
1216
|
+
try {
|
|
1217
|
+
await this.check(path, options);
|
|
1218
|
+
await this.check(temporary, options);
|
|
1219
|
+
options?.signal?.throwIfAborted();
|
|
1220
|
+
const file = await (0, node_fs_promises.open)(temporary, "wx");
|
|
1221
|
+
owned = true;
|
|
1222
|
+
try {
|
|
1223
|
+
options?.signal?.throwIfAborted();
|
|
1224
|
+
await file.writeFile(value, { signal: options?.signal });
|
|
1225
|
+
} finally {
|
|
1226
|
+
await file.close();
|
|
1227
|
+
}
|
|
1228
|
+
await this.check(path, options);
|
|
1229
|
+
await this.check(temporary, options);
|
|
1230
|
+
options?.signal?.throwIfAborted();
|
|
1231
|
+
await (0, node_fs_promises.rename)(temporary, path);
|
|
1232
|
+
owned = false;
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
options?.signal?.throwIfAborted();
|
|
1235
|
+
throw this.translateError(path, error);
|
|
1236
|
+
} finally {
|
|
1237
|
+
if (owned) await this.remove(temporary);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
/** Removes a checked file, accepting absence. @param path - File @param options - Cancellation options */
|
|
1241
|
+
async remove(path, options) {
|
|
1242
|
+
if (!await this.check(path, options)) return;
|
|
1243
|
+
try {
|
|
1244
|
+
options?.signal?.throwIfAborted();
|
|
1245
|
+
await (0, node_fs_promises.unlink)(path);
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
options?.signal?.throwIfAborted();
|
|
1248
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw this.translateError(path, error);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Holds a directory lock through a mutation and releases only its named entry.
|
|
1253
|
+
* @remarks Reclaims dead or empty locks within a bounded attempt count; a live or unknown holder refuses.
|
|
1254
|
+
* @param path - Lock directory
|
|
1255
|
+
* @param action - Mutation inside the lock
|
|
1256
|
+
* @param options - Cancellation options
|
|
1257
|
+
* @returns The mutation result
|
|
1258
|
+
*/
|
|
1259
|
+
async lock(path, action, options) {
|
|
1260
|
+
await this.createDirectory((0, node_path.dirname)(path), options);
|
|
1261
|
+
const name = formatBrowserLockEntry(process.pid, (0, node_crypto.randomUUID)());
|
|
1262
|
+
const entry = this.resolvePath(path, name);
|
|
1263
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1264
|
+
await this.check(path, options);
|
|
1265
|
+
options?.signal?.throwIfAborted();
|
|
1266
|
+
try {
|
|
1267
|
+
await (0, node_fs_promises.mkdir)(path);
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw this.translateError(path, error);
|
|
1270
|
+
const entries = await this.#readLock(path, options);
|
|
1271
|
+
if (entries === void 0) continue;
|
|
1272
|
+
if (entries.length > 1) break;
|
|
1273
|
+
const holder = entries[0];
|
|
1274
|
+
if (holder !== void 0) {
|
|
1275
|
+
const pid = parseBrowserLockEntry(holder);
|
|
1276
|
+
if (pid === void 0) break;
|
|
1277
|
+
try {
|
|
1278
|
+
process.kill(pid, 0);
|
|
1279
|
+
break;
|
|
1280
|
+
} catch (cause) {
|
|
1281
|
+
if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) break;
|
|
1282
|
+
}
|
|
1283
|
+
if (!await this.#unlinkLock(this.resolvePath(path, holder))) continue;
|
|
1284
|
+
}
|
|
1285
|
+
await this.#removeLock(path);
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
let owned = false;
|
|
1289
|
+
try {
|
|
1290
|
+
await this.check(entry, options);
|
|
1291
|
+
try {
|
|
1292
|
+
const file = await (0, node_fs_promises.open)(entry, "wx");
|
|
1293
|
+
owned = true;
|
|
1294
|
+
await file.close();
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") continue;
|
|
1297
|
+
throw this.translateError(entry, error);
|
|
1298
|
+
}
|
|
1299
|
+
const entries = await this.#readLock(path, options);
|
|
1300
|
+
if (entries?.length !== 1 || entries[0] !== name) break;
|
|
1301
|
+
options?.signal?.throwIfAborted();
|
|
1302
|
+
return await action();
|
|
1303
|
+
} finally {
|
|
1304
|
+
if (owned) await this.#unlinkLock(entry);
|
|
1305
|
+
await this.#removeLock(path);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
throw new _src_core.BrowserError(`Journey is locked: ${path}`, "BROWSER_JOURNEY_LOCKED");
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Lists readable entries in sorted order with a bounded page and one extra readable probe.
|
|
1312
|
+
* @param path - Parent directory
|
|
1313
|
+
* @param reader - Entry reader
|
|
1314
|
+
* @param options - Paging and cancellation
|
|
1315
|
+
* @returns Page and observed entry faults
|
|
1316
|
+
*/
|
|
1317
|
+
async list(path, reader, options) {
|
|
1318
|
+
const offset = options?.offset ?? 0;
|
|
1319
|
+
const requested = options?.limit ?? this.#limit;
|
|
1320
|
+
(0, _src_core.validateBrowserStorePage)(offset, requested);
|
|
1321
|
+
const limit = Math.min(requested, this.#limit);
|
|
1322
|
+
const entries = [];
|
|
1323
|
+
const faults = [];
|
|
1324
|
+
if (!await this.check(path, options)) return {
|
|
1325
|
+
entries,
|
|
1326
|
+
faults,
|
|
1327
|
+
truncated: false
|
|
1328
|
+
};
|
|
1329
|
+
let names;
|
|
1330
|
+
try {
|
|
1331
|
+
options?.signal?.throwIfAborted();
|
|
1332
|
+
names = (await (0, node_fs_promises.readdir)(path)).sort();
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
options?.signal?.throwIfAborted();
|
|
1335
|
+
throw this.translateError(path, error);
|
|
1336
|
+
}
|
|
1337
|
+
let count = 0;
|
|
1338
|
+
for (const name of names) {
|
|
1339
|
+
options?.signal?.throwIfAborted();
|
|
1340
|
+
try {
|
|
1341
|
+
const entry = await reader(name);
|
|
1342
|
+
if (entry === void 0) continue;
|
|
1343
|
+
if (count++ < offset) continue;
|
|
1344
|
+
if (entries.length === limit) return {
|
|
1345
|
+
entries,
|
|
1346
|
+
faults,
|
|
1347
|
+
truncated: true
|
|
1348
|
+
};
|
|
1349
|
+
entries.push(entry);
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
options?.signal?.throwIfAborted();
|
|
1352
|
+
const reason = error instanceof _src_core.BrowserError ? error.context?.["reason"] : void 0;
|
|
1353
|
+
faults.push({
|
|
1354
|
+
name,
|
|
1355
|
+
reason: typeof reason === "string" ? reason : error instanceof _src_core.BrowserError && error.code === "BROWSER_JOURNEY_PATH" ? "The entry path is refused" : error instanceof _src_core.BrowserError && error.code === "BROWSER_JOURNEY_ACCESS" ? "Permission denied" : error instanceof _src_core.BrowserError && error.code === "BROWSER_JOURNEY_FORMAT" ? "Unknown file format" : "The stored entry is malformed or unreadable"
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return {
|
|
1360
|
+
entries,
|
|
1361
|
+
faults,
|
|
1362
|
+
truncated: false
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Allocates a run directory exclusively, retrying colliding producer candidates.
|
|
1367
|
+
* @param parent - Checked parent directory
|
|
1368
|
+
* @param candidate - Producer id source
|
|
1369
|
+
* @param options - Cancellation options
|
|
1370
|
+
* @returns Allocated id and absolute directory
|
|
1371
|
+
*/
|
|
1372
|
+
async allocate(parent, candidate, options) {
|
|
1373
|
+
await this.createDirectory(parent, options);
|
|
1374
|
+
for (;;) {
|
|
1375
|
+
options?.signal?.throwIfAborted();
|
|
1376
|
+
const id = candidate();
|
|
1377
|
+
this.validateId(id);
|
|
1378
|
+
const path = this.resolvePath(parent, id);
|
|
1379
|
+
await this.check(path, options);
|
|
1380
|
+
try {
|
|
1381
|
+
options?.signal?.throwIfAborted();
|
|
1382
|
+
await (0, node_fs_promises.mkdir)(path);
|
|
1383
|
+
options?.signal?.throwIfAborted();
|
|
1384
|
+
return {
|
|
1385
|
+
id,
|
|
1386
|
+
path
|
|
1387
|
+
};
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
options?.signal?.throwIfAborted();
|
|
1390
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") continue;
|
|
1391
|
+
throw this.translateError(path, error);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
/** Translates filesystem and validation failures with their path. @param path - Failed path @param error - Cause @returns Coded error */
|
|
1396
|
+
translateError(path, error) {
|
|
1397
|
+
const code = error instanceof Error && "code" in error ? error.code : void 0;
|
|
1398
|
+
return new _src_core.BrowserError(`${path}: ${error instanceof Error ? error.message : String(error)}`, code === "EACCES" || code === "EPERM" ? "BROWSER_JOURNEY_ACCESS" : error instanceof _src_core.BrowserError && code !== "BROWSER_JOURNEY_INVALID" ? error.code : "BROWSER_JOURNEY_FILE", { path });
|
|
1399
|
+
}
|
|
1400
|
+
async #readLock(path, options) {
|
|
1401
|
+
await this.check(path, options);
|
|
1402
|
+
try {
|
|
1403
|
+
const entries = await (0, node_fs_promises.readdir)(path, { withFileTypes: true });
|
|
1404
|
+
for (const entry of entries) {
|
|
1405
|
+
await this.check(this.resolvePath(path, entry.name), options);
|
|
1406
|
+
if (!entry.isFile()) throw new _src_core.BrowserError(`Journey is locked: ${path}`, "BROWSER_JOURNEY_LOCKED");
|
|
1407
|
+
}
|
|
1408
|
+
return entries.map((entry) => entry.name);
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
if (error instanceof Error && "code" in error) {
|
|
1411
|
+
if (error.code === "ENOENT") return void 0;
|
|
1412
|
+
if (error.code === "ENOTDIR") throw new _src_core.BrowserError(`Journey is locked: ${path}`, "BROWSER_JOURNEY_LOCKED");
|
|
1413
|
+
}
|
|
1414
|
+
throw this.translateError(path, error);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
async #unlinkLock(path) {
|
|
1418
|
+
await this.check(path);
|
|
1419
|
+
try {
|
|
1420
|
+
await (0, node_fs_promises.unlink)(path);
|
|
1421
|
+
return true;
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTEMPTY")) return false;
|
|
1424
|
+
throw new _src_core.BrowserError(`Cannot remove lock entry: ${path}`, "BROWSER_JOURNEY_ACCESS", { path });
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
async #removeLock(path) {
|
|
1428
|
+
await this.check(path);
|
|
1429
|
+
try {
|
|
1430
|
+
await (0, node_fs_promises.rmdir)(path);
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTEMPTY")) return;
|
|
1433
|
+
throw new _src_core.BrowserError(`Cannot remove lock directory: ${path}`, "BROWSER_JOURNEY_ACCESS", { path });
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
//#endregion
|
|
1438
|
+
//#region src/server/stores/FileBrowserJourneyStore.ts
|
|
1439
|
+
/**
|
|
1440
|
+
* Persists journeys with exclusive writes and revision counters retained across deletion.
|
|
1441
|
+
* @remarks Requires an existing root. A live holder refuses immediately; dead or empty locks are reclaimed within a bounded attempt count.
|
|
1442
|
+
* @example
|
|
1443
|
+
* const store = new FileBrowserJourneyStore({ root: directory })
|
|
1444
|
+
* await store.set(journey)
|
|
1445
|
+
*/
|
|
1446
|
+
var FileBrowserJourneyStore = class {
|
|
1447
|
+
#files;
|
|
1448
|
+
constructor(options) {
|
|
1449
|
+
this.#files = new FileBrowserStore(options);
|
|
1450
|
+
}
|
|
1451
|
+
async get(name, options) {
|
|
1452
|
+
options?.signal?.throwIfAborted();
|
|
1453
|
+
this.#files.validateName(name);
|
|
1454
|
+
const path = this.#files.resolvePath(name, BROWSER_JOURNEY_SNAPSHOT_FILE);
|
|
1455
|
+
const source = await this.#files.read(path, options);
|
|
1456
|
+
if (source === void 0) return void 0;
|
|
1457
|
+
try {
|
|
1458
|
+
const value = (0, _orkestrel_contract.parseJSON)(source);
|
|
1459
|
+
if (!(0, _orkestrel_contract.isRecord)(value) || typeof value["revision"] !== "number" || !Number.isSafeInteger(value["revision"]) || value["revision"] < 1) throw new _src_core.BrowserError("Malformed journey revision", "BROWSER_JOURNEY_FILE");
|
|
1460
|
+
if (!(0, _orkestrel_contract.isRecord)(value["journey"]) || !("format" in value["journey"])) throw new _src_core.BrowserError("Missing journey format", "BROWSER_JOURNEY_FILE");
|
|
1461
|
+
(0, _src_core.validateBrowserJourney)(value["journey"]);
|
|
1462
|
+
if (value["journey"].name !== name) throw new _src_core.BrowserError("Journey name differs from its directory", "BROWSER_JOURNEY_FILE");
|
|
1463
|
+
return {
|
|
1464
|
+
journey: value["journey"],
|
|
1465
|
+
revision: value["revision"]
|
|
1466
|
+
};
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
const translated = this.#files.translateError(path, error);
|
|
1469
|
+
throw new _src_core.BrowserError(translated.message, translated.code, {
|
|
1470
|
+
...translated.context,
|
|
1471
|
+
reason: (0, _src_core.normalizeBrowserJourneyReason)(error)
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
async set(journey, expected, options) {
|
|
1476
|
+
options?.signal?.throwIfAborted();
|
|
1477
|
+
this.#files.validateName(journey.name);
|
|
1478
|
+
(0, _src_core.validateBrowserJourney)(journey);
|
|
1479
|
+
const owned = structuredClone(journey);
|
|
1480
|
+
const path = this.#files.resolvePath(owned.name, BROWSER_JOURNEY_SNAPSHOT_FILE);
|
|
1481
|
+
const counter = this.#files.resolvePath(owned.name, BROWSER_JOURNEY_REVISION_FILE);
|
|
1482
|
+
return this.#files.lock(this.#files.resolvePath(owned.name, BROWSER_JOURNEY_LOCK_DIRECTORY), async () => {
|
|
1483
|
+
const current = await this.get(owned.name, options);
|
|
1484
|
+
if (expected !== void 0 && expected !== (current?.revision ?? 0)) throw new _src_core.BrowserError(`Journey ${owned.name} changed since you read it`, "BROWSER_JOURNEY_STALE");
|
|
1485
|
+
const source = await this.#files.read(counter, options);
|
|
1486
|
+
const previous = source === void 0 ? 0 : Number(source);
|
|
1487
|
+
if (!Number.isSafeInteger(previous) || previous < 0 || source?.trim() === "") throw new _src_core.BrowserError(`Malformed revision: ${counter}`, "BROWSER_JOURNEY_FILE");
|
|
1488
|
+
const revision = Math.max(previous, current?.revision ?? 0) + 1;
|
|
1489
|
+
if (!Number.isSafeInteger(revision)) throw new _src_core.BrowserError(`Exhausted revision: ${counter}`, "BROWSER_JOURNEY_FILE");
|
|
1490
|
+
const saved = {
|
|
1491
|
+
journey: owned,
|
|
1492
|
+
revision
|
|
1493
|
+
};
|
|
1494
|
+
await this.#files.write(path, JSON.stringify(saved), options);
|
|
1495
|
+
try {
|
|
1496
|
+
await this.#files.write(counter, String(revision), options);
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
if (current === void 0) await this.#files.remove(path);
|
|
1499
|
+
else await this.#files.write(path, JSON.stringify(current));
|
|
1500
|
+
throw error;
|
|
1501
|
+
}
|
|
1502
|
+
return saved;
|
|
1503
|
+
}, options);
|
|
1504
|
+
}
|
|
1505
|
+
async delete(name, options) {
|
|
1506
|
+
options?.signal?.throwIfAborted();
|
|
1507
|
+
this.#files.validateName(name);
|
|
1508
|
+
await this.#files.lock(this.#files.resolvePath(name, BROWSER_JOURNEY_LOCK_DIRECTORY), async () => {
|
|
1509
|
+
const current = await this.get(name, options);
|
|
1510
|
+
if (current === void 0) return;
|
|
1511
|
+
const counter = this.#files.resolvePath(name, BROWSER_JOURNEY_REVISION_FILE);
|
|
1512
|
+
const source = await this.#files.read(counter, options);
|
|
1513
|
+
const previous = source === void 0 ? 0 : Number(source);
|
|
1514
|
+
if (!Number.isSafeInteger(previous) || previous < 0 || source?.trim() === "") throw new _src_core.BrowserError(`Malformed revision: ${counter}`, "BROWSER_JOURNEY_FILE");
|
|
1515
|
+
await this.#files.write(counter, String(Math.max(previous, current.revision ?? 0)), options);
|
|
1516
|
+
await this.#files.remove(this.#files.resolvePath(name, BROWSER_JOURNEY_SNAPSHOT_FILE), options);
|
|
1517
|
+
}, options);
|
|
1518
|
+
}
|
|
1519
|
+
async list(options) {
|
|
1520
|
+
options?.signal?.throwIfAborted();
|
|
1521
|
+
return this.#files.list(this.#files.resolvePath(), async (name) => {
|
|
1522
|
+
if (!_src_core.BROWSER_JOURNEY_NAME_PATTERN.test(name)) return void 0;
|
|
1523
|
+
return this.get(name, options);
|
|
1524
|
+
}, options);
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
//#endregion
|
|
1528
|
+
//#region src/server/stores/FileBrowserRunStore.ts
|
|
1529
|
+
/**
|
|
1530
|
+
* Persists runs and captures only in directories allocated by this instance.
|
|
1531
|
+
* @remarks Requires an existing root. Reopened stores can read and delete saved runs.
|
|
1532
|
+
* @example
|
|
1533
|
+
* const store = new FileBrowserRunStore({ root: directory })
|
|
1534
|
+
* const slot = await store.open('check-ready')
|
|
1535
|
+
*/
|
|
1536
|
+
var FileBrowserRunStore = class {
|
|
1537
|
+
#files;
|
|
1538
|
+
#slots = /* @__PURE__ */ new WeakMap();
|
|
1539
|
+
#directories = /* @__PURE__ */ new Set();
|
|
1540
|
+
constructor(options) {
|
|
1541
|
+
this.#files = new FileBrowserStore(options);
|
|
1542
|
+
}
|
|
1543
|
+
async open(name, options) {
|
|
1544
|
+
options?.signal?.throwIfAborted();
|
|
1545
|
+
this.#files.validateName(name);
|
|
1546
|
+
return this.#files.lock(this.#files.resolvePath(name, BROWSER_JOURNEY_LOCK_DIRECTORY), async () => {
|
|
1547
|
+
const directory = await this.#files.allocate(this.#files.resolvePath(name, BROWSER_RUN_DIRECTORY), _src_core.generateBrowserRunId, options);
|
|
1548
|
+
const slot = Object.freeze({
|
|
1549
|
+
id: directory.id,
|
|
1550
|
+
directory: directory.path
|
|
1551
|
+
});
|
|
1552
|
+
this.#directories.add(directory.path);
|
|
1553
|
+
this.#slots.set(slot, directory.path);
|
|
1554
|
+
return slot;
|
|
1555
|
+
}, options);
|
|
1556
|
+
}
|
|
1557
|
+
async get(name, id, options) {
|
|
1558
|
+
options?.signal?.throwIfAborted();
|
|
1559
|
+
this.#files.validateName(name);
|
|
1560
|
+
this.#files.validateId(id);
|
|
1561
|
+
const path = this.#files.resolvePath(name, BROWSER_RUN_DIRECTORY, id, BROWSER_RUN_FILE);
|
|
1562
|
+
const source = await this.#files.read(path, options);
|
|
1563
|
+
if (source === void 0) return void 0;
|
|
1564
|
+
try {
|
|
1565
|
+
const value = (0, _orkestrel_contract.parseJSON)(source);
|
|
1566
|
+
if (!(0, _orkestrel_contract.isRecord)(value) || !("format" in value)) throw new _src_core.BrowserError("Missing run format", "BROWSER_JOURNEY_FILE");
|
|
1567
|
+
if (value["format"] === _src_core.BROWSER_JOURNEY_FORMAT_VERSION && (!(0, _orkestrel_contract.isRecord)(value["journey"]) || !("format" in value["journey"]))) throw new _src_core.BrowserError("Missing run journey format", "BROWSER_JOURNEY_FILE");
|
|
1568
|
+
(0, _src_core.validateBrowserRun)(value);
|
|
1569
|
+
if (value.id !== id || value.journey.name !== name) throw new _src_core.BrowserError("Run identity differs from its directory", "BROWSER_JOURNEY_FILE");
|
|
1570
|
+
return value;
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
throw this.#files.translateError(path, error);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
async set(run, options) {
|
|
1576
|
+
options?.signal?.throwIfAborted();
|
|
1577
|
+
this.#files.validateName(run.journey.name);
|
|
1578
|
+
this.#files.validateId(run.id);
|
|
1579
|
+
(0, _src_core.validateBrowserRun)(run);
|
|
1580
|
+
const source = JSON.stringify(run);
|
|
1581
|
+
const directory = this.#files.resolvePath(run.journey.name, BROWSER_RUN_DIRECTORY, run.id);
|
|
1582
|
+
await this.#requireDirectory(directory, options);
|
|
1583
|
+
await this.#files.write(this.#files.resolvePath(directory, BROWSER_RUN_FILE), source, options);
|
|
1584
|
+
}
|
|
1585
|
+
async capture(slot, name, bytes, options) {
|
|
1586
|
+
options?.signal?.throwIfAborted();
|
|
1587
|
+
const directory = this.#slots.get(slot);
|
|
1588
|
+
if (directory === void 0 || !/^s[1-9]\d*\.png$/.test(name)) throw new _src_core.BrowserError("Capture requires an opened slot and an sN.png name", "BROWSER_JOURNEY_PATH");
|
|
1589
|
+
const owned = new Uint8Array(bytes);
|
|
1590
|
+
await this.#requireDirectory(directory, options);
|
|
1591
|
+
await this.#files.write(this.#files.resolvePath(directory, name), owned, options);
|
|
1592
|
+
return name;
|
|
1593
|
+
}
|
|
1594
|
+
async delete(name, id, options) {
|
|
1595
|
+
options?.signal?.throwIfAborted();
|
|
1596
|
+
this.#files.validateName(name);
|
|
1597
|
+
this.#files.validateId(id);
|
|
1598
|
+
const directory = this.#files.resolvePath(name, BROWSER_RUN_DIRECTORY, id);
|
|
1599
|
+
await this.#files.check(this.#files.resolvePath(directory, BROWSER_RUN_FILE), options);
|
|
1600
|
+
try {
|
|
1601
|
+
options?.signal?.throwIfAborted();
|
|
1602
|
+
await (0, node_fs_promises.rm)(directory, {
|
|
1603
|
+
recursive: true,
|
|
1604
|
+
force: true,
|
|
1605
|
+
maxRetries: 3
|
|
1606
|
+
});
|
|
1607
|
+
this.#directories.delete(directory);
|
|
1608
|
+
options?.signal?.throwIfAborted();
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
options?.signal?.throwIfAborted();
|
|
1611
|
+
throw this.#files.translateError(directory, error);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
async clear(name, options) {
|
|
1615
|
+
options?.signal?.throwIfAborted();
|
|
1616
|
+
this.#files.validateName(name);
|
|
1617
|
+
const parent = this.#files.resolvePath(name, BROWSER_RUN_DIRECTORY);
|
|
1618
|
+
if (!await this.#files.check(parent, options)) return 0;
|
|
1619
|
+
return this.#files.lock(this.#files.resolvePath(name, BROWSER_JOURNEY_LOCK_DIRECTORY), async () => {
|
|
1620
|
+
try {
|
|
1621
|
+
options?.signal?.throwIfAborted();
|
|
1622
|
+
const ids = await (0, node_fs_promises.readdir)(parent);
|
|
1623
|
+
for (const id of ids) {
|
|
1624
|
+
this.#files.validateId(id);
|
|
1625
|
+
await this.#files.check(this.#files.resolvePath(parent, id, BROWSER_RUN_FILE), options);
|
|
1626
|
+
}
|
|
1627
|
+
for (const id of ids) await this.delete(name, id, options);
|
|
1628
|
+
return ids.length;
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
options?.signal?.throwIfAborted();
|
|
1631
|
+
throw this.#files.translateError(parent, error);
|
|
1632
|
+
}
|
|
1633
|
+
}, options);
|
|
1634
|
+
}
|
|
1635
|
+
async list(name, options) {
|
|
1636
|
+
options?.signal?.throwIfAborted();
|
|
1637
|
+
this.#files.validateName(name);
|
|
1638
|
+
return this.#files.list(this.#files.resolvePath(name, BROWSER_RUN_DIRECTORY), (id) => this.get(name, id, options), options);
|
|
1639
|
+
}
|
|
1640
|
+
async #requireDirectory(directory, options) {
|
|
1641
|
+
if (!this.#directories.has(directory) || !await this.#files.check(directory, options)) throw new _src_core.BrowserError(`Run directory was not opened or is missing: ${directory}`, "BROWSER_JOURNEY_PATH");
|
|
1642
|
+
try {
|
|
1643
|
+
options?.signal?.throwIfAborted();
|
|
1644
|
+
const status = await (0, node_fs_promises.lstat)(directory);
|
|
1645
|
+
options?.signal?.throwIfAborted();
|
|
1646
|
+
if (!status.isDirectory()) throw new _src_core.BrowserError(`Run directory is not a directory: ${directory}`, "BROWSER_JOURNEY_PATH");
|
|
1647
|
+
} catch (error) {
|
|
1648
|
+
options?.signal?.throwIfAborted();
|
|
1649
|
+
throw this.#files.translateError(directory, error);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
//#endregion
|
|
818
1654
|
//#region src/server/factories.ts
|
|
819
1655
|
/**
|
|
820
1656
|
* Creates a raw-CDP `BrowserInterface` façade with discovery, connection, and lifecycle
|
|
@@ -830,7 +1666,7 @@ var FileBrowserWriter = class {
|
|
|
830
1666
|
* const browser = createBrowser({ headless: true })
|
|
831
1667
|
* await browser.connect() // CDP endpoint discovery → connect, else launch
|
|
832
1668
|
* const page = await browser.create({ url: 'https://example.com' })
|
|
833
|
-
* await page.
|
|
1669
|
+
* await (await page.elements.find({ css: '#accept' }))[0]?.click()
|
|
834
1670
|
* const shot = await page.screenshot({ path: './out.png' })
|
|
835
1671
|
* await browser.destroy()
|
|
836
1672
|
* ```
|
|
@@ -856,6 +1692,45 @@ function createCDPTransport(options) {
|
|
|
856
1692
|
function createBrowserWriter() {
|
|
857
1693
|
return new FileBrowserWriter();
|
|
858
1694
|
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Creates a journey store over an existing filesystem root.
|
|
1697
|
+
* @param options - Root and listing cap
|
|
1698
|
+
* @returns A durable journey store
|
|
1699
|
+
* @example
|
|
1700
|
+
* const store = createFileBrowserJourneyStore({ root: directory })
|
|
1701
|
+
*/
|
|
1702
|
+
function createFileBrowserJourneyStore(options) {
|
|
1703
|
+
return new FileBrowserJourneyStore(options);
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Creates a run store over an existing filesystem root.
|
|
1707
|
+
* @param options - Root and listing cap
|
|
1708
|
+
* @returns A durable run and capture store
|
|
1709
|
+
* @example
|
|
1710
|
+
* const store = createFileBrowserRunStore({ root: directory })
|
|
1711
|
+
*/
|
|
1712
|
+
function createFileBrowserRunStore(options) {
|
|
1713
|
+
return new FileBrowserRunStore(options);
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Creates the browse server, which serves the browser vocabulary and the journey tools over the
|
|
1717
|
+
* Model Context Protocol on stdio and launches Chromium on the first tool call.
|
|
1718
|
+
*
|
|
1719
|
+
* @param options - The root, the launch, the journeys' read-only switch, and the streams
|
|
1720
|
+
* @returns A {@link BrowserMCPServerInterface} that reads nothing until `start()`
|
|
1721
|
+
*
|
|
1722
|
+
* @example
|
|
1723
|
+
* ```ts
|
|
1724
|
+
* import { createBrowserMCPServer } from '@orkestrel/browser/server'
|
|
1725
|
+
*
|
|
1726
|
+
* const server = createBrowserMCPServer({ root: 'tmp/browsers', headless: true })
|
|
1727
|
+
* await server.start() // answers tools/list before Chromium starts
|
|
1728
|
+
* await server.destroy()
|
|
1729
|
+
* ```
|
|
1730
|
+
*/
|
|
1731
|
+
function createBrowserMCPServer(options) {
|
|
1732
|
+
return new BrowserMCPServer(options);
|
|
1733
|
+
}
|
|
859
1734
|
//#endregion
|
|
860
1735
|
//#region src/server/Browser.ts
|
|
861
1736
|
/**
|
|
@@ -975,7 +1850,7 @@ var Browser = class {
|
|
|
975
1850
|
}
|
|
976
1851
|
if (options?.origins !== void 0) params["originsWithUniversalNetworkAccess"] = [...options.origins];
|
|
977
1852
|
const result = await client.send("Target.createBrowserContext", params);
|
|
978
|
-
if (!(0, _orkestrel_contract.isRecord)(result) || !(0, _orkestrel_contract.isString)(result["browserContextId"])) throw new BrowserConnectionError("Failed to create isolated browser context");
|
|
1853
|
+
if (!(0, _orkestrel_contract.isRecord)(result) || !(0, _orkestrel_contract.isString)(result["browserContextId"])) throw new _src_core.BrowserConnectionError("Failed to create isolated browser context");
|
|
979
1854
|
const id = result["browserContextId"];
|
|
980
1855
|
const context = new _src_core.BrowserContext(client, id, options?.emulation?.viewport ?? this.#options.viewport, createBrowserWriter(), options?.emulation, options?.downloads, options);
|
|
981
1856
|
try {
|
|
@@ -1057,8 +1932,9 @@ var Browser = class {
|
|
|
1057
1932
|
if (this.#destroyed) throw new BrowserDestroyedError();
|
|
1058
1933
|
this.#status = "error";
|
|
1059
1934
|
this.#emitter.emit("error", error);
|
|
1060
|
-
if (isBrowserConnectionError(error)) throw error;
|
|
1061
|
-
|
|
1935
|
+
if ((0, _src_core.isBrowserConnectionError)(error)) throw error;
|
|
1936
|
+
const message = (0, _orkestrel_contract.isError)(error) ? error.message : String(error);
|
|
1937
|
+
throw new _src_core.BrowserConnectionError(message, { executable: this.#options.executable });
|
|
1062
1938
|
}
|
|
1063
1939
|
}
|
|
1064
1940
|
async #detach() {
|
|
@@ -1079,7 +1955,7 @@ var Browser = class {
|
|
|
1079
1955
|
this.#emitter.emit("idle");
|
|
1080
1956
|
}
|
|
1081
1957
|
#assertNotAborted() {
|
|
1082
|
-
if (this.#signal().aborted) throw new BrowserConnectionError("Connection aborted");
|
|
1958
|
+
if (this.#signal().aborted) throw new _src_core.BrowserConnectionError("Connection aborted");
|
|
1083
1959
|
}
|
|
1084
1960
|
#signal() {
|
|
1085
1961
|
const external = this.#options.signal;
|
|
@@ -1087,10 +1963,10 @@ var Browser = class {
|
|
|
1087
1963
|
}
|
|
1088
1964
|
async #raceAbort(promise) {
|
|
1089
1965
|
const signal = this.#signal();
|
|
1090
|
-
if (signal.aborted) throw new BrowserConnectionError("Connection aborted");
|
|
1966
|
+
if (signal.aborted) throw new _src_core.BrowserConnectionError("Connection aborted");
|
|
1091
1967
|
const aborted = Promise.withResolvers();
|
|
1092
1968
|
const listener = (0, node_events.addAbortListener)(signal, () => {
|
|
1093
|
-
aborted.reject(new BrowserConnectionError("Connection aborted"));
|
|
1969
|
+
aborted.reject(new _src_core.BrowserConnectionError("Connection aborted"));
|
|
1094
1970
|
});
|
|
1095
1971
|
try {
|
|
1096
1972
|
return await Promise.race([promise, aborted.promise]);
|
|
@@ -1118,7 +1994,7 @@ var Browser = class {
|
|
|
1118
1994
|
this.#owned = void 0;
|
|
1119
1995
|
this.#endpoint = void 0;
|
|
1120
1996
|
if (connected) this.#status = "disconnected";
|
|
1121
|
-
this.#emitter.emit("error", new BrowserConnectionError("The browser process exited unexpectedly", { cause: BROWSER_PROCESS_EXIT_CAUSE }));
|
|
1997
|
+
this.#emitter.emit("error", new _src_core.BrowserConnectionError("The browser process exited unexpectedly", { cause: BROWSER_PROCESS_EXIT_CAUSE }));
|
|
1122
1998
|
if (connected) {
|
|
1123
1999
|
this.#emitter.emit("disconnect");
|
|
1124
2000
|
this.#emitter.emit("idle");
|
|
@@ -1156,7 +2032,7 @@ var Browser = class {
|
|
|
1156
2032
|
}
|
|
1157
2033
|
this.#destroyContextList(contexts);
|
|
1158
2034
|
this.#closeClient(client);
|
|
1159
|
-
this.#emitter.emit("error", new BrowserConnectionError("The CDP transport connection was lost", { cause: BROWSER_TRANSPORT_LOSS_CAUSE }));
|
|
2035
|
+
this.#emitter.emit("error", new _src_core.BrowserConnectionError("The CDP transport connection was lost", { cause: BROWSER_TRANSPORT_LOSS_CAUSE }));
|
|
1160
2036
|
this.#emitter.emit("disconnect");
|
|
1161
2037
|
this.#emitter.emit("idle");
|
|
1162
2038
|
}
|
|
@@ -1209,7 +2085,7 @@ var Browser = class {
|
|
|
1209
2085
|
};
|
|
1210
2086
|
}
|
|
1211
2087
|
async #assertPortFree() {
|
|
1212
|
-
if (await this.#probePort()) throw new BrowserConnectionError(`Port ${this.#cdpPort} on ${this.#cdpHost} is already occupied by another CDP endpoint`, {
|
|
2088
|
+
if (await this.#probePort()) throw new _src_core.BrowserConnectionError(`Port ${this.#cdpPort} on ${this.#cdpHost} is already occupied by another CDP endpoint`, {
|
|
1213
2089
|
port: this.#cdpPort,
|
|
1214
2090
|
host: this.#cdpHost
|
|
1215
2091
|
});
|
|
@@ -1256,7 +2132,7 @@ var Browser = class {
|
|
|
1256
2132
|
}
|
|
1257
2133
|
}
|
|
1258
2134
|
async #launch() {
|
|
1259
|
-
if (this.#process !== void 0) throw new BrowserConnectionError("A browser process is already active on this instance");
|
|
2135
|
+
if (this.#process !== void 0) throw new _src_core.BrowserConnectionError("A browser process is already active on this instance");
|
|
1260
2136
|
const requestedEngine = this.#options.engine ?? this.#options.browsers?.engine;
|
|
1261
2137
|
let executable = this.#options.executable;
|
|
1262
2138
|
let resolvedEngine;
|
|
@@ -1269,14 +2145,14 @@ var Browser = class {
|
|
|
1269
2145
|
executable = found?.executable;
|
|
1270
2146
|
resolvedEngine = found?.engine;
|
|
1271
2147
|
}
|
|
1272
|
-
if (executable === void 0) throw new BrowserConnectionError("No Chromium browser found. Install Chrome, Edge, or Chromium.", requestedEngine === void 0 ? void 0 : { engine: requestedEngine });
|
|
2148
|
+
if (executable === void 0) throw new _src_core.BrowserConnectionError("No Chromium browser found. Install Chrome, Edge, or Chromium.", requestedEngine === void 0 ? void 0 : { engine: requestedEngine });
|
|
1273
2149
|
this.#engine = resolvedEngine ?? "chromium";
|
|
1274
2150
|
await this.#releaseProfile();
|
|
1275
2151
|
const profile = await createBrowserProfile(this.#options.profile);
|
|
1276
2152
|
this.#profile = profile;
|
|
1277
2153
|
let process;
|
|
1278
2154
|
try {
|
|
1279
|
-
process = launchBrowserProcess(executable, this.#
|
|
2155
|
+
process = launchBrowserProcess(executable, this.#options.cdp?.port, this.#options.headless ?? true, profile.path, this.#options.args);
|
|
1280
2156
|
} catch (error) {
|
|
1281
2157
|
await this.#releaseProfile();
|
|
1282
2158
|
throw error;
|
|
@@ -1349,7 +2225,7 @@ var Browser = class {
|
|
|
1349
2225
|
}
|
|
1350
2226
|
if (pid === void 0) {
|
|
1351
2227
|
await this.#closeRemote(client);
|
|
1352
|
-
throw new BrowserConnectionError("The browser launcher exited without naming the process serving its CDP endpoint", {
|
|
2228
|
+
throw new _src_core.BrowserConnectionError("The browser launcher exited without naming the process serving its CDP endpoint", {
|
|
1353
2229
|
executable: this.#options.executable,
|
|
1354
2230
|
pid: process.pid
|
|
1355
2231
|
});
|
|
@@ -1361,21 +2237,41 @@ var Browser = class {
|
|
|
1361
2237
|
executable,
|
|
1362
2238
|
args
|
|
1363
2239
|
};
|
|
2240
|
+
const timeout = this.#timeout();
|
|
1364
2241
|
const controller = new AbortController();
|
|
1365
|
-
const
|
|
1366
|
-
const
|
|
1367
|
-
|
|
2242
|
+
const deadline = AbortSignal.timeout(timeout);
|
|
2243
|
+
const signal = AbortSignal.any([
|
|
2244
|
+
controller.signal,
|
|
2245
|
+
this.#signal(),
|
|
2246
|
+
deadline
|
|
2247
|
+
]);
|
|
2248
|
+
const stderr = process.stderr;
|
|
2249
|
+
if (stderr === null) throw new _src_core.BrowserConnectionError("The browser process has no standard error to read", context);
|
|
2250
|
+
const exit = (0, node_events.once)(process, "exit", { signal });
|
|
2251
|
+
exit.catch(() => void 0);
|
|
2252
|
+
const ready = readBrowserEndpoint(stderr, signal).catch(async (error) => {
|
|
2253
|
+
if (signal.aborted) throw error;
|
|
2254
|
+
const values = await exit;
|
|
2255
|
+
if (values[0] === 0 && values[1] === null) throw error;
|
|
2256
|
+
throw new _src_core.BrowserConnectionError(this.#formatLaunchExit((0, _orkestrel_contract.isInteger)(values[0]) ? values[0] : null, (0, _orkestrel_contract.isString)(values[1]) ? values[1] : null), context);
|
|
2257
|
+
});
|
|
2258
|
+
const exited = exit.then((values) => {
|
|
1368
2259
|
const code = (0, _orkestrel_contract.isInteger)(values[0]) ? values[0] : null;
|
|
1369
2260
|
const exitSignal = (0, _orkestrel_contract.isString)(values[1]) ? values[1] : null;
|
|
1370
2261
|
if (code === 0 && exitSignal === null) return ready;
|
|
1371
|
-
throw new BrowserConnectionError(this.#formatLaunchExit(code, exitSignal), context);
|
|
2262
|
+
throw new _src_core.BrowserConnectionError(this.#formatLaunchExit(code, exitSignal), context);
|
|
1372
2263
|
});
|
|
1373
2264
|
try {
|
|
1374
2265
|
return await Promise.race([ready, exited]);
|
|
1375
2266
|
} catch (error) {
|
|
1376
|
-
if (isBrowserConnectionError(error)) throw error;
|
|
1377
|
-
if (this.#signal().aborted) throw new BrowserConnectionError("Connection aborted", context);
|
|
1378
|
-
throw new BrowserConnectionError(
|
|
2267
|
+
if ((0, _src_core.isBrowserConnectionError)(error)) throw error;
|
|
2268
|
+
if (this.#signal().aborted) throw new _src_core.BrowserConnectionError("Connection aborted", context);
|
|
2269
|
+
if (deadline.aborted) throw new _src_core.BrowserConnectionError(`Browser did not report a CDP endpoint within ${timeout}ms`, {
|
|
2270
|
+
...context,
|
|
2271
|
+
timeout
|
|
2272
|
+
});
|
|
2273
|
+
const message = (0, _orkestrel_contract.isError)(error) ? error.message : String(error);
|
|
2274
|
+
throw new _src_core.BrowserConnectionError(message, context);
|
|
1379
2275
|
} finally {
|
|
1380
2276
|
controller.abort();
|
|
1381
2277
|
ready.catch(() => void 0);
|
|
@@ -1593,13 +2489,23 @@ var Browser = class {
|
|
|
1593
2489
|
const code = (0, _orkestrel_contract.isError)(error) && "code" in error && (0, _orkestrel_contract.isString)(error.code) ? error.code : void 0;
|
|
1594
2490
|
if (code === "ESRCH") return false;
|
|
1595
2491
|
if (signal === 0) return code === "EPERM" ? true : void 0;
|
|
1596
|
-
throw new BrowserConnectionError("Failed to signal the browser process", {
|
|
2492
|
+
throw new _src_core.BrowserConnectionError("Failed to signal the browser process", {
|
|
1597
2493
|
pid: serving ?? pid,
|
|
1598
2494
|
signal,
|
|
1599
2495
|
cause: error
|
|
1600
2496
|
});
|
|
1601
2497
|
}
|
|
1602
2498
|
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Waits within a bound for the rest of a terminated process group, or the process a
|
|
2501
|
+
* launcher handed the endpoint to, to disappear.
|
|
2502
|
+
*
|
|
2503
|
+
* @remarks
|
|
2504
|
+
* This is the one interval loop in the server. Node raises an exit event for the direct
|
|
2505
|
+
* child only, so no event source reports the remainder of a POSIX process group or a
|
|
2506
|
+
* handed-off process, and the drain probes on `BROWSER_DRAIN_INTERVAL_MS` until the
|
|
2507
|
+
* remainder is gone or `timeout` elapses.
|
|
2508
|
+
*/
|
|
1603
2509
|
async #waitForRemainderWithin(process, timeout) {
|
|
1604
2510
|
const deadline = performance.now() + timeout;
|
|
1605
2511
|
let confirmed = false;
|
|
@@ -1609,7 +2515,7 @@ var Browser = class {
|
|
|
1609
2515
|
if (alive === true) confirmed = true;
|
|
1610
2516
|
const remaining = deadline - performance.now();
|
|
1611
2517
|
if (remaining <= 0) return confirmed ? false : void 0;
|
|
1612
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(
|
|
2518
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100, remaining)));
|
|
1613
2519
|
}
|
|
1614
2520
|
}
|
|
1615
2521
|
async #waitForTerminationWithin(process, timeout, final = false) {
|
|
@@ -1624,7 +2530,7 @@ var Browser = class {
|
|
|
1624
2530
|
if (await this.#waitForTerminationWithin(process, 3e3)) return;
|
|
1625
2531
|
if (this.#signalProcess(process, "SIGKILL") === false) return;
|
|
1626
2532
|
if (await this.#waitForTerminationWithin(process, 3e3, true)) return;
|
|
1627
|
-
throw new BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: this.#servingPid ?? process.pid });
|
|
2533
|
+
throw new _src_core.BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: this.#servingPid ?? process.pid });
|
|
1628
2534
|
}
|
|
1629
2535
|
#finish() {
|
|
1630
2536
|
this.#unbindTransport();
|
|
@@ -1648,16 +2554,25 @@ exports.BROWSER_CDP_PROTOCOL = BROWSER_CDP_PROTOCOL;
|
|
|
1648
2554
|
exports.BROWSER_CDP_VERSION_PATH = BROWSER_CDP_VERSION_PATH;
|
|
1649
2555
|
exports.BROWSER_DEFAULT_CDP_PORT = BROWSER_DEFAULT_CDP_PORT;
|
|
1650
2556
|
exports.BROWSER_DEFAULT_HOST = BROWSER_DEFAULT_HOST;
|
|
2557
|
+
exports.BROWSER_DEVTOOLS_PATTERN = BROWSER_DEVTOOLS_PATTERN;
|
|
2558
|
+
exports.BROWSER_DRAIN_INTERVAL_MS = BROWSER_DRAIN_INTERVAL_MS;
|
|
1651
2559
|
exports.BROWSER_ENGINE_HINTS = BROWSER_ENGINE_HINTS;
|
|
1652
2560
|
exports.BROWSER_ENV_PATH_KEYS = BROWSER_ENV_PATH_KEYS;
|
|
1653
2561
|
exports.BROWSER_EXECUTABLE_NAMES = BROWSER_EXECUTABLE_NAMES;
|
|
1654
2562
|
exports.BROWSER_EXECUTABLE_PATHS = BROWSER_EXECUTABLE_PATHS;
|
|
2563
|
+
exports.BROWSER_FILE_STORE_LIMIT = BROWSER_FILE_STORE_LIMIT;
|
|
1655
2564
|
exports.BROWSER_HEADLESS_ARG = BROWSER_HEADLESS_ARG;
|
|
2565
|
+
exports.BROWSER_JOURNEY_LOCK_ATTEMPTS = BROWSER_JOURNEY_LOCK_ATTEMPTS;
|
|
2566
|
+
exports.BROWSER_JOURNEY_LOCK_DIRECTORY = BROWSER_JOURNEY_LOCK_DIRECTORY;
|
|
2567
|
+
exports.BROWSER_JOURNEY_REVISION_FILE = BROWSER_JOURNEY_REVISION_FILE;
|
|
2568
|
+
exports.BROWSER_JOURNEY_SNAPSHOT_FILE = BROWSER_JOURNEY_SNAPSHOT_FILE;
|
|
1656
2569
|
exports.BROWSER_KILL_GRACE_MS = BROWSER_KILL_GRACE_MS;
|
|
1657
2570
|
exports.BROWSER_LAUNCH_ARGS = BROWSER_LAUNCH_ARGS;
|
|
1658
2571
|
exports.BROWSER_PORT_PROBE_TIMEOUT_MS = BROWSER_PORT_PROBE_TIMEOUT_MS;
|
|
1659
2572
|
exports.BROWSER_PROCESS_EXIT_CAUSE = BROWSER_PROCESS_EXIT_CAUSE;
|
|
1660
2573
|
exports.BROWSER_PROFILE_PREFIX = BROWSER_PROFILE_PREFIX;
|
|
2574
|
+
exports.BROWSER_RUN_DIRECTORY = BROWSER_RUN_DIRECTORY;
|
|
2575
|
+
exports.BROWSER_RUN_FILE = BROWSER_RUN_FILE;
|
|
1661
2576
|
exports.BROWSER_STORE_CACHE_DIRS = BROWSER_STORE_CACHE_DIRS;
|
|
1662
2577
|
exports.BROWSER_STORE_DEFAULT_DIRS = BROWSER_STORE_DEFAULT_DIRS;
|
|
1663
2578
|
exports.BROWSER_STORE_ENV_KEY = BROWSER_STORE_ENV_KEY;
|
|
@@ -1668,9 +2583,11 @@ exports.BROWSER_TRANSPORT_LOSS_DEFER_MS = BROWSER_TRANSPORT_LOSS_DEFER_MS;
|
|
|
1668
2583
|
exports.BROWSER_WINDOWS_ROOT_FALLBACKS = BROWSER_WINDOWS_ROOT_FALLBACKS;
|
|
1669
2584
|
exports.BROWSER_WINDOWS_SUFFIXES = BROWSER_WINDOWS_SUFFIXES;
|
|
1670
2585
|
exports.Browser = Browser;
|
|
1671
|
-
exports.BrowserConnectionError = BrowserConnectionError;
|
|
1672
2586
|
exports.BrowserDestroyedError = BrowserDestroyedError;
|
|
2587
|
+
exports.BrowserMCPServer = BrowserMCPServer;
|
|
1673
2588
|
exports.BrowserNotConnectedError = BrowserNotConnectedError;
|
|
2589
|
+
exports.FileBrowserJourneyStore = FileBrowserJourneyStore;
|
|
2590
|
+
exports.FileBrowserRunStore = FileBrowserRunStore;
|
|
1674
2591
|
exports.FileBrowserWriter = FileBrowserWriter;
|
|
1675
2592
|
exports.WebSocketCDPTransport = WebSocketCDPTransport;
|
|
1676
2593
|
exports.browserToEngine = browserToEngine;
|
|
@@ -1678,24 +2595,28 @@ exports.buildInstallPaths = buildInstallPaths;
|
|
|
1678
2595
|
exports.buildStoreBases = buildStoreBases;
|
|
1679
2596
|
exports.buildWindowsRoots = buildWindowsRoots;
|
|
1680
2597
|
exports.createBrowser = createBrowser;
|
|
2598
|
+
exports.createBrowserMCPServer = createBrowserMCPServer;
|
|
1681
2599
|
exports.createBrowserProfile = createBrowserProfile;
|
|
1682
2600
|
exports.createBrowserWriter = createBrowserWriter;
|
|
1683
2601
|
exports.createCDPTransport = createCDPTransport;
|
|
2602
|
+
exports.createFileBrowserJourneyStore = createFileBrowserJourneyStore;
|
|
2603
|
+
exports.createFileBrowserRunStore = createFileBrowserRunStore;
|
|
1684
2604
|
exports.fetchCDPTargets = fetchCDPTargets;
|
|
1685
2605
|
exports.findEnvOverrides = findEnvOverrides;
|
|
1686
2606
|
exports.findInstallPaths = findInstallPaths;
|
|
1687
2607
|
exports.findStorePaths = findStorePaths;
|
|
1688
2608
|
exports.findSystemBrowser = findSystemBrowser;
|
|
1689
2609
|
exports.findSystemBrowsers = findSystemBrowsers;
|
|
1690
|
-
exports.
|
|
2610
|
+
exports.formatBrowserLockEntry = formatBrowserLockEntry;
|
|
1691
2611
|
exports.isBrowserDestroyedError = isBrowserDestroyedError;
|
|
1692
2612
|
exports.isBrowserNotConnectedError = isBrowserNotConnectedError;
|
|
1693
2613
|
exports.launchBrowserProcess = launchBrowserProcess;
|
|
1694
2614
|
exports.normalizeExecutablePath = normalizeExecutablePath;
|
|
1695
2615
|
exports.parseBrowserEngine = parseBrowserEngine;
|
|
2616
|
+
exports.parseBrowserLockEntry = parseBrowserLockEntry;
|
|
1696
2617
|
exports.probePathNames = probePathNames;
|
|
2618
|
+
exports.readBrowserEndpoint = readBrowserEndpoint;
|
|
1697
2619
|
exports.readFirstLine = readFirstLine;
|
|
1698
2620
|
exports.removeBrowserProfile = removeBrowserProfile;
|
|
1699
|
-
exports.waitForCDPReady = waitForCDPReady;
|
|
1700
2621
|
|
|
1701
2622
|
//# sourceMappingURL=index.cjs.map
|