feat: Add browserless.io as a browser provider (#502)

* feat: Add browserless as a hosted option + boolean env-parsing utility

* Add ensureDomainFilter, sanitizeExistingPage and move parseBooleanParam

* Add docs in relevant places, fix utils, rename of API env var

* Update readme

* Fix env variable name in readme

* Cleanup session stop urls when errors happen

* Fix browserlessStopUrl not being assigned in happy path
This commit is contained in:
Joel Griffith
2026-03-13 00:45:02 -05:00
committed by GitHub
parent fb7185d860
commit b9a24df40f
6 changed files with 279 additions and 74 deletions
+142
View File
@@ -48,6 +48,31 @@ export function getDefaultTimeout(): number {
return 25000;
}
/**
* Handles boolean env vars and parsing (e.g., "true", "1", "false", "0"),
* with a default value if not set or invalid
*/
export function parseBooleanEnvVar(name: string, defaultValue: boolean): boolean {
const truthyVals = ['1', 'true'];
const falsyVals = ['0', 'false'];
if (!Object.hasOwn(process.env, name)) {
return defaultValue;
}
const param = process.env[name]!.toLowerCase();
if (truthyVals.includes(param)) {
return true;
}
if (falsyVals.includes(param)) {
return false;
}
return defaultValue;
}
// Screencast frame data from CDP
export interface ScreencastFrame {
data: string; // base64 encoded image
@@ -105,6 +130,7 @@ export class BrowserManager {
private browserUseApiKey: string | null = null;
private kernelSessionId: string | null = null;
private kernelApiKey: string | null = null;
private browserlessStopUrl: string | null = null;
private contexts: BrowserContext[] = [];
private pages: Page[] = [];
private activePageIndex: number = 0;
@@ -939,6 +965,19 @@ export class BrowserManager {
}
}
/**
* Close a Browserless session via its stop URL
*/
private async closeBrowserlessSession(stopUrl: string): Promise<void> {
const response = await fetch(stopUrl, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to close Browserless session: ${response.statusText}`);
}
}
/**
* Connect to Browserbase remote browser via CDP.
* Requires BROWSERBASE_API_KEY environment variable.
@@ -1221,6 +1260,99 @@ export class BrowserManager {
}
}
/**
* Connect to Browserless remote browser via CDP.
* Requires BROWSERLESS_API_KEY environment variable.
*/
private async connectToBrowserless(): Promise<void> {
const browserlessToken = process.env.BROWSERLESS_API_KEY;
if (!browserlessToken) {
throw new Error('BROWSERLESS_API_KEY is required when using browserless as a provider');
}
const supportedBrowsers = ['chromium', 'chrome'];
const apiUrl = process.env.BROWSERLESS_API_URL || 'https://production-sfo.browserless.io';
const browserType = process.env.BROWSERLESS_BROWSER_TYPE || 'chromium';
const ttl = parseInt(process.env.BROWSERLESS_TTL || '300000', 10);
const stealth = parseBooleanEnvVar('BROWSERLESS_STEALTH', true);
if (!supportedBrowsers.includes(browserType)) {
throw new Error(
`BROWSERLESS_BROWSER_TYPE "${browserType}" is not supported. Only ${supportedBrowsers.join(', ')} are allowed.`
);
}
const response = await fetch(
`${apiUrl}/session?token=${encodeURIComponent(browserlessToken)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ttl,
stealth,
browser: browserType,
}),
}
);
if (!response.ok) {
throw new Error(`Failed to create Browserless session: ${response.statusText}`);
}
let session: { connect: string; stop: string };
try {
session = (await response.json()) as { connect: string; stop: string };
} catch (error) {
throw new Error(
`Failed to parse Browserless session response: ${error instanceof Error ? error.message : String(error)}`
);
}
if (!session.connect || !session.stop) {
throw new Error(
`Invalid Browserless session response: missing ${!session.connect ? 'connect' : 'stop'}`
);
}
const browser = await chromium.connectOverCDP(session.connect).catch(() => {
throw new Error('Failed to connect to Browserless session via CDP');
});
try {
const contexts = browser.contexts();
let context: BrowserContext;
let page: Page;
if (contexts.length === 0) {
context = await browser.newContext();
page = await context.newPage();
} else {
context = contexts[0];
const pages = context.pages();
page = pages[0] ?? (await context.newPage());
}
this.browser = browser;
this.browserlessStopUrl = session.stop;
context.setDefaultTimeout(getDefaultTimeout());
this.contexts.push(context);
this.setupContextTracking(context);
await this.ensureDomainFilter(context);
await this.sanitizeExistingPages([page]);
this.pages.push(page);
this.activePageIndex = 0;
this.setupPageTracking(page);
} catch (error) {
await this.closeBrowserlessSession(session.stop).catch((sessionError) => {
console.error('Failed to close Browserless session during cleanup:', sessionError);
});
this.browserlessStopUrl = null;
throw error;
}
}
/**
* Launch the browser with the specified options
* If already launched, this is a no-op (browser stays open)
@@ -1324,6 +1456,10 @@ export class BrowserManager {
await this.connectToKernel();
return;
}
if (provider === 'browserless') {
await this.connectToBrowserless();
return;
}
if (this.downloadPath) {
const resolved = path.resolve(this.downloadPath);
@@ -2579,6 +2715,11 @@ export class BrowserManager {
}
);
this.browser = null;
} else if (this.browserlessStopUrl) {
await this.closeBrowserlessSession(this.browserlessStopUrl).catch((error) => {
console.error('Failed to close Browserless session:', error);
});
this.browser = null;
} else if (this.cdpEndpoint !== null) {
// CDP: only disconnect, don't close external app's pages
if (this.browser) {
@@ -2609,6 +2750,7 @@ export class BrowserManager {
this.browserUseApiKey = null;
this.kernelSessionId = null;
this.kernelApiKey = null;
this.browserlessStopUrl = null;
this.isPersistentContext = false;
this.activePageIndex = 0;
this.colorScheme = null;