surfsky 0.0.5 → 0.0.8

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/README.md CHANGED
@@ -99,7 +99,7 @@ Cookies and browser state persist between leases. Handlers are not retried.
99
99
 
100
100
  - [SDK API reference](https://github.com/surfskyio/surfsky-js/blob/main/docs/api.md)
101
101
  - [REST API](https://docs.surfsky.io/api-reference)
102
- - [Examples](https://github.com/surfskyio/surfsky-js/tree/main/examples): forms, tabs, retries, profiles and CDP connections.
102
+ - [Examples](https://github.com/surfskyio/surfsky-js/tree/main/examples): forms, tabs, retries, profiles, a closed shadow root and CDP connections.
103
103
 
104
104
  `client.session()` exposes a WebSocket URL for external browser clients.
105
105
  See the [Playwright](https://github.com/surfskyio/surfsky-js/blob/main/examples/playwright_connect.ts),
package/dist/index.d.ts CHANGED
@@ -1266,7 +1266,6 @@ declare class Deadline {
1266
1266
  readonly until: number;
1267
1267
  constructor(timeout: number | null | undefined, message: string);
1268
1268
  error(): BrowserTimeoutError;
1269
- check(): void;
1270
1269
  race<T>(promise: Promise<T>): Promise<T>;
1271
1270
  }
1272
1271
  declare class CapturedResponse {
@@ -1330,6 +1329,7 @@ declare class Page extends Actions {
1330
1329
  /** @internal */
1331
1330
  _send(method: string, params?: Record<string, unknown>): Promise<any>;
1332
1331
  /** @internal */
1332
+ _change(): Promise<void>;
1333
1333
  _waitReady(): Promise<void>;
1334
1334
  /** @internal */
1335
1335
  _setup(waiting: boolean): Promise<void>;
@@ -1370,6 +1370,12 @@ declare class Page extends Actions {
1370
1370
  fill(selector: string, text: string): Promise<any>;
1371
1371
  getAttribute(selector: string, name: string): Promise<string | null>;
1372
1372
  count(selector: string): Promise<number>;
1373
+ boundingBox(selector: string): Promise<{
1374
+ x: number;
1375
+ y: number;
1376
+ width: number;
1377
+ height: number;
1378
+ } | null>;
1373
1379
  selectOption(selector: string, option: string | {
1374
1380
  value?: string;
1375
1381
  label?: string;
@@ -1422,6 +1428,7 @@ declare class Browser extends Page implements AsyncDisposable {
1422
1428
  /** @internal */ _pages: Map<string, Page>;
1423
1429
  /** @internal aborts on close: background sleeps end early */
1424
1430
  _signal: AbortSignal;
1431
+ _changed: PromiseWithResolvers<void>;
1425
1432
  constructor(session: Session, options?: BrowserOptions);
1426
1433
  get internalUuid(): string;
1427
1434
  get cdp(): CDPClient;
@@ -1460,6 +1467,8 @@ declare class Browser extends Page implements AsyncDisposable {
1460
1467
  /** @internal */
1461
1468
  _onAttached(event: Record<string, any>): void;
1462
1469
  /** @internal */
1470
+ _notify(): void;
1471
+ /** @internal */
1463
1472
  _onDetached(event: Record<string, any>): void;
1464
1473
  /** @internal */
1465
1474
  _drop(page: Page): void;
@@ -1622,6 +1631,7 @@ interface RequestOptions {
1622
1631
  interface SessionStartOptions extends SessionOptions {
1623
1632
  /** A saved profile to start; a one-time session when absent. */
1624
1633
  profileUuid?: string;
1634
+ signal?: AbortSignal;
1625
1635
  }
1626
1636
  interface BrowserStartOptions extends SessionStartOptions {
1627
1637
  /** CDP resource types to drop: `["image", "font", "media"]` saves proxy traffic. */
@@ -1668,7 +1678,7 @@ declare class Surfsky implements AsyncDisposable {
1668
1678
  /** An open `BrowserPool`. `close()` stops every browser in it. */
1669
1679
  browsers(options?: PoolOptions): Promise<BrowserPool>;
1670
1680
  /** `browsers()` and `pool.map()` in 1 call. */
1671
- map<I, R>(handler: PoolHandler<I, R>, items: Iterable<I>, options?: PoolOptions): Promise<PoolOutcome<I, R>[]>;
1681
+ map<I, R>(handler: PoolHandler<I, R>, items: Iterable<I>, options?: PoolOptions & LeaseOptions): Promise<PoolOutcome<I, R>[]>;
1672
1682
  /** Nothing to release with fetch; here for `await using` symmetry. */
1673
1683
  close(): Promise<void>;
1674
1684
  [Symbol.asyncDispose](): Promise<void>;
@@ -1698,6 +1708,9 @@ type PoolOutcome<I, R> = {
1698
1708
  index: number;
1699
1709
  error: unknown;
1700
1710
  };
1711
+ interface LeaseOptions {
1712
+ signal?: AbortSignal;
1713
+ }
1701
1714
  declare class BrowserPool implements AsyncDisposable {
1702
1715
  #private;
1703
1716
  readonly sessionOptions: SessionOptions;
@@ -1711,9 +1724,9 @@ declare class BrowserPool implements AsyncDisposable {
1711
1724
  close(): Promise<void>;
1712
1725
  [Symbol.asyncDispose](): Promise<void>;
1713
1726
  /** Run `fn` on a live browser, then hand the browser back. Waits while all are busy. */
1714
- lease<R>(fn: (browser: Browser) => Promise<R>): Promise<R>;
1727
+ lease<R>(fn: (browser: Browser) => Promise<R>, { signal }?: LeaseOptions): Promise<R>;
1715
1728
  /** Run every item through `handler` on a leased browser, `capacity` at a time. */
1716
- map<I, R>(handler: PoolHandler<I, R>, items: Iterable<I>): Promise<PoolOutcome<I, R>[]>;
1729
+ map<I, R>(handler: PoolHandler<I, R>, items: Iterable<I>, { signal }?: LeaseOptions): Promise<PoolOutcome<I, R>[]>;
1717
1730
  }
1718
1731
  //#endregion
1719
1732
  //#region src/version.d.ts
package/dist/index.js CHANGED
@@ -860,18 +860,26 @@ const FUNCTION = /^\s*(async\s+)?(function\b|\([^()]*\)\s*=>|[\w$]+\s*=>)/;
860
860
  function asXPath(s) {
861
861
  return /^\s*(xpath=|\/\/|\.\.)/.test(s) ? s.trim().replace(/^xpath=/, "") : void 0;
862
862
  }
863
- const SELECT = `(selector, value, label) => {
864
- const el = document.querySelector(selector);
865
- if (!el) return null;
866
- const option = Array.from(el.options).find(
863
+ const SELECT = `function (value, label) {
864
+ const option = Array.from(this.options).find(
867
865
  o => label === null ? o.value === value : o.label === label || o.text === label
868
866
  );
869
867
  if (!option) return false;
870
- el.value = option.value;
871
- el.dispatchEvent(new Event("input", {bubbles: true}));
872
- el.dispatchEvent(new Event("change", {bubbles: true}));
868
+ this.value = option.value;
869
+ this.dispatchEvent(new Event("input", {bubbles: true}));
870
+ this.dispatchEvent(new Event("change", {bubbles: true}));
873
871
  return option.value;
874
872
  }`;
873
+ const INNER_TEXT = "function () { return this.innerText }";
874
+ const IS_FOCUSED = "function () { return this.getRootNode().activeElement === this }";
875
+ const MATCHES = "function (s) { return this.nodeType === 1 && (s == null || this.matches(s)) }";
876
+ const WORLD_GONE = ["Cannot find context", "does not belong to the document"];
877
+ const worldIsGone = (err) => err instanceof CDPError && WORLD_GONE.some((text) => err.message.includes(text));
878
+ function remoteResult(result, what) {
879
+ const details = result.exceptionDetails;
880
+ if (details) throw new CDPError(`${what} failed: ${(details.exception ?? {}).description ?? details.text}`);
881
+ return result.result;
882
+ }
875
883
  function cookieParam(cookie) {
876
884
  const out = dropNulls(cookie);
877
885
  const expires = out.expirationDate;
@@ -912,9 +920,6 @@ var Deadline = class {
912
920
  error() {
913
921
  return new BrowserTimeoutError(`${this.message} within ${this.timeout}ms`);
914
922
  }
915
- check() {
916
- if (Date.now() >= this.until) throw this.error();
917
- }
918
923
  race(promise) {
919
924
  if (this.timeout === null) return promise;
920
925
  return withTimeout(promise, Math.max(0, this.until - Date.now()), () => this.error());
@@ -1056,17 +1061,22 @@ var Page = class extends Actions {
1056
1061
  }
1057
1062
  }
1058
1063
  /** @internal */
1064
+ _change() {
1065
+ return Promise.race([this._browser._changed.promise, sleep(100)]);
1066
+ }
1059
1067
  async _waitReady() {
1060
1068
  await this._ready.wait();
1061
1069
  if (this._setupError !== void 0) throw this._setupError;
1062
1070
  }
1063
1071
  /** @internal */
1064
1072
  async _setup(waiting) {
1073
+ const patterns = this._browser._fetchPatterns;
1065
1074
  const commands = [
1066
1075
  ["Page.enable", void 0],
1067
1076
  ["Page.setLifecycleEventsEnabled", { enabled: true }],
1068
- ["Fetch.enable", { patterns: this._browser._fetchPatterns }]
1077
+ ["Network.enable", void 0]
1069
1078
  ];
1079
+ if (patterns.length > 0) commands.push(["Fetch.enable", { patterns }]);
1070
1080
  if (waiting) commands.push(["Runtime.runIfWaitingForDebugger", void 0]);
1071
1081
  const timeout = this._browser.commandTimeout;
1072
1082
  let posted = [];
@@ -1103,8 +1113,7 @@ var Page = class extends Actions {
1103
1113
  const deadline = new Deadline(options.timeout ?? 3e4, `the page did not reach '${state}'`);
1104
1114
  while (!this._milestones.has(milestone)) {
1105
1115
  this._requireOpen();
1106
- deadline.check();
1107
- await sleep(100);
1116
+ await deadline.race(this._change());
1108
1117
  }
1109
1118
  }
1110
1119
  async goto(url, options = {}) {
@@ -1156,8 +1165,7 @@ var Page = class extends Actions {
1156
1165
  const deadline = new Deadline(timeout, "history did not move");
1157
1166
  while (true) {
1158
1167
  if ((await deadline.race(this.send("Page.getNavigationHistory"))).currentIndex === index) break;
1159
- deadline.check();
1160
- await sleep(100);
1168
+ await deadline.race(sleep(100));
1161
1169
  }
1162
1170
  return this.url();
1163
1171
  }
@@ -1167,15 +1175,13 @@ var Page = class extends Actions {
1167
1175
  /** Record responses whose URL contains a fragment. Call before navigating. */
1168
1176
  async captureResponses(...fragments) {
1169
1177
  if (fragments.length === 0) throw new TypeError("captureResponses needs at least one URL fragment");
1170
- if (this._captures.length === 0) await this.send("Network.enable");
1178
+ this._requireOpen();
1171
1179
  this._captures.push(...fragments);
1172
1180
  }
1173
1181
  async stopCapturing() {
1174
- if (this._captures.length === 0) return;
1175
1182
  this._captures = [];
1176
1183
  this._responses = [];
1177
1184
  this._inFlight.clear();
1178
- await this.send("Network.disable");
1179
1185
  }
1180
1186
  async waitForResponse(fragment, options = {}) {
1181
1187
  if (this._captures.length === 0) throw new Error("call captureResponses() before the navigation");
@@ -1184,13 +1190,13 @@ var Page = class extends Actions {
1184
1190
  const found = this._responses.find((response) => response.url.includes(fragment));
1185
1191
  if (found !== void 0) return found;
1186
1192
  this._requireOpen();
1187
- deadline.check();
1188
- await sleep(100);
1193
+ await deadline.race(this._change());
1189
1194
  }
1190
1195
  }
1191
1196
  /** @internal */
1192
1197
  _onResponse(event) {
1193
1198
  const response = isRecord(event.response) ? event.response : {};
1199
+ if (event.type === "Document" && event.frameId === this._frameId) this._status = typeof response.status === "number" ? response.status : void 0;
1194
1200
  const url = typeof response.url === "string" ? response.url : "";
1195
1201
  if (this._captures.some((fragment) => url.includes(fragment))) this._inFlight.set(String(event.requestId), response);
1196
1202
  }
@@ -1212,6 +1218,7 @@ var Page = class extends Actions {
1212
1218
  this._browser.logger.debug(`no body for ${String(response.url)}: ${String(err)}`);
1213
1219
  }
1214
1220
  into.push(new CapturedResponse(typeof response.url === "string" ? response.url : "", typeof response.status === "number" ? response.status : 0, isRecord(response.headers) ? response.headers : {}, body));
1221
+ this._browser._notify();
1215
1222
  }
1216
1223
  localStorage() {
1217
1224
  return this.#storage(true);
@@ -1279,14 +1286,38 @@ var Page = class extends Actions {
1279
1286
  try {
1280
1287
  result = await this.send("Runtime.evaluate", params);
1281
1288
  } catch (err) {
1282
- if (!isolated || !(err instanceof CDPError) || !err.message.includes("Cannot find context")) throw err;
1289
+ if (!isolated || !worldIsGone(err)) throw err;
1283
1290
  this._worldId = void 0;
1284
1291
  params.contextId = await this.#world();
1285
1292
  result = await this.send("Runtime.evaluate", params);
1286
1293
  }
1287
- const details = result.exceptionDetails;
1288
- if (details) throw new CDPError(`evaluate failed: ${(details.exception ?? {}).description ?? details.text}`);
1289
- return result.result;
1294
+ return remoteResult(result, "evaluate");
1295
+ }
1296
+ async #call(nodeId, fn, ...args) {
1297
+ const params = {
1298
+ functionDeclaration: fn,
1299
+ arguments: args.map((value) => ({ value })),
1300
+ returnByValue: true
1301
+ };
1302
+ let result;
1303
+ try {
1304
+ result = await this.#callInWorld(nodeId, params);
1305
+ } catch (err) {
1306
+ if (!worldIsGone(err)) throw err;
1307
+ this._worldId = void 0;
1308
+ result = await this.#callInWorld(nodeId, params);
1309
+ }
1310
+ return remoteResult(result, "call").value;
1311
+ }
1312
+ async #callInWorld(nodeId, params) {
1313
+ const resolved = await this.send("DOM.resolveNode", {
1314
+ nodeId,
1315
+ executionContextId: await this.#world()
1316
+ });
1317
+ return await this.send("Runtime.callFunctionOn", {
1318
+ ...params,
1319
+ objectId: resolved.object.objectId
1320
+ });
1290
1321
  }
1291
1322
  async #world() {
1292
1323
  if (this._worldId === void 0) {
@@ -1305,12 +1336,13 @@ var Page = class extends Actions {
1305
1336
  while (true) {
1306
1337
  const value = await deadline.race(this.evaluate(expression, rest));
1307
1338
  if (value) return value;
1308
- deadline.check();
1309
- await sleep(100);
1339
+ await deadline.race(sleep(100));
1310
1340
  }
1311
1341
  }
1312
- innerText(selector) {
1313
- return this.evaluate("s => document.querySelector(s)?.innerText ?? null", { args: [selector] });
1342
+ async innerText(selector) {
1343
+ const nodeId = await this.#nodeId(selector);
1344
+ if (nodeId === void 0) return null;
1345
+ return await this.#call(nodeId, INNER_TEXT);
1314
1346
  }
1315
1347
  allInnerTexts(selector) {
1316
1348
  return this.evaluate("s => Array.from(document.querySelectorAll(s), e => e.innerText)", { args: [selector] });
@@ -1327,7 +1359,8 @@ var Page = class extends Actions {
1327
1359
  return this.keyboard.type(text);
1328
1360
  }
1329
1361
  async #validateFocus(selector) {
1330
- if (!await this.evaluate("s => document.activeElement === document.querySelector(s)", { args: [selector] })) throw new Error(`'${selector}' did not take focus`);
1362
+ const nodeId = await this.#nodeId(selector);
1363
+ if (nodeId === void 0 || !await this.#call(nodeId, IS_FOCUSED)) throw new Error(`'${selector}' did not take focus`);
1331
1364
  }
1332
1365
  async getAttribute(selector, name) {
1333
1366
  const nodeId = await this.#nodeId(selector);
@@ -1338,22 +1371,38 @@ var Page = class extends Actions {
1338
1371
  }
1339
1372
  async count(selector) {
1340
1373
  const document = await this.send("DOM.getDocument", { depth: 0 });
1341
- const xpath = asXPath(selector);
1342
- if (xpath !== void 0) return (await this.#search(xpath)).length;
1343
- return ((await this.send("DOM.querySelectorAll", {
1374
+ if (asXPath(selector) !== void 0) return (await this.#search(selector)).length;
1375
+ const nodeIds = (await this.send("DOM.querySelectorAll", {
1344
1376
  nodeId: document.root.nodeId,
1345
1377
  selector
1346
- })).nodeIds ?? []).length;
1378
+ })).nodeIds ?? [];
1379
+ if (nodeIds.length > 0) return nodeIds.length;
1380
+ return (await this.#search(selector)).length;
1381
+ }
1382
+ async boundingBox(selector) {
1383
+ const nodeId = await this.#nodeId(selector);
1384
+ if (nodeId === void 0) return null;
1385
+ let box;
1386
+ try {
1387
+ box = await this.send("DOM.getBoxModel", { nodeId });
1388
+ } catch (err) {
1389
+ if (err instanceof CDPError) return null;
1390
+ throw err;
1391
+ }
1392
+ const quad = box.model.content;
1393
+ return {
1394
+ x: quad[0],
1395
+ y: quad[1],
1396
+ width: quad[2] - quad[0],
1397
+ height: quad[5] - quad[1]
1398
+ };
1347
1399
  }
1348
1400
  async selectOption(selector, option) {
1349
1401
  const { value, label } = typeof option === "string" ? { value: option } : option;
1350
1402
  if (value === void 0 === (label === void 0)) throw new TypeError("selectOption takes either a value or a label");
1351
- const picked = await this.evaluate(SELECT, { args: [
1352
- selector,
1353
- value ?? null,
1354
- label ?? null
1355
- ] });
1356
- if (picked === null) throw new Error(`nothing matches '${selector}'`);
1403
+ const nodeId = await this.#nodeId(selector);
1404
+ if (nodeId === void 0) throw new Error(`nothing matches '${selector}'`);
1405
+ const picked = await this.#call(nodeId, SELECT, value ?? null, label ?? null);
1357
1406
  if (picked === false) throw new Error(`'${selector}' has no option '${label ?? value}'`);
1358
1407
  return picked;
1359
1408
  }
@@ -1361,10 +1410,7 @@ var Page = class extends Actions {
1361
1410
  const visible = options.visible ?? true;
1362
1411
  const state = visible ? "visible" : "in the document";
1363
1412
  const deadline = new Deadline(options.timeout ?? 3e4, `'${selector}' was not ${state}`);
1364
- while (!await deadline.race(this.#matches(selector, visible))) {
1365
- deadline.check();
1366
- await sleep(100);
1367
- }
1413
+ while (!await deadline.race(this.#matches(selector, visible))) await deadline.race(sleep(100));
1368
1414
  }
1369
1415
  async #matches(selector, visible) {
1370
1416
  let box;
@@ -1382,22 +1428,34 @@ var Page = class extends Actions {
1382
1428
  }
1383
1429
  async #nodeId(selector) {
1384
1430
  const document = await this.send("DOM.getDocument", { depth: 0 });
1385
- const xpath = asXPath(selector);
1386
- if (xpath !== void 0) return (await this.#search(xpath))[0];
1387
- return (await this.send("DOM.querySelector", {
1431
+ if (asXPath(selector) !== void 0) return (await this.#search(selector))[0];
1432
+ const found = await this.send("DOM.querySelector", {
1388
1433
  nodeId: document.root.nodeId,
1389
1434
  selector
1390
- })).nodeId || void 0;
1435
+ });
1436
+ if (found.nodeId) return found.nodeId;
1437
+ return (await this.#search(selector))[0];
1391
1438
  }
1392
- async #search(query) {
1393
- const { searchId, resultCount = 0 } = await this.send("DOM.performSearch", { query });
1439
+ async #search(selector) {
1440
+ const xpath = asXPath(selector);
1441
+ const { searchId, resultCount = 0 } = await this.send("DOM.performSearch", {
1442
+ query: xpath ?? selector,
1443
+ includeUserAgentShadowDOM: false
1444
+ });
1394
1445
  try {
1395
1446
  if (!resultCount) return [];
1396
- return (await this.send("DOM.getSearchResults", {
1447
+ const nodeIds = (await this.send("DOM.getSearchResults", {
1397
1448
  searchId,
1398
1449
  fromIndex: 0,
1399
1450
  toIndex: resultCount
1400
1451
  })).nodeIds ?? [];
1452
+ const css = xpath === void 0 ? selector : null;
1453
+ await this.#world();
1454
+ const kept = await Promise.all(nodeIds.map((nodeId) => this.#call(nodeId, MATCHES, css).catch((err) => {
1455
+ if (err instanceof CDPError) return false;
1456
+ throw err;
1457
+ })));
1458
+ return nodeIds.filter((_, i) => kept[i]);
1401
1459
  } finally {
1402
1460
  await this.send("DOM.discardSearchResults", { searchId }).catch(() => void 0);
1403
1461
  }
@@ -1408,8 +1466,7 @@ var Page = class extends Actions {
1408
1466
  while (true) {
1409
1467
  const url = await deadline.race(this.url());
1410
1468
  if (url.includes(fragment)) return url;
1411
- deadline.check();
1412
- await sleep(100);
1469
+ await deadline.race(sleep(100));
1413
1470
  }
1414
1471
  }
1415
1472
  /** The first match has a bounding box. */
@@ -1487,6 +1544,7 @@ var Page = class extends Actions {
1487
1544
  this._loaderId = loaderId;
1488
1545
  this._milestones = /* @__PURE__ */ new Set([name]);
1489
1546
  }
1547
+ this._browser._notify();
1490
1548
  this._waiter?.observe(name, loaderId);
1491
1549
  }
1492
1550
  /** @internal */
@@ -1520,12 +1578,7 @@ var Page = class extends Actions {
1520
1578
  this._browser.logger.warn("Fetch.requestPaused without a requestId");
1521
1579
  return;
1522
1580
  }
1523
- const status = event.responseStatusCode;
1524
- if (status !== void 0 && status !== null) {
1525
- if (event.frameId === this._frameId) this._status = status;
1526
- this._spawn(this._send("Fetch.continueResponse", { requestId }));
1527
- } else if (event.responseErrorReason) this._spawn(this._send("Fetch.continueRequest", { requestId }));
1528
- else this._spawn(this._send("Fetch.failRequest", {
1581
+ this._spawn(this._send("Fetch.failRequest", {
1529
1582
  requestId,
1530
1583
  errorReason: "BlockedByClient"
1531
1584
  }));
@@ -1537,11 +1590,6 @@ var Page = class extends Actions {
1537
1590
  };
1538
1591
  //#endregion
1539
1592
  //#region src/browser/browser.ts
1540
- const STATUS_PATTERN = {
1541
- urlPattern: "*",
1542
- resourceType: "Document",
1543
- requestStage: "Response"
1544
- };
1545
1593
  const AUTO_ATTACH = {
1546
1594
  autoAttach: true,
1547
1595
  waitForDebuggerOnStart: true,
@@ -1609,6 +1657,7 @@ var Browser = class extends Page {
1609
1657
  #client;
1610
1658
  #abort = new AbortController();
1611
1659
  #pending = /* @__PURE__ */ new Set();
1660
+ _changed = Promise.withResolvers();
1612
1661
  #closing;
1613
1662
  #retired = false;
1614
1663
  #useCount = 0;
@@ -1659,7 +1708,7 @@ var Browser = class extends Page {
1659
1708
  resourceType: RESOURCE_TYPES[name]
1660
1709
  }));
1661
1710
  for (const pattern of this.blockedUrls) patterns.push({ urlPattern: pattern });
1662
- return [...patterns, STATUS_PATTERN];
1711
+ return patterns;
1663
1712
  }
1664
1713
  /** Replace this browser with a fresh one after the lease. */
1665
1714
  retire() {
@@ -1690,8 +1739,7 @@ var Browser = class extends Page {
1690
1739
  const last = opened[opened.length - 1];
1691
1740
  if (last !== void 0) return last;
1692
1741
  this._requireOpen();
1693
- deadline.check();
1694
- await sleep(100);
1742
+ await deadline.race(this._change());
1695
1743
  }
1696
1744
  }
1697
1745
  async connect() {
@@ -1763,8 +1811,7 @@ var Browser = class extends Page {
1763
1811
  let page = this.#pageFor(targetId);
1764
1812
  while (page === void 0) {
1765
1813
  this._requireOpen();
1766
- deadline.check();
1767
- await sleep(100);
1814
+ await deadline.race(this._change());
1768
1815
  page = this.#pageFor(targetId);
1769
1816
  }
1770
1817
  await deadline.race(page._waitReady());
@@ -1794,8 +1841,14 @@ var Browser = class extends Page {
1794
1841
  page._frameId = page._targetId;
1795
1842
  page._ready = new Flag();
1796
1843
  this._pages.set(sessionId, page);
1844
+ this._notify();
1797
1845
  this._spawn(page._setup(waiting));
1798
1846
  }
1847
+ /** @internal */
1848
+ _notify() {
1849
+ this._changed.resolve();
1850
+ this._changed = Promise.withResolvers();
1851
+ }
1799
1852
  async #letGo(sessionId, waiting) {
1800
1853
  const timeout = this.commandTimeout;
1801
1854
  const detach = async () => {
@@ -1813,6 +1866,7 @@ var Browser = class extends Page {
1813
1866
  _drop(page) {
1814
1867
  this._pages.delete(page._sessionId);
1815
1868
  page._closed = true;
1869
+ this._notify();
1816
1870
  page._waiter?.fail("page closed");
1817
1871
  if (page === this) {
1818
1872
  this.logger.warn(`page target gone for ${this.internalUuid}`);
@@ -1831,6 +1885,7 @@ var Browser = class extends Page {
1831
1885
  page._closed = true;
1832
1886
  page._waiter?.fail("CDP connection closed");
1833
1887
  }
1888
+ this._notify();
1834
1889
  }
1835
1890
  /** @internal track a background task; its failure is logged at debug level */
1836
1891
  _spawn(task) {
@@ -2176,7 +2231,6 @@ var BrowserPool = class {
2176
2231
  #idle = [];
2177
2232
  #owned = 0;
2178
2233
  #planFull = false;
2179
- #planFullError;
2180
2234
  #waiters = [];
2181
2235
  #capacity = 0;
2182
2236
  #slots;
@@ -2209,7 +2263,6 @@ var BrowserPool = class {
2209
2263
  this.#capacity = Math.max(1, total);
2210
2264
  this.#slots = new Semaphore(this.#capacity);
2211
2265
  this.#planFull = false;
2212
- this.#planFullError = void 0;
2213
2266
  this.#owned = 0;
2214
2267
  return this;
2215
2268
  }
@@ -2225,14 +2278,17 @@ var BrowserPool = class {
2225
2278
  return this.close();
2226
2279
  }
2227
2280
  /** Run `fn` on a live browser, then hand the browser back. Waits while all are busy. */
2228
- async lease(fn) {
2281
+ async lease(fn, { signal } = {}) {
2282
+ signal?.throwIfAborted();
2229
2283
  const slots = this.#requireOpen();
2230
2284
  await slots.acquire();
2231
2285
  try {
2232
2286
  if (this.#slots !== slots) throw new Error("the pool was closed while waiting for a slot");
2287
+ signal?.throwIfAborted();
2233
2288
  const browser = await this.#acquire();
2234
2289
  browser._lease();
2235
2290
  try {
2291
+ signal?.throwIfAborted();
2236
2292
  return await fn(browser);
2237
2293
  } finally {
2238
2294
  await this.#release(browser);
@@ -2263,7 +2319,6 @@ var BrowserPool = class {
2263
2319
  const idle = this.#idle.pop();
2264
2320
  if (idle !== void 0) return idle;
2265
2321
  if (this.#planFull) {
2266
- if (this.#owned === 0) throw this.#planFullError ?? new RateLimitError("parallel browser limit reached", { statusCode: 429 });
2267
2322
  await this.#wait();
2268
2323
  continue;
2269
2324
  }
@@ -2272,15 +2327,10 @@ var BrowserPool = class {
2272
2327
  try {
2273
2328
  browser = await this.#startBrowser();
2274
2329
  } catch (err) {
2275
- const full = planIsFull(err);
2276
2330
  this.#owned -= 1;
2277
- if (full) {
2278
- this.#planFull = true;
2279
- this.#planFullError = err;
2280
- }
2281
- const nothingLeft = this.#owned === 0;
2331
+ this.#planFull = planIsFull(err) && this.#owned > 0;
2282
2332
  this.#notifyAll();
2283
- if (!full || nothingLeft) throw err;
2333
+ if (!this.#planFull) throw err;
2284
2334
  this.#client.logger.info("plan is full, waiting for a browser of ours");
2285
2335
  continue;
2286
2336
  }
@@ -2304,20 +2354,20 @@ var BrowserPool = class {
2304
2354
  this.#notifyAll();
2305
2355
  }
2306
2356
  /** Run every item through `handler` on a leased browser, `capacity` at a time. */
2307
- async map(handler, items) {
2357
+ async map(handler, items, { signal } = {}) {
2308
2358
  const work = items[Symbol.iterator]();
2309
2359
  const outcomes = [];
2310
2360
  let index = 0;
2311
2361
  let stopped = false;
2312
2362
  const worker = async () => {
2313
- while (!stopped) {
2363
+ while (!stopped && !signal?.aborted) {
2314
2364
  const next = work.next();
2315
2365
  if (next.done) return;
2316
2366
  const item = next.value;
2317
2367
  const at = index;
2318
2368
  index += 1;
2319
2369
  try {
2320
- const value = await this.lease((browser) => handler(browser, item));
2370
+ const value = await this.lease((browser) => handler(browser, item), { signal });
2321
2371
  outcomes.push({
2322
2372
  ok: true,
2323
2373
  item,
@@ -2624,7 +2674,7 @@ var Proxies = class {
2624
2674
  };
2625
2675
  //#endregion
2626
2676
  //#region src/version.ts
2627
- const VERSION = "0.0.5";
2677
+ const VERSION = "0.0.8";
2628
2678
  //#endregion
2629
2679
  //#region src/client.ts
2630
2680
  function userAgent() {
@@ -2723,13 +2773,18 @@ var Surfsky = class Surfsky {
2723
2773
  }
2724
2774
  /** Start a session; `stop()` it (or `await using`) so it does not keep billing. */
2725
2775
  async session(options = {}) {
2726
- const { profileUuid, ...rest } = options;
2776
+ const { profileUuid, signal, ...rest } = options;
2777
+ signal?.throwIfAborted();
2727
2778
  const session = profileUuid === void 0 ? await this.profiles.startOneTime(rest) : await this.profiles.start(profileUuid, rest);
2728
2779
  let stopping;
2729
2780
  const stop = () => {
2730
2781
  stopping ??= stopSession(this, session.internal_uuid);
2731
2782
  return stopping;
2732
2783
  };
2784
+ if (signal?.aborted) {
2785
+ await stop();
2786
+ throw signal.reason;
2787
+ }
2733
2788
  return {
2734
2789
  ...session,
2735
2790
  stop,
@@ -2738,11 +2793,12 @@ var Surfsky = class Surfsky {
2738
2793
  }
2739
2794
  /** Start a session and connect a `Browser` to it. `close()` stops the session. */
2740
2795
  async browser(options = {}) {
2741
- const { profileUuid, blockResources, blockUrls, connectTimeout, commandTimeout, createWebSocket, ...sessionOptions } = options;
2796
+ const { profileUuid, signal, blockResources, blockUrls, connectTimeout, commandTimeout, createWebSocket, ...sessionOptions } = options;
2742
2797
  const blocked = normalizeBlocked(blockResources);
2743
2798
  const urls = normalizeUrls(blockUrls);
2744
2799
  const session = await this.session({
2745
2800
  profileUuid,
2801
+ signal,
2746
2802
  ...sessionOptions
2747
2803
  });
2748
2804
  const browser = new Browser(session, {
@@ -2754,7 +2810,19 @@ var Surfsky = class Surfsky {
2754
2810
  logger: this.logger,
2755
2811
  onClose: () => session.stop()
2756
2812
  });
2757
- await browser.connect();
2813
+ const onAbort = () => void browser.close();
2814
+ signal?.addEventListener("abort", onAbort, { once: true });
2815
+ try {
2816
+ await browser.connect();
2817
+ } catch (err) {
2818
+ throw signal?.aborted ? signal.reason : err;
2819
+ } finally {
2820
+ signal?.removeEventListener("abort", onAbort);
2821
+ }
2822
+ if (signal?.aborted) {
2823
+ await browser.close();
2824
+ throw signal.reason;
2825
+ }
2758
2826
  return browser;
2759
2827
  }
2760
2828
  /** An open `BrowserPool`. `close()` stops every browser in it. */
@@ -2763,9 +2831,10 @@ var Surfsky = class Surfsky {
2763
2831
  }
2764
2832
  /** `browsers()` and `pool.map()` in 1 call. */
2765
2833
  async map(handler, items, options = {}) {
2766
- const pool = await this.browsers(options);
2834
+ const { signal, ...poolOptions } = options;
2835
+ const pool = await this.browsers(poolOptions);
2767
2836
  try {
2768
- return await pool.map(handler, items);
2837
+ return await pool.map(handler, items, { signal });
2769
2838
  } finally {
2770
2839
  await pool.close();
2771
2840
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surfsky",
3
- "version": "0.0.5",
3
+ "version": "0.0.8",
4
4
  "description": "TypeScript SDK for the Surfsky antidetect browser cloud.",
5
5
  "keywords": [
6
6
  "surfsky",