Custom transports in BAP
The TypeScript SDK sends every BrowserQL frame through a transport, and you can supply your own, the same way Puppeteer accepts a custom transport. That's the hook for logging frames, injecting auth at the socket layer, tunneling through infrastructure that plain WebSockets can't cross, or mocking the server in tests.
Custom transports are a TypeScript SDK feature. The Python SDK always uses its built-in WebSocket transport.
- A Browserless API token from your account dashboard
- The TypeScript SDK installed and connecting, from the BAP Quickstart
The transport contract
A transport implements ConnectionTransport, modeled on Puppeteer's interface: send() writes one text frame, close() tears the connection down, and the library assigns onmessage, onclose, and onerror after creation. Your transport must call onmessage for every frame it receives. The frames are graphql-transport-ws envelopes; the transport is just the pipe, and the SDK does the framing on top. A WebSocket-based transport must offer graphql-transport-ws as its subprotocol, because a server that sees none falls back to its legacy response protocol and the handshake breaks.
You supply a factory, not an instance, through the transport option on Browserless.connect(). Nothing connects at connect() time. On each newPage():
- The endpoint URL is resolved, with
?token=appended when atokenis set. - Your factory is called once with that URL.
- The library awaits the factory, so resolve only when the connection is open. Frames are sent the moment it resolves.
- The handlers are assigned and the transport belongs to that page.
browserWSEndpoint and transport aren't two connections. The endpoint is the argument handed to your factory, which gives you two patterns.
Pattern 1: wrap the endpoint connection
Set browserWSEndpoint as usual and read the factory's url argument. This decorates the default connection, which is how you add logging, metrics, or extra headers without changing anything else:
import Browserless, {
type ConnectionTransport,
} from "@browserless.io/bap-ts";
const TOKEN = "YOUR_API_TOKEN_HERE";
// 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) => {
// Offer the graphql-transport-ws subprotocol: the SDK's frames are that
// protocol's envelopes, and a server seeing no subprotocol falls back to
// its legacy protocol and the handshake breaks.
const ws = new WebSocket(url, "graphql-transport-ws");
// Resolve only once open: the SDK sends frames as soon as this settles.
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: TOKEN,
// url is the endpoint above with the token already appended.
transport: (url) => LoggingTransport.create(url!),
});
const page = await browser.newPage();
await page.goto("https://example.com");
// close() ends the session and calls your transport's close().
await browser.close();
Response, one line per outbound frame:
→ {"id":"1","type":"subscribe","payload":{"query":"mutation ..."}}
The built-in WebSocketTransport is exported, so wrapping beats hand-rolling a socket when all you want is a hook around the default behavior:
import { WebSocketTransport } from "@browserless.io/bap-ts";
Browserless.connect({
browserWSEndpoint: "wss://production-sfo.browserless.io/chromium/bql",
token: TOKEN,
transport: (url) => WebSocketTransport.create(url!),
});
Pattern 2: let the transport own the connection
Omit browserWSEndpoint entirely. The factory's url argument is then undefined and your transport connects wherever it likes: a tunnel, a reused socket, or a mock server in tests.
const browser = Browserless.connect({
// No endpoint: the transport knows where to connect.
transport: () => MyTunnelTransport.create(),
});
Pick exactly one pattern. Supplying both an endpoint and a transport that ignores its url argument leaves the endpoint silently unused, which reads like a configuration bug to whoever maintains it next.
One transport per page
The factory runs again for every newPage(), so one page maps to one transport and typically one connection. Return a new instance on every call. The library assigns onmessage, onclose, and 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 your transport multiplexes frames per page itself.
FAQ & Troubleshooting
My transport connects but the first operation hangs
The factory resolved before the connection was open. The SDK writes frames immediately after awaiting the factory, so resolve on the socket's open event, not on construction.
Two pages are receiving each other's responses
The factory returned the same transport instance for both pages, so the second page's handler assignment overwrote the first's. Return a fresh instance per call, or multiplex inside the transport.
Where did my token go in pattern 2?
Token appending only happens when the SDK resolves browserWSEndpoint. When your transport owns the connection, authentication is your transport's job, which is often the very reason to use pattern 2.
Can I use this to correlate subscription frames?
Yes, and it's the right layer for it. BrowserQL is serial request/response, so page.on('message') sees every frame but can't tell you which in-flight operation a push belongs to. A transport sits below that and can track operation ids itself.