552 lines
15 KiB
TypeScript
552 lines
15 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2017 Google Inc.
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
import {existsSync} from 'node:fs';
|
|
import {tmpdir} from 'node:os';
|
|
import {join} from 'node:path';
|
|
|
|
import {
|
|
Browser as InstalledBrowser,
|
|
CDP_WEBSOCKET_ENDPOINT_REGEX,
|
|
launch,
|
|
TimeoutError as BrowsersTimeoutError,
|
|
WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX,
|
|
computeExecutablePath,
|
|
} from '@puppeteer/browsers';
|
|
|
|
import {
|
|
firstValueFrom,
|
|
from,
|
|
map,
|
|
race,
|
|
timer,
|
|
} from '../../third_party/rxjs/rxjs.js';
|
|
import type {Browser, BrowserCloseCallback} from '../api/Browser.js';
|
|
import {CdpBrowser} from '../cdp/Browser.js';
|
|
import {Connection} from '../cdp/Connection.js';
|
|
import {TimeoutError} from '../common/Errors.js';
|
|
import type {SupportedBrowser} from '../common/SupportedBrowser.js';
|
|
import {debugError, DEFAULT_VIEWPORT} from '../common/util.js';
|
|
import type {Viewport} from '../common/Viewport.js';
|
|
import {
|
|
createIncrementalIdGenerator,
|
|
type GetIdFn,
|
|
} from '../util/incremental-id-generator.js';
|
|
|
|
import type {ChromeReleaseChannel, LaunchOptions} from './LaunchOptions.js';
|
|
import {NodeWebSocketTransport as WebSocketTransport} from './NodeWebSocketTransport.js';
|
|
import {PipeTransport} from './PipeTransport.js';
|
|
import type {PuppeteerNode} from './PuppeteerNode.js';
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
export interface ResolvedLaunchArgs {
|
|
isTempUserDataDir: boolean;
|
|
userDataDir: string;
|
|
executablePath: string;
|
|
args: string[];
|
|
}
|
|
|
|
/**
|
|
* Describes a launcher - a class that is able to create and launch a browser instance.
|
|
*
|
|
* @public
|
|
*/
|
|
export abstract class BrowserLauncher {
|
|
#browser: SupportedBrowser;
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
puppeteer: PuppeteerNode;
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
constructor(puppeteer: PuppeteerNode, browser: SupportedBrowser) {
|
|
this.puppeteer = puppeteer;
|
|
this.#browser = browser;
|
|
}
|
|
|
|
get browser(): SupportedBrowser {
|
|
return this.#browser;
|
|
}
|
|
|
|
async launch(options: LaunchOptions = {}): Promise<Browser> {
|
|
const {
|
|
dumpio = false,
|
|
enableExtensions = false,
|
|
env = process.env,
|
|
handleSIGINT = true,
|
|
handleSIGTERM = true,
|
|
handleSIGHUP = true,
|
|
acceptInsecureCerts = false,
|
|
networkEnabled = true,
|
|
defaultViewport = DEFAULT_VIEWPORT,
|
|
downloadBehavior,
|
|
slowMo = 0,
|
|
timeout = 30000,
|
|
waitForInitialPage = true,
|
|
protocolTimeout,
|
|
handleDevToolsAsPage,
|
|
idGenerator = createIncrementalIdGenerator(),
|
|
} = options;
|
|
|
|
let {protocol} = options;
|
|
|
|
// Default to 'webDriverBiDi' for Firefox.
|
|
if (this.#browser === 'firefox' && protocol === undefined) {
|
|
protocol = 'webDriverBiDi';
|
|
}
|
|
|
|
if (this.#browser === 'firefox' && protocol === 'cdp') {
|
|
throw new Error('Connecting to Firefox using CDP is no longer supported');
|
|
}
|
|
|
|
const launchArgs = await this.computeLaunchArguments({
|
|
...options,
|
|
protocol,
|
|
});
|
|
|
|
if (!existsSync(launchArgs.executablePath)) {
|
|
throw new Error(
|
|
`Browser was not found at the configured executablePath (${launchArgs.executablePath})`,
|
|
);
|
|
}
|
|
|
|
const usePipe = launchArgs.args.includes('--remote-debugging-pipe');
|
|
|
|
const onProcessExit = async () => {
|
|
await this.cleanUserDataDir(launchArgs.userDataDir, {
|
|
isTemp: launchArgs.isTempUserDataDir,
|
|
});
|
|
};
|
|
|
|
if (
|
|
this.#browser === 'firefox' &&
|
|
protocol === 'webDriverBiDi' &&
|
|
usePipe
|
|
) {
|
|
throw new Error(
|
|
'Pipe connections are not supported with Firefox and WebDriver BiDi',
|
|
);
|
|
}
|
|
|
|
const browserProcess = launch({
|
|
executablePath: launchArgs.executablePath,
|
|
args: launchArgs.args,
|
|
handleSIGHUP,
|
|
handleSIGTERM,
|
|
handleSIGINT,
|
|
dumpio,
|
|
env,
|
|
pipe: usePipe,
|
|
onExit: onProcessExit,
|
|
signal: options.signal,
|
|
});
|
|
|
|
let browser: Browser;
|
|
let cdpConnection: Connection;
|
|
let closing = false;
|
|
|
|
const browserCloseCallback: BrowserCloseCallback = async () => {
|
|
if (closing) {
|
|
return;
|
|
}
|
|
closing = true;
|
|
await this.closeBrowser(browserProcess, cdpConnection);
|
|
};
|
|
|
|
try {
|
|
if (this.#browser === 'firefox') {
|
|
browser = await this.createBiDiBrowser(
|
|
browserProcess,
|
|
browserCloseCallback,
|
|
{
|
|
timeout,
|
|
protocolTimeout,
|
|
slowMo,
|
|
defaultViewport,
|
|
acceptInsecureCerts,
|
|
networkEnabled,
|
|
idGenerator,
|
|
},
|
|
);
|
|
} else {
|
|
if (usePipe) {
|
|
cdpConnection = await this.createCdpPipeConnection(browserProcess, {
|
|
timeout,
|
|
protocolTimeout,
|
|
slowMo,
|
|
idGenerator,
|
|
});
|
|
} else {
|
|
cdpConnection = await this.createCdpSocketConnection(browserProcess, {
|
|
timeout,
|
|
protocolTimeout,
|
|
slowMo,
|
|
idGenerator,
|
|
});
|
|
}
|
|
|
|
if (protocol === 'webDriverBiDi') {
|
|
browser = await this.createBiDiOverCdpBrowser(
|
|
browserProcess,
|
|
cdpConnection,
|
|
browserCloseCallback,
|
|
{
|
|
defaultViewport,
|
|
acceptInsecureCerts,
|
|
networkEnabled,
|
|
},
|
|
);
|
|
} else {
|
|
browser = await CdpBrowser._create(
|
|
cdpConnection,
|
|
[],
|
|
acceptInsecureCerts,
|
|
defaultViewport,
|
|
downloadBehavior,
|
|
browserProcess.nodeProcess,
|
|
browserCloseCallback,
|
|
options.targetFilter,
|
|
undefined,
|
|
undefined,
|
|
networkEnabled,
|
|
handleDevToolsAsPage,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
void browserCloseCallback();
|
|
const logs = browserProcess.getRecentLogs().join('\n');
|
|
if (
|
|
logs.includes(
|
|
'Failed to create a ProcessSingleton for your profile directory',
|
|
) ||
|
|
// On Windows we will not get logs due to the singleton process
|
|
// handover. See
|
|
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/process_singleton_win.cc;l=46;drc=fc7952f0422b5073515a205a04ec9c3a1ae81658
|
|
(process.platform === 'win32' &&
|
|
existsSync(join(launchArgs.userDataDir, 'lockfile')))
|
|
) {
|
|
throw new Error(
|
|
`The browser is already running for ${launchArgs.userDataDir}. Use a different \`userDataDir\` or stop the running browser first.`,
|
|
);
|
|
}
|
|
if (logs.includes('Missing X server') && options.headless === false) {
|
|
throw new Error(
|
|
`Missing X server to start the headful browser. Either set headless to true or use xvfb-run to run your Puppeteer script.`,
|
|
);
|
|
}
|
|
if (error instanceof BrowsersTimeoutError) {
|
|
throw new TimeoutError(error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (Array.isArray(enableExtensions)) {
|
|
if (this.#browser === 'chrome' && !usePipe) {
|
|
throw new Error(
|
|
'To use `enableExtensions` with a list of paths in Chrome, you must be connected with `--remote-debugging-pipe` (`pipe: true`).',
|
|
);
|
|
}
|
|
|
|
await Promise.all([
|
|
enableExtensions.map(path => {
|
|
return browser.installExtension(path);
|
|
}),
|
|
]);
|
|
}
|
|
|
|
if (waitForInitialPage) {
|
|
await this.waitForPageTarget(browser, timeout);
|
|
}
|
|
|
|
return browser;
|
|
}
|
|
|
|
abstract executablePath(
|
|
channel?: ChromeReleaseChannel,
|
|
validatePath?: boolean,
|
|
): string;
|
|
|
|
abstract defaultArgs(object: LaunchOptions): string[];
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected abstract computeLaunchArguments(
|
|
options: LaunchOptions,
|
|
): Promise<ResolvedLaunchArgs>;
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected abstract cleanUserDataDir(
|
|
path: string,
|
|
opts: {isTemp: boolean},
|
|
): Promise<void>;
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected async closeBrowser(
|
|
browserProcess: ReturnType<typeof launch>,
|
|
cdpConnection?: Connection,
|
|
): Promise<void> {
|
|
if (cdpConnection) {
|
|
// Attempt to close the browser gracefully
|
|
try {
|
|
await cdpConnection.closeBrowser();
|
|
await browserProcess.hasClosed();
|
|
} catch (error) {
|
|
debugError(error);
|
|
await browserProcess.close();
|
|
}
|
|
} else {
|
|
// Wait for a possible graceful shutdown.
|
|
await firstValueFrom(
|
|
race(
|
|
from(browserProcess.hasClosed()),
|
|
timer(5000).pipe(
|
|
map(() => {
|
|
return from(browserProcess.close());
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected async waitForPageTarget(
|
|
browser: Browser,
|
|
timeout: number,
|
|
): Promise<void> {
|
|
try {
|
|
await browser.waitForTarget(
|
|
t => {
|
|
return t.type() === 'page';
|
|
},
|
|
{timeout},
|
|
);
|
|
} catch (error) {
|
|
await browser.close();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected async createCdpSocketConnection(
|
|
browserProcess: ReturnType<typeof launch>,
|
|
opts: {
|
|
timeout: number;
|
|
protocolTimeout: number | undefined;
|
|
slowMo: number;
|
|
idGenerator: GetIdFn;
|
|
},
|
|
): Promise<Connection> {
|
|
const browserWSEndpoint = await browserProcess.waitForLineOutput(
|
|
CDP_WEBSOCKET_ENDPOINT_REGEX,
|
|
opts.timeout,
|
|
);
|
|
const transport = await WebSocketTransport.create(browserWSEndpoint);
|
|
return new Connection(
|
|
browserWSEndpoint,
|
|
transport,
|
|
opts.slowMo,
|
|
opts.protocolTimeout,
|
|
/* rawErrors */ false,
|
|
opts.idGenerator,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected async createCdpPipeConnection(
|
|
browserProcess: ReturnType<typeof launch>,
|
|
opts: {
|
|
timeout: number;
|
|
protocolTimeout: number | undefined;
|
|
slowMo: number;
|
|
idGenerator: GetIdFn;
|
|
},
|
|
): Promise<Connection> {
|
|
// stdio was assigned during start(), and the 'pipe' option there adds the
|
|
// 4th and 5th items to stdio array
|
|
const {3: pipeWrite, 4: pipeRead} = browserProcess.nodeProcess.stdio;
|
|
const transport = new PipeTransport(
|
|
pipeWrite as NodeJS.WritableStream,
|
|
pipeRead as NodeJS.ReadableStream,
|
|
);
|
|
return new Connection(
|
|
'',
|
|
transport,
|
|
opts.slowMo,
|
|
opts.protocolTimeout,
|
|
/* rawErrors */ false,
|
|
opts.idGenerator,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected async createBiDiOverCdpBrowser(
|
|
browserProcess: ReturnType<typeof launch>,
|
|
cdpConnection: Connection,
|
|
closeCallback: BrowserCloseCallback,
|
|
opts: {
|
|
defaultViewport: Viewport | null;
|
|
acceptInsecureCerts?: boolean;
|
|
networkEnabled: boolean;
|
|
},
|
|
): Promise<Browser> {
|
|
const bidiOnly = process.env['PUPPETEER_WEBDRIVER_BIDI_ONLY'] === 'true';
|
|
const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js');
|
|
const bidiConnection = await BiDi.connectBidiOverCdp(cdpConnection);
|
|
return await BiDi.BidiBrowser.create({
|
|
connection: bidiConnection,
|
|
// Do not provide CDP connection to Browser, if BiDi-only mode is enabled. This
|
|
// would restrict Browser to use only BiDi endpoint.
|
|
cdpConnection: bidiOnly ? undefined : cdpConnection,
|
|
closeCallback,
|
|
process: browserProcess.nodeProcess,
|
|
defaultViewport: opts.defaultViewport,
|
|
acceptInsecureCerts: opts.acceptInsecureCerts,
|
|
networkEnabled: opts.networkEnabled,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected async createBiDiBrowser(
|
|
browserProcess: ReturnType<typeof launch>,
|
|
closeCallback: BrowserCloseCallback,
|
|
opts: {
|
|
timeout: number;
|
|
protocolTimeout: number | undefined;
|
|
slowMo: number;
|
|
idGenerator: GetIdFn;
|
|
defaultViewport: Viewport | null;
|
|
acceptInsecureCerts?: boolean;
|
|
networkEnabled?: boolean;
|
|
},
|
|
): Promise<Browser> {
|
|
const browserWSEndpoint =
|
|
(await browserProcess.waitForLineOutput(
|
|
WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX,
|
|
opts.timeout,
|
|
)) + '/session';
|
|
const transport = await WebSocketTransport.create(browserWSEndpoint);
|
|
const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js');
|
|
const bidiConnection = new BiDi.BidiConnection(
|
|
browserWSEndpoint,
|
|
transport,
|
|
opts.idGenerator,
|
|
opts.slowMo,
|
|
opts.protocolTimeout,
|
|
);
|
|
return await BiDi.BidiBrowser.create({
|
|
connection: bidiConnection,
|
|
closeCallback,
|
|
process: browserProcess.nodeProcess,
|
|
defaultViewport: opts.defaultViewport,
|
|
acceptInsecureCerts: opts.acceptInsecureCerts,
|
|
networkEnabled: opts.networkEnabled ?? true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
protected getProfilePath(): string {
|
|
return join(
|
|
this.puppeteer.configuration.temporaryDirectory ?? tmpdir(),
|
|
`puppeteer_dev_${this.browser}_profile-`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
resolveExecutablePath(
|
|
headless?: boolean | 'shell',
|
|
validatePath = true,
|
|
): string {
|
|
let executablePath = this.puppeteer.configuration.executablePath;
|
|
if (executablePath) {
|
|
if (validatePath && !existsSync(executablePath)) {
|
|
throw new Error(
|
|
`Tried to find the browser at the configured path (${executablePath}), but no executable was found.`,
|
|
);
|
|
}
|
|
return executablePath;
|
|
}
|
|
|
|
function puppeteerBrowserToInstalledBrowser(
|
|
browser?: SupportedBrowser,
|
|
headless?: boolean | 'shell',
|
|
) {
|
|
switch (browser) {
|
|
case 'chrome':
|
|
if (headless === 'shell') {
|
|
return InstalledBrowser.CHROMEHEADLESSSHELL;
|
|
}
|
|
return InstalledBrowser.CHROME;
|
|
case 'firefox':
|
|
return InstalledBrowser.FIREFOX;
|
|
}
|
|
return InstalledBrowser.CHROME;
|
|
}
|
|
|
|
const browserType = puppeteerBrowserToInstalledBrowser(
|
|
this.browser,
|
|
headless,
|
|
);
|
|
|
|
executablePath = computeExecutablePath({
|
|
cacheDir: this.puppeteer.defaultDownloadPath!,
|
|
browser: browserType,
|
|
buildId: this.puppeteer.browserVersion,
|
|
});
|
|
|
|
if (validatePath && !existsSync(executablePath)) {
|
|
const configVersion =
|
|
this.puppeteer.configuration?.[this.browser]?.version;
|
|
if (configVersion) {
|
|
throw new Error(
|
|
`Tried to find the browser at the configured path (${executablePath}) for version ${configVersion}, but no executable was found.`,
|
|
);
|
|
}
|
|
switch (this.browser) {
|
|
case 'chrome':
|
|
throw new Error(
|
|
`Could not find Chrome (ver. ${this.puppeteer.browserVersion}). This can occur if either\n` +
|
|
` 1. you did not perform an installation before running the script (e.g. \`npx puppeteer browsers install ${browserType}\`) or\n` +
|
|
` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` +
|
|
'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.',
|
|
);
|
|
case 'firefox':
|
|
throw new Error(
|
|
`Could not find Firefox (rev. ${this.puppeteer.browserVersion}). This can occur if either\n` +
|
|
' 1. you did not perform an installation for Firefox before running the script (e.g. `npx puppeteer browsers install firefox`) or\n' +
|
|
` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` +
|
|
'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.',
|
|
);
|
|
}
|
|
}
|
|
return executablePath;
|
|
}
|
|
}
|