launch.ts (16264B)
1 /** 2 * @license 3 * Copyright 2023 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import childProcess from 'node:child_process'; 8 import {accessSync} from 'node:fs'; 9 import os from 'node:os'; 10 import readline from 'node:readline'; 11 12 import { 13 type Browser, 14 type BrowserPlatform, 15 resolveSystemExecutablePath, 16 type ChromeReleaseChannel, 17 executablePathByBrowser, 18 } from './browser-data/browser-data.js'; 19 import {Cache} from './Cache.js'; 20 import {debug} from './debug.js'; 21 import {detectBrowserPlatform} from './detectPlatform.js'; 22 23 const debugLaunch = debug('puppeteer:browsers:launcher'); 24 25 /** 26 * @public 27 */ 28 export interface ComputeExecutablePathOptions { 29 /** 30 * Root path to the storage directory. 31 * 32 * Can be set to `null` if the executable path should be relative 33 * to the extracted download location. E.g. `./chrome-linux64/chrome`. 34 */ 35 cacheDir: string | null; 36 /** 37 * Determines which platform the browser will be suited for. 38 * 39 * @defaultValue **Auto-detected.** 40 */ 41 platform?: BrowserPlatform; 42 /** 43 * Determines which browser to launch. 44 */ 45 browser: Browser; 46 /** 47 * Determines which buildId to download. BuildId should uniquely identify 48 * binaries and they are used for caching. 49 */ 50 buildId: string; 51 } 52 53 /** 54 * @public 55 */ 56 export function computeExecutablePath( 57 options: ComputeExecutablePathOptions, 58 ): string { 59 if (options.cacheDir === null) { 60 options.platform ??= detectBrowserPlatform(); 61 if (options.platform === undefined) { 62 throw new Error( 63 `No platform specified. Couldn't auto-detect browser platform.`, 64 ); 65 } 66 return executablePathByBrowser[options.browser]( 67 options.platform, 68 options.buildId, 69 ); 70 } 71 72 return new Cache(options.cacheDir).computeExecutablePath(options); 73 } 74 75 /** 76 * @public 77 */ 78 export interface SystemOptions { 79 /** 80 * Determines which platform the browser will be suited for. 81 * 82 * @defaultValue **Auto-detected.** 83 */ 84 platform?: BrowserPlatform; 85 /** 86 * Determines which browser to launch. 87 */ 88 browser: Browser; 89 /** 90 * Release channel to look for on the system. 91 */ 92 channel: ChromeReleaseChannel; 93 } 94 95 /** 96 * Returns a path to a system-wide Chrome installation given a release channel 97 * name by checking known installation locations (using 98 * https://pptr.dev/browsers-api/browsers.computesystemexecutablepath/). If 99 * Chrome instance is not found at the expected path, an error is thrown. 100 * 101 * @public 102 */ 103 export function computeSystemExecutablePath(options: SystemOptions): string { 104 options.platform ??= detectBrowserPlatform(); 105 if (!options.platform) { 106 throw new Error( 107 `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})`, 108 ); 109 } 110 const path = resolveSystemExecutablePath( 111 options.browser, 112 options.platform, 113 options.channel, 114 ); 115 try { 116 accessSync(path); 117 } catch { 118 throw new Error( 119 `Could not find Google Chrome executable for channel '${options.channel}' at '${path}'.`, 120 ); 121 } 122 return path; 123 } 124 125 /** 126 * @public 127 */ 128 export interface LaunchOptions { 129 /** 130 * Absolute path to the browser's executable. 131 */ 132 executablePath: string; 133 /** 134 * Configures stdio streams to open two additional streams for automation over 135 * those streams instead of WebSocket. 136 * 137 * @defaultValue `false`. 138 */ 139 pipe?: boolean; 140 /** 141 * If true, forwards the browser's process stdout and stderr to the Node's 142 * process stdout and stderr. 143 * 144 * @defaultValue `false`. 145 */ 146 dumpio?: boolean; 147 /** 148 * Additional arguments to pass to the executable when launching. 149 */ 150 args?: string[]; 151 /** 152 * Environment variables to set for the browser process. 153 */ 154 env?: Record<string, string | undefined>; 155 /** 156 * Handles SIGINT in the Node process and tries to kill the browser process. 157 * 158 * @defaultValue `true`. 159 */ 160 handleSIGINT?: boolean; 161 /** 162 * Handles SIGTERM in the Node process and tries to gracefully close the browser 163 * process. 164 * 165 * @defaultValue `true`. 166 */ 167 handleSIGTERM?: boolean; 168 /** 169 * Handles SIGHUP in the Node process and tries to gracefully close the browser process. 170 * 171 * @defaultValue `true`. 172 */ 173 handleSIGHUP?: boolean; 174 /** 175 * Whether to spawn process in the {@link https://nodejs.org/api/child_process.html#optionsdetached | detached} 176 * mode. 177 * 178 * @defaultValue `true` except on Windows. 179 */ 180 detached?: boolean; 181 /** 182 * A callback to run after the browser process exits or before the process 183 * will be closed via the {@link Process.close} call (including when handling 184 * signals). The callback is only run once. 185 */ 186 onExit?: () => Promise<void>; 187 } 188 189 /** 190 * Launches a browser process according to {@link LaunchOptions}. 191 * 192 * @public 193 */ 194 export function launch(opts: LaunchOptions): Process { 195 return new Process(opts); 196 } 197 198 /** 199 * @public 200 */ 201 export const CDP_WEBSOCKET_ENDPOINT_REGEX = 202 /^DevTools listening on (ws:\/\/.*)$/; 203 204 /** 205 * @public 206 */ 207 export const WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX = 208 /^WebDriver BiDi listening on (ws:\/\/.*)$/; 209 210 type EventHandler = (...args: any[]) => void; 211 const processListeners = new Map<string, EventHandler[]>(); 212 const dispatchers = { 213 exit: (...args: any[]) => { 214 processListeners.get('exit')?.forEach(handler => { 215 return handler(...args); 216 }); 217 }, 218 SIGINT: (...args: any[]) => { 219 processListeners.get('SIGINT')?.forEach(handler => { 220 return handler(...args); 221 }); 222 }, 223 SIGHUP: (...args: any[]) => { 224 processListeners.get('SIGHUP')?.forEach(handler => { 225 return handler(...args); 226 }); 227 }, 228 SIGTERM: (...args: any[]) => { 229 processListeners.get('SIGTERM')?.forEach(handler => { 230 return handler(...args); 231 }); 232 }, 233 }; 234 235 function subscribeToProcessEvent( 236 event: 'exit' | 'SIGINT' | 'SIGHUP' | 'SIGTERM', 237 handler: EventHandler, 238 ): void { 239 const listeners = processListeners.get(event) || []; 240 if (listeners.length === 0) { 241 process.on(event, dispatchers[event]); 242 } 243 listeners.push(handler); 244 processListeners.set(event, listeners); 245 } 246 247 function unsubscribeFromProcessEvent( 248 event: 'exit' | 'SIGINT' | 'SIGHUP' | 'SIGTERM', 249 handler: EventHandler, 250 ): void { 251 const listeners = processListeners.get(event) || []; 252 const existingListenerIdx = listeners.indexOf(handler); 253 if (existingListenerIdx === -1) { 254 return; 255 } 256 listeners.splice(existingListenerIdx, 1); 257 processListeners.set(event, listeners); 258 if (listeners.length === 0) { 259 process.off(event, dispatchers[event]); 260 } 261 } 262 263 /** 264 * @public 265 */ 266 export class Process { 267 #executablePath; 268 #args: string[]; 269 #browserProcess: childProcess.ChildProcess; 270 #exited = false; 271 // The browser process can be closed externally or from the driver process. We 272 // need to invoke the hooks only once though but we don't know how many times 273 // we will be invoked. 274 #hooksRan = false; 275 #onExitHook = async () => {}; 276 #browserProcessExiting: Promise<void>; 277 278 constructor(opts: LaunchOptions) { 279 this.#executablePath = opts.executablePath; 280 this.#args = opts.args ?? []; 281 282 opts.pipe ??= false; 283 opts.dumpio ??= false; 284 opts.handleSIGINT ??= true; 285 opts.handleSIGTERM ??= true; 286 opts.handleSIGHUP ??= true; 287 // On non-windows platforms, `detached: true` makes child process a 288 // leader of a new process group, making it possible to kill child 289 // process tree with `.kill(-pid)` command. @see 290 // https://nodejs.org/api/child_process.html#child_process_options_detached 291 opts.detached ??= process.platform !== 'win32'; 292 293 const stdio = this.#configureStdio({ 294 pipe: opts.pipe, 295 dumpio: opts.dumpio, 296 }); 297 298 const env = opts.env || {}; 299 300 debugLaunch(`Launching ${this.#executablePath} ${this.#args.join(' ')}`, { 301 detached: opts.detached, 302 env: Object.keys(env).reduce<Record<string, string | undefined>>( 303 (res, key) => { 304 if (key.toLowerCase().startsWith('puppeteer_')) { 305 res[key] = env[key]; 306 } 307 return res; 308 }, 309 {}, 310 ), 311 stdio, 312 }); 313 314 this.#browserProcess = childProcess.spawn( 315 this.#executablePath, 316 this.#args, 317 { 318 detached: opts.detached, 319 env, 320 stdio, 321 }, 322 ); 323 324 debugLaunch(`Launched ${this.#browserProcess.pid}`); 325 if (opts.dumpio) { 326 this.#browserProcess.stderr?.pipe(process.stderr); 327 this.#browserProcess.stdout?.pipe(process.stdout); 328 } 329 subscribeToProcessEvent('exit', this.#onDriverProcessExit); 330 if (opts.handleSIGINT) { 331 subscribeToProcessEvent('SIGINT', this.#onDriverProcessSignal); 332 } 333 if (opts.handleSIGTERM) { 334 subscribeToProcessEvent('SIGTERM', this.#onDriverProcessSignal); 335 } 336 if (opts.handleSIGHUP) { 337 subscribeToProcessEvent('SIGHUP', this.#onDriverProcessSignal); 338 } 339 if (opts.onExit) { 340 this.#onExitHook = opts.onExit; 341 } 342 this.#browserProcessExiting = new Promise((resolve, reject) => { 343 this.#browserProcess.once('exit', async () => { 344 debugLaunch(`Browser process ${this.#browserProcess.pid} onExit`); 345 this.#clearListeners(); 346 this.#exited = true; 347 try { 348 await this.#runHooks(); 349 } catch (err) { 350 reject(err); 351 return; 352 } 353 resolve(); 354 }); 355 }); 356 } 357 358 async #runHooks() { 359 if (this.#hooksRan) { 360 return; 361 } 362 this.#hooksRan = true; 363 await this.#onExitHook(); 364 } 365 366 get nodeProcess(): childProcess.ChildProcess { 367 return this.#browserProcess; 368 } 369 370 #configureStdio(opts: { 371 pipe: boolean; 372 dumpio: boolean; 373 }): Array<'ignore' | 'pipe'> { 374 if (opts.pipe) { 375 if (opts.dumpio) { 376 return ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; 377 } else { 378 return ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; 379 } 380 } else { 381 if (opts.dumpio) { 382 return ['pipe', 'pipe', 'pipe']; 383 } else { 384 return ['pipe', 'ignore', 'pipe']; 385 } 386 } 387 } 388 389 #clearListeners(): void { 390 unsubscribeFromProcessEvent('exit', this.#onDriverProcessExit); 391 unsubscribeFromProcessEvent('SIGINT', this.#onDriverProcessSignal); 392 unsubscribeFromProcessEvent('SIGTERM', this.#onDriverProcessSignal); 393 unsubscribeFromProcessEvent('SIGHUP', this.#onDriverProcessSignal); 394 } 395 396 #onDriverProcessExit = (_code: number) => { 397 this.kill(); 398 }; 399 400 #onDriverProcessSignal = (signal: string): void => { 401 switch (signal) { 402 case 'SIGINT': 403 this.kill(); 404 process.exit(130); 405 case 'SIGTERM': 406 case 'SIGHUP': 407 void this.close(); 408 break; 409 } 410 }; 411 412 async close(): Promise<void> { 413 await this.#runHooks(); 414 if (!this.#exited) { 415 this.kill(); 416 } 417 return await this.#browserProcessExiting; 418 } 419 420 hasClosed(): Promise<void> { 421 return this.#browserProcessExiting; 422 } 423 424 kill(): void { 425 debugLaunch(`Trying to kill ${this.#browserProcess.pid}`); 426 // If the process failed to launch (for example if the browser executable path 427 // is invalid), then the process does not get a pid assigned. A call to 428 // `proc.kill` would error, as the `pid` to-be-killed can not be found. 429 if ( 430 this.#browserProcess && 431 this.#browserProcess.pid && 432 pidExists(this.#browserProcess.pid) 433 ) { 434 try { 435 debugLaunch(`Browser process ${this.#browserProcess.pid} exists`); 436 if (process.platform === 'win32') { 437 try { 438 childProcess.execSync( 439 `taskkill /pid ${this.#browserProcess.pid} /T /F`, 440 ); 441 } catch (error) { 442 debugLaunch( 443 `Killing ${this.#browserProcess.pid} using taskkill failed`, 444 error, 445 ); 446 // taskkill can fail to kill the process e.g. due to missing permissions. 447 // Let's kill the process via Node API. This delays killing of all child 448 // processes of `this.proc` until the main Node.js process dies. 449 this.#browserProcess.kill(); 450 } 451 } else { 452 // on linux the process group can be killed with the group id prefixed with 453 // a minus sign. The process group id is the group leader's pid. 454 const processGroupId = -this.#browserProcess.pid; 455 456 try { 457 process.kill(processGroupId, 'SIGKILL'); 458 } catch (error) { 459 debugLaunch( 460 `Killing ${this.#browserProcess.pid} using process.kill failed`, 461 error, 462 ); 463 // Killing the process group can fail due e.g. to missing permissions. 464 // Let's kill the process via Node API. This delays killing of all child 465 // processes of `this.proc` until the main Node.js process dies. 466 this.#browserProcess.kill('SIGKILL'); 467 } 468 } 469 } catch (error) { 470 throw new Error( 471 `${PROCESS_ERROR_EXPLANATION}\nError cause: ${ 472 isErrorLike(error) ? error.stack : error 473 }`, 474 ); 475 } 476 } 477 this.#clearListeners(); 478 } 479 480 waitForLineOutput(regex: RegExp, timeout = 0): Promise<string> { 481 if (!this.#browserProcess.stderr) { 482 throw new Error('`browserProcess` does not have stderr.'); 483 } 484 const rl = readline.createInterface(this.#browserProcess.stderr); 485 let stderr = ''; 486 487 return new Promise((resolve, reject) => { 488 rl.on('line', onLine); 489 rl.on('close', onClose); 490 this.#browserProcess.on('exit', onClose); 491 this.#browserProcess.on('error', onClose); 492 const timeoutId = 493 timeout > 0 ? setTimeout(onTimeout, timeout) : undefined; 494 495 const cleanup = (): void => { 496 clearTimeout(timeoutId); 497 rl.off('line', onLine); 498 rl.off('close', onClose); 499 rl.close(); 500 this.#browserProcess.off('exit', onClose); 501 this.#browserProcess.off('error', onClose); 502 }; 503 504 function onClose(error?: Error): void { 505 cleanup(); 506 reject( 507 new Error( 508 [ 509 `Failed to launch the browser process!${ 510 error ? ' ' + error.message : '' 511 }`, 512 stderr, 513 '', 514 'TROUBLESHOOTING: https://pptr.dev/troubleshooting', 515 '', 516 ].join('\n'), 517 ), 518 ); 519 } 520 521 function onTimeout(): void { 522 cleanup(); 523 reject( 524 new TimeoutError( 525 `Timed out after ${timeout} ms while waiting for the WS endpoint URL to appear in stdout!`, 526 ), 527 ); 528 } 529 530 function onLine(line: string): void { 531 stderr += line + '\n'; 532 const match = line.match(regex); 533 if (!match) { 534 return; 535 } 536 cleanup(); 537 // The RegExp matches, so this will obviously exist. 538 resolve(match[1]!); 539 } 540 }); 541 } 542 } 543 544 const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary. 545 This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser. 546 Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed. 547 If you think this is a bug, please report it on the Puppeteer issue tracker.`; 548 549 /** 550 * @internal 551 */ 552 function pidExists(pid: number): boolean { 553 try { 554 return process.kill(pid, 0); 555 } catch (error) { 556 if (isErrnoException(error)) { 557 if (error.code && error.code === 'ESRCH') { 558 return false; 559 } 560 } 561 throw error; 562 } 563 } 564 565 /** 566 * @internal 567 */ 568 export interface ErrorLike extends Error { 569 name: string; 570 message: string; 571 } 572 573 /** 574 * @internal 575 */ 576 export function isErrorLike(obj: unknown): obj is ErrorLike { 577 return ( 578 typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj 579 ); 580 } 581 /** 582 * @internal 583 */ 584 export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException { 585 return ( 586 isErrorLike(obj) && 587 ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj) 588 ); 589 } 590 591 /** 592 * @public 593 */ 594 export class TimeoutError extends Error { 595 /** 596 * @internal 597 */ 598 constructor(message?: string) { 599 super(message); 600 this.name = this.constructor.name; 601 Error.captureStackTrace(this, this.constructor); 602 } 603 }