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:
+142
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user