npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@browserless.io/bap-ts

v2.0.0

Published

A JavaScript client for the browserless platform

Readme

browser-automation-protocol

⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠇⡅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠧⡇⠀⠀⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀⡤⡆⠦⠆⢀⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠧⣷⣆⠅⢦⠀⠀⠀⠀⠀⠀⠀⠀⠠⠀⠈⠀⠀⠀⠀⠀⢤⣤⣆⢇⣶⣤⡤⡯⣦⣌⡡⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠷⣿⣷⣆⣐⡆⠀⠀⠀⠀⢀⠤⠊⠀⠀⢀⣠⣾⢯⣦⣴⣜⣺⣾⣿⣤⠟⠋⣷⢛⡣⠭⠢⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠯⣿⣷⢫⡯⠄⠀⠀⢀⠐⠁⠀⠀⠀⠠⣤⣿⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣙⣷⡗⢤⡤⠀⠈⣰⠶⡤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⣩⣿⡏⠉⠉⠀⢠⡔⠁⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠑⣏⠶⡉⠖⣡⠂⣈⣤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⣮⣿⣧⣤⣤⠖⠁⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⢉⡻⣿⣿⣿⣿⣿⣿⣿⣿⠟⠓⠈⠅⠈⠀⠀⠘⢒⣽⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⣿⡿⠛⠉⠀⠀⠀⣀⠔⢀⡴⣃⠀⠀⢀⠷⠲⡄⠸⠟⢋⣿⣿⣿⣿⣿⡇⠀⠀⠀⠐⠁⠀⠀⠂⠀⠀⠰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⡆⣷⣆⡐⠶⠤⢤⣷⣀⣀⣩⢐⣟⣥⠜⣤⣀⣠⣤⠀⠈⠉⢀⣹⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⢃⣿⣞⣫⡔⢆⡸⡿⣿⣿⣄⣰⣿⠁⢀⣛⠿⣻⣿⣿⣧⣬⣿⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⠀⠀⢀
⢼⣿⣟⢿⣧⣾⣵⣷⣿⣿⣟⡿⢿⣶⣞⣍⡴⢿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⣠⠈⠀⢀⣀⣼
⠋⣿⣟⡛⢿⣿⣿⣿⣿⣿⣭⣿⣿⣿⣿⣯⣽⣿⣿⣿⣿⠟⠛⠿⢽⣿⣿⣆⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⣀⢀⡠⣤⣤⣰⣿⠟⠁⠀⠀⡼⢾⣿
⣻⣿⣟⣇⠈⣉⣯⠿browserless⣿⠿⠃⠀⠀⠀⠀⠀⠻⣿⣿⣿⣿⣴⣶⣤⣤⣤⣤⣴⣴⣴⣶⣦⣦⣤⣦⣀⣦⣤⣶⣿⣿⣿⣿⣿⣿⣿⠿⠁⠀⠀⡀⣤⣬⣾⣿
⡝⣿⣿⣇⣤⣶⣿⣷⣾⣭⡿⠻⢿⣿⣿⣿⣿⠿⠃⠀⠀⠀⠀⡄⠀⠀⠀⢊⡻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠋⢻⣿bap⣟⢿⠟⢉⠀⡀⢤⣴⣿⣿⣿⠿⠻
⡁⣻⣿⣿⣿⣿⣷⣿⣿⣿⣿⠾⣿⡿⠞⠁⠀⠀⠀⠀⠀⠔⠫⡅⠀⠀⠀⠀⠁⣀⠀⠈⠻⣿⣿⣿⣿⣻⢟⣁⣄⡄⣀⠙⠻⣿⣿⡿⠿⠛⡋⠕⠂⢀⣀⣄⣓⣳⢿⠟⢛⣩⠴⠈⠀
⠂⡁⠈⠛⠛⠛⠛⠋⠁⠀⠈⠈⡀⠀⠀⠀⠀⢀⠘⠀⠀⠀⠆⠀⡀⡢⣀⣆⠄⠈⠨⢦⡀⣈⠙⠛⠿⢿⣿⣿⣿⣿⣿⡿⡿⠿⠟⠆⠒⠁⠀⢶⣾⠿⠟⠛⢉⣀⣠⡶⠚⠁⠀⠀⣠
⠀⡇⡄⣀⡀⠀⠀⠀⠀⠀⠀⠀⢬⠠⠀⡀⠀⠋⠁⠀⡀⠀⠀⡀⠆⢱⣿⣿⣧⣧⣄⠛⣿⣞⣵⣤⣷⣄⠀⠀⠀⠐⠀⠀⠀⠀⠀⠈⠉⠁⠁⠀⠠⢤⣶⣾⣿⡿⠋⢀⣀⣰⣶⣾⣿
⡀⡆⠀⡉⡁⢿⣉⢀⠀⣰⣷⣿⣟⠠⡽⢂⡀⡄⠀⠰⣖⢱⢖⢂⡆⠈⣿⣿⣿⣿⣿⣶⣄⡙⠻⢿⣿⣿⣷⣦⣀⠀⠠⣤⣀⡀⢈⣓⣶⣶⣿⣿⣿⣿⣿⠟⠉⠀⠀⠀⣉⣭⣽⣿⣿
⡇⣯⣿⣿⣿⣾⣿⣿⣿⠿⠟⡡⢞⣹⠾⢻⣚⣛⢺⠞⢋⣭⣾⣧⡃⢄⡈⢿⣿⣿⣿⣿⣿⣿⣯⣿⣮⣽⣿⣿⣿⣿⣷⣬⣽⣿⣿⣿⣽⡿⣿⡿⠟⠋⢀⣀⣐⣺⣿⣿⣟⣫⣭⣿⣿
⢳⣿⣿⣿⣿⣿⣿⣿⣿⣤⣿⣿⣿⣿⣿⣦⠒⠉⢁⡀⠀⣙⣛⢿⣷⣶⣅⠀⠙⠻⣿⣿⣿⣿⣟⡚⠛⠻⠞⠿⠿⡿⡿⠯⠁⠟⣊⠾⠝⢋⣁⣀⣤⣤⣿⣿⣿⡿⠿⠿⠻⠛⠻⠻⠿
⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣐⣾⡿⡟⢶⠾⢋⢹⠿⢿⣿⣿⣷⣦⡈⠙⠛⠿⠿⢿⣶⣶⣶⣶⣶⢶⠟⠚⠀⠁⠀⠀⠙⠛⠛⠛⠛⠛⠋⠉⠁⠀⠀⠀⠀⠀⢀⠀⠀

