@orkestrel/browser 0.0.22 → 0.0.24

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,5 +1,5 @@
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";
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, createCDPClient, generateBrowserRunId, isBrowserConnectionError, isBrowserError, isCDPTimeoutError, normalizeBrowserJourneyReason, validateBrowserContextOptions, validateBrowserJourney, validateBrowserRun, validateBrowserStorePage } from "../core/index.js";
2
+ import { attempt, isArray, isError, isInstance, isInteger, isNumber, isRecord, isString, parseInteger, parseJSON } from "@orkestrel/contract";
3
3
  import { existsSync, globSync, realpathSync } from "node:fs";
4
4
  import { lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
@@ -11,7 +11,8 @@ import { request as request$1 } from "node:https";
11
11
  import { Emitter } from "@orkestrel/emitter";
12
12
  import { WEBSOCKET_READY_CLOSED, WEBSOCKET_READY_OPEN, WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
13
13
  import { addAbortListener, once } from "node:events";
14
- import { createMCPLegacy, createMCPServer } from "@orkestrel/mcp";
14
+ import { JSONRPC_SERVER_ERROR, MCPError, createMCPLegacy, createMCPServer } from "@orkestrel/mcp";
15
+ import { createPool, isPoolError } from "@orkestrel/pool";
15
16
  import { createStdioServer } from "@orkestrel/mcp/server";
16
17
  import { createTool, createToolManager, toolToDefinition } from "@orkestrel/tool";
17
18
  //#region src/server/constants.ts
@@ -211,6 +212,108 @@ var BROWSER_RUN_FILE = "run.json";
211
212
  var BROWSER_RUN_DIRECTORY = "runs";
212
213
  /** Bounds a file-store listing page by default. */
213
214
  var BROWSER_FILE_STORE_LIMIT = 100;
215
+ /** Names the refusal when no browser can serve a call. */
216
+ var BROWSER_SERVER_UNAVAILABLE = "BROWSER_SERVER_UNAVAILABLE";
217
+ /** Names a failed attempt to warm a browser. */
218
+ var BROWSER_SERVER_LAUNCH = "BROWSER_SERVER_LAUNCH";
219
+ /** Sets the default number of warm browsers to 1. */
220
+ var BROWSER_SERVER_POOL_SIZE = 1;
221
+ /** Limits the number of warm browsers to 3. */
222
+ var BROWSER_SERVER_POOL_LIMIT = 3;
223
+ /** Permits one failed refill before the next failure spends the bound. */
224
+ var BROWSER_SERVER_RESTARTS = 1;
225
+ /** Names the browser process record in each profile. */
226
+ var BROWSER_SERVER_RECORD = "browse.json";
227
+ /** Names the diagnostic when the warm floor spends its restart bound. */
228
+ var BROWSER_SERVER_EXHAUSTED = "BROWSER_SERVER_EXHAUSTED";
229
+ /** Names a failed browser server teardown. */
230
+ var BROWSER_SERVER_TEARDOWN = "BROWSER_SERVER_TEARDOWN";
231
+ /** Names a failed profile sweep. */
232
+ var BROWSER_SERVER_SWEEP = "BROWSER_SERVER_SWEEP";
233
+ /** Names a refused browser server option. */
234
+ var BROWSER_SERVER_OPTIONS = "BROWSER_SERVER_OPTIONS";
235
+ /** Names the notice that a browser and its session state were lost. */
236
+ var BROWSER_SERVER_CRASH = "BROWSER_SERVER_CRASH";
237
+ /** Names an interrupted call whose outcome is unknown. */
238
+ var BROWSER_SERVER_UNRESOLVED = "BROWSER_SERVER_UNRESOLVED";
239
+ /** Names the refusal when every browser is admitted to a holder. */
240
+ var BROWSER_SERVER_BUSY = "BROWSER_SERVER_BUSY";
241
+ /** Names the refusal for an unknown or ended holder. */
242
+ var BROWSER_SERVER_HOLDER = "BROWSER_SERVER_HOLDER";
243
+ /** Defines the holder tools independently of the shared browser's vocabulary. */
244
+ var BROWSER_SERVER_COPY = Object.freeze({
245
+ acquire: Object.freeze({
246
+ name: "acquire",
247
+ description: "Acquires an independent browser and returns its holder id and tool catalog. Destroy the holder when its work ends.",
248
+ parameters: Object.freeze({
249
+ type: "object",
250
+ properties: Object.freeze({ purpose: Object.freeze({
251
+ type: "string",
252
+ description: "The work this browser will serve."
253
+ }) }),
254
+ required: Object.freeze(["purpose"])
255
+ })
256
+ }),
257
+ execute: Object.freeze({
258
+ name: "execute",
259
+ description: "Runs a tool on the selected holder, with that tool’s arguments inside arguments. Get its available tools with tools.",
260
+ parameters: Object.freeze({
261
+ type: "object",
262
+ properties: Object.freeze({
263
+ holder: Object.freeze({
264
+ type: "string",
265
+ description: "The holder id returned by acquire."
266
+ }),
267
+ name: Object.freeze({
268
+ type: "string",
269
+ description: "The tool name from this holder’s catalog."
270
+ }),
271
+ arguments: Object.freeze({
272
+ type: "object",
273
+ description: "The arguments for the selected tool."
274
+ })
275
+ }),
276
+ required: Object.freeze([
277
+ "holder",
278
+ "name",
279
+ "arguments"
280
+ ])
281
+ }),
282
+ annotations: Object.freeze({
283
+ pure: false,
284
+ untrusted: true
285
+ })
286
+ }),
287
+ tools: Object.freeze({
288
+ name: "tools",
289
+ description: "Lists the selected holder’s tools, including tools supplied by its page.",
290
+ parameters: Object.freeze({
291
+ type: "object",
292
+ properties: Object.freeze({ holder: Object.freeze({
293
+ type: "string",
294
+ description: "The holder id returned by acquire."
295
+ }) }),
296
+ required: Object.freeze(["holder"])
297
+ }),
298
+ annotations: Object.freeze({
299
+ pure: true,
300
+ untrusted: true
301
+ })
302
+ }),
303
+ destroy: Object.freeze({
304
+ name: "destroy",
305
+ description: "Ends the selected holder and destroys its browser. Calls still running on that holder report an unresolved outcome.",
306
+ parameters: Object.freeze({
307
+ type: "object",
308
+ properties: Object.freeze({ holder: Object.freeze({
309
+ type: "string",
310
+ description: "The holder id returned by acquire."
311
+ }) }),
312
+ required: Object.freeze(["holder"])
313
+ }),
314
+ annotations: Object.freeze({ pure: false })
315
+ })
316
+ });
214
317
  //#endregion
215
318
  //#region src/server/errors.ts
216
319
  /**
@@ -254,6 +357,77 @@ function isBrowserDestroyedError(value) {
254
357
  //#endregion
255
358
  //#region src/server/helpers.ts
256
359
  /**
360
+ * Parses positive integer viewport dimensions in WIDTHxHEIGHT form.
361
+ * @param value - The viewport environment variable, or undefined when unset
362
+ * @returns The viewport, or undefined when absent or malformed
363
+ * @example
364
+ * parseBrowserViewport('1280x720') // { width: 1280, height: 720 }
365
+ */
366
+ function parseBrowserViewport(value) {
367
+ if (value === void 0) return void 0;
368
+ const match = /^([0-9]+)x([0-9]+)$/u.exec(value);
369
+ if (match === null || match[0] !== value) return void 0;
370
+ const width = parseInteger(match[1]);
371
+ const height = parseInteger(match[2]);
372
+ if (width === void 0 || height === void 0 || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) return void 0;
373
+ return {
374
+ width,
375
+ height
376
+ };
377
+ }
378
+ /**
379
+ * Probes whether a process has not been confirmed absent.
380
+ * @param pid - Process identifier to probe with signal zero
381
+ * @returns True if the host has not confirmed absence with ESRCH; false otherwise
382
+ * @example
383
+ * probeProcess(process.pid) // true
384
+ */
385
+ function probeProcess(pid) {
386
+ try {
387
+ process.kill(pid, 0);
388
+ return true;
389
+ } catch (error) {
390
+ return !(isError(error) && "code" in error && error.code === "ESRCH");
391
+ }
392
+ }
393
+ /**
394
+ * Parses a profile record naming a positive process identifier and a loopback DevTools endpoint.
395
+ * @param text - JSON record read from a browse profile
396
+ * @returns The record, or undefined when its shape or endpoint is refused
397
+ * @example
398
+ * parseBrowserProfileRecord('{"pid":123,"endpoint":"ws://127.0.0.1:9222/devtools/browser/session"}')
399
+ */
400
+ function parseBrowserProfileRecord(text) {
401
+ const record = parseJSON(text);
402
+ if (!isRecord(record)) return void 0;
403
+ const pid = record["pid"];
404
+ const endpoint = record["endpoint"];
405
+ if (!isNumber(pid) || !Number.isSafeInteger(pid) || pid <= 0 || !isString(endpoint)) return void 0;
406
+ const match = /^ws:\/\/127\.0\.0\.1:([1-9]\d*)\/devtools\/browser\/[^/?#\s\\]+$/.exec(endpoint);
407
+ if (match === null || Number(match[1]) > 65535) return void 0;
408
+ return {
409
+ pid,
410
+ endpoint
411
+ };
412
+ }
413
+ /**
414
+ * Describes a browser loss with its code, cause, and the session state it invalidates.
415
+ * @param code - Browser server diagnostic code
416
+ * @param cause - The loss or refusal, including its process context when present
417
+ * @param url - The lost page's last URL, when known
418
+ * @returns The coded diagnostic and any operation-lifecycle guidance
419
+ * @example
420
+ * describeBrowserServerLoss('BROWSER_SERVER_UNAVAILABLE', new Error('Browser exited'))
421
+ */
422
+ function describeBrowserServerLoss(code, cause, url) {
423
+ const pid = isBrowserError(cause) ? cause.context?.["pid"] : void 0;
424
+ const message = `${code}: ${(cause === void 0 ? "Browser session lost" : isError(cause) ? cause.message : String(cause)).replace(/\.+$/u, "")}${isNumber(pid) ? ` (pid ${pid})` : ""}.`;
425
+ if (code !== "BROWSER_SERVER_CRASH" && code !== "BROWSER_SERVER_UNRESOLVED") return message;
426
+ const lost = `Lost the page${url === void 0 ? "" : ` at ${url}`}, its tabs, every element reference, the retained reading, dialogs, holds, an unsaved recording, the active replay, and the isolated context's cookies.`;
427
+ const next = `The next call acquires a browser that starts at about:blank, or answers ${BROWSER_SERVER_UNAVAILABLE} when none can serve.`;
428
+ return code === "BROWSER_SERVER_UNRESOLVED" ? `${message} The outcome is unknown. ${lost} Browse did not repeat the call. ${next}` : `${message} ${lost}`;
429
+ }
430
+ /**
257
431
  * Formats a process identifier and UUID token as a lock entry name.
258
432
  * @param pid - Positive safe integer identifying the holder process
259
433
  * @param token - Lowercase UUID token identifying this acquisition
@@ -867,12 +1041,12 @@ var FileBrowserWriter = class {
867
1041
  };
868
1042
  //#endregion
869
1043
  //#region package.json
870
- var version = "0.0.22";
1044
+ var version = "0.0.24";
871
1045
  //#endregion
872
1046
  //#region src/server/BrowserMCPServer.ts
873
1047
  /**
874
1048
  * 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.
1049
+ * the Model Context Protocol on stdio, and warms Chromium at server start.
876
1050
  *
877
1051
  * @remarks
878
1052
  * The server's own manager holds one dispatcher per name of the vocabulary — `look`, `read`, `plain`,
@@ -882,18 +1056,27 @@ var version = "0.0.22";
882
1056
  * absent. After the launch, a tool the toolset's manager adds under another name, such as a page
883
1057
  * tool it adopts, is mirrored as a dispatcher with that tool's definition, and the mirror is
884
1058
  * 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
1059
+ * client is notified. The server also advertises `acquire`, `execute`, `tools`, and `destroy` with
1060
+ * `BROWSER_SERVER_COPY`. Startup warms the pool and leases the shared browser. The legacy
1061
+ * handshake and browser calls await that setup. Named holders each own a lease, a pending grant,
1062
+ * and loss notices; concurrent calls on a holder share its replacement grant. Ending a holder
1063
+ * destroys its token, and admission counts that holder until disposal settles.
1064
+ * A launch creates `ROOT/.profiles/PID-UUID/` exclusively, connects a browser that never attaches to an
888
1065
  * existing endpoint, opens one page in an isolated context, and constructs the page toolset with
889
1066
  * that context and the file journey and run stores under the root, on a manager the toolset owns.
890
1067
  * A dispatcher forwards its arguments and the call's context, signal included, to the toolset's
891
1068
  * manager and returns that manager's value, or rejects with its failure message; a text value
892
1069
  * reaches the client as one text block rather than as its JSON text.
1070
+ * A disconnect or current-page crash retires its slot. A failed call receives a liveness ping;
1071
+ * loss makes its outcome unresolved, while a known success remains successful. Pending losses
1072
+ * prefix the next outcome on a successor, or a refusal, and calls are never repeated.
1073
+ * Concurrent checks share one ping per slot. Cancelling a call ends only its wait; the ping keeps
1074
+ * its deadline and records loss for the next call.
893
1075
  *
894
1076
  * `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.
1077
+ * `SIGTERM`. `destroy()` stops reading requests and removes every dispatcher, then destroys every
1078
+ * slot through the pool — its toolset, which aborts the active replay, then its browser, then its
1079
+ * profile — and rechecks the profile folders this server made or kept.
897
1080
  *
898
1081
  * Chromium on Linux refuses to start as the root user unless its sandbox is disabled, so a server
899
1082
  * running as root on Linux launches Chromium with `--no-sandbox`; a launch that could run
@@ -912,6 +1095,7 @@ var BrowserMCPServer = class {
912
1095
  #root;
913
1096
  #headless;
914
1097
  #executable;
1098
+ #viewport;
915
1099
  #readonly;
916
1100
  #launcher;
917
1101
  #input;
@@ -920,14 +1104,34 @@ var BrowserMCPServer = class {
920
1104
  #abort = new AbortController();
921
1105
  #signal;
922
1106
  #vocabulary;
923
- #added;
924
- #removed;
925
- #cleared;
926
1107
  #mirrored;
927
- #session;
928
- #browser;
929
- #profile;
930
- #started = false;
1108
+ #pool;
1109
+ #log;
1110
+ #folders = /* @__PURE__ */ new Set();
1111
+ #faults = /* @__PURE__ */ new Set();
1112
+ #losses = /* @__PURE__ */ new WeakMap();
1113
+ #pings = /* @__PURE__ */ new WeakMap();
1114
+ #watches = /* @__PURE__ */ new Map();
1115
+ #shared = {
1116
+ id: "shared",
1117
+ purpose: "Shared browser",
1118
+ abort: this.#abort
1119
+ };
1120
+ #holders = /* @__PURE__ */ new Map([[this.#shared.id, this.#shared]]);
1121
+ #leases = /* @__PURE__ */ new Map();
1122
+ #grants = /* @__PURE__ */ new Map();
1123
+ #notices = /* @__PURE__ */ new Map();
1124
+ #disposals = /* @__PURE__ */ new Map();
1125
+ #drains = /* @__PURE__ */ new Map();
1126
+ #readers = /* @__PURE__ */ new Map();
1127
+ #writers = /* @__PURE__ */ new Set();
1128
+ #size;
1129
+ #reference;
1130
+ #references = 0;
1131
+ #failure;
1132
+ #stranded;
1133
+ #starting;
1134
+ #sweeping;
931
1135
  #closing;
932
1136
  /**
933
1137
  * Registers the dispatchers and binds the protocol to the stdio streams without reading them.
@@ -935,18 +1139,49 @@ var BrowserMCPServer = class {
935
1139
  * @param options - The root, the launch, the journeys' read-only switch, and the streams
936
1140
  */
937
1141
  constructor(options) {
1142
+ const size = options?.pool?.size ?? 1;
1143
+ if (!isInteger(size) || size < 1 || size > 3) throw new BrowserError(`pool.size must be an integer from 1 through 3`, BROWSER_SERVER_OPTIONS);
1144
+ this.#size = size;
1145
+ this.#log = options?.log ?? process.stderr;
1146
+ this.#reference = this.#issue.bind(this);
1147
+ this.#pool = createPool({
1148
+ create: this.#warm.bind(this),
1149
+ destroy: (slot) => this.#destroySlot(slot.profile, slot.browser, slot.toolset, this.#losses.get(slot)?.cause),
1150
+ validate: this.#validate.bind(this),
1151
+ watch: this.#watch.bind(this),
1152
+ error: this.#fault.bind(this),
1153
+ min: size,
1154
+ restarts: 1
1155
+ });
938
1156
  this.#root = resolve(options?.root ?? "tmp/browsers");
939
1157
  this.#headless = options?.headless ?? true;
940
1158
  this.#executable = options?.executable;
1159
+ this.#viewport = options?.viewport;
941
1160
  this.#readonly = options?.readonly ?? false;
942
1161
  this.#launcher = options?.launch ?? createBrowser;
943
1162
  this.#input = options?.stdio?.input ?? process.stdin;
944
1163
  this.#tools = createToolManager();
945
1164
  const names = [...BROWSER_TOOL_NAMES, ...BROWSER_JOURNEY_TOOL_NAMES];
946
- this.#vocabulary = new Set(names);
1165
+ this.#vocabulary = /* @__PURE__ */ new Set([...names, ...Object.keys(BROWSER_SERVER_COPY)]);
947
1166
  for (const name of names) this.#tools.add(createTool({
948
1167
  ...BROWSER_TOOL_COPY[name],
949
- execute: this.#forward.bind(this, name)
1168
+ execute: this.#forward.bind(this, this.#shared, name)
1169
+ }));
1170
+ this.#tools.add(createTool({
1171
+ ...BROWSER_SERVER_COPY.acquire,
1172
+ execute: this.#acquire.bind(this)
1173
+ }));
1174
+ this.#tools.add(createTool({
1175
+ ...BROWSER_SERVER_COPY.execute,
1176
+ execute: this.#dispatch.bind(this)
1177
+ }));
1178
+ this.#tools.add(createTool({
1179
+ ...BROWSER_SERVER_COPY.tools,
1180
+ execute: this.#catalog.bind(this)
1181
+ }));
1182
+ this.#tools.add(createTool({
1183
+ ...BROWSER_SERVER_COPY.destroy,
1184
+ execute: this.#dispose.bind(this)
950
1185
  }));
