@orkestrel/browser 0.0.22 → 0.0.23

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, 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,30 @@ 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";
214
239
  //#endregion
215
240
  //#region src/server/errors.ts
216
241
  /**
@@ -254,6 +279,58 @@ function isBrowserDestroyedError(value) {
254
279
  //#endregion
255
280
  //#region src/server/helpers.ts
256
281
  /**
282
+ * Probes whether a process has not been confirmed absent.
283
+ * @param pid - Process identifier to probe with signal zero
284
+ * @returns True if the host has not confirmed absence with ESRCH; false otherwise
285
+ * @example
286
+ * probeProcess(process.pid) // true
287
+ */
288
+ function probeProcess(pid) {
289
+ try {
290
+ process.kill(pid, 0);
291
+ return true;
292
+ } catch (error) {
293
+ return !(isError(error) && "code" in error && error.code === "ESRCH");
294
+ }
295
+ }
296
+ /**
297
+ * Parses a profile record naming a positive process identifier and a loopback DevTools endpoint.
298
+ * @param text - JSON record read from a browse profile
299
+ * @returns The record, or undefined when its shape or endpoint is refused
300
+ * @example
301
+ * parseBrowserProfileRecord('{"pid":123,"endpoint":"ws://127.0.0.1:9222/devtools/browser/session"}')
302
+ */
303
+ function parseBrowserProfileRecord(text) {
304
+ const record = parseJSON(text);
305
+ if (!isRecord(record)) return void 0;
306
+ const pid = record["pid"];
307
+ const endpoint = record["endpoint"];
308
+ if (!isNumber(pid) || !Number.isSafeInteger(pid) || pid <= 0 || !isString(endpoint)) return void 0;
309
+ const match = /^ws:\/\/127\.0\.0\.1:([1-9]\d*)\/devtools\/browser\/[^/?#\s\\]+$/.exec(endpoint);
310
+ if (match === null || Number(match[1]) > 65535) return void 0;
311
+ return {
312
+ pid,
313
+ endpoint
314
+ };
315
+ }
316
+ /**
317
+ * Describes a browser loss with its code, cause, and the session state it invalidates.
318
+ * @param code - Browser server diagnostic code
319
+ * @param cause - The loss or refusal, including its process context when present
320
+ * @param url - The lost page's last URL, when known
321
+ * @returns The coded diagnostic and any operation-lifecycle guidance
322
+ * @example
323
+ * describeBrowserServerLoss('BROWSER_SERVER_UNAVAILABLE', new Error('Browser exited'))
324
+ */
325
+ function describeBrowserServerLoss(code, cause, url) {
326
+ const pid = isBrowserError(cause) ? cause.context?.["pid"] : void 0;
327
+ const message = `${code}: ${(cause === void 0 ? "Browser session lost" : isError(cause) ? cause.message : String(cause)).replace(/\.+$/u, "")}${isNumber(pid) ? ` (pid ${pid})` : ""}.`;
328
+ if (code !== "BROWSER_SERVER_CRASH" && code !== "BROWSER_SERVER_UNRESOLVED") return message;
329
+ 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.`;
330
+ const next = `The next call acquires a browser that starts at about:blank, or answers ${BROWSER_SERVER_UNAVAILABLE} when none can serve.`;
331
+ return code === "BROWSER_SERVER_UNRESOLVED" ? `${message} The outcome is unknown. ${lost} Browse did not repeat the call. ${next}` : `${message} ${lost}`;
332
+ }
333
+ /**
257
334
  * Formats a process identifier and UUID token as a lock entry name.
258
335
  * @param pid - Positive safe integer identifying the holder process
259
336
  * @param token - Lowercase UUID token identifying this acquisition
@@ -867,12 +944,12 @@ var FileBrowserWriter = class {
867
944
  };
868
945
  //#endregion
869
946
  //#region package.json
870
- var version = "0.0.22";
947
+ var version = "0.0.23";
871
948
  //#endregion
872
949
  //#region src/server/BrowserMCPServer.ts
873
950
  /**
874
951
  * 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.
952
+ * the Model Context Protocol on stdio, and warms Chromium at server start.
876
953
  *
877
954
  * @remarks
878
955
  * The server's own manager holds one dispatcher per name of the vocabulary — `look`, `read`, `plain`,
@@ -882,18 +959,22 @@ var version = "0.0.22";
882
959
  * absent. After the launch, a tool the toolset's manager adds under another name, such as a page
883
960
  * tool it adopts, is mirrored as a dispatcher with that tool's definition, and the mirror is
884
961
  * 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
962
+ * client is notified. Startup warms the pool and leases one validated browser. The legacy
963
+ * handshake and every tool call await that setup; concurrent callers share a replacement lease.
964
+ * A launch creates `ROOT/.profiles/PID-UUID/` exclusively, connects a browser that never attaches to an
888
965
  * existing endpoint, opens one page in an isolated context, and constructs the page toolset with
889
966
  * that context and the file journey and run stores under the root, on a manager the toolset owns.
890
967
  * A dispatcher forwards its arguments and the call's context, signal included, to the toolset's
891
968
  * manager and returns that manager's value, or rejects with its failure message; a text value
892
969
  * reaches the client as one text block rather than as its JSON text.
970
+ * A disconnect or current-page crash retires its slot. A failed call receives a liveness ping;
971
+ * loss makes its outcome unresolved, while a known success remains successful. Pending losses
972
+ * prefix the next outcome on a successor, or a refusal, and calls are never repeated.
893
973
  *
894
974
  * `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.
975
+ * `SIGTERM`. `destroy()` stops reading requests and removes every dispatcher, then destroys every
976
+ * slot through the pool — its toolset, which aborts the active replay, then its browser, then its
977
+ * profile — and rechecks the profile folders this server made or kept.
897
978
  *
898
979
  * Chromium on Linux refuses to start as the root user unless its sandbox is disabled, so a server
899
980
  * running as root on Linux launches Chromium with `--no-sandbox`; a launch that could run
@@ -923,11 +1004,21 @@ var BrowserMCPServer = class {
923
1004
  #added;
924
1005
  #removed;
925
1006
  #cleared;
926
- #mirrored;
927
- #session;
928
- #browser;
929
- #profile;
930
- #started = false;
1007
+ #pool;
1008
+ #log;
1009
+ #folders = /* @__PURE__ */ new Set();
1010
+ #faults = /* @__PURE__ */ new Set();
1011
+ #losses = /* @__PURE__ */ new WeakMap();
1012
+ #watches = /* @__PURE__ */ new Map();
1013
+ #notice = /* @__PURE__ */ new Set();
1014
+ #reference;
1015
+ #references = 0;
1016
+ #failure;
1017
+ #stranded;
1018
+ #lease;
1019
+ #granting;
1020
+ #starting;
1021
+ #sweeping;
931
1022
  #closing;
932
1023
  /**
933
1024
  * Registers the dispatchers and binds the protocol to the stdio streams without reading them.
@@ -935,6 +1026,19 @@ var BrowserMCPServer = class {
935
1026
  * @param options - The root, the launch, the journeys' read-only switch, and the streams
936
1027
  */
937
1028
  constructor(options) {
1029
+ const size = options?.pool?.size ?? 1;
1030
+ if (!isInteger(size) || size < 1 || size > 3) throw new BrowserError(`pool.size must be an integer from 1 through 3`, BROWSER_SERVER_OPTIONS);
1031
+ this.#log = options?.log ?? process.stderr;
1032
+ this.#reference = this.#issue.bind(this);
1033
+ this.#pool = createPool({
1034
+ create: this.#warm.bind(this),
1035
+ destroy: (slot) => this.#destroySlot(slot.profile, slot.browser, slot.toolset, this.#losses.get(slot)?.cause),
1036
+ validate: this.#validate.bind(this),
1037
+ watch: this.#watch.bind(this),
1038
+ error: this.#fault.bind(this),
1039
+ min: size,
1040
+ restarts: 1
1041
+ });
938
1042
  this.#root = resolve(options?.root ?? "tmp/browsers");
939
1043
  this.#headless = options?.headless ?? true;
940
1044
  this.#executable = options?.executable;
@@ -954,7 +1058,8 @@ var BrowserMCPServer = class {
954
1058
  version
955
1059
  },
956
1060
  tools: this.#tools,
957
- execution: this.#execute.bind(this)
1061
+ execution: this.#execute.bind(this),
1062
+ handshake: this.#handshake.bind(this)
958
1063
  });