A Puppeteer-like TypeScript SDK for Browserless. Browser Automation Protocol wraps the BrowserQL GraphQL-over-WebSocket API with a familiar, strongly-typed interface. Enjoy a puppeteer-like experience, with the best-in-class browser automation engine.

Table of Contents

Install

npm install @browserless.io/bap-ts

Updating the Generated Types

src/mutations.graphql is the versioned schema snapshot used by the TypeScript generator. Sync it from the matching enterprise branch that owns the BrowserQL change. BAP client prerequisites can land before their companion Enterprise PRs, so enterprise/main is not always the correct source for an in-flight migration.

After checking out the matching Enterprise branch, update the snapshot and generated client:

npm run update:schema

That command copies the enterprise schema into src/mutations.graphql and regenerates:

  • src/types/enums.ts
  • src/types/inputs.ts
  • src/types/responses.ts
  • src/types/options.ts
  • src/types/events.ts
  • src/graphql/mutations.ts
  • src/graphql/subscriptions.ts
  • src/page-generated.ts
  • src/version.ts
  • generated API tables in this README

To compare the snapshot with the currently checked-out sibling Enterprise schema:

npm run check:schema

Do not overwrite the snapshot merely to match an unrelated Enterprise branch: syncing can remove typed SDK fields that are waiting on a companion server PR. CI instead verifies that every generated artifact matches the committed snapshot:

npm run check:codegen

If the enterprise repo is not checked out next to this repo, pass a schema path:

npm run sync:schema -- --schema /path/to/mutations.graphql
npm run codegen

Generating Public SDK Docs

BAP uses TypeDoc to generate public SDK reference docs for browserless-docs. Run this after code or generated type changes:

npm run docs:sdk

The command finds either a sibling browserless-docs or workspace docs checkout and writes to docs/sdk/bap/reference within it. Use --docs-root <path> or BROWSERLESS_DOCS_PATH to select another checkout:

npm run docs:sdk -- --docs-root /path/to/browserless-docs

TypeDoc reads the complete public surface from src/index.ts, including the high-level browser API, transport contracts, GraphQL builder, and generated option and response types. Documentation warnings fail generation so public exports and links cannot silently drift.

Quick Start

import Browserless from "@browserless.io/bap-ts";

const browser = Browserless.connect({
  browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
  token: "your-api-token",
});

const page = await browser.newPage();
await page.goto("https://example.com");

const title = await page.title();
console.log(title); // "Example Domain"

await browser.close();

Browser & Node

The library is isomorphic. In Node it uses the ws package; bundlers targeting the browser (Vite, webpack, esbuild, Rollup) automatically pick the native WebSocket build via the browser export condition — no ws and no Node built-ins end up in the bundle.

Two platform differences:

  • screenshot() / pdf() resolve to a Uint8Array (in Node the value is a Buffer, which is a Uint8Array).
  • The path option on screenshot() / pdf() writes to disk in Node only; in the browser it rejects — use the returned bytes instead.

In the browser, take the Uint8Array and turn it into something renderable or downloadable with a Blob:

import { Browserless } from "@browserless.io/bap-ts";

const browser = Browserless.connect({
  browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
  token: import.meta.env.VITE_BROWSERLESS_TOKEN,
});

const page = await browser.newPage();
await page.goto("https://example.com");

// No `path` — use the returned bytes directly
const bytes = await page.screenshot({ type: "png" });

// Show it in an <img>
const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));
document.querySelector("img")!.src = url;

// …or trigger a download
const pdf = await page.pdf({ format: "a4" });
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([pdf], { type: "application/pdf" }));
a.download = "page.pdf";
a.click();

await browser.close();

The browser must be able to reach the Browserless WebSocket endpoint directly — make sure your endpoint allows the token and origin you connect from.

Examples

Take a Screenshot