951
1186
  const server = createMCPServer({
952
1187
  identity: {
@@ -954,30 +1189,43 @@ var BrowserMCPServer = class {
954
1189
  version
955
1190
  },
956
1191
  tools: this.#tools,
957
- execution: this.#execute.bind(this)
1192
+ execution: this.#execute.bind(this),
1193
+ handshake: this.#handshake.bind(this)
958
1194
  });
959
1195
  this.#transport = createStdioServer(createMCPLegacy(server), {
960
1196
  input: this.#input,
961
1197
  ...options?.stdio?.output === void 0 ? {} : { output: options.stdio.output }
962
1198
  });
963
1199
  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
1200
  }
968
1201
  async start() {
969
1202
  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);
1203
+ this.#starting ??= this.#setup();
1204
+ return this.#starting;
976
1205
  }
977
1206
  destroy() {
978
- this.#closing ??= this.#destroy();
1207
+ this.#closing ??= Promise.resolve().then(this.#destroy.bind(this));
979
1208
  return this.#closing;
980
1209
  }
1210
+ async #setup() {
1211
+ this.#input.on("end", this.#signal);
1212
+ process.on("SIGINT", this.#signal);
1213
+ process.on("SIGTERM", this.#signal);
1214
+ this.#transport.start();
1215
+ try {
1216
+ await mkdir(join(this.#root, ".profiles"), { recursive: true });
1217
+ if (this.#closing !== void 0) return;
1218
+ this.#sweeping = this.#sweep();
1219
+ const warming = this.#pool.start().then(void 0, (error) => error);
1220
+ await this.#grant(this.#shared, this.#abort.signal);
1221
+ if (this.#closing !== void 0) return;
1222
+ warming.then(this.#exhaust.bind(this));
1223
+ } catch (error) {
1224
+ if (this.#closing !== void 0) return;
1225
+ if (isBrowserError(error) && error.code === "BROWSER_SERVER_UNAVAILABLE") throw error;
1226
+ throw this.#unavailable(isPoolError(error) ? error.cause ?? this.#failure : error);
1227
+ }
1228
+ }
981
1229
  async #destroy() {
982
1230
  process.removeListener("SIGINT", this.#signal);
983
1231
  process.removeListener("SIGTERM", this.#signal);
@@ -985,19 +1233,37 @@ var BrowserMCPServer = class {
985
1233
  this.#transport.stop();
986
1234
  for (const tool of this.#tools.tools()) this.#tools.remove(tool.name);
987
1235
  this.#unmirror();
1236
+ this.#leases.clear();
1237
+ for (const holder of this.#holders.values()) holder.abort.abort();
988
1238
  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
1239
+ const barrier = this.#pool.destroy().catch((error) => {
1240
+ for (const failure of isPoolError(error) ? error.context?.failures ?? [error] : [error]) this.#faults.add(failure);
999
1241
  });
1000
- if (failures.length > 0) throw new AggregateError(failures, "The browse server teardown failed");
1242
+ await this.#starting?.catch(() => void 0);
1243
+ await barrier;
1244
+ await Promise.allSettled(this.#disposals.values());
1245
+ this.#holders.clear();
1246
+ await this.#sweeping;
1247
+ if (this.#stranded !== void 0) this.#faults.add(this.#stranded);
1248
+ for (const folder of this.#folders) {
1249
+ try {
1250
+ const record = parseBrowserProfileRecord(await readFile(join(folder, BROWSER_SERVER_RECORD), "utf8"));
1251
+ if (record === void 0 || probeProcess(record.pid)) continue;
1252
+ } catch (error) {
1253
+ if (!isError(error) || !("code" in error) || error.code !== "ENOENT") continue;
1254
+ }
1255
+ try {
1256
+ await rm(folder, {
1257
+ recursive: true,
1258
+ force: true,
1259
+ maxRetries: 5
1260
+ });
1261
+ this.#folders.delete(folder);
1262
+ } catch (error) {
1263
+ this.#faults.add(error);
1264
+ }
1265
+ }
1266
+ if (this.#faults.size > 0) throw new AggregateError([...this.#faults], "The browse server teardown failed");
1001
1267
  }
1002
1268
  async #execute(context) {
1003
1269
  const result = await context.tools.execute(context.call, {
@@ -1014,49 +1280,395 @@ var BrowserMCPServer = class {
1014
1280
  };
1015
1281
  }
1016
1282
  #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;
1283
+ if (this.#closing !== void 0) return;
1284
+ this.destroy().catch(this.#endFailure.bind(this));
1285
+ }
1286
+ #endFailure(error) {
1287
+ process.exitCode = 1;
1288
+ this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_TEARDOWN, error)}`);
1289
+ }
1290
+ async #acquire(args, context) {
1291
+ context.signal.throwIfAborted();
1292
+ if (this.#closing !== void 0) throw this.#ended();
1293
+ const purpose = args["purpose"];
1294
+ if (!isString(purpose) || purpose.trim().length === 0) throw new BrowserError("purpose must describe the work", "BROWSER_TOOLSET_ARGUMENT");
1295
+ if (this.#holders.size >= this.#size) throw new BrowserError(`${BROWSER_SERVER_BUSY}: ${[...this.#holders.values()].map((holder) => `${holder.id} (${holder.purpose})`).join(", ")}. Call destroy for a holder no longer needed or call the named tools to share the shared browser.`, BROWSER_SERVER_BUSY);
1296
+ const holder = {
1297
+ id: randomUUID(),
1298
+ purpose,
1299
+ abort: new AbortController()
1300
+ };
1301
+ this.#holders.set(holder.id, holder);
1302
+ const listener = addAbortListener(context.signal, () => holder.abort.abort(context.signal.reason));
1303
+ try {
1304
+ const slot = await this.#serve(holder, context.signal);
1305
+ context.signal.throwIfAborted();
1306
+ if (holder.abort.signal.aborted || this.#holders.get(holder.id) !== holder) throw this.#ended();
1307
+ if (this.#leases.get(holder)?.value !== slot) throw this.#unavailable(this.#losses.get(slot)?.cause);
1308
+ const catalog = {
1309
+ holder: holder.id,
1310
+ tools: slot.toolset.tools.definitions()
1311
+ };
1312
+ return JSON.stringify(catalog);
1313
+ } catch (error) {
1314
+ await this.#retire(holder).catch(this.#fault.bind(this));
1315
+ throw error;
1316
+ } finally {
1317
+ listener[Symbol.dispose]();
1318
+ }
1319
+ }
1320
+ #holder(value) {
1321
+ const holder = isString(value) ? this.#holders.get(value) : void 0;
1322
+ if (holder === void 0 || holder === this.#shared || holder.abort.signal.aborted) throw new BrowserError(`${BROWSER_SERVER_HOLDER}: unknown or ended holder`, BROWSER_SERVER_HOLDER);
1323
+ return holder;
1324
+ }
1325
+ async #dispatch(args, context) {
1326
+ const holder = this.#holder(args["holder"]);
1327
+ const name = args["name"];
1328
+ const parameters = args["arguments"];
1329
+ if (!isString(name) || !isRecord(parameters)) throw new BrowserError("execute requires name and arguments", "BROWSER_TOOLSET_ARGUMENT");
1330
+ return this.#forward(holder, name, parameters, context);
1331
+ }
1332
+ async #catalog(args, context) {
1333
+ const holder = this.#holder(args["holder"]);
1334
+ const signal = AbortSignal.any([context.signal, holder.abort.signal]);
1335
+ try {
1336
+ const slot = await this.#serve(holder, signal);
1337
+ signal.throwIfAborted();
1338
+ if (this.#leases.get(holder)?.value !== slot) throw this.#unavailable(this.#losses.get(slot)?.cause);
1339
+ const catalog = {
1340
+ holder: holder.id,
1341
+ tools: slot.toolset.tools.definitions()
1342
+ };
1343
+ return this.#annotate(holder, JSON.stringify(catalog));
1344
+ } catch (error) {
1345
+ context.signal.throwIfAborted();
1346
+ if (holder.abort.signal.aborted) throw new BrowserError(`${BROWSER_SERVER_UNRESOLVED}: the holder ended during the call`, BROWSER_SERVER_UNRESOLVED);
1347
+ if (isBrowserError(error)) throw new BrowserError(this.#annotate(holder, `${error.code}: ${error.message}`), error.code);
1348
+ throw error;
1349
+ }
1350
+ }
1351
+ async #dispose(args) {
1352
+ const id = args["holder"];
1353
+ await ((isString(id) ? this.#disposals.get(id) : void 0) ?? this.#retire(this.#holder(id)));
1354
+ return "Holder destroyed.";
1355
+ }
1356
+ #retire(holder) {
1357
+ const pending = this.#disposals.get(holder.id);
1358
+ if (pending !== void 0) return pending;
1359
+ holder.abort.abort();
1360
+ const token = this.#leases.get(holder);
1361
+ this.#leases.delete(holder);
1362
+ const disposal = Promise.resolve().then(async () => {
1363
+ try {
1364
+ await this.#grants.get(holder)?.catch(() => void 0);
1365
+ await token?.destroy();
1366
+ await Promise.all(this.#drains.get(holder) ?? []);
1367
+ } catch (error) {
1368
+ this.#faults.add(error);
1369
+ } finally {
1370
+ if (this.#holders.get(holder.id) === holder) this.#holders.delete(holder.id);
1371
+ this.#notices.delete(holder);
1372
+ this.#drains.delete(holder);
1373
+ }
1036
1374
  });
1037
- return session;
1375
+ this.#disposals.set(holder.id, disposal);
1376
+ return disposal;
1377
+ }
1378
+ async #forward(holder, name, args, context) {
1379
+ context.signal.throwIfAborted();
1380
+ const journey = args["journey"];
1381
+ if (name !== "replay" && name !== "forget" || !isString(journey)) return this.#perform(holder, name, args, context);
1382
+ if (this.#writers.has(journey) || name === "forget" && this.#readers.has(journey)) throw new BrowserError(`BROWSER_JOURNEY_LOCKED: Journey ${journey} is locked; call ${name} again.`, "BROWSER_JOURNEY_LOCKED", { name: journey });
1383
+ const reader = Symbol(journey);
1384
+ if (name === "forget") this.#writers.add(journey);
1385
+ else {
1386
+ const readers = this.#readers.get(journey) ?? /* @__PURE__ */ new Set();
1387
+ readers.add(reader);
1388
+ this.#readers.set(journey, readers);
1389
+ }
1390
+ try {
1391
+ return await this.#perform(holder, name, args, context);
1392
+ } finally {
1393
+ if (name === "forget") this.#writers.delete(journey);
1394
+ else {
1395
+ const readers = this.#readers.get(journey);
1396
+ readers?.delete(reader);
1397
+ if (readers?.size === 0) this.#readers.delete(journey);
1398
+ }
1399
+ }
1038
1400
  }
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;
1401
+ async #perform(holder, name, args, context) {
1402
+ const signal = holder === this.#shared ? context.signal : AbortSignal.any([context.signal, holder.abort.signal]);
1403
+ let slot;
1404
+ try {
1405
+ slot = await this.#serve(holder, signal);
1406
+ } catch (error) {
1407
+ context.signal.throwIfAborted();
1408
+ if (holder !== this.#shared && holder.abort.signal.aborted) throw new BrowserError(`${BROWSER_SERVER_UNRESOLVED}: the holder ended during the call`, BROWSER_SERVER_UNRESOLVED);
1409
+ if (isBrowserError(error)) throw new BrowserError(this.#annotate(holder, `${error.code}: ${error.message}`), error.code);
1410
+ throw error;
1411
+ }
1412
+ let result;
1413
+ try {
1414
+ const execution = slot.toolset.tools.execute({
1415
+ id: name,
1416
+ name,
1417
+ arguments: args
1418
+ }, {
1419
+ ...context,
1420
+ signal
1421
+ });
1422
+ const outcome = await (name === "replay" || name === "forget" ? execution : this.#race(execution, signal));
1423
+ if (outcome === void 0) throw this.#ended();
1424
+ result = outcome;
1425
+ } catch (error) {
1426
+ result = {
1427
+ id: name,
1428
+ name,
1429
+ success: false,
1430
+ error: isError(error) ? error.message : String(error)
1431
+ };
1432
+ }
1433
+ context.signal.throwIfAborted();
1434
+ if (holder !== this.#shared && holder.abort.signal.aborted) throw new BrowserError(BROWSER_SERVER_UNRESOLVED + ": the holder ended during the call", BROWSER_SERVER_UNRESOLVED);
1435
+ if (!result.success) {
1436
+ if (this.#leases.get(holder)?.value === slot && !context.signal.aborted) try {
1437
+ await this.#race(this.#ping(slot), context.signal);
1438
+ } catch {}
1439
+ context.signal.throwIfAborted();
1440
+ if (this.#leases.get(holder)?.value !== slot) {
1441
+ const loss = this.#losses.get(slot);
1442
+ throw new BrowserError(this.#annotate(holder, describeBrowserServerLoss(BROWSER_SERVER_UNRESOLVED, loss?.cause, loss?.url), slot), BROWSER_SERVER_UNRESOLVED);
1443
+ }
1444
+ throw new BrowserError(this.#annotate(holder, result.error, slot));
1445
+ }
1446
+ return typeof result.value === "string" ? this.#annotate(holder, result.value, slot) : result.value;
1447
+ }
1448
+ #annotate(holder, value, slot) {
1449
+ const notes = [];
1450
+ for (const lost of this.#notices.get(holder) ?? []) {
1451
+ if (lost === slot) continue;
1452
+ const loss = this.#losses.get(lost);
1453
+ notes.push(describeBrowserServerLoss(BROWSER_SERVER_CRASH, loss?.cause, loss?.url));
1454
+ this.#notices.get(holder)?.delete(lost);
1455
+ }
1456
+ return [...notes, value].join("\n");
1457
+ }
1458
+ async #handshake(options) {
1459
+ try {
1460
+ await this.#race(this.#starting, options.signal);
1461
+ if (this.#closing !== void 0) throw this.#ended();
1462
+ } catch (error) {
1463
+ options.signal.throwIfAborted();
1464
+ if (isBrowserError(error)) throw new MCPError(`${error.code}: ${error.message}`, JSONRPC_SERVER_ERROR, { code: error.code });
1465
+ throw error;
1466
+ }
1467
+ }
1468
+ async #race(promise, signal) {
1469
+ signal.throwIfAborted();
1470
+ const aborted = Promise.withResolvers();
1471
+ const listener = addAbortListener(signal, () => aborted.reject(signal.reason));
1472
+ try {
1473
+ return await Promise.race([promise, aborted.promise]);
1474
+ } finally {
1475
+ listener[Symbol.dispose]();
1476
+ }
1477
+ }
1478
+ async #grant(holder, signal) {
1479
+ const held = this.#leases.get(holder);
1480
+ if (held !== void 0) return held;
1481
+ let granting = this.#grants.get(holder);
1482
+ if (granting === void 0) {
1483
+ granting = this.#pool.acquire(holder.abort.signal).then(this.#hold.bind(this, holder));
1484
+ this.#grants.set(holder, granting);
1485
+ granting.finally(() => {
1486
+ if (this.#grants.get(holder) === granting) this.#grants.delete(holder);
1487
+ }).catch(() => void 0);
1488
+ }
1489
+ const token = await this.#race(granting, signal);
1490
+ if (token === void 0) throw this.#ended();
1491
+ return token;
1492
+ }
1493
+ async #hold(holder, token) {
1494
+ if (this.#closing !== void 0 || this.#holders.get(holder.id) !== holder || holder.abort.signal.aborted) {
1495
+ await token.destroy();
1496
+ throw this.#ended();
1497
+ }
1498
+ if (this.#losses.has(token.value)) {
1499
+ await token.destroy().catch(() => void 0);
1500
+ throw this.#unavailable(this.#losses.get(token.value)?.cause);
1501
+ }
1502
+ this.#leases.set(holder, token);
1503
+ if (holder === this.#shared) {
1504
+ const tools = token.value.toolset.tools;
1505
+ const mirrored = {
1506
+ token,
1507
+ added: this.#mirror.bind(this, token),
1508
+ removed: this.#withdraw.bind(this, token),
1509
+ cleared: this.#clear.bind(this, token)
1510
+ };
1511
+ this.#mirrored = mirrored;
1512
+ for (const tool of tools.tools()) this.#mirror(token, tool);
1513
+ tools.emitter.on("add", mirrored.added);
1514
+ tools.emitter.on("remove", mirrored.removed);
1515
+ tools.emitter.on("clear", mirrored.cleared);
1516
+ }
1517
+ return token;
1518
+ }
1519
+ async #serve(holder, signal) {
1520
+ if (this.#closing !== void 0 || holder.abort.signal.aborted) throw this.#ended();
1521
+ await this.#race(this.#starting, signal);
1522
+ if (this.#closing !== void 0 || holder.abort.signal.aborted) throw this.#ended();
1523
+ const held = this.#leases.get(holder);
1524
+ if (held !== void 0) try {
1525
+ await this.#race(this.#ping(held.value), signal);
1526
+ if (this.#leases.get(holder) === held) return held.value;
1527
+ } catch {
1528
+ signal.throwIfAborted();
1529
+ if (holder.abort.signal.aborted) throw this.#ended();
1530
+ }
1531
+ try {
1532
+ const token = await this.#grant(holder, signal);
1533
+ if (this.#leases.get(holder) !== token) throw this.#unavailable(this.#losses.get(token.value)?.cause);
1534
+ return token.value;
1535
+ } catch (error) {
1536
+ signal.throwIfAborted();
1537
+ if (holder.abort.signal.aborted || isPoolError(error) && error.code === "destroyed") throw this.#ended();
1538
+ if (isPoolError(error) && (error.code === "create" || error.code === "cleanup")) {
1539
+ this.#exhaust(error);
1540
+ throw this.#unavailable(error.cause ?? this.#failure);
1541
+ }
1542
+ throw error;
1543
+ }
1544
+ }
1545
+ #ping(slot) {
1546
+ const pending = this.#pings.get(slot);
1547
+ if (pending !== void 0) return pending;
1548
+ const ping = slot.browser.ping({ signal: this.#abort.signal }).catch((error) => {
1549
+ if (!this.#abort.signal.aborted) this.#lose(slot, error);
1550
+ }).finally(() => this.#pings.delete(slot));
1551
+ this.#pings.set(slot, ping);
1552
+ return ping;
1553
+ }
1554
+ async #validate(slot) {
1555
+ try {
1556
+ await slot.browser.ping({ signal: this.#abort.signal });
1557
+ return true;
1558
+ } catch (error) {
1559
+ if (!this.#abort.signal.aborted) this.#lose(slot, error);
1560
+ return false;
1561
+ }
1562
+ }
1563
+ #lose(slot, cause) {
1564
+ if (this.#closing !== void 0 || this.#losses.has(slot)) return;
1565
+ this.#losses.set(slot, {
1566
+ cause,
1567
+ url: slot.toolset.view.url
1568
+ });
1569
+ this.#failure = cause;
1570
+ for (const [holder, token] of this.#leases) {
1571
+ if (token.value !== slot || this.#holders.get(holder.id) !== holder) continue;
1572
+ if (holder === this.#shared) this.#unmirror();
1573
+ this.#leases.delete(holder);
1574
+ const notices = this.#notices.get(holder) ?? /* @__PURE__ */ new Set();
1575
+ notices.add(slot);
1576
+ this.#notices.set(holder, notices);
1577
+ const drain = token.destroy();
1578
+ const drains = this.#drains.get(holder) ?? /* @__PURE__ */ new Set();
1579
+ drains.add(drain);
1580
+ this.#drains.set(holder, drains);
1581
+ drain.finally(() => drains.delete(drain)).catch(() => void 0);
1582
+ }
1583
+ }
1584
+ #watch(slot, signal) {
1585
+ const loss = Promise.withResolvers();
1586
+ const disconnect = this.#disconnect.bind(this, slot);
1587
+ const page = this.#track.bind(this, slot);
1588
+ const subscription = addAbortListener(signal, this.#unwatch.bind(this, slot));
1589
+ this.#watches.set(slot, {
1590
+ resolve: loss.resolve,
1591
+ disconnect,
1592
+ page,
1593
+ subscription,
1594
+ crashes: /* @__PURE__ */ new Map()
1595
+ });
1596
+ slot.browser.emitter.on("disconnect", disconnect);
1597
+ slot.context.emitter.on("page", page);
1598
+ for (const current of slot.context.pages()) this.#track(slot, current);
1599
+ if (signal.aborted) this.#unwatch(slot);
1600
+ else if (slot.browser.status !== "connected") this.#disconnect(slot);
1601
+ return loss.promise;
1602
+ }
1603
+ #track(slot, page) {
1604
+ const watch = this.#watches.get(slot);
1605
+ if (watch === void 0 || watch.crashes.has(page)) return;
1606
+ const crash = this.#createCrash(slot, page);
1607
+ page.emitter.on("crash", crash);
1608
+ this.#watches.set(slot, {
1609
+ ...watch,
1610
+ crashes: new Map([...watch.crashes, [page, crash]])
1611
+ });
1612
+ }
1613
+ #createCrash(slot, page) {
1614
+ return () => {
1615
+ if (page === slot.toolset.view) this.#report(slot, /* @__PURE__ */ new Error("The current page renderer crashed"));
1616
+ };
1617
+ }
1618
+ #disconnect(slot) {
1619
+ this.#report(slot, /* @__PURE__ */ new Error(slot.browser.pid === void 0 ? "The browser process exited" : "The browser transport disconnected"));
1620
+ }
1621
+ #report(slot, cause) {
1622
+ const watch = this.#watches.get(slot);
1623
+ if (watch === void 0) return;
1624
+ this.#unwatch(slot);
1625
+ this.#lose(slot, cause);
1626
+ watch.resolve(cause);
1627
+ }
1628
+ #unwatch(slot) {
1629
+ const watch = this.#watches.get(slot);
1630
+ if (watch === void 0) return;
1631
+ this.#watches.delete(slot);
1632
+ slot.browser.emitter.off("disconnect", watch.disconnect);
1633
+ slot.context.emitter.off("page", watch.page);
1634
+ for (const [page, crash] of watch.crashes) page.emitter.off("crash", crash);
1635
+ watch.subscription[Symbol.dispose]();
1636
+ }
1637
+ #fault(error) {
1638
+ this.#faults.add(error);
1639
+ }
1640
+ async #warm() {
1641
+ if (this.#closing !== void 0) throw this.#ended();
1642
+ if (this.#stranded !== void 0) throw new BrowserError(isError(this.#stranded) ? this.#stranded.message : String(this.#stranded), BROWSER_SERVER_TEARDOWN, isBrowserError(this.#stranded) ? this.#stranded.context : void 0);
1643
+ const profile = join(this.#root, ".profiles", formatBrowserLockEntry(process.pid, randomUUID()));
1046
1644
  let browser;
1047
1645
  let toolset;
1048
1646
  try {
1647
+ await mkdir(profile);
1648
+ this.#folders.add(profile);
1049
1649
  browser = this.#launcher({
1050
1650
  headless: this.#headless,
1051
1651
  profile,
1052
1652
  cdp: { discover: false },
1053
1653
  signal: this.#abort.signal,
1054
1654
  ...this.#executable === void 0 ? {} : { executable: this.#executable },
1655
+ ...this.#viewport === void 0 ? {} : { viewport: this.#viewport },
1055
1656
  ...process.platform === "linux" && process.getuid?.() === 0 ? { args: ["--no-sandbox"] } : {}
1056
1657
  });
1057
- this.#browser = browser;
1058
1658
  await browser.connect();
1059
- const context = await browser.isolate();
1659
+ if (browser.pid !== void 0 && browser.endpoint !== void 0) {
1660
+ const record = join(profile, BROWSER_SERVER_RECORD);
1661
+ await writeFile(`${record}.tmp`, JSON.stringify({
1662
+ pid: browser.pid,
1663
+ endpoint: browser.endpoint
1664
+ }));
1665
+ await rename(`${record}.tmp`, record);
1666
+ }
1667
+ const context = await browser.isolate({
1668
+ reference: this.#reference,
1669
+ downloads: { path: join(profile, "downloads") },
1670
+ ...this.#viewport === void 0 ? {} : { emulation: { viewport: this.#viewport } }
1671
+ });
1060
1672
  const page = await context.create();
1061
1673
  toolset = createBrowserToolset(page, {
1062
1674
  context,
@@ -1066,49 +1678,128 @@ var BrowserMCPServer = class {
1066
1678
  readonly: this.#readonly
1067
1679
  }
1068
1680
  });
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
1681
  await toolset.start();
1074
- return toolset;
1682
+ return {
1683
+ browser,
1684
+ profile,
1685
+ context,
1686
+ toolset
1687
+ };
1075
1688
  } 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);
1689
+ if (this.#folders.has(profile)) await this.#destroySlot(profile, browser, toolset).catch((failure) => {
1690
+ this.#stranded = failure;
1691
+ });
1692
+ if (this.#closing === void 0) this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_LAUNCH, error)}`);
1693
+ throw error;
1694
+ }
1695
+ }
1696
+ async #destroySlot(profile, browser, toolset, cause) {
1697
+ if (isCDPTimeoutError(cause) && browser?.pid !== void 0) try {
1698
+ process.kill(browser.pid, "SIGKILL");
1699
+ } catch (error) {
1700
+ if (!isError(error) || !("code" in error) || error.code !== "ESRCH") this.#faults.add(error);
1701
+ }
1702
+ await toolset?.destroy().catch((error) => this.#faults.add(error));
1703
+ try {
1704
+ await browser?.destroy();
1705
+ } catch (error) {
1706
+ if (this.#closing === void 0) this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_TEARDOWN, error)}`);
1707
+ throw error;
1708
+ }
1709
+ try {
1081
1710
  await rm(profile, {
1082
1711
  recursive: true,
1083
1712
  force: true,
1084
1713
  maxRetries: 5
1085
1714
  });
1086
- throw error;
1715
+ this.#folders.delete(profile);
1716
+ } catch {}
1717
+ }
1718
+ async #sweep() {
1719
+ const profiles = join(this.#root, ".profiles");
1720
+ try {
1721
+ for (const entry of await readdir(profiles)) {
1722
+ if (this.#closing !== void 0) return;
1723
+ const pid = parseBrowserLockEntry(entry);
1724
+ if (pid === void 0 || pid === process.pid || probeProcess(pid)) continue;
1725
+ const folder = join(profiles, entry);
1726
+ this.#folders.add(folder);
1727
+ try {
1728
+ const record = parseBrowserProfileRecord(await readFile(join(folder, BROWSER_SERVER_RECORD), "utf8"));
1729
+ if (record === void 0) continue;
1730
+ if (probeProcess(record.pid)) {
1731
+ const transport = createCDPTransport({ url: record.endpoint });
1732
+ const client = createCDPClient({ transport });
1733
+ try {
1734
+ this.#abort.signal.throwIfAborted();
1735
+ await this.#race(client.connect(), this.#abort.signal);
1736
+ await client.send("Browser.close", void 0, { signal: this.#abort.signal });
1737
+ } catch {} finally {
1738
+ await transport.close();
1739
+ await client.close();
1740
+ }
1741
+ if (probeProcess(record.pid)) continue;
1742
+ }
1743
+ } catch (error) {
1744
+ if (!isError(error) || !("code" in error) || error.code !== "ENOENT") continue;
1745
+ }
1746
+ try {
1747
+ await rm(folder, {
1748
+ recursive: true,
1749
+ force: true,
1750
+ maxRetries: 5
1751
+ });
1752
+ this.#folders.delete(folder);
1753
+ } catch {}
1754
+ }
1755
+ } catch (error) {
1756
+ this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_SWEEP, error)}`);
1087
1757
  }
1088
1758
  }
1089
- #mirror(tool) {
1090
- if (this.#vocabulary.has(tool.name)) return;
1759
+ #write(line) {
1760
+ try {
1761
+ this.#log.write(`${line}\n`, (error) => {
1762
+ if (error !== void 0 && error !== null) this.#faults.add(error);
1763
+ });
1764
+ } catch (error) {
1765
+ this.#faults.add(error);
1766
+ }
1767
+ }
1768
+ #exhaust(error) {
1769
+ if (isPoolError(error) && error.code === "create") this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_EXHAUSTED, error.cause ?? this.#failure)}`);
1770
+ }
1771
+ #unavailable(cause) {
1772
+ const message = describeBrowserServerLoss(BROWSER_SERVER_UNAVAILABLE, cause);
1773
+ return new BrowserError(message.slice(BROWSER_SERVER_UNAVAILABLE.length + 2), BROWSER_SERVER_UNAVAILABLE);
1774
+ }
1775
+ #issue() {
1776
+ return `e${++this.#references}`;
1777
+ }
1778
+ #mirror(token, tool) {
1779
+ if (this.#leases.get(this.#shared) !== token || this.#vocabulary.has(tool.name)) return;
1091
1780
  this.#tools.add(createTool({
1092
1781
  ...toolToDefinition(tool),
1093
- execute: this.#forward.bind(this, tool.name)
1782
+ execute: this.#forward.bind(this, this.#shared, tool.name)
1094
1783
  }));
1095
1784
  }
1096
- #withdraw(tool) {
1097
- if (this.#vocabulary.has(tool.name)) return;
1098
- const current = this.#mirrored?.tool(tool.name);
1785
+ #withdraw(token, tool) {
1786
+ if (this.#leases.get(this.#shared) !== token || this.#vocabulary.has(tool.name)) return;
1787
+ const current = token.value.toolset.tools.tool(tool.name);
1099
1788
  if (current === void 0) this.#tools.remove(tool.name);
1100
- else this.#mirror(current);
1789
+ else this.#mirror(token, current);
1101
1790
  }
1102
- #clear(tools) {
1103
- for (const tool of tools) this.#withdraw(tool);
1791
+ #clear(token, tools) {
1792
+ for (const tool of tools) this.#withdraw(token, tool);
1104
1793
  }
1105
1794
  #unmirror() {
1106
1795
  const mirrored = this.#mirrored;
1107
1796
  if (mirrored === void 0) return;
1108
1797
  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);
1798
+ const tools = mirrored.token.value.toolset.tools;
1799
+ for (const tool of tools.tools()) if (!this.#vocabulary.has(tool.name)) this.#tools.remove(tool.name);
1800
+ tools.emitter.off("add", mirrored.added);
1801
+ tools.emitter.off("remove", mirrored.removed);
1802
+ tools.emitter.off("clear", mirrored.cleared);
1112
1803
  }
1113
1804
  #ended() {
1114
1805
  return new BrowserError("the browser session ended", "BROWSER_TOOLSET_ENDED");
@@ -1278,13 +1969,7 @@ var FileBrowserStore = class {
1278
1969
  const holder = entries[0];
1279
1970
  if (holder !== void 0) {
1280
1971
  const pid = parseBrowserLockEntry(holder);
1281
- if (pid === void 0) break;
1282
- try {
1283
- process.kill(pid, 0);
1284
- break;
1285
- } catch (cause) {
1286
- if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) break;
1287
- }
1972
+ if (pid === void 0 || probeProcess(pid)) break;
1288
1973
  if (!await this.#unlinkLock(this.resolvePath(path, holder))) continue;
1289
1974
  }
1290
1975
  await this.#removeLock(path);
@@ -1552,6 +2237,24 @@ var FileBrowserRunStore = class {
1552
2237
  constructor(options) {
1553
2238
  this.#files = new FileBrowserStore(options);
1554
2239
  }
2240
+ async snapshot(bytes, options) {
2241
+ options?.signal?.throwIfAborted();
2242
+ const owned = new Uint8Array(bytes);
2243
+ const directory = await this.#files.allocate(this.#files.resolvePath(BROWSER_RUN_DIRECTORY), generateBrowserRunId, options);
2244
+ const path = this.#files.resolvePath(directory.path, "view.png");
2245
+ try {
2246
+ await this.#files.check(path, options);
2247
+ options?.signal?.throwIfAborted();
2248
+ await writeFile(path, owned, {
2249
+ flag: "wx",
2250
+ signal: options?.signal
2251
+ });
2252
+ return path;
2253
+ } catch (error) {
2254
+ options?.signal?.throwIfAborted();
2255
+ throw this.#files.translateError(path, error);
2256
+ }
2257
+ }
1555
2258
  async open(name, options) {
1556
2259
  options?.signal?.throwIfAborted();
1557
2260
  this.#files.validateName(name);
@@ -1726,7 +2429,7 @@ function createFileBrowserRunStore(options) {
1726
2429
  }
1727
2430
  /**
1728
2431
  * Creates the browse server, which serves the browser vocabulary and the journey tools over the
1729
- * Model Context Protocol on stdio and launches Chromium on the first tool call.
2432
+ * Model Context Protocol on stdio and warms Chromium at server start.
1730
2433
  *
1731
2434
  * @param options - The root, the launch, the journeys' read-only switch, and the streams
1732
2435
  * @returns A {@link BrowserMCPServerInterface} that reads nothing until `start()`
@@ -1736,7 +2439,7 @@ function createFileBrowserRunStore(options) {
1736
2439
  * import { createBrowserMCPServer } from '@orkestrel/browser/server'
1737
2440
  *
1738
2441
  * const server = createBrowserMCPServer({ root: 'tmp/browsers', headless: true })
1739
- * await server.start() // answers tools/list before Chromium starts
2442
+ * await server.start() // resolves after leasing a connected browser
1740
2443
  * await server.destroy()
1741
2444
  * ```
1742
2445
  */
@@ -1812,6 +2515,15 @@ var Browser = class {
1812
2515
  get pid() {
1813
2516
  return this.#servingPid ?? this.#process?.pid;
1814
2517
  }
2518
+ get endpoint() {
2519
+ return this.#endpoint;
2520
+ }
2521
+ async ping(options) {
2522
+ if (this.#destroyed) throw new BrowserDestroyedError();
2523
+ const client = this.#client;
2524
+ if (this.#status !== "connected" || client === void 0) throw new BrowserNotConnectedError();
2525
+ await client.send("Browser.getVersion", void 0, options);
2526
+ }
1815
2527
  async discover() {
1816
2528
  const result = await this.#discoverCDP();
1817
2529
  this.#emitter.emit("discover", result);
@@ -1937,7 +2649,7 @@ var Browser = class {
1937
2649
  await this.#connectCDP(discovery.endpoint);
1938
2650
  return;
1939
2651
  }
1940
- } else await this.#raceAbort(this.#assertPortFree());
2652
+ } else if (this.#options.cdp?.port !== void 0) await this.#raceAbort(this.#assertPortFree());
1941
2653
  this.#assertNotAborted();
1942
2654
  await this.#launch();
1943
2655
  } catch (error) {
@@ -2573,6 +3285,6 @@ var Browser = class {
2573
3285
  }
2574
3286
  };
2575
3287
  //#endregion
2576
- 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 };
3288
+ 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_SERVER_BUSY, BROWSER_SERVER_COPY, BROWSER_SERVER_CRASH, BROWSER_SERVER_EXHAUSTED, BROWSER_SERVER_HOLDER, BROWSER_SERVER_LAUNCH, BROWSER_SERVER_OPTIONS, BROWSER_SERVER_POOL_LIMIT, BROWSER_SERVER_POOL_SIZE, BROWSER_SERVER_RECORD, BROWSER_SERVER_RESTARTS, BROWSER_SERVER_SWEEP, BROWSER_SERVER_TEARDOWN, BROWSER_SERVER_UNAVAILABLE, BROWSER_SERVER_UNRESOLVED, 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, describeBrowserServerLoss, fetchCDPTargets, findEnvOverrides, findInstallPaths, findStorePaths, findSystemBrowser, findSystemBrowsers, formatBrowserLockEntry, isBrowserDestroyedError, isBrowserNotConnectedError, launchBrowserProcess, normalizeExecutablePath, parseBrowserEngine, parseBrowserLockEntry, parseBrowserProfileRecord, parseBrowserViewport, probePathNames, probeProcess, readBrowserEndpoint, readFirstLine, removeBrowserProfile };
2577
3289
 
2578
3290
  //# sourceMappingURL=index.js.map