browserclaw 0.5.0 → 0.5.2

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/index.d.cts CHANGED
@@ -227,6 +227,8 @@ interface ClickOptions {
227
227
  button?: 'left' | 'right' | 'middle';
228
228
  /** Modifier keys to hold during click */
229
229
  modifiers?: ('Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift')[];
230
+ /** Delay in ms between hover and click (hovers first, waits, then clicks). Max: `5000` */
231
+ delayMs?: number;
230
232
  /** Timeout in milliseconds. Default: `8000` */
231
233
  timeoutMs?: number;
232
234
  }
@@ -1238,8 +1240,8 @@ declare function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: Ssrf
1238
1240
  declare function ensureContextState(context: BrowserContext): ContextState;
1239
1241
  /**
1240
1242
  * Force-disconnect a Playwright browser connection for a given CDP target.
1241
- * Clears the connection cache, sends Runtime.terminateExecution via CDP
1242
- * session to kill stuck evals, and closes the browser.
1243
+ * Clears the connection cache, sends Runtime.terminateExecution via raw CDP
1244
+ * websocket to kill stuck evals (bypassing Playwright), and closes the browser.
1243
1245
  */
1244
1246
  declare function forceDisconnectPlaywrightForTarget(opts: {
1245
1247
  cdpUrl: string;
package/dist/index.d.ts CHANGED
@@ -227,6 +227,8 @@ interface ClickOptions {
227
227
  button?: 'left' | 'right' | 'middle';
228
228
  /** Modifier keys to hold during click */
229
229
  modifiers?: ('Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift')[];
230
+ /** Delay in ms between hover and click (hovers first, waits, then clicks). Max: `5000` */
231
+ delayMs?: number;
230
232
  /** Timeout in milliseconds. Default: `8000` */
231
233
  timeoutMs?: number;
232
234
  }
@@ -1238,8 +1240,8 @@ declare function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: Ssrf
1238
1240
  declare function ensureContextState(context: BrowserContext): ContextState;
1239
1241
  /**
1240
1242
  * Force-disconnect a Playwright browser connection for a given CDP target.
1241
- * Clears the connection cache, sends Runtime.terminateExecution via CDP
1242
- * session to kill stuck evals, and closes the browser.
1243
+ * Clears the connection cache, sends Runtime.terminateExecution via raw CDP
1244
+ * websocket to kill stuck evals (bypassing Playwright), and closes the browser.
1243
1245
  */
1244
1246
  declare function forceDisconnectPlaywrightForTarget(opts: {
1245
1247
  cdpUrl: string;
package/dist/index.js CHANGED
@@ -813,6 +813,7 @@ async function connectBrowser(cdpUrl, authToken) {
813
813
  return connected;
814
814
  } catch (err) {
815
815
  lastErr = err;
816
+ if ((err instanceof Error ? err.message : String(err)).includes("rate limit")) break;
816
817
  await new Promise((r) => setTimeout(r, 250 + attempt * 250));
817
818
  }
818
819
  }
@@ -838,6 +839,55 @@ async function disconnectBrowser() {
838
839
  if (cur) await cur.browser.close().catch(() => {
839
840
  });
840
841
  }
842
+ async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
843
+ const httpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
844
+ const ctrl = new AbortController();
845
+ const t = setTimeout(() => ctrl.abort(), 2e3);
846
+ let targets;
847
+ try {
848
+ const res = await fetch(`${httpBase}/json/list`, { signal: ctrl.signal });
849
+ if (!res.ok) return;
850
+ targets = await res.json();
851
+ } catch {
852
+ return;
853
+ } finally {
854
+ clearTimeout(t);
855
+ }
856
+ if (!Array.isArray(targets)) return;
857
+ const target = targets.find((entry) => String(entry?.id ?? "").trim() === targetId);
858
+ const wsUrl = String(target?.webSocketDebuggerUrl ?? "").trim();
859
+ if (!wsUrl) return;
860
+ await new Promise((resolve2) => {
861
+ let done = false;
862
+ const finish = () => {
863
+ if (done) return;
864
+ done = true;
865
+ clearTimeout(timer);
866
+ try {
867
+ ws.close();
868
+ } catch {
869
+ }
870
+ resolve2();
871
+ };
872
+ const timer = setTimeout(finish, 2e3);
873
+ let ws;
874
+ try {
875
+ ws = new WebSocket(wsUrl);
876
+ } catch {
877
+ finish();
878
+ return;
879
+ }
880
+ ws.onopen = () => {
881
+ try {
882
+ ws.send(JSON.stringify({ id: 1, method: "Runtime.terminateExecution" }));
883
+ } catch {
884
+ }
885
+ setTimeout(finish, 300);
886
+ };
887
+ ws.onerror = () => finish();
888
+ ws.onclose = () => finish();
889
+ });
890
+ }
841
891
  async function forceDisconnectPlaywrightForTarget(opts) {
842
892
  const normalized = normalizeCdpUrl(opts.cdpUrl);
843
893
  const cur = cached;
@@ -846,23 +896,8 @@ async function forceDisconnectPlaywrightForTarget(opts) {
846
896
  connectingByUrl.delete(normalized);
847
897
  const targetId = opts.targetId?.trim() || "";
848
898
  if (targetId) {
849
- try {
850
- const pages = await getAllPages(cur.browser);
851
- for (const page of pages) {
852
- const tid = await pageTargetId(page).catch(() => null);
853
- if (tid === targetId) {
854
- const session = await page.context().newCDPSession(page);
855
- try {
856
- await session.send("Runtime.terminateExecution");
857
- } finally {
858
- await session.detach().catch(() => {
859
- });
860
- }
861
- break;
862
- }
863
- }
864
- } catch {
865
- }
899
+ await tryTerminateExecutionViaCdp(normalized, targetId).catch(() => {
900
+ });
866
901
  }
867
902
  cur.browser.close().catch(() => {
868
903
  });
@@ -1284,6 +1319,35 @@ async function snapshotRole(opts) {
1284
1319
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1285
1320
  ensurePageState(page);
1286
1321
  const sourceUrl = page.url();
1322
+ if (opts.refsMode === "aria") {
1323
+ if (opts.selector?.trim() || opts.frameSelector?.trim()) {
1324
+ throw new Error("refs=aria does not support selector/frame snapshots yet.");
1325
+ }
1326
+ const maybe = page;
1327
+ if (!maybe._snapshotForAI) {
1328
+ throw new Error("refs=aria requires Playwright _snapshotForAI support.");
1329
+ }
1330
+ const result = await maybe._snapshotForAI({ timeout: 5e3, track: "response" });
1331
+ const built2 = buildRoleSnapshotFromAiSnapshot(String(result?.full ?? ""), opts.options);
1332
+ storeRoleRefsForTarget({
1333
+ page,
1334
+ cdpUrl: opts.cdpUrl,
1335
+ targetId: opts.targetId,
1336
+ refs: built2.refs,
1337
+ mode: "aria"
1338
+ });
1339
+ return {
1340
+ snapshot: built2.snapshot,
1341
+ refs: built2.refs,
1342
+ stats: getRoleSnapshotStats(built2.snapshot, built2.refs),
1343
+ untrusted: true,
1344
+ contentMeta: {
1345
+ sourceUrl,
1346
+ contentType: "browser-snapshot",
1347
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
1348
+ }
1349
+ };
1350
+ }
1287
1351
  const frameSelector = opts.frameSelector?.trim() || "";
1288
1352
  const selector = opts.selector?.trim() || "";
1289
1353
  const locator = frameSelector ? selector ? page.frameLocator(frameSelector).locator(selector) : page.frameLocator(frameSelector).locator(":root") : selector ? page.locator(selector) : page.locator(":root");
@@ -1295,7 +1359,7 @@ async function snapshotRole(opts) {
1295
1359
  targetId: opts.targetId,
1296
1360
  refs: built.refs,
1297
1361
  frameSelector: frameSelector || void 0,
1298
- mode: opts.refsMode ?? "role"
1362
+ mode: "role"
1299
1363
  });
1300
1364
  return {
1301
1365
  snapshot: built.snapshot,
@@ -1383,7 +1447,7 @@ var InvalidBrowserNavigationUrlError = class extends Error {
1383
1447
  }
1384
1448
  };
1385
1449
  function withBrowserNavigationPolicy(ssrfPolicy) {
1386
- return { ssrfPolicy };
1450
+ return ssrfPolicy ? { ssrfPolicy } : {};
1387
1451
  }
1388
1452
  var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
1389
1453
  var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
@@ -1496,6 +1560,30 @@ function extractEmbeddedIpv4FromIpv6(v6, opts) {
1496
1560
  return true;
1497
1561
  }
1498
1562
  }
1563
+ if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 65535 && parts[5] === 0) {
1564
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1565
+ try {
1566
+ return isBlockedSpecialUseIpv4Address(ipaddr.IPv4.parse(ip4str), opts);
1567
+ } catch {
1568
+ return true;
1569
+ }
1570
+ }
1571
+ if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0) {
1572
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1573
+ try {
1574
+ return isBlockedSpecialUseIpv4Address(ipaddr.IPv4.parse(ip4str), opts);
1575
+ } catch {
1576
+ return true;
1577
+ }
1578
+ }
1579
+ if ((parts[4] & 65023) === 0 && parts[5] === 24318) {
1580
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1581
+ try {
1582
+ return isBlockedSpecialUseIpv4Address(ipaddr.IPv4.parse(ip4str), opts);
1583
+ } catch {
1584
+ return true;
1585
+ }
1586
+ }
1499
1587
  return null;
1500
1588
  }
