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