const browser = Browserless.connect({
  browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
  token: "your-api-token",
});

const page = await browser.newPage();
await page.goto("https://example.com");

// Returns a Uint8Array, optionally writes to disk (Node only)
await page.screenshot({ path: "screenshot.png" });

// Full-page screenshot as WebP
await page.screenshot({
  path: "full.webp",
  type: "webp",
  fullPage: true,
  quality: 80,
});

await browser.close();

Generate a PDF

const page = await browser.newPage();
await page.goto("https://example.com");

await page.pdf({
  path: "page.pdf",
  format: "a4",
  printBackground: true,
  landscape: true,
});

await browser.close();

Fill Out a Form

const page = await browser.newPage();
await page.goto("https://example.com/login");

await page.type("#username", "[email protected]");
await page.type("#password", "secret", { delay: [100, 200] });
await page.click("#submit");

await page.waitForNavigation({ waitUntil: "networkIdle" });
console.log(await page.url());

await browser.close();

Query the DOM

const page = await browser.newPage();
await page.goto("https://example.com");

// Single element
const heading = await page.$("h1");
console.log(heading?.innerText);

// Multiple elements. `innerHTML`/`outerHTML` are opt-in: serializing every
// match's subtree is the expensive part of a query, and most callers only
// want to act on the elements.
const links = await page.$$("a", { html: true });
for (const link of links) {
  console.log(link.innerHTML);
}

// Extract text from a selector
const text = await page.$eval(".content");
console.log(text);

// Map over multiple elements
const items = await page.$$eval("ul li");
for (const item of items) {
  console.log(item.innerText);
}

Wait for Elements and Navigation

const page = await browser.newPage();
await page.goto("https://example.com/spa");

// Wait for an element to appear
await page.waitForSelector(".loaded-content", {
  visible: true,
  timeout: 10000,
});

// Wait for a specific network request
await page.waitForRequest("https://api.example.com/data");

// Wait for a specific response status
await page.waitForResponse({
  url: "https://api.example.com/data",
  statuses: [200],
});

Listen to Page Events

Streaming events use a puppeteer-style event emitter. Attaching the first listener for console, request, or response lazily opens a GraphQL subscription (over the graphql-transport-ws subprotocol); removing the last listener closes it. Use on, once, and off just like puppeteer.

const page = await browser.newPage();

// Console output as the page emits it
page.on("console", (msg) => {
  console.log(`[${msg.type}] ${msg.text}`);
});

// Every network request as it is issued
page.on("request", (req) => {
  console.log(`${req.method} ${req.url}`);
});

// Every network response as it is received. The response body is NOT fetched
// by default (streaming many bodies is expensive).
page.on("response", (res) => {
  console.log(`${res.status} ${res.url}`);
});

// One-shot listener, then auto-removed
page.once("request", (req) => console.log("first request:", req.url));

// Non-fatal streaming/handshake failures surface as an `error` event
page.on("error", (err) => console.error("event stream error:", err.message));

await page.goto("https://example.com");

Run JavaScript in the Page

const page = await browser.newPage();
await page.goto("https://example.com");

// Pass a string
const result = await page.evaluate("document.title");
console.log(result);

// Pass a function
const dims = await page.evaluate(() => {
  return JSON.stringify({
    width: window.innerWidth,
    height: window.innerHeight,
  });
});
console.log(dims);

Set Viewport, Headers, and Cookies

const page = await browser.newPage();

// Mobile viewport
await page.setViewport({
  width: 375,
  height: 812,
  mobile: true,
  deviceScaleFactor: 3,
});

// Custom headers
await page.setExtraHTTPHeaders({
  "Accept-Language": "en-US",
  "X-Custom": "value",
});

// Set cookies before navigating
await page.setCookie(
  { name: "session", value: "abc123", domain: ".example.com" },
  { name: "prefs", value: "dark", domain: ".example.com" },
);

await page.goto("https://example.com");

// Read cookies back
const cookies = await page.cookies();
console.log(cookies);

Block Requests

const page = await browser.newPage();

// Block images and stylesheets
await page.reject({
  type: ["image", "stylesheet"],
  operator: "or",
});

await page.goto("https://example.com");

Use a Proxy

const page = await browser.newPage();

await page.proxy({
  country: "US",
  state: "California",
  sticky: true,
});

await page.goto("https://example.com");

Solve CAPTCHAs

const page = await browser.newPage();
await page.goto("https://example.com/protected");

const result = await page.solve({
  type: "cloudflare",
  timeout: 30000,
});

console.log(result.solved); // true

Get a Live URL for Debugging

const page = await browser.newPage();
await page.goto("https://example.com");

const { liveURL } = await page.liveURL({
  interactable: true,
  type: "png",
});

console.log(`Watch live: ${liveURL}`);

Reconnect to a Session

const page = await browser.newPage();
await page.goto("https://example.com");

const session = await page.reconnect({ timeout: 60000 });
console.log(session.browserWSEndpoint);
// Use this endpoint to connect a new browser to the same session

Bring Your Own Transport

