cdp-toolkit 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -7081,6 +7081,252 @@ async function connectBidiSessionUrl(wsUrl, opts = {}) {
7081
7081
  return conn;
7082
7082
  }
7083
7083
 
7084
+ // src/bidi/session-lease.ts
7085
+ import { mkdir as mkdir12, readFile as readFile8, unlink as unlink5, writeFile as writeFile9 } from "node:fs/promises";
7086
+ import { join as join10 } from "node:path";
7087
+ var DEFAULT_SESSION_WAIT_MS = 1e4;
7088
+ var DEFAULT_POLL_MS = 250;
7089
+ function safeEndpoint(endpoint) {
7090
+ return endpoint.replace(/[^A-Za-z0-9._-]/g, "_");
7091
+ }
7092
+ function sessionSlotFile(endpoint) {
7093
+ return join10(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
7094
+ }
7095
+ function sessionWaitMs() {
7096
+ const raw = Number(process.env.CDP_FIREFOX_SESSION_WAIT_MS);
7097
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_WAIT_MS;
7098
+ }
7099
+
7100
+ class SessionSlotBusyError extends Error {
7101
+ endpoint;
7102
+ holderPid;
7103
+ holderLabel;
7104
+ constructor(endpoint, holder) {
7105
+ const who = holder ? `'${holder.label}' (pid ${holder.pid})` : "another live process";
7106
+ super(`Firefox BiDi session slot for endpoint '${endpoint}' is held by ${who}. ` + `Firefox serves exactly ONE WebDriver-BiDi session per browser instance, and this slot is held ` + `by a LIVE process, so it cannot be taken. Wait for that process to release it and retry, or point ` + `this server at a different endpoint/browser.`);
7107
+ this.name = "SessionSlotBusyError";
7108
+ this.endpoint = endpoint;
7109
+ if (holder) {
7110
+ this.holderPid = holder.pid;
7111
+ this.holderLabel = holder.label;
7112
+ }
7113
+ }
7114
+ }
7115
+ async function readSessionSlot(endpoint) {
7116
+ let raw;
7117
+ try {
7118
+ raw = await readFile8(sessionSlotFile(endpoint), "utf8");
7119
+ } catch (err) {
7120
+ if (err.code === "ENOENT")
7121
+ return;
7122
+ throw err;
7123
+ }
7124
+ try {
7125
+ const rec = JSON.parse(raw);
7126
+ if (typeof rec?.pid === "number" && typeof rec?.endpoint === "string" && typeof rec?.label === "string") {
7127
+ return rec;
7128
+ }
7129
+ } catch {}
7130
+ return;
7131
+ }
7132
+ async function acquireSessionSlot(endpoint, opts) {
7133
+ const waitMs = opts.waitMs ?? sessionWaitMs();
7134
+ const pollMs = opts.pollMs !== undefined && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
7135
+ const createdAt = opts.now ?? Date.now();
7136
+ const file = sessionSlotFile(endpoint);
7137
+ const record2 = {
7138
+ endpoint,
7139
+ pid: process.pid,
7140
+ label: opts.label,
7141
+ createdAt
7142
+ };
7143
+ if (opts.marionettePort !== undefined)
7144
+ record2.marionettePort = opts.marionettePort;
7145
+ const handle = { endpoint, pid: process.pid, createdAt };
7146
+ const payload = JSON.stringify(record2);
7147
+ await mkdir12(leaseDir(), { recursive: true });
7148
+ const deadline = Date.now() + waitMs;
7149
+ const maxAttempts = Math.max(100, Math.ceil(waitMs / pollMs) + 100);
7150
+ let attempts = 0;
7151
+ let lastLiveHolder;
7152
+ while (true) {
7153
+ if (attempts >= maxAttempts)
7154
+ throw new SessionSlotBusyError(endpoint, lastLiveHolder);
7155
+ attempts++;
7156
+ try {
7157
+ await writeFile9(file, payload, { flag: "wx" });
7158
+ return { handle };
7159
+ } catch (err) {
7160
+ if (err.code !== "EEXIST")
7161
+ throw err;
7162
+ }
7163
+ const holder = await readSessionSlot(endpoint);
7164
+ if (!holder)
7165
+ continue;
7166
+ if (!isPidAlive(holder.pid)) {
7167
+ await unlink5(file).catch(() => {
7168
+ return;
7169
+ });
7170
+ try {
7171
+ await writeFile9(file, payload, { flag: "wx" });
7172
+ return { handle, staleHolder: holder };
7173
+ } catch (err) {
7174
+ if (err.code !== "EEXIST")
7175
+ throw err;
7176
+ continue;
7177
+ }
7178
+ }
7179
+ lastLiveHolder = holder;
7180
+ const remaining = deadline - Date.now();
7181
+ if (remaining <= 0)
7182
+ throw new SessionSlotBusyError(endpoint, holder);
7183
+ await sleep2(Math.min(pollMs, remaining));
7184
+ }
7185
+ }
7186
+ async function releaseSessionSlot(handle) {
7187
+ const current = await readSessionSlot(handle.endpoint);
7188
+ if (!current)
7189
+ return { released: false };
7190
+ if (current.pid !== process.pid || current.createdAt !== handle.createdAt)
7191
+ return { released: false };
7192
+ await unlink5(sessionSlotFile(handle.endpoint)).catch(() => {
7193
+ return;
7194
+ });
7195
+ return { released: true };
7196
+ }
7197
+ function sleep2(ms) {
7198
+ return new Promise((resolve) => setTimeout(resolve, ms));
7199
+ }
7200
+
7201
+ // src/bidi/marionette.ts
7202
+ import { Socket } from "node:net";
7203
+ var DEFAULT_MARIONETTE_PORT = 2828;
7204
+ var DEFAULT_TIMEOUT_MS3 = 4000;
7205
+ var HOST = "127.0.0.1";
7206
+ var COLON = 58;
7207
+ var DELETE_SESSION_FRAME = encodeFrame([0, 1, "WebDriver:DeleteSession", {}]);
7208
+ function defaultMarionettePort() {
7209
+ const raw = Number(process.env.CDP_FIREFOX_MARIONETTE_PORT);
7210
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MARIONETTE_PORT;
7211
+ }
7212
+ function encodeFrame(json) {
7213
+ const body = Buffer.from(JSON.stringify(json), "utf8");
7214
+ const prefix = Buffer.from(`${body.length}:`, "ascii");
7215
+ return Buffer.concat([prefix, body]);
7216
+ }
7217
+
7218
+ class MarionetteProtocolError extends Error {
7219
+ constructor(message) {
7220
+ super(message);
7221
+ this.name = "MarionetteProtocolError";
7222
+ }
7223
+ }
7224
+
7225
+ class FrameDecoder {
7226
+ buf = Buffer.alloc(0);
7227
+ push(chunk) {
7228
+ this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk]);
7229
+ }
7230
+ next() {
7231
+ const colon = this.buf.indexOf(COLON);
7232
+ if (colon === -1) {
7233
+ this.assertDigitsPrefix(this.buf.length);
7234
+ return;
7235
+ }
7236
+ if (colon === 0)
7237
+ throw new MarionetteProtocolError("empty frame length prefix");
7238
+ this.assertDigitsPrefix(colon);
7239
+ const len = Number.parseInt(this.buf.toString("ascii", 0, colon), 10);
7240
+ const start = colon + 1;
7241
+ const end = start + len;
7242
+ if (this.buf.length < end)
7243
+ return;
7244
+ const payload = this.buf.toString("utf8", start, end);
7245
+ this.buf = this.buf.subarray(end);
7246
+ try {
7247
+ return JSON.parse(payload);
7248
+ } catch {
7249
+ throw new MarionetteProtocolError(`frame body was not valid JSON (${len} bytes): ${payload.slice(0, 80)}`);
7250
+ }
7251
+ }
7252
+ assertDigitsPrefix(upTo) {
7253
+ for (let i = 0;i < upTo; i++) {
7254
+ const b = this.buf[i];
7255
+ if (b < 48 || b > 57) {
7256
+ throw new MarionetteProtocolError(`non-digit byte 0x${b.toString(16)} in frame length prefix`);
7257
+ }
7258
+ }
7259
+ }
7260
+ }
7261
+ function isSlotFreedReply(frame) {
7262
+ if (!Array.isArray(frame))
7263
+ return false;
7264
+ const err = frame[2];
7265
+ if (err === null)
7266
+ return true;
7267
+ if (typeof err === "object" && err !== null && err.error === "invalid session id") {
7268
+ return true;
7269
+ }
7270
+ return false;
7271
+ }
7272
+ function forceClearOrphanSession(marionettePort, opts = {}) {
7273
+ const timeoutMs = opts.timeoutMs !== undefined && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS3;
7274
+ return new Promise((resolve) => {
7275
+ if (!Number.isFinite(marionettePort) || marionettePort <= 0 || marionettePort > 65535) {
7276
+ resolve(false);
7277
+ return;
7278
+ }
7279
+ const sock = new Socket;
7280
+ const decoder = new FrameDecoder;
7281
+ let settled = false;
7282
+ let handshakeSeen = false;
7283
+ let commandSent = false;
7284
+ const settle = (value) => {
7285
+ if (settled)
7286
+ return;
7287
+ settled = true;
7288
+ clearTimeout(timer);
7289
+ sock.removeAllListeners();
7290
+ sock.destroy();
7291
+ resolve(value);
7292
+ };
7293
+ const timer = setTimeout(() => settle(false), timeoutMs);
7294
+ if (typeof timer.unref === "function")
7295
+ timer.unref();
7296
+ sock.on("error", () => settle(false));
7297
+ sock.on("close", () => settle(false));
7298
+ sock.on("data", (chunk) => {
7299
+ if (settled)
7300
+ return;
7301
+ try {
7302
+ decoder.push(chunk);
7303
+ for (;; ) {
7304
+ const frame = decoder.next();
7305
+ if (frame === undefined)
7306
+ break;
7307
+ if (!handshakeSeen) {
7308
+ handshakeSeen = true;
7309
+ continue;
7310
+ }
7311
+ settle(isSlotFreedReply(frame));
7312
+ return;
7313
+ }
7314
+ if (handshakeSeen && !commandSent) {
7315
+ commandSent = true;
7316
+ sock.write(DELETE_SESSION_FRAME);
7317
+ }
7318
+ } catch {
7319
+ settle(false);
7320
+ }
7321
+ });
7322
+ try {
7323
+ sock.connect(marionettePort, HOST);
7324
+ } catch {
7325
+ settle(false);
7326
+ }
7327
+ });
7328
+ }
7329
+
7084
7330
  // src/bidi/snapshot.ts
