BrowserLauncher.ts (13548B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 import {existsSync} from 'node:fs'; 7 import {tmpdir} from 'node:os'; 8 import {join} from 'node:path'; 9 10 import { 11 Browser as InstalledBrowser, 12 CDP_WEBSOCKET_ENDPOINT_REGEX, 13 launch, 14 TimeoutError as BrowsersTimeoutError, 15 WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, 16 computeExecutablePath, 17 } from '@puppeteer/browsers'; 18 19 import { 20 firstValueFrom, 21 from, 22 map, 23 race, 24 timer, 25 } from '../../third_party/rxjs/rxjs.js'; 26 import type {Browser, BrowserCloseCallback} from '../api/Browser.js'; 27 import {CdpBrowser} from '../cdp/Browser.js'; 28 import {Connection} from '../cdp/Connection.js'; 29 import {TimeoutError} from '../common/Errors.js'; 30 import type {SupportedBrowser} from '../common/SupportedBrowser.js'; 31 import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; 32 import type {Viewport} from '../common/Viewport.js'; 33 34 import type {ChromeReleaseChannel, LaunchOptions} from './LaunchOptions.js'; 35 import {NodeWebSocketTransport as WebSocketTransport} from './NodeWebSocketTransport.js'; 36 import {PipeTransport} from './PipeTransport.js'; 37 import type {PuppeteerNode} from './PuppeteerNode.js'; 38 39 /** 40 * @internal 41 */ 42 export interface ResolvedLaunchArgs { 43 isTempUserDataDir: boolean; 44 userDataDir: string; 45 executablePath: string; 46 args: string[]; 47 } 48 49 /** 50 * Describes a launcher - a class that is able to create and launch a browser instance. 51 * 52 * @public 53 */ 54 export abstract class BrowserLauncher { 55 #browser: SupportedBrowser; 56 57 /** 58 * @internal 59 */ 60 puppeteer: PuppeteerNode; 61 62 /** 63 * @internal 64 */ 65 constructor(puppeteer: PuppeteerNode, browser: SupportedBrowser) { 66 this.puppeteer = puppeteer; 67 this.#browser = browser; 68 } 69 70 get browser(): SupportedBrowser { 71 return this.#browser; 72 } 73 74 async launch(options: LaunchOptions = {}): Promise<Browser> { 75 const { 76 dumpio = false, 77 enableExtensions = false, 78 env = process.env, 79 handleSIGINT = true, 80 handleSIGTERM = true, 81 handleSIGHUP = true, 82 acceptInsecureCerts = false, 83 defaultViewport = DEFAULT_VIEWPORT, 84 downloadBehavior, 85 slowMo = 0, 86 timeout = 30000, 87 waitForInitialPage = true, 88 protocolTimeout, 89 } = options; 90 91 let {protocol} = options; 92 93 // Default to 'webDriverBiDi' for Firefox. 94 if (this.#browser === 'firefox' && protocol === undefined) { 95 protocol = 'webDriverBiDi'; 96 } 97 98 if (this.#browser === 'firefox' && protocol === 'cdp') { 99 throw new Error('Connecting to Firefox using CDP is no longer supported'); 100 } 101 102 const launchArgs = await this.computeLaunchArguments({ 103 ...options, 104 protocol, 105 }); 106 107 if (!existsSync(launchArgs.executablePath)) { 108 throw new Error( 109 `Browser was not found at the configured executablePath (${launchArgs.executablePath})`, 110 ); 111 } 112 113 const usePipe = launchArgs.args.includes('--remote-debugging-pipe'); 114 115 const onProcessExit = async () => { 116 await this.cleanUserDataDir(launchArgs.userDataDir, { 117 isTemp: launchArgs.isTempUserDataDir, 118 }); 119 }; 120 121 if ( 122 this.#browser === 'firefox' && 123 protocol === 'webDriverBiDi' && 124 usePipe 125 ) { 126 throw new Error( 127 'Pipe connections are not supported with Firefox and WebDriver BiDi', 128 ); 129 } 130 131 const browserProcess = launch({ 132 executablePath: launchArgs.executablePath, 133 args: launchArgs.args, 134 handleSIGHUP, 135 handleSIGTERM, 136 handleSIGINT, 137 dumpio, 138 env, 139 pipe: usePipe, 140 onExit: onProcessExit, 141 }); 142 143 let browser: Browser; 144 let cdpConnection: Connection; 145 let closing = false; 146 147 const browserCloseCallback: BrowserCloseCallback = async () => { 148 if (closing) { 149 return; 150 } 151 closing = true; 152 await this.closeBrowser(browserProcess, cdpConnection); 153 }; 154 155 try { 156 if (this.#browser === 'firefox' && protocol === 'webDriverBiDi') { 157 browser = await this.createBiDiBrowser( 158 browserProcess, 159 browserCloseCallback, 160 { 161 timeout, 162 protocolTimeout, 163 slowMo, 164 defaultViewport, 165 acceptInsecureCerts, 166 }, 167 ); 168 } else { 169 if (usePipe) { 170 cdpConnection = await this.createCdpPipeConnection(browserProcess, { 171 timeout, 172 protocolTimeout, 173 slowMo, 174 }); 175 } else { 176 cdpConnection = await this.createCdpSocketConnection(browserProcess, { 177 timeout, 178 protocolTimeout, 179 slowMo, 180 }); 181 } 182 if (protocol === 'webDriverBiDi') { 183 browser = await this.createBiDiOverCdpBrowser( 184 browserProcess, 185 cdpConnection, 186 browserCloseCallback, 187 { 188 defaultViewport, 189 acceptInsecureCerts, 190 }, 191 ); 192 } else { 193 browser = await CdpBrowser._create( 194 cdpConnection, 195 [], 196 acceptInsecureCerts, 197 defaultViewport, 198 downloadBehavior, 199 browserProcess.nodeProcess, 200 browserCloseCallback, 201 options.targetFilter, 202 ); 203 } 204 } 205 } catch (error) { 206 void browserCloseCallback(); 207 if (error instanceof BrowsersTimeoutError) { 208 throw new TimeoutError(error.message); 209 } 210 throw error; 211 } 212 213 if (Array.isArray(enableExtensions)) { 214 if (this.#browser === 'chrome' && !usePipe) { 215 throw new Error( 216 'To use `enableExtensions` with a list of paths in Chrome, you must be connected with `--remote-debugging-pipe` (`pipe: true`).', 217 ); 218 } 219 220 await Promise.all([ 221 enableExtensions.map(path => { 222 return browser.installExtension(path); 223 }), 224 ]); 225 } 226 227 if (waitForInitialPage) { 228 await this.waitForPageTarget(browser, timeout); 229 } 230 231 return browser; 232 } 233 234 abstract executablePath( 235 channel?: ChromeReleaseChannel, 236 validatePath?: boolean, 237 ): string; 238 239 abstract defaultArgs(object: LaunchOptions): string[]; 240 241 /** 242 * @internal 243 */ 244 protected abstract computeLaunchArguments( 245 options: LaunchOptions, 246 ): Promise<ResolvedLaunchArgs>; 247 248 /** 249 * @internal 250 */ 251 protected abstract cleanUserDataDir( 252 path: string, 253 opts: {isTemp: boolean}, 254 ): Promise<void>; 255 256 /** 257 * @internal 258 */ 259 protected async closeBrowser( 260 browserProcess: ReturnType<typeof launch>, 261 cdpConnection?: Connection, 262 ): Promise<void> { 263 if (cdpConnection) { 264 // Attempt to close the browser gracefully 265 try { 266 await cdpConnection.closeBrowser(); 267 await browserProcess.hasClosed(); 268 } catch (error) { 269 debugError(error); 270 await browserProcess.close(); 271 } 272 } else { 273 // Wait for a possible graceful shutdown. 274 await firstValueFrom( 275 race( 276 from(browserProcess.hasClosed()), 277 timer(5000).pipe( 278 map(() => { 279 return from(browserProcess.close()); 280 }), 281 ), 282 ), 283 ); 284 } 285 } 286 287 /** 288 * @internal 289 */ 290 protected async waitForPageTarget( 291 browser: Browser, 292 timeout: number, 293 ): Promise<void> { 294 try { 295 await browser.waitForTarget( 296 t => { 297 return t.type() === 'page'; 298 }, 299 {timeout}, 300 ); 301 } catch (error) { 302 await browser.close(); 303 throw error; 304 } 305 } 306 307 /** 308 * @internal 309 */ 310 protected async createCdpSocketConnection( 311 browserProcess: ReturnType<typeof launch>, 312 opts: { 313 timeout: number; 314 protocolTimeout: number | undefined; 315 slowMo: number; 316 }, 317 ): Promise<Connection> { 318 const browserWSEndpoint = await browserProcess.waitForLineOutput( 319 CDP_WEBSOCKET_ENDPOINT_REGEX, 320 opts.timeout, 321 ); 322 const transport = await WebSocketTransport.create(browserWSEndpoint); 323 return new Connection( 324 browserWSEndpoint, 325 transport, 326 opts.slowMo, 327 opts.protocolTimeout, 328 ); 329 } 330 331 /** 332 * @internal 333 */ 334 protected async createCdpPipeConnection( 335 browserProcess: ReturnType<typeof launch>, 336 opts: { 337 timeout: number; 338 protocolTimeout: number | undefined; 339 slowMo: number; 340 }, 341 ): Promise<Connection> { 342 // stdio was assigned during start(), and the 'pipe' option there adds the 343 // 4th and 5th items to stdio array 344 const {3: pipeWrite, 4: pipeRead} = browserProcess.nodeProcess.stdio; 345 const transport = new PipeTransport( 346 pipeWrite as NodeJS.WritableStream, 347 pipeRead as NodeJS.ReadableStream, 348 ); 349 return new Connection('', transport, opts.slowMo, opts.protocolTimeout); 350 } 351 352 /** 353 * @internal 354 */ 355 protected async createBiDiOverCdpBrowser( 356 browserProcess: ReturnType<typeof launch>, 357 connection: Connection, 358 closeCallback: BrowserCloseCallback, 359 opts: { 360 defaultViewport: Viewport | null; 361 acceptInsecureCerts?: boolean; 362 }, 363 ): Promise<Browser> { 364 const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js'); 365 const bidiConnection = await BiDi.connectBidiOverCdp(connection); 366 return await BiDi.BidiBrowser.create({ 367 connection: bidiConnection, 368 cdpConnection: connection, 369 closeCallback, 370 process: browserProcess.nodeProcess, 371 defaultViewport: opts.defaultViewport, 372 acceptInsecureCerts: opts.acceptInsecureCerts, 373 }); 374 } 375 376 /** 377 * @internal 378 */ 379 protected async createBiDiBrowser( 380 browserProcess: ReturnType<typeof launch>, 381 closeCallback: BrowserCloseCallback, 382 opts: { 383 timeout: number; 384 protocolTimeout: number | undefined; 385 slowMo: number; 386 defaultViewport: Viewport | null; 387 acceptInsecureCerts?: boolean; 388 }, 389 ): Promise<Browser> { 390 const browserWSEndpoint = 391 (await browserProcess.waitForLineOutput( 392 WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, 393 opts.timeout, 394 )) + '/session'; 395 const transport = await WebSocketTransport.create(browserWSEndpoint); 396 const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js'); 397 const bidiConnection = new BiDi.BidiConnection( 398 browserWSEndpoint, 399 transport, 400 opts.slowMo, 401 opts.protocolTimeout, 402 ); 403 return await BiDi.BidiBrowser.create({ 404 connection: bidiConnection, 405 closeCallback, 406 process: browserProcess.nodeProcess, 407 defaultViewport: opts.defaultViewport, 408 acceptInsecureCerts: opts.acceptInsecureCerts, 409 }); 410 } 411 412 /** 413 * @internal 414 */ 415 protected getProfilePath(): string { 416 return join( 417 this.puppeteer.configuration.temporaryDirectory ?? tmpdir(), 418 `puppeteer_dev_${this.browser}_profile-`, 419 ); 420 } 421 422 /** 423 * @internal 424 */ 425 resolveExecutablePath( 426 headless?: boolean | 'shell', 427 validatePath = true, 428 ): string { 429 let executablePath = this.puppeteer.configuration.executablePath; 430 if (executablePath) { 431 if (validatePath && !existsSync(executablePath)) { 432 throw new Error( 433 `Tried to find the browser at the configured path (${executablePath}), but no executable was found.`, 434 ); 435 } 436 return executablePath; 437 } 438 439 function puppeteerBrowserToInstalledBrowser( 440 browser?: SupportedBrowser, 441 headless?: boolean | 'shell', 442 ) { 443 switch (browser) { 444 case 'chrome': 445 if (headless === 'shell') { 446 return InstalledBrowser.CHROMEHEADLESSSHELL; 447 } 448 return InstalledBrowser.CHROME; 449 case 'firefox': 450 return InstalledBrowser.FIREFOX; 451 } 452 return InstalledBrowser.CHROME; 453 } 454 455 const browserType = puppeteerBrowserToInstalledBrowser( 456 this.browser, 457 headless, 458 ); 459 460 executablePath = computeExecutablePath({ 461 cacheDir: this.puppeteer.defaultDownloadPath!, 462 browser: browserType, 463 buildId: this.puppeteer.browserVersion, 464 }); 465 466 if (validatePath && !existsSync(executablePath)) { 467 const configVersion = 468 this.puppeteer.configuration?.[this.browser]?.version; 469 if (configVersion) { 470 throw new Error( 471 `Tried to find the browser at the configured path (${executablePath}) for version ${configVersion}, but no executable was found.`, 472 ); 473 } 474 switch (this.browser) { 475 case 'chrome': 476 throw new Error( 477 `Could not find Chrome (ver. ${this.puppeteer.browserVersion}). This can occur if either\n` + 478 ` 1. you did not perform an installation before running the script (e.g. \`npx puppeteer browsers install ${browserType}\`) or\n` + 479 ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + 480 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.', 481 ); 482 case 'firefox': 483 throw new Error( 484 `Could not find Firefox (rev. ${this.puppeteer.browserVersion}). This can occur if either\n` + 485 ' 1. you did not perform an installation for Firefox before running the script (e.g. `npx puppeteer browsers install firefox`) or\n' + 486 ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + 487 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.', 488 ); 489 } 490 } 491 return executablePath; 492 } 493 }