/** * utils around puppeteer * * @module */ import { Browser, Cache, detectBrowserPlatform, getInstalledBrowsers, install, InstalledBrowser, resolveBuildId, } from "@puppeteer/browsers"; import * as Fs from "node:fs"; import * as Path from "node:path"; import process from "node:process"; import readline from "node:readline"; import { launch, type LaunchOptions as PuppeteerLaunchOptions, type SupportedBrowser as PuppeteerSupportedBrowser, } from "puppeteer-core"; import { getUserDataDir } from "./Env.js"; export type SupportedBrowser = `${PuppeteerSupportedBrowser}@${string}`; export type LaunchOptions = Omit< PuppeteerLaunchOptions, "browser" | "executablePath" > & { browser: SupportedBrowser; }; /** * makes sure the given browser is installed and returns it. */ export const ensureBrowserInstalled = async function ensureBrowserInstalled( browser: SupportedBrowser, opts = {} as { cacheDir?: string }, ): Promise { const cacheDir = process.env["PUPPETEER_BROWSERS_CACHE_DIR"] ?? opts?.cacheDir ?? Path.resolve(getUserDataDir() ?? process.cwd(), "puppeteer/browsers"); const cache = new Cache(cacheDir); const expected = await (async (supportedBrowser: SupportedBrowser) => { const [browser, browserVersion] = supportedBrowser.split("@") as [ Browser, string, ]; const platform = detectBrowserPlatform()!; const buildId = await resolveBuildId( Browser.CHROME, platform, browserVersion, ); return { browser, buildId, platform, }; })(browser); return getInstalledBrowsers({ cacheDir, }).then((browsers) => { const installed = browsers.find( (installed: InstalledBrowser): boolean => installed.browser === expected.browser && installed.buildId === expected.buildId, ); const installedPath = cache.installationDir( expected.browser, expected.platform, expected.buildId, ); const lockFilePath = Path.join(cacheDir, "download.lock"); Fs.mkdirSync(installedPath, { recursive: true }); Fs.mkdirSync(Path.dirname(lockFilePath), { recursive: true }); const readLock = () => { try { return JSON.parse(Fs.readFileSync(lockFilePath, "utf8")) as string[]; } catch { return []; } }; const writeLock = (lock: string[]) => Fs.writeFileSync(lockFilePath, JSON.stringify(lock), "utf8"); const updateLock = (updateFn: (current: string[]) => string[]) => writeLock(updateFn(readLock())); const isDownloadInProgress = readLock().includes(installedPath); async function installExpected() { updateLock((current) => current.concat(installedPath)); const downloadCacheDir = Path.join(cacheDir, crypto.randomUUID()); const downloaded = await install({ downloadProgressCallback: !process.stdout.isTTY || !!process.env["CI"] ? undefined : (received, total) => { const progress = ((received / total) * 100).toFixed(2); readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); process.stdout.write( `downloading ${expected.browser}@${expected.buildId}: ${progress}%`, ); if (received === total) { process.stdout.write("\n"); } }, cacheDir: downloadCacheDir, // random cache dir to prevent clashes with the target browser: expected.browser, buildId: expected.buildId, platform: expected.platform, }); Fs.renameSync(downloaded.path, installedPath); Fs.rmSync(downloadCacheDir, { recursive: true, force: true }); const cleanup = () => { updateLock((current) => current.filter((locked) => locked !== installedPath), ); }; process.on("SIGTERM", cleanup); process.on("SIGINT", cleanup); process.on("uncaughtException", cleanup); process.on("unhandledRejection", cleanup); process.on("exit", cleanup); cleanup(); return ensureBrowserInstalled(browser, opts); } async function waitForOtherDownload() { console.log( `waiting for other download to finish (${lockFilePath} exists. If you are sure no other instance is downloading a browser you can delete it.)`, ); await new Promise((resolve) => { const watcher = Fs.watch(lockFilePath, (event) => { if (event === "change" && !readLock().includes(installedPath)) { watcher.close(); resolve(); } }); }); return ensureBrowserInstalled(browser, opts); } return isDownloadInProgress ? waitForOtherDownload() : installed != null ? (() => { console.log("resolving", installed.executablePath); return Promise.resolve(installed); })() : installExpected(); }); }; /** * try to launch the browser as determined by the env BROWSER variable */ export const getBrowser = ( launchOpts: Partial = {}, opts = {} as { cacheDir?: string }, ) => { return ensureBrowserInstalled( launchOpts.browser ?? "chrome@canary", opts, ).then((installed) => { return launch({ ...launchOpts, browser: installed.browser === Browser.FIREFOX ? ("firefox" as const) : "chrome", executablePath: installed.executablePath, }).then((browser) => Object.assign(browser, { product: installed.browser }), ); }); };