7085
7331
  var STAMP_ATTR = "data-cdp-uid";
7086
7332
  async function takeStampedSnapshot(evaluate) {
@@ -7657,6 +7903,22 @@ var BIDI_CAPABILITIES = new Set([
7657
7903
  "locate.xpath"
7658
7904
  ]);
7659
7905
  var connections = new Map;
7906
+ var slotHandles = new Map;
7907
+ var connecting = new Map;
7908
+ async function releaseSlotOnDialFailure(endpoint, owned) {
7909
+ if (owned)
7910
+ return;
7911
+ const h = slotHandles.get(endpoint);
7912
+ if (h) {
7913
+ await releaseSessionSlot(h).catch(() => {
7914
+ return;
7915
+ });
7916
+ slotHandles.delete(endpoint);
7917
+ }
7918
+ }
7919
+ function orphanNotClearedError(endpoint) {
7920
+ return driverError2("disconnected", `Firefox holds an orphaned WebDriver BiDi session on ${endpoint} that could not be auto-cleared. ` + `Marionette recovery needs Firefox launched with --marionette (default port 2828, or set ` + `CDP_FIREFOX_MARIONETTE_PORT); otherwise restart Firefox to free the slot. (A client killed without ` + `session.end can leave a session Firefox does not reap.)`);
7921
+ }
7660
7922
  async function getConnection(endpoint, timeoutMs) {
7661
7923
  const existing = connections.get(endpoint);
7662
7924
  if (existing && existing.isOpen) {
@@ -7665,17 +7927,68 @@ async function getConnection(endpoint, timeoutMs) {
7665
7927
  }
7666
7928
  if (existing)
7667
7929
  connections.delete(endpoint);
7668
- const conn = await connectBidiSessionUrl(endpoint, {
7669
- ...timeoutMs !== undefined ? { timeoutMs } : {},
7670
- capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } }
7671
- }).catch((e) => {
7672
- if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
7673
- throw driverError2("disconnected", `Firefox already has an active WebDriver BiDi session on ${endpoint}; it allows only one. ` + `Close the other client, or restart Firefox, then retry. (A previously killed client can leave a ` + `session that Firefox does not reap.)`);
7930
+ const inflight = connecting.get(endpoint);
7931
+ if (inflight) {
7932
+ const conn = await inflight;
7933
+ conn.retain();
7934
+ return conn;
7935
+ }
7936
+ const create = (async () => {
7937
+ const mport = defaultMarionettePort();
7938
+ const owned = slotHandles.has(endpoint);
7939
+ if (!owned) {
7940
+ let acq;
7941
+ try {
7942
+ acq = await acquireSessionSlot(endpoint, { label: defaultLabel(), marionettePort: mport });
7943
+ } catch (e) {
7944
+ if (e instanceof SessionSlotBusyError) {
7945
+ const who = e.holderLabel ? `'${e.holderLabel}' (pid ${e.holderPid})` : "another live process";
7946
+ throw driverError2("disconnected", `Firefox's single WebDriver BiDi session on ${endpoint} is held by a LIVE process ${who}. ` + `Firefox serves exactly ONE BiDi session per browser instance, so this server cannot take it ` + `while that process is running. Wait for it to release the session and retry, or point this ` + `server at a different endpoint/browser.`);
7947
+ }
7948
+ throw e;
7949
+ }
7950
+ slotHandles.set(endpoint, acq.handle);
7951
+ if (acq.staleHolder) {
7952
+ await forceClearOrphanSession(acq.staleHolder.marionettePort ?? mport);
7953
+ }
7674
7954
  }
7675
- throw e;
7676
- });
7677
- connections.set(endpoint, conn);
7678
- return conn;
7955
+ const dialOpts = {
7956
+ ...timeoutMs !== undefined ? { timeoutMs } : {},
7957
+ capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } }
7958
+ };
7959
+ let conn;
7960
+ try {
7961
+ conn = await connectBidiSessionUrl(endpoint, dialOpts);
7962
+ } catch (e) {
7963
+ if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
7964
+ const cleared = await forceClearOrphanSession(mport);
7965
+ if (!cleared) {
7966
+ await releaseSlotOnDialFailure(endpoint, owned);
7967
+ throw orphanNotClearedError(endpoint);
7968
+ }
7969
+ try {
7970
+ conn = await connectBidiSessionUrl(endpoint, dialOpts);
7971
+ } catch (e2) {
7972
+ await releaseSlotOnDialFailure(endpoint, owned);
7973
+ if (e2 instanceof BidiError && /maximum number of active sessions/i.test(e2.message)) {
7974
+ throw orphanNotClearedError(endpoint);
7975
+ }
7976
+ throw e2;
7977
+ }
7978
+ } else {
7979
+ await releaseSlotOnDialFailure(endpoint, owned);
7980
+ throw e;
7981
+ }
7982
+ }
7983
+ connections.set(endpoint, conn);
7984
+ return conn;
7985
+ })();
7986
+ connecting.set(endpoint, create);
7987
+ try {
7988
+ return await create;
7989
+ } finally {
7990
+ connecting.delete(endpoint);
7991
+ }
7679
7992
  }