959
1064
  this.#transport = createStdioServer(createMCPLegacy(server), {
960
1065
  input: this.#input,
@@ -967,17 +1072,32 @@ var BrowserMCPServer = class {
967
1072
  }
968
1073
  async start() {
969
1074
  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);
1075
+ this.#starting ??= this.#setup();
1076
+ return this.#starting;
976
1077
  }
977
1078
  destroy() {
978
- this.#closing ??= this.#destroy();
1079
+ this.#closing ??= Promise.resolve().then(this.#destroy.bind(this));
979
1080
  return this.#closing;
980
1081
  }
1082
+ async #setup() {
1083
+ this.#input.on("end", this.#signal);
1084
+ process.on("SIGINT", this.#signal);
1085
+ process.on("SIGTERM", this.#signal);
1086
+ this.#transport.start();
1087
+ try {
1088
+ await mkdir(join(this.#root, ".profiles"), { recursive: true });
1089
+ if (this.#closing !== void 0) return;
1090
+ this.#sweeping = this.#sweep();
1091
+ const warming = this.#pool.start().then(void 0, (error) => error);
1092
+ await this.#grant(this.#abort.signal);
1093
+ if (this.#closing !== void 0) return;
1094
+ warming.then(this.#exhaust.bind(this));
1095
+ } catch (error) {
1096
+ if (this.#closing !== void 0) return;
1097
+ if (isBrowserError(error) && error.code === "BROWSER_SERVER_UNAVAILABLE") throw error;
1098
+ throw this.#unavailable(isPoolError(error) ? error.cause ?? this.#failure : error);
1099
+ }
1100
+ }
981
1101
  async #destroy() {
982
1102
  process.removeListener("SIGINT", this.#signal);
983
1103
  process.removeListener("SIGTERM", this.#signal);
@@ -985,19 +1105,34 @@ var BrowserMCPServer = class {
985
1105
  this.#transport.stop();
986
1106
  for (const tool of this.#tools.tools()) this.#tools.remove(tool.name);
987
1107
  this.#unmirror();
1108
+ this.#lease = void 0;
988
1109
  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
1110
+ const barrier = this.#pool.destroy().catch((error) => {
1111
+ for (const failure of isPoolError(error) ? error.context?.failures ?? [error] : [error]) this.#faults.add(failure);
999
1112
  });
1000
- if (failures.length > 0) throw new AggregateError(failures, "The browse server teardown failed");
1113
+ await this.#starting?.catch(() => void 0);
1114
+ await barrier;
1115
+ await this.#sweeping;
1116
+ if (this.#stranded !== void 0) this.#faults.add(this.#stranded);
1117
+ for (const folder of this.#folders) {
1118
+ try {
1119
+ const record = parseBrowserProfileRecord(await readFile(join(folder, BROWSER_SERVER_RECORD), "utf8"));
1120
+ if (record === void 0 || probeProcess(record.pid)) continue;
1121
+ } catch (error) {
1122
+ if (!isError(error) || !("code" in error) || error.code !== "ENOENT") continue;
1123
+ }
1124
+ try {
1125
+ await rm(folder, {
1126
+ recursive: true,
1127
+ force: true,
1128
+ maxRetries: 5
1129
+ });
1130
+ this.#folders.delete(folder);
1131
+ } catch (error) {
1132
+ this.#faults.add(error);
1133
+ }
1134
+ }
1135
+ if (this.#faults.size > 0) throw new AggregateError([...this.#faults], "The browse server teardown failed");
1001
1136
  }
1002
1137
  async #execute(context) {
1003
1138
  const result = await context.tools.execute(context.call, {
@@ -1014,38 +1149,224 @@ var BrowserMCPServer = class {
1014
1149
  };
1015
1150
  }
1016
1151
  #end() {
1017
- this.destroy();
1152
+ if (this.#closing !== void 0) return;
1153
+ this.destroy().catch(this.#endFailure.bind(this));
1154
+ }
1155
+ #endFailure(error) {
1156
+ process.exitCode = 1;
1157
+ this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_TEARDOWN, error)}`);
1018
1158
  }
1019
1159
  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;
1160
+ let slot;
1161
+ try {
1162
+ slot = await this.#serve(context.signal);
1163
+ } catch (error) {
1164
+ context.signal.throwIfAborted();
1165
+ if (isBrowserError(error)) throw new BrowserError(this.#annotate(`${error.code}: ${error.message}`), error.code);
1166
+ throw error;
1167
+ }
1168
+ let result;
1169
+ try {
1170
+ result = await slot.toolset.tools.execute({
1171
+ id: name,
1172
+ name,
1173
+ arguments: args
1174
+ }, context);
1175
+ } catch (error) {
1176
+ result = {
1177
+ id: name,
1178
+ name,
1179
+ success: false,
1180
+ error: isError(error) ? error.message : String(error)
1181
+ };
1182
+ }
1183
+ context.signal.throwIfAborted();
1184
+ if (!result.success) {
1185
+ if (this.#lease?.value === slot && !context.signal.aborted) try {
1186
+ await slot.browser.ping({ signal: AbortSignal.any([context.signal, this.#abort.signal]) });
1187
+ } catch (error) {
1188
+ if (!context.signal.aborted && !this.#abort.signal.aborted) this.#lose(slot, error);
1189
+ }
1190
+ context.signal.throwIfAborted();
1191
+ if (this.#lease?.value !== slot) {
1192
+ const loss = this.#losses.get(slot);
1193
+ throw new BrowserError(this.#annotate(describeBrowserServerLoss(BROWSER_SERVER_UNRESOLVED, loss?.cause, loss?.url), slot), BROWSER_SERVER_UNRESOLVED);
1194
+ }
1195
+ throw new BrowserError(this.#annotate(result.error, slot));
1196
+ }
1197
+ return typeof result.value === "string" ? this.#annotate(result.value, slot) : result.value;
1198
+ }
1199
+ #annotate(value, slot) {
1200
+ const notes = [];
1201
+ for (const lost of this.#notice) {
1202
+ if (lost === slot) continue;
1203
+ const loss = this.#losses.get(lost);
1204
+ notes.push(describeBrowserServerLoss(BROWSER_SERVER_CRASH, loss?.cause, loss?.url));
1205
+ this.#notice.delete(lost);
1206
+ }
1207
+ return [...notes, value].join("\n");
1208
+ }
1209
+ async #handshake(options) {
1210
+ try {
1211
+ await this.#race(this.#starting, options.signal);
1212
+ if (this.#closing !== void 0) throw this.#ended();
1213
+ } catch (error) {
1214
+ options.signal.throwIfAborted();
1215
+ if (isBrowserError(error)) throw new MCPError(`${error.code}: ${error.message}`, JSONRPC_SERVER_ERROR, { code: error.code });
1216
+ throw error;
1217
+ }
1218
+ }
1219
+ async #race(promise, signal) {
1220
+ signal.throwIfAborted();
1221
+ const aborted = Promise.withResolvers();
1222
+ const listener = addAbortListener(signal, () => aborted.reject(signal.reason));
1223
+ try {
1224
+ return await Promise.race([promise, aborted.promise]);
1225
+ } finally {
1226
+ listener[Symbol.dispose]();
1227
+ }
1228
+ }
1229
+ async #grant(signal) {
1230
+ if (this.#lease !== void 0) return this.#lease;
1231
+ this.#granting ??= this.#pool.acquire(this.#abort.signal).then(this.#hold.bind(this), this.#refuse.bind(this));
1232
+ const token = await this.#race(this.#granting, signal);
1233
+ if (token === void 0) throw this.#ended();
1234
+ return token;
1235
+ }
1236
+ #hold(token) {
1237
+ this.#granting = void 0;
1238
+ if (this.#closing !== void 0) throw this.#ended();
1239
+ if (this.#losses.has(token.value)) {
1240
+ token.destroy().catch(() => void 0);
1241
+ throw this.#unavailable(this.#losses.get(token.value)?.cause);
1242
+ }
1243
+ this.#lease = token;
1244
+ const tools = token.value.toolset.tools;
1245
+ for (const tool of tools.tools()) this.#mirror(tool);
1246
+ tools.emitter.on("add", this.#added);
1247
+ tools.emitter.on("remove", this.#removed);
1248
+ tools.emitter.on("clear", this.#cleared);
1249
+ return token;
1250
+ }
1251
+ #refuse(error) {
1252
+ this.#granting = void 0;
1253
+ throw error;
1254
+ }
1255
+ async #serve(signal) {
1256
+ if (this.#closing !== void 0) throw this.#ended();
1257
+ await this.#race(this.#starting, signal);
1258
+ if (this.#closing !== void 0) throw this.#ended();
1259
+ const held = this.#lease;
1260
+ if (held !== void 0) try {
1261
+ await held.value.browser.ping({ signal: AbortSignal.any([signal, this.#abort.signal]) });
1262
+ if (this.#lease === held) return held.value;
1263
+ } catch (error) {
1264
+ signal.throwIfAborted();
1265
+ if (this.#abort.signal.aborted) throw this.#ended();
1266
+ this.#lose(held.value, error);
1267
+ }
1268
+ try {
1269
+ const token = await this.#grant(signal);
1270
+ if (this.#lease !== token) throw this.#unavailable(this.#losses.get(token.value)?.cause);
1271
+ return token.value;
1272
+ } catch (error) {
1273
+ signal.throwIfAborted();
1274
+ if (this.#abort.signal.aborted || isPoolError(error) && error.code === "destroyed") throw this.#ended();
1275
+ if (isPoolError(error) && (error.code === "create" || error.code === "cleanup")) {
1276
+ this.#exhaust(error);
1277
+ throw this.#unavailable(error.cause ?? this.#failure);
1278
+ }
1279
+ throw error;
1280
+ }
1281
+ }
1282
+ async #validate(slot) {
1283
+ try {
1284
+ await slot.browser.ping({ signal: this.#abort.signal });
1285
+ return true;
1286
+ } catch (error) {
1287
+ if (!this.#abort.signal.aborted) this.#lose(slot, error);
1288
+ return false;
1289
+ }
1290
+ }
1291
+ #lose(slot, cause) {
1292
+ if (this.#closing !== void 0 || this.#losses.has(slot)) return;
1293
+ this.#losses.set(slot, {
1294
+ cause,
1295
+ url: slot.toolset.view.url
1296
+ });
1297
+ this.#failure = cause;
1298
+ if (this.#lease?.value !== slot) return;
1299
+ const token = this.#lease;
1300
+ this.#unmirror();
1301
+ this.#lease = void 0;
1302
+ this.#notice.add(slot);
1303
+ token.destroy().catch(() => void 0);
1304
+ }
1305
+ #watch(slot, signal) {
1306
+ const loss = Promise.withResolvers();
1307
+ const disconnect = this.#disconnect.bind(this, slot);
1308
+ const page = this.#track.bind(this, slot);
1309
+ const subscription = addAbortListener(signal, this.#unwatch.bind(this, slot));
1310
+ this.#watches.set(slot, {
1311
+ resolve: loss.resolve,
1312
+ disconnect,
1313
+ page,
1314
+ subscription,
1315
+ crashes: /* @__PURE__ */ new Map()
1316
+ });
1317
+ slot.browser.emitter.on("disconnect", disconnect);
1318
+ slot.context.emitter.on("page", page);
1319
+ for (const current of slot.context.pages()) this.#track(slot, current);
1320
+ if (signal.aborted) this.#unwatch(slot);
1321
+ else if (slot.browser.status !== "connected") this.#disconnect(slot);
1322
+ return loss.promise;
1323
+ }
1324
+ #track(slot, page) {
1325
+ const watch = this.#watches.get(slot);
1326
+ if (watch === void 0 || watch.crashes.has(page)) return;
1327
+ const crash = this.#createCrash(slot, page);
1328
+ page.emitter.on("crash", crash);
1329
+ this.#watches.set(slot, {
1330
+ ...watch,
1331
+ crashes: new Map([...watch.crashes, [page, crash]])
1036
1332
  });
1037
- return session;
1038
1333
  }
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;
1334
+ #createCrash(slot, page) {
1335
+ return () => {
1336
+ if (page === slot.toolset.view) this.#report(slot, /* @__PURE__ */ new Error("The current page renderer crashed"));
1337
+ };
1338
+ }
1339
+ #disconnect(slot) {
1340
+ this.#report(slot, /* @__PURE__ */ new Error(slot.browser.pid === void 0 ? "The browser process exited" : "The browser transport disconnected"));
1341
+ }
1342
+ #report(slot, cause) {
1343
+ const watch = this.#watches.get(slot);
1344
+ if (watch === void 0) return;
1345
+ this.#unwatch(slot);
1346
+ this.#lose(slot, cause);
1347
+ watch.resolve(cause);
1348
+ }
1349
+ #unwatch(slot) {
1350
+ const watch = this.#watches.get(slot);
1351
+ if (watch === void 0) return;
1352
+ this.#watches.delete(slot);
1353
+ slot.browser.emitter.off("disconnect", watch.disconnect);
1354
+ slot.context.emitter.off("page", watch.page);
1355
+ for (const [page, crash] of watch.crashes) page.emitter.off("crash", crash);
1356
+ watch.subscription[Symbol.dispose]();
1357
+ }
1358
+ #fault(error) {
1359
+ this.#faults.add(error);
1360
+ }
1361
+ async #warm() {
1362
+ if (this.#closing !== void 0) throw this.#ended();
1363
+ 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);
1364
+ const profile = join(this.#root, ".profiles", formatBrowserLockEntry(process.pid, randomUUID()));
1046
1365
  let browser;
1047
1366
  let toolset;
1048
1367
  try {
1368
+ await mkdir(profile);
1369
+ this.#folders.add(profile);
1049
1370
  browser = this.#launcher({
1050
1371
  headless: this.#headless,
1051
1372
  profile,
@@ -1054,9 +1375,16 @@ var BrowserMCPServer = class {
1054
1375
  ...this.#executable === void 0 ? {} : { executable: this.#executable },
1055
1376
  ...process.platform === "linux" && process.getuid?.() === 0 ? { args: ["--no-sandbox"] } : {}
1056
1377
  });
1057
- this.#browser = browser;
1058
1378
  await browser.connect();
1059
- const context = await browser.isolate();
1379
+ if (browser.pid !== void 0 && browser.endpoint !== void 0) {
1380
+ const record = join(profile, BROWSER_SERVER_RECORD);
1381
+ await writeFile(`${record}.tmp`, JSON.stringify({
1382
+ pid: browser.pid,
1383
+ endpoint: browser.endpoint
1384
+ }));
1385
+ await rename(`${record}.tmp`, record);
1386
+ }
1387
+ const context = await browser.isolate({ reference: this.#reference });
1060
1388
  const page = await context.create();
1061
1389
  toolset = createBrowserToolset(page, {
1062
1390
  context,
@@ -1066,26 +1394,103 @@ var BrowserMCPServer = class {
1066
1394
  readonly: this.#readonly
1067
1395
  }
1068
1396
  });
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
1397
  await toolset.start();
1074
- return toolset;
1398
+ return {
1399
+ browser,
1400
+ profile,
1401
+ context,
1402
+ toolset
1403
+ };
1404
+ } catch (error) {
1405
+ if (this.#folders.has(profile)) await this.#destroySlot(profile, browser, toolset).catch((failure) => {
1406
+ this.#stranded = failure;
1407
+ });
1408
+ if (this.#closing === void 0) this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_LAUNCH, error)}`);
1409
+ throw error;
1410
+ }
1411
+ }
1412
+ async #destroySlot(profile, browser, toolset, cause) {
1413
+ if (isCDPTimeoutError(cause) && browser?.pid !== void 0) try {
1414
+ process.kill(browser.pid, "SIGKILL");
1075
1415
  } 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);
1416
+ if (!isError(error) || !("code" in error) || error.code !== "ESRCH") this.#faults.add(error);
1417
+ }
1418
+ await toolset?.destroy().catch((error) => this.#faults.add(error));
1419
+ try {
1420
+ await browser?.destroy();
1421
+ } catch (error) {
1422
+ if (this.#closing === void 0) this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_TEARDOWN, error)}`);
1423
+ throw error;
1424
+ }
1425
+ try {
1081
1426
  await rm(profile, {
1082
1427
  recursive: true,
1083
1428
  force: true,
1084
1429
  maxRetries: 5
1085
1430
  });
1086
- throw error;
1431
+ this.#folders.delete(profile);
1432
+ } catch {}
1433
+ }
1434
+ async #sweep() {
1435
+ const profiles = join(this.#root, ".profiles");
1436
+ try {
1437
+ for (const entry of await readdir(profiles)) {
1438
+ if (this.#closing !== void 0) return;
1439
+ const pid = parseBrowserLockEntry(entry);
1440
+ if (pid === void 0 || pid === process.pid || probeProcess(pid)) continue;
1441
+ const folder = join(profiles, entry);
1442
+ this.#folders.add(folder);
1443
+ try {
1444
+ const record = parseBrowserProfileRecord(await readFile(join(folder, BROWSER_SERVER_RECORD), "utf8"));
1445
+ if (record === void 0) continue;
1446
+ if (probeProcess(record.pid)) {
1447
+ const transport = createCDPTransport({ url: record.endpoint });
1448
+ const client = createCDPClient({ transport });
1449
+ try {
1450
+ this.#abort.signal.throwIfAborted();
1451
+ await this.#race(client.connect(), this.#abort.signal);
1452
+ await client.send("Browser.close", void 0, { signal: this.#abort.signal });
1453
+ } catch {} finally {
1454
+ await transport.close();
1455
+ await client.close();
1456
+ }
1457
+ if (probeProcess(record.pid)) continue;
1458
+ }
1459
+ } catch (error) {
1460
+ if (!isError(error) || !("code" in error) || error.code !== "ENOENT") continue;
1461
+ }
1462
+ try {
1463
+ await rm(folder, {
1464
+ recursive: true,
1465
+ force: true,
1466
+ maxRetries: 5
1467
+ });
1468
+ this.#folders.delete(folder);
1469
+ } catch {}
1470
+ }
1471
+ } catch (error) {
1472
+ this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_SWEEP, error)}`);
1087
1473
  }
1088
1474
  }
1475
+ #write(line) {
1476
+ try {
1477
+ this.#log.write(`${line}\n`, (error) => {
1478
+ if (error !== void 0 && error !== null) this.#faults.add(error);
1479
+ });
1480
+ } catch (error) {
1481
+ this.#faults.add(error);
1482
+ }
1483
+ }
1484
+ #exhaust(error) {
1485
+ if (isPoolError(error) && error.code === "create") this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_EXHAUSTED, error.cause ?? this.#failure)}`);
1486
+ }
1487
+ #unavailable(cause) {
1488
+ const message = describeBrowserServerLoss(BROWSER_SERVER_UNAVAILABLE, cause);
1489
+ return new BrowserError(message.slice(BROWSER_SERVER_UNAVAILABLE.length + 2), BROWSER_SERVER_UNAVAILABLE);
1490
+ }
1491
+ #issue() {
1492
+ return `e${++this.#references}`;
1493
+ }
1089
1494
  #mirror(tool) {
1090
1495
  if (this.#vocabulary.has(tool.name)) return;
1091
1496
  this.#tools.add(createTool({
@@ -1095,7 +1500,7 @@ var BrowserMCPServer = class {
1095
1500
  }
1096
1501
  #withdraw(tool) {
1097
1502
  if (this.#vocabulary.has(tool.name)) return;
1098
- const current = this.#mirrored?.tool(tool.name);
1503
+ const current = this.#lease?.value.toolset.tools.tool(tool.name);
1099
1504
  if (current === void 0) this.#tools.remove(tool.name);
1100
1505
  else this.#mirror(current);
1101
1506
  }
@@ -1103,9 +1508,9 @@ var BrowserMCPServer = class {
1103
1508
  for (const tool of tools) this.#withdraw(tool);
1104
1509
  }
1105
1510
  #unmirror() {
1106
- const mirrored = this.#mirrored;
1511
+ const mirrored = this.#lease?.value.toolset.tools;
1107
1512
  if (mirrored === void 0) return;
1108
- this.#mirrored = void 0;
1513
+ for (const tool of mirrored.tools()) if (!this.#vocabulary.has(tool.name)) this.#tools.remove(tool.name);
1109
1514
  mirrored.emitter.off("add", this.#added);
1110
1515
  mirrored.emitter.off("remove", this.#removed);
1111
1516
  mirrored.emitter.off("clear", this.#cleared);
@@ -1278,13 +1683,7 @@ var FileBrowserStore = class {
1278
1683
  const holder = entries[0];
1279
1684
  if (holder !== void 0) {
1280
1685
  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
- }
1686
+ if (pid === void 0 || probeProcess(pid)) break;
1288
1687
  if (!await this.#unlinkLock(this.resolvePath(path, holder))) continue;
1289
1688
  }
1290
1689
  await this.#removeLock(path);
@@ -1726,7 +2125,7 @@ function createFileBrowserRunStore(options) {
1726
2125
  }
1727
2126
  /**
1728
2127
  * 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.
2128
+ * Model Context Protocol on stdio and warms Chromium at server start.
1730
2129
  *
1731
2130
  * @param options - The root, the launch, the journeys' read-only switch, and the streams
1732
2131
  * @returns A {@link BrowserMCPServerInterface} that reads nothing until `start()`
@@ -1736,7 +2135,7 @@ function createFileBrowserRunStore(options) {
1736
2135
  * import { createBrowserMCPServer } from '@orkestrel/browser/server'
1737
2136
  *
1738
2137
  * const server = createBrowserMCPServer({ root: 'tmp/browsers', headless: true })
1739
- * await server.start() // answers tools/list before Chromium starts
2138
+ * await server.start() // resolves after leasing a connected browser
1740
2139
  * await server.destroy()
1741
2140
  * ```
1742
2141
  */
@@ -1812,6 +2211,15 @@ var Browser = class {
1812
2211
  get pid() {
1813
2212
  return this.#servingPid ?? this.#process?.pid;
1814
2213
  }
2214
+ get endpoint() {
2215
+ return this.#endpoint;
2216
+ }
2217
+ async ping(options) {
2218
+ if (this.#destroyed) throw new BrowserDestroyedError();
2219
+ const client = this.#client;
2220
+ if (this.#status !== "connected" || client === void 0) throw new BrowserNotConnectedError();
2221
+ await client.send("Browser.getVersion", void 0, options);
2222
+ }
1815
2223
  async discover() {
1816
2224
  const result = await this.#discoverCDP();
1817
2225
  this.#emitter.emit("discover", result);
@@ -1937,7 +2345,7 @@ var Browser = class {
1937
2345
  await this.#connectCDP(discovery.endpoint);
1938
2346
  return;
1939
2347
  }
1940
- } else await this.#raceAbort(this.#assertPortFree());
2348
+ } else if (this.#options.cdp?.port !== void 0) await this.#raceAbort(this.#assertPortFree());
1941
2349
  this.#assertNotAborted();
1942
2350
  await this.#launch();
1943
2351
  } catch (error) {
@@ -2573,6 +2981,6 @@ var Browser = class {
2573
2981
  }
2574
2982
  };
2575
2983
  //#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 };
2984
+ 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_CRASH, BROWSER_SERVER_EXHAUSTED, 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, probePathNames, probeProcess, readBrowserEndpoint, readFirstLine, removeBrowserProfile };
2577
2985
 
2578
2986
  //# sourceMappingURL=index.js.map