1501
1589
  function isPrivateIpAddress(address, opts) {
@@ -1518,6 +1606,30 @@ function isPrivateIpAddress(address, opts) {
1518
1606
  if (normalized.includes(":")) return true;
1519
1607
  return false;
1520
1608
  }
1609
+ function normalizeHostnameSet(values) {
1610
+ if (!values || values.length === 0) return /* @__PURE__ */ new Set();
1611
+ return new Set(values.map((v) => normalizeHostname(v)).filter(Boolean));
1612
+ }
1613
+ function normalizeHostnameAllowlist(values) {
1614
+ if (!values || values.length === 0) return [];
1615
+ return Array.from(
1616
+ new Set(
1617
+ values.map((v) => normalizeHostname(v)).filter((v) => v !== "*" && v !== "*." && v.length > 0)
1618
+ )
1619
+ );
1620
+ }
1621
+ function isHostnameAllowedByPattern(hostname, pattern) {
1622
+ if (pattern.startsWith("*.")) {
1623
+ const suffix = pattern.slice(2);
1624
+ if (!suffix || hostname === suffix) return false;
1625
+ return hostname.endsWith(`.${suffix}`);
1626
+ }
1627
+ return hostname === pattern;
1628
+ }
1629
+ function matchesHostnameAllowlist(hostname, allowlist) {
1630
+ if (allowlist.length === 0) return true;
1631
+ return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern));
1632
+ }
1521
1633
  function dedupeAndPreferIpv4(results) {
1522
1634
  const seen = /* @__PURE__ */ new Set();
1523
1635
  const ipv4 = [];
@@ -1563,12 +1675,15 @@ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1563
1675
  const normalized = normalizeHostname(hostname);
1564
1676
  if (!normalized) throw new InvalidBrowserNavigationUrlError(`Invalid hostname: "${hostname}"`);
1565
1677
  const allowPrivateNetwork = isPrivateNetworkAllowedByPolicy(params.policy);
1566
- const allowedHostnames = [
1567
- ...params.policy?.allowedHostnames ?? [],
1568
- ...params.policy?.hostnameAllowlist ?? []
1569
- ].map((h) => normalizeHostname(h));
1570
- const isExplicitlyAllowed = allowedHostnames.some((h) => h === normalized);
1678
+ const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
1679
+ const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
1680
+ const isExplicitlyAllowed = allowedHostnames.has(normalized);
1571
1681
  const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitlyAllowed;
1682
+ if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
1683
+ throw new InvalidBrowserNavigationUrlError(
1684
+ `Navigation blocked: hostname "${hostname}" is not in the allowlist.`
1685
+ );
1686
+ }
1572
1687
  if (!skipPrivateNetworkChecks) {
1573
1688
  if (isBlockedHostnameNormalized(normalized)) {
1574
1689
  throw new InvalidBrowserNavigationUrlError(
@@ -1635,11 +1750,9 @@ async function assertBrowserNavigationAllowed(opts) {
1635
1750
  "Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set"
1636
1751
  );
1637
1752
  }
1638
- const policy = opts.ssrfPolicy;
1639
- if (policy?.dangerouslyAllowPrivateNetwork ?? policy?.allowPrivateNetwork ?? true) return;
1640
1753
  await resolvePinnedHostnameWithPolicy(parsed.hostname, {
1641
1754
  lookupFn: opts.lookupFn,
1642
- policy
1755
+ policy: opts.ssrfPolicy
1643
1756
  });
1644
1757
  }
1645
1758
  async function assertSafeOutputPath(path2, allowedRoots) {
@@ -1771,6 +1884,13 @@ function requiresInspectableBrowserNavigationRedirects(ssrfPolicy) {
1771
1884
  }
1772
1885
 
1773
1886
  // src/actions/interaction.ts
1887
+ var MAX_CLICK_DELAY_MS = 5e3;
1888
+ function resolveBoundedDelayMs(value, label, maxMs) {
1889
+ const normalized = Math.floor(value ?? 0);
1890
+ if (!Number.isFinite(normalized) || normalized < 0) throw new Error(`${label} must be >= 0`);
1891
+ if (normalized > maxMs) throw new Error(`${label} exceeds maximum of ${maxMs}ms`);
1892
+ return normalized;
1893
+ }
1774
1894
  async function clickViaPlaywright(opts) {
1775
1895
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1776
1896
  ensurePageState(page);
@@ -1778,6 +1898,11 @@ async function clickViaPlaywright(opts) {
1778
1898
  const locator = refLocator(page, opts.ref);
1779
1899
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
1780
1900
  try {
1901
+ const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
1902
+ if (delayMs > 0) {
1903
+ await locator.hover({ timeout });
1904
+ await new Promise((resolve2) => setTimeout(resolve2, delayMs));
1905
+ }
1781
1906
  if (opts.doubleClick) {
1782
1907
  await locator.dblclick({ timeout, button: opts.button, modifiers: opts.modifiers });
1783
1908
  } else {
@@ -2088,12 +2213,14 @@ async function resizeViewportViaPlaywright(opts) {
2088
2213
  }
2089
2214
 
2090
2215
  // src/actions/wait.ts
2216
+ var MAX_WAIT_TIME_MS = 3e4;
2091
2217
  async function waitForViaPlaywright(opts) {
2092
2218
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2093
2219
  ensurePageState(page);
2094
2220
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 2e4);
2095
2221
  if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
2096
- await page.waitForTimeout(Math.max(0, opts.timeMs));
2222
+ const bounded = Math.max(0, Math.min(MAX_WAIT_TIME_MS, Math.floor(opts.timeMs)));
2223
+ await page.waitForTimeout(bounded);
2097
2224
  }
2098
2225
  if (opts.text) {
2099
2226
  await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
@@ -2782,6 +2909,7 @@ var CrawlPage = class {
2782
2909
  doubleClick: opts?.doubleClick,
2783
2910
  button: opts?.button,
2784
2911
  modifiers: opts?.modifiers,
2912
+ delayMs: opts?.delayMs,
2785
2913
  timeoutMs: opts?.timeoutMs
2786
2914
  });
2787
2915
  }