By default, connecting to a WebSocket URL runs through the library's built-in WebSocketTransport (native WebSocket in the browser, ws in Node). You can replace it with your own — the same way Puppeteer lets you pass a custom transport — to tunnel BrowserQL frames over a different channel, add logging, inject auth, or reuse an existing connection.

Pass a transport factory to Browserless.connect. It's called once per newPage() and must resolve a ConnectionTransport that is already open.

browserWSEndpoint and transport are not two separate connections — the endpoint is simply the argument handed to your factory. There are two ways to use them, and you pick exactly one:

1. Let the library supply the endpoint. Set browserWSEndpoint (with a token, ?token=… is appended) and read it as the factory's url argument. Use this to wrap or decorate the default connection — logging, metrics, auth:

import Browserless, {
  type ConnectionTransport,
} from "@browserless.io/bap-ts";

// A minimal transport, modeled on puppeteer's ConnectionTransport.
class LoggingTransport implements ConnectionTransport {
  onmessage?: (message: string) => void;
  onclose?: () => void;
  onerror?: (error: unknown) => void;

  private constructor(private readonly ws: WebSocket) {
    ws.addEventListener("message", (e) => this.onmessage?.(String(e.data)));
    ws.addEventListener("close", () => this.onclose?.());
    ws.addEventListener("error", (e) => this.onerror?.(e));
  }

  static create(url: string): Promise<LoggingTransport> {
    return new Promise((resolve, reject) => {
      const ws = new WebSocket(url);
      ws.addEventListener("open", () => resolve(new LoggingTransport(ws)), {
        once: true,
      });
      ws.addEventListener("error", reject, { once: true });
    });
  }

  send(message: string): void {
    console.log("→", message);
    this.ws.send(message);
  }

  close(): void {
    this.ws.close();
  }
}

const browser = Browserless.connect({
  browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
  token: "your-api-token",
  transport: (url) => LoggingTransport.create(url), // `url` is the endpoint above
});

2. Let the transport own the connection. Omit browserWSEndpoint — the factory's url is then undefined, and your transport connects wherever it likes (a tunnel, a reused socket, a mock):

const browser = Browserless.connect({
  transport: () => MyTransport.create(), // endpoint baked into the transport
});

What happens on each newPage(). The connection is opened lazily — nothing connects at Browserless.connect() time. When you call browser.newPage():

  1. The endpoint URL is resolved: browserWSEndpoint with ?token=… appended when a token is set, or undefined if you omitted browserWSEndpoint.
  2. Your transport factory is invoked once, with that URL and a CreateWebSocketOptions object carrying the connect deadline (per pattern 1 or 2 above). The SDK races the connect against its deadline, but this bounds the SDK call only. A custom factory must enforce its own handshake deadline and cancel or close pending dials; the SDK cannot cancel a factory promise that never settles. A transport returned after the SDK retires the dial is closed without sending any operations.
  3. The library awaits the factory, so it must resolve only once the connection is open — frames are sent the moment it resolves.
  4. The library assigns onmessage/onclose/onerror on the returned transport, then hands it to the new Page. Every BrowserQL operation on that page is serialized to a frame and written via your transport's send; you deliver replies back by calling onmessage.

A fresh transport is created per newPage() — the factory runs again for each page, so one page maps to one transport (and typically one connection). Return a new instance on every call: the library assigns onmessage/onclose/onerror on whatever the factory returns, so handing back the same instance across pages overwrites the earlier pages' handlers and cross-wires their frames. Share an underlying connection across pages only if the transport multiplexes frames per page itself.

The built-in WebSocketTransport is exported too, so pattern 1 can wrap it instead of hand-rolling a socket:

import { WebSocketTransport } from "@browserless.io/bap-ts";

Browserless.connect({
  browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
  transport: (url) => WebSocketTransport.create(url),
});

Subscribe to Raw Frames

Every frame the transport receives can be observed with page.on('message') — including server-pushed frames that don't correspond to a request. Pair it with page.send() to drive raw BrowserQL:

const page = await browser.newPage();

// Listen for every incoming raw frame
page.on("message", (frame) => {
  console.log("frame:", frame);
});

// `subscribe` is sugar that returns an unsubscribe function
const unsubscribe = page.subscribe((frame) => console.log(frame));

// Send a raw query yourself
await page.send("mutation { goto(url: \"https://example.com\") { status } }");

unsubscribe();
// page.off("message", listener) also removes a listener

BrowserQL is a serial request/response protocol: a pushed frame that arrives while a request is in flight is consumed as that request's response. on('message') sees all frames; correlating subscription traffic is up to your custom transport.

Payload Size and Timeouts

Two defaults are tuned for the common case rather than the maximal one. Both matter most on endpoints where sessions are expensive to start — /stealth/bql above all, where a busy endpoint may hold the WebSocket upgrade open until a worker frees up.

Large fields are opt-in. The client requests the cheap fields of a response by default and lets you ask for the expensive ones. Nothing here changes what the server does — only what it is asked to serialize and send.

// Default: metadata only — no bodies on the wire.
const responses = await page.response({ url: ["*/api/*"] });

// Opt in when you actually read them.
const withBodies = await page.response({ url: ["*/api/*"], body: true });
console.log(withBodies[0]?.body);

