@orkestrel/browser 0.0.18 → 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.
@@ -1,17 +1,19 @@
1
- import { BROWSER_DEFAULT_TIMEOUT_MS, BROWSER_WAIT_POLL_INTERVAL_MS, BrowserContext, BrowserError, BrowserTransition, CDPClient, validateBrowserContextOptions } from "../core/index.js";
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 { setTimeout as setTimeout$1 } from "node:timers/promises";
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
- * giving a near-simultaneous process-exit event, which libuv may reap slightly later than the
62
- * socket close, first say over the diagnosis.
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: "ignore",
523
+ stdio: [
524
+ "ignore",
525
+ "ignore",
526
+ "pipe"
527
+ ],
484
528
  detached: process.platform !== "win32"
485
529
  });
486
530
  }
487
531
  /**
488
- * Polls a browser's CDP version endpoint until it responds or the timeout elapses.
532
+ * Reads the CDP endpoint a launched browser announces on its standard error.
489
533
  *
490
- * @param port - Port the browser exposes its CDP endpoint on
491
- * @param timeout - Maximum time to wait in milliseconds
492
- * @param host - Host the browser exposes its CDP endpoint on (default `127.0.0.1`)
493
- * @param signal - Optional external abort; an abort while waiting rethrows rather than resolving
494
- * @returns The browser's WebSocket debugger URL
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 endpoint does not become ready before the timeout
544
+ * @throws When the stream closes without the line, or the signal aborts first
497
545
  */
498
- async function waitForCDPReady(port, timeout, host = BROWSER_DEFAULT_HOST, signal) {
499
- const url = `${BROWSER_CDP_PROTOCOL}://${host}:${port}${BROWSER_CDP_VERSION_PATH}`;
500
- const deadline = Date.now() + timeout;
501
- while (Date.now() < deadline) {
502
- signal?.throwIfAborted();
503
- const remaining = Math.max(0, deadline - Date.now());
504
- const requestSignal = signal === void 0 ? AbortSignal.timeout(remaining) : AbortSignal.any([signal, AbortSignal.timeout(remaining)]);
505
- try {
506
- const response = await fetch(url, { signal: requestSignal });
507
- if (response.ok) {
508
- const info = await response.json();
509
- if (isRecord(info) && isString(info["webSocketDebuggerUrl"])) return info["webSocketDebuggerUrl"];
510
- }
511
- } catch (error) {
512
- if (signal?.aborted === true) throw error;
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.click('#accept')
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
- throw new BrowserConnectionError(isError(error) ? error.message : String(error), { executable: this.#options.executable });
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.#cdpPort, this.#options.headless ?? true, profile.path, this.#options.args);
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 signal = AbortSignal.any([controller.signal, this.#signal()]);
1365
- const ready = waitForCDPReady(this.#cdpPort, this.#timeout(), this.#cdpHost, signal);
1366
- const exited = once(process, "exit", { signal }).then((values) => {
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(isError(error) ? error.message : String(error), context);
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(BROWSER_WAIT_POLL_INTERVAL_MS, remaining)));
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, BrowserConnectionError, BrowserDestroyedError, BrowserNotConnectedError, FileBrowserWriter, WebSocketCDPTransport, browserToEngine, buildInstallPaths, buildStoreBases, buildWindowsRoots, createBrowser, createBrowserProfile, createBrowserWriter, createCDPTransport, fetchCDPTargets, findEnvOverrides, findInstallPaths, findStorePaths, findSystemBrowser, findSystemBrowsers, isBrowserConnectionError, isBrowserDestroyedError, isBrowserNotConnectedError, launchBrowserProcess, normalizeExecutablePath, parseBrowserEngine, probePathNames, readFirstLine, removeBrowserProfile, waitForCDPReady };
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