7680
7993
  var bidiBeaconScripts = new Map;
7681
7994
  async function safeTitle(conn, contextId) {
@@ -8441,14 +8754,27 @@ class BidiBrowserDriver {
8441
8754
  if (key.startsWith(`${this.endpoint}:`))
8442
8755
  bidiBeaconScripts.delete(key);
8443
8756
  }
8757
+ connecting.delete(this.endpoint);
8444
8758
  const conn = connections.get(this.endpoint);
8445
- if (!conn)
8759
+ if (!conn) {
8760
+ await this.releaseSessionSlotIfHeld();
8446
8761
  return;
8762
+ }
8447
8763
  connections.delete(this.endpoint);
8448
8764
  await conn.send("session.end", {}).catch(() => {
8449
8765
  return;
8450
8766
  });
8451
8767
  conn.dispose();
8768
+ await this.releaseSessionSlotIfHeld();
8769
+ }
8770
+ async releaseSessionSlotIfHeld() {
8771
+ const h = slotHandles.get(this.endpoint);
8772
+ if (!h)
8773
+ return;
8774
+ await releaseSessionSlot(h).catch(() => {
8775
+ return;
8776
+ });
8777
+ slotHandles.delete(this.endpoint);
8452
8778
  }
8453
8779
  }
8454
8780
  function createFirefoxDriver(port, opts) {
@@ -8463,7 +8789,7 @@ import { spawn as spawn3 } from "node:child_process";
8463
8789
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
8464
8790
  import { createConnection, createServer } from "node:net";
8465
8791
  import { tmpdir } from "node:os";
8466
- import { join as join10 } from "node:path";
8792
+ import { join as join11 } from "node:path";
8467
8793
  function resolveBinary() {
8468
8794
  const candidates = [
8469
8795
  process.env.FIREFOX_BIN,
@@ -8541,7 +8867,7 @@ async function launchFirefox(opts = {}) {
8541
8867
  const binary = resolveBinary();
8542
8868
  const port = await pickPort(opts.port);
8543
8869
  const isThrowaway = opts.profilePath === undefined;
8544
- const profilePath = opts.profilePath ?? mkdtempSync(join10(tmpdir(), "cdp-toolkit-ff-profile-"));
8870
+ const profilePath = opts.profilePath ?? mkdtempSync(join11(tmpdir(), "cdp-toolkit-ff-profile-"));
8545
8871
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
8546
8872
  if (opts.headless)
8547
8873
  args.push("--headless");
@@ -8892,7 +9218,7 @@ var FIREFOX_TOOLS = {
8892
9218
  };
8893
9219
 
8894
9220
  // src/mcp.ts
8895
- var VERSION = "1.10.0";
9221
+ var VERSION = "1.11.0";
8896
9222
  var { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
8897
9223
  var AVAILABILITY = toolAvailability(BROWSER);
8898
9224
  var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 45 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -79,6 +79,7 @@
79
79
  "mcp:smoke": "bun run ./test/mcp-smoke.ts",
80
80
  "firefox:smoke": "bun run ./test/firefox-smoke.ts",
81
81
  "firefox:attach:smoke": "bun run ./test/firefox-attach-smoke.ts",
82
+ "firefox:coord:smoke": "bun run ./test/firefox-session-coord-smoke.ts",
82
83
  "lease:smoke": "bun run ./test/lease-smoke.ts",
83
84
  "staleness:smoke": "bun run ./test/staleness-smoke.ts",
84
85
  "input:smoke": "bun run ./test/input-smoke.ts",