// Same idea for element queries: `innerHTML`/`outerHTML` are off by default.
const rows = await page.$$("table tr"); // snapshots without serialized HTML
const html = await page.$$("table tr", { html: true }); // serialized subtrees

Reading innerHTML or outerHTML on a handle that was queried without { html: true } throws, so a missing opt-in never looks like an empty element.

The full set of opt-in groups is exported as OPTIONAL_FIELDS, keyed by response type:

| Option | Adds | | ------------------------------- | ------------------------ | | goto(url, { remoteResponseMetadata: true }) | ip, port | | response({ body: true }) | body | | response({ bodyEncoding: true }) | body, base64Encoded | | $/$$(selector, { html: true }) | innerHTML, outerHTML |

Connecting is bounded. newPage() opens the socket, and that dial is bounded by connectTimeout — or by timeout when you do not set one — so it fails on schedule instead of hanging on a queued session. Set connectTimeout when operations are long but a queued session should fail fast, since a large timeout would otherwise be how long newPage() waits for a free worker. Only a positive value sets a deadline: 0 (or any negative number) means no timeout, for either option.

const browser = Browserless.connect({
  browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
  token: "your-api-token",
  timeout: 300000, // long scrapes
  connectTimeout: 15000, // but do not queue for five minutes
});

A per-call timeout covers that operation. Because newPage() has already awaited the connection, it does not normally also have to cover connect time — that only applies when driving the exported Transport directly, where the first operation triggers the dial and its budget covers both:

try {
  await page.goto("https://example.com", { timeout: 15000 });
} catch (error) {
  if (error instanceof TimeoutError) {
    // Message distinguishes a connect that never completed from an
    // operation that did not answer in time.
    console.error(error.message);
  }
}

In Node the socket handshake deadline is the resolved connection timeout plus 1000 ms, capped at the runtime timer maximum. This gives the SDK race time to report TimeoutError before ws aborts the handshake. In the browser there is no socket-level handshake deadline: the SDK call times out, but the pending socket cannot be cancelled through the current factory contract. A late connection is closed when it arrives; a dial that never settles remains pending.

When using Transport.send() directly, a per-call timeout retires a pending dial only after its last operation waiter leaves. An explicit connect() call, subscription, or another waiting operation keeps the shared dial alive.

A timed-out dial may still consume a server session before its late connection is closed. Avoid automatic retries on a saturated account; check concurrency and choose a connection deadline that allows for expected queue time.

Error Handling

import Browserless, {
  BrowserQLError,
  ConnectionError,
  TimeoutError,
} from "@browserless.io/bap-ts";

try {
  const browser = Browserless.connect({
    browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
    token: "your-api-token",
  });
  const page = await browser.newPage();
  await page.goto("https://example.com", { timeout: 5000 });
} catch (error) {
  if (error instanceof TimeoutError) {
    console.error("Operation timed out");
  } else if (error instanceof ConnectionError) {
    console.error("WebSocket connection failed");
  } else if (error instanceof BrowserQLError) {
    console.error("GraphQL errors:", error.errors);
  }
}

API Reference

Browserless.connect(options)

Creates a Browser instance. The WebSocket connection opens when you call newPage().

| Option | Type | Description | | ------------------- | ------------------- | --------------------------------------------------------------------------------------- | | browserWSEndpoint | string? | WebSocket URL (e.g. wss://production-sfo.browserless.io/chromium/bql). Optional when a custom transport is supplied | | token | string? | API token | | timeout | number? | Default per-operation timeout in ms (default: 30000). Also the dial deadline unless connectTimeout is set. 0 or negative disables it — see Payload Size and Timeouts | | connectTimeout | number? | Deadline for opening the WebSocket, in ms (default: timeout). 0 or negative disables it. Set it when operations are long but a queued session should fail fast | | transport | TransportFactory? | Override the built-in WebSocket with your own transport — see Bring Your Own Transport |

Browser

| Method | Returns | Description | | ------------ | --------------- | ---------------------------------------- | | newPage() | Promise<Page> | Opens a WebSocket and returns a new Page | | getPages() | Page[] | Returns all non-closed pages | | close() | Promise<void> | Closes all pages |

Page

Navigation

| Method | Returns | | ----------------------------- | ------------------------------- | | goto(url, options?) | Promise<HTTPResponse \| null> | | goBack(options?) | Promise<HTTPResponse \| null> | | goForward(options?) | Promise<HTTPResponse \| null> | | reload(options?) | Promise<HTTPResponse \| null> | | setContent(html, options?) | Promise<HTTPResponse \| null> | | waitForNavigation(options?) | Promise<HTTPResponse> |

Interaction

| Method | Returns | | -------------------------------- | ------------------------- | | click(selector, options?) | Promise<ClickResponse> | | hover(options?) | Promise<HoverResponse> | | scroll(options?) | Promise<ScrollResponse> | | type(selector, text, options?) | Promise<TypeResponse> | | check(selector, options?) | Promise<ClickResponse> | | uncheck(selector, options?) | Promise<ClickResponse> | | select(selector, ...values) | Promise<SelectResponse> |

Content

| Method | Returns | | ---------------------- | ----------------------- | | content() | Promise<string> | | html(options?) | Promise<HTMLResponse> | | text(options?) | Promise<TextResponse> | | title() | Promise<string> | | url() | Promise<string> | | screenshot(options?) | Promise<Uint8Array> | | pdf(options?) | Promise<Uint8Array> |

Scripts & Styles

| Method | Returns | | ------------------------ | ------------------------------- | | addScriptTag(options?) | Promise<AddScriptTagResponse> | | addStyleTag(options?) | Promise<AddStyleTagResponse> |

Pass url to load from a URL, or content for inline source.

Note: inline content must be a single line. The BrowserQL server rejects multi-line content with SyntaxError: Invalid or unexpected token. For multi-line scripts, host them and use url, or collapse the source to one line first (e.g. bundle with minification) when it has no newline-sensitive syntax such as // comments or multi-line template literals.

Selectors

| Method | Returns | | ------------------------------------- | ---------------------------------- | | $(selector, options?) | Promise<ElementHandle \| null> | | $$(selector, options?) | Promise<ElementHandle[]> | | $eval(selector) | Promise<string> | | $$eval(selector, options?) | Promise<MapSelectorResponse[]> | | mapSelector(selector, options?) | Promise<MapSelectorResponse[]> | | waitForSelector(selector, options?) | Promise<WaitForSelectorResponse> |

Wait

| Method | Returns | | -------------------------------- | ---------------------------------- | | waitForTimeout(ms) | Promise<void> | | waitForRequest(urlOrOptions?) | Promise<WaitForRequestResponse> | | waitForResponse(urlOrOptions?) | Promise<WaitForResponseResponse> |

Settings

| Method | Returns | | ------------------------------- | ------------------------------ | | setUserAgent(ua) | Promise<UserAgentResponse> | | setViewport(options) | Promise<ViewportResponse> | | setCookie(...cookies) | Promise<CookieResponse> | | cookies() | Promise<StandardCookie[]> | | setExtraHTTPHeaders(headers) | Promise<HTTPHeadersResponse> | | setJavaScriptEnabled(enabled) | Promise<JavaScriptResponse> | | evaluate(content, options?) | Promise<string \| null> |

Network

| Method | Returns | | -------------------- | ----------------------------- | | request(options?) | Promise<RequestResponse[]> | | response(options?) | Promise<ResponseResponse[]> | | reject(options?) | Promise<RejectResponse> | | proxy(options) | Promise<ProxyResponse> |

Events

Puppeteer-style emitter. Subscription-backed events open a stream lazily on the first listener and close it on the last removal. See Listen to Page Events.

| Method | Returns | Event payloads | | -------------------------- | --------- | ---------------------------------------------------------------------------------- | | on(event, listener) | this | console → ConsoleMessage, request → RequestResponse, response → ResponseResponse, error → Error | | once(event, listener) | this | one-shot variant of on | | off(event, listener) | this | remove a listener | | removeAllListeners(event?) | this | remove all listeners for an event, or all events |

Session

| Method | Returns | | ---------------------------- | --------------------------------------- | | reconnect(options?) | Promise<ReconnectionResponse> | | liveURL(options?) | Promise<LiveURLResponse> | | stopSessionRecording() | Promise<StopSessionRecordingResponse> | | switchToWindow(options?) | Promise<SwitchWindowResponse> | | solve(options?) | Promise<CaptchaResponse> | | solveImageCaptcha(options) | Promise<CaptchaResponse> | | close() | Promise<void> |

Events

Raw-frame listeners over the underlying transport (see Subscribe to Raw Frames).

| Method | Returns | Description | | ---------------------------- | ------------ | ------------------------------------------------- | | on('message', listener) | this | Listen for every raw frame the transport receives | | off('message', listener) | this | Remove a listener added with on | | subscribe(listener) | () => void | Like on('message'), returns an unsubscribe fn | | send(query, options?) | Promise<T> | Send a raw BrowserQL query and await the response |

ElementHandle

Property getters are a snapshot taken when the handle was created. innerHTML and outerHTML are only captured when the query passed { html: true }; reading them otherwise throws, rather than reporting HTML that was never fetched as absent.

Actions on $$ handles re-query the original matching set and select the match at its original index, including nonconsecutive matches and matches under different parents. Scoped $/$$ queries search beneath that element. These are selector-based handles, not persistent DOM references: if matches are inserted, removed, or reordered, an action can target a different element. Use a unique selector when identity must survive changes to the matching set.

| Property / Method | Type | | ---------------------- | ------------------------ | | innerHTML | string \| null | | innerText | string \| null | | id | string \| null | | className | string \| null | | localName | string \| null | | outerHTML | string \| null | | childElementCount | number \| null | | click(options?) | Promise<ClickResponse> | | hover() | Promise<void> | | type(text, options?) | Promise<TypeResponse> | | screenshot(options?) | Promise<Uint8Array> | | textContent() | Promise<string> |

Migrating to 2.0

2.0.0 trims default response fields and adds a default connection deadline. Both are client-side behavior changes; the server is unchanged.

Breaking: newPage() can now time out while queued. In 1.x the dial had no SDK deadline. In 2.0 it defaults to timeout (30000 ms), so a queued session that previously waited longer and succeeded can now throw TimeoutError. Set connectTimeout to your expected queue budget, or connectTimeout: 0 to retain the unbounded 1.x dial without disabling operation timeouts. A timeout does not guarantee the pending dial was cancelled; see Payload Size and Timeouts before adding retries.

Response bodies are no longer selected. response() previously returned the body of every matched response.

// 1.x
const responses = await page.response({ url: ["*/api/*"] });
responses[0].body;

// 2.0 — ask for it
const responses = await page.response({ url: ["*/api/*"], body: true });
responses[0].body;

innerHTML/outerHTML are no longer selected by $/$$. Reading either one off a handle that did not opt in throws a BrowserQLError (code: 'BQL') rather than returning undefined, so a missed migration surfaces immediately:

// 1.x
const rows = await page.$$("table tr");
rows[0].innerHTML;

// 2.0 — ask for it
const rows = await page.$$("table tr", { html: true });
rows[0].innerHTML;

The other handle properties (innerText, id, className, localName, childElementCount) are unchanged, and the action methods never needed the opt-in. ElementHandle.$/$$ take the same option for scoped queries.

Optional-by-type. body, base64Encoded, innerHTML, outerHTML, ip and port are now optional properties on their response types. With strict null checks, TypeScript catches assignments to required values, but it does not catch every missed opt-in: truthiness checks, ?? "", string interpolation, and JavaScript callers can silently treat an unrequested value as empty. Audit reads of these fields explicitly. Only the handle HTML getters throw on an unrequested field.

Remote metadata can currently be requested only through goto, not the other methods returning HTTPResponse; response subscriptions have no body opt-in.

Removed: the HTTPResponseWithRemoteMetadata and ResponseResponseWithBodyEncoding keys of RESPONSE_FIELDS. Use the exported OPTIONAL_FIELDS map plus selectionFor(responseType, optIn) instead.

Additive signatures: ConnectOptions.connectTimeout, Page.execute's fifth optIn parameter, and a second options argument to TransportFactory. Existing factories that ignore it still type-check, but must manage their own pending-dial cleanup as described above. The default dial bound itself is a breaking behavior change.

Browser Automation Protocol vs Puppeteer

Browser Automation Protocol's Page class borrows Puppeteer's naming conventions so the API feels familiar, but there are important differences in architecture, scope, and behavior.

Architecture

Puppeteer controls a local Chrome instance over the Chrome DevTools Protocol (CDP) via a WebSocket. Browser Automation Protocol is a GraphQL client — every method builds a mutation, sends it over a single WebSocket to the Browserless BQL server, and waits for the response. There is no direct CDP connection. This means a massive reduction in the amount and size of messages being sent over the network, making scripting performance much faster.

Because this client builds on top of our BrowserQL service, it also means all the languages we support are (mostly) compiled from the BrowserQL mutation specification. This means all clients are treated equally, operate the same, and will perform similarly.

Shared Methods

These methods exist in both libraries with similar signatures:

| Category | Methods | | ----------- | ---------------------------------------------------------------------------------------------------- | | Navigation | goto, goBack, goForward, reload, setContent, waitForNavigation | | Interaction | click, type, select, hover | | Content | content, title, url, screenshot, pdf | | Selectors | $, $$, waitForSelector | | Evaluate | evaluate | | Settings | setUserAgent, setViewport, setCookie, cookies, setExtraHTTPHeaders, setJavaScriptEnabled | | Wait | waitForTimeout, waitForRequest, waitForResponse | | Lifecycle | close |

Complete Method Reference

Every Page method and its source BrowserQL mutation. This table is generated from src/mutations.graphql by npm run codegen, so it stays in sync with the schema:

| Method | Source mutation | Returns | Puppeteer-named | | ------ | --------------- | ------- | --------------- | | $ | querySelector | ElementHandle \| null | ✅ | | $$ | querySelectorAll | ElementHandle[] | ✅ | | $$eval | mapSelector | MapSelectorResponse[] | ✅ | | $eval | text | string | ✅ | | addScriptTag | addScriptTag | AddScriptTagResponse | ✅ | | addStyleTag | addStyleTag | AddStyleTagResponse | ✅ | | authenticate | authenticate | HTTPResponse \| null | — | | check | checkbox | ClickResponse | — | | click | click | ClickResponse | ✅ | | close | — | void | ✅ | | content | html | string | ✅ | | cookies | cookies | StandardCookie[] | ✅ | | emulateMediaType | emulateMediaType | EmulateMediaTypeResponse | ✅ | | evaluate | evaluate | string \| null | ✅ | | fulfill | fulfill | FulfillResponse | — | | goBack | back | HTTPResponse \| null | ✅ | | goForward | forward | HTTPResponse \| null | ✅ | | goto | goto | HTTPResponse \| null | ✅ | | hover | hover | HoverResponse | ✅ | | html | html | HTMLResponse | — | | liveURL | liveURL | LiveURLResponse | — | | loadSecret | loadSecret | LoadSecretResponse | — | | mapSelector | mapSelector | MapSelectorResponse[] | — | | markdown | markdown | MarkdownResponse | — | | pdf | pdf | Uint8Array | ✅ | | preferences | preferences | DefaultResponse | — | | proxy | proxy | ProxyResponse | — | | reconnect | reconnect | ReconnectionResponse | — | | reject | reject | RejectResponse | — | | reload | reload | HTTPResponse \| null | ✅ | | request | request | RequestResponse[] | — | | response | response | ResponseResponse[] | — | | screenshot | screenshot | Uint8Array | ✅ | | scroll | scroll | ScrollResponse | — | | select | select | SelectResponse | ✅ | | send | — | T | — | | setContent | content | HTTPResponse \| null | ✅ | | setCookie | cookies | CookieResponse | ✅ | | setExtraHTTPHeaders | setExtraHTTPHeaders | HTTPHeadersResponse | ✅ | | setJavaScriptEnabled | javaScriptEnabled | JavaScriptResponse | ✅ | | setUserAgent | userAgent | UserAgentResponse | ✅ | | setViewport | viewport | ViewportResponse | ✅ | | solve | solve | CaptchaResponse | — | | solveImageCaptcha | solveImageCaptcha | CaptchaResponse | — | | stopSessionRecording | stopSessionRecording | StopSessionRecordingResponse | — | | switchToWindow | switchToWindow | SwitchWindowResponse | — | | text | text | TextResponse | — | | title | title | string | ✅ | | type | type | TypeResponse | ✅ | | uncheck | checkbox | ClickResponse | — | | url | url | string | ✅ | | waitForEvent | waitForEvent | WaitForEvent | — | | waitForFunction | waitForFunction | WaitForFunction | ✅ | | waitForNavigation | waitForNavigation | HTTPResponse | ✅ | | waitForNetworkIdle | waitForNetworkIdle | WaitForTimeoutResponse | — | | waitForRequest | waitForRequest | WaitForRequestResponse | ✅ | | waitForResponse | waitForResponse | WaitForResponseResponse | ✅ | | waitForSelector | waitForSelector | WaitForSelectorResponse | ✅ | | waitForTimeout | waitForTimeout | void | ✅ | | watchEvent | watchEvent | WaitForEvent | — |

Behavioral Differences

| Method | Puppeteer | Browser Automation Protocol | | ------------------------------------ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | $eval(selector, fn) | Runs a function in the browser with the matched element as the argument and returns the result | Returns the text content of the matched selector (no function argument) | | $$eval(selector, fn) | Runs a function in the browser with all matched elements as an array argument | Delegates to mapSelector — returns structured MapSelectorResponse[] with element properties | | scroll() | Not available on Page — you use mouse.wheel() or evaluate | First-class method with selector targeting and coordinate support | | evaluate(fn, ...args) | Passes serialized arguments to the function and returns deserialized results | Accepts a string or function but always returns string \| null — no argument passing | | waitForRequest / waitForResponse | Accept a URL string or a predicate function | Accept a URL string or an options object (no predicate functions) |

Not in Browser Automation Protocol

Browser Automation Protocol does not include Puppeteer's event system or lower-level primitives. These might be added in a later release:

  • Events — no per-domain event listeners (page.on('request' | 'response' | 'console' | 'dialog' | ...)). There is a raw-frame listener — page.on('message', …) / page.subscribe(…) — for observing the underlying transport (see Subscribe to Raw Frames)
  • Input devices — no page.keyboard, page.mouse, page.touchscreen
  • Frames — no page.frames(), page.mainFrame(), or frame targeting
  • Workers — no page.workers()
  • Function exposure — no exposeFunction()
  • Emulation — no emulate() or emulateCPUThrottling() (emulateMediaType() is supported)
  • Security/Cache — no setBypassCSP(), setCacheEnabled(), setOfflineMode()
  • Coverage/Tracing — no page.coverage, page.tracing
  • Accessibility — no page.accessibility

Browser Automation Protocol-Only Features

These methods have no equivalent in Puppeteer:

| Method | Description | | ------------------------------------------ | ------------------------------------------------------------- | | html(options?) | Extract HTML with optional selector targeting and cleaning | | text(options?) | Extract text with optional selector targeting and cleaning | | check(selector) / uncheck(selector) | Checkbox helpers | | mapSelector(selector, options?) | Map over matched elements and return structured properties | | reject(options?) | Block network requests by type, URL, or method | | proxy(options) | Route traffic through a proxy with geo-targeting | | solve(options?) | Solve CAPTCHAs (Cloudflare, reCAPTCHA, etc.) | | solveImageCaptcha(options) | Solve image-based CAPTCHAs with selector targeting | | liveURL(options?) | Get a shareable live-view URL for debugging | | reconnect(options?) | Get a new WebSocket endpoint to reconnect to the same session | | switchToWindow(options?) | Switch between browser tabs/windows | | stopSessionRecording() | Stop recording the current session | | preferences(options?) | Dismiss cookie banners and preference dialogs | | request(options?) / response(options?) | Query captured network traffic with filters |

How It Works

Browser Automation Protocol communicates with Browserless over a single WebSocket connection per page. Each method call constructs a GraphQL mutation, sends it as a JSON message, and waits for the response. Operations are queued and executed serially, matching BrowserQL's server-side concurrency model. Browser and page state persists across calls on the same connection.