@softvisio/core
Version:
Softisio core
440 lines (368 loc) • 14.2 kB
JavaScript
import childProcess from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import Events from "#lib/events";
import externalResources from "#lib/external-resources";
import { TmpDir } from "#lib/tmp";
const CHROME_HEADLESS_SHELL = await externalResources
.add( `softvisio-node/core/resources/google-chrome-headless-shell-stable-${ process.platform }-${ process.arch }`, {
"autoUpdate": false,
} )
.check( {
"install": false,
} ),
CHROME_FOR_TESTING = await externalResources
.add( `softvisio-node/core/resources/google-chrome-for-testing-stable-${ process.platform }-${ process.arch }`, {
"autoUpdate": false,
} )
.check( {
"install": false,
} );
const PREFERRED_BROWSERS = {
"darwin": "chrome",
"linux": "chrome",
"win32": "msedge",
},
PREFERRED_CHROMIUM_BROWSERS = {
"darwin": "chrome",
"linux": "chrome",
"win32": "msedge",
},
PREFIXES = {
"darwin": [ "/" ],
"linux": [ "/" ],
"win32": [
//
process.env.LOCALAPPDATA,
process.env.PROGRAMFILES,
process.env[ "PROGRAMFILES(X86)" ],
],
},
BROWSERS = {
"chrome": {
"chromium": true,
"channel": "chrome",
"paths": {
"darwin": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"linux": "/opt/google/chrome/chrome",
"win32": "Google\\Chrome\\Application\\chrome.exe",
},
},
"chrome-for-testing": {
"chromium": true,
"channel": "chrome",
"paths": {
"darwin": CHROME_FOR_TESTING.getResourcePath( "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" ),
"linux": CHROME_FOR_TESTING.getResourcePath( "chrome" ),
"win32": CHROME_FOR_TESTING.getResourcePath( "chrome.exe" ),
},
},
"chrome-headless-shell": {
"chromium": true,
"channel": "chrome",
"headless": true,
"paths": {
"darwin": CHROME_HEADLESS_SHELL.getResourcePath( "chrome-headless-shell" ),
"linux": CHROME_HEADLESS_SHELL.getResourcePath( "chrome-headless-shell" ),
"win32": CHROME_HEADLESS_SHELL.getResourcePath( "chrome-headless-shell.exe" ),
},
},
"chromium": {
"chromium": true,
"channel": "chromium",
"paths": {
"linux": "/usr/bin/chromium-browser",
},
},
"msedge": {
"chromium": true,
"channel": "msedge",
"paths": {
"darwin": "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"linux": "/opt/microsoft/msedge/msedge",
"win32": "Microsoft\\Edge\\Application\\msedge.exe",
},
},
"vivaldi": {
"chromium": true,
"channel": "chrome",
"paths": {
"darwin": "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi",
"linux": "/opt/vivaldi/vivaldi",
"win32": "Application\\vivaldi\\vivaldi.exe",
},
},
},
// https://www.remotion.dev/docs/miscellaneous/linux-dependencies
DEPENDENCIES = {
"ubuntu-24.04": [ "ttf-mscorefonts-installer", "libnss3", "libdbus-1-3", "libatk1.0-0", "libasound2t64", "libxrandr2", "libxkbcommon-dev", "libxfixes3", "libxcomposite1", "libxdamage1", "libgbm-dev", "libatk-bridge2.0-0" ],
},
PREFERRED_BROWSER = PREFERRED_BROWSERS[ process.platform ],
PREFERRED_CHROMIUM_BROWSER = PREFERRED_CHROMIUM_BROWSERS[ process.platform ];
const DEFAULT_CHROMIUM_ARGS = [
"--disable-field-trial-config", // https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md
"--disable-background-networking",
"--enable-features=NetworkService,NetworkServiceInProcess",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
"--disable-client-side-phishing-detection",
"--disable-component-extensions-with-background-pages",
"--disable-default-apps",
"--disable-dev-shm-usage",
"--disable-extensions", // AvoidUnnecessaryBeforeUnloadCheckSync - https://github.com/microsoft/playwright/issues/14047
"--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync",
"--allow-pre-commit-input",
"--disable-hang-monitor",
"--disable-ipc-flooding-protection",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-renderer-backgrounding",
"--disable-sync",
"--force-color-profile=srgb",
"--metrics-recording-only",
"--no-first-run",
"--password-store=basic",
"--use-mock-keychain", // See https://chromium-review.googlesource.com/c/chromium/src/+/2436773
"--no-service-autorun",
"--export-tagged-pdf",
];
export default class Browser extends Events {
static #availableBrowsers;
static #defaultBrowser;
static #defaultChromiumBrowser;
static #defaultChromiumHeadlessBrowser;
#ref = true;
#userDataDir;
#tmpUserDataDir;
#proc;
// NOTE use "userDataDir": true option to run browser in a new process, otherwise it will be attached to the already running browser process and child process will be closed immediately
constructor ( url, { browser, incognito, headless, app, newWindow, width, height, maximized = true, signal, userDataDir, chromiumSandbox, detached } = {} ) {
super();
if ( !( url instanceof URL ) ) url = new URL( url );
url = encodeURI( url.href );
browser = this.constructor.getBrowser( { browser, headless } );
if ( !browser ) throw new Error( "No compatible browser found" );
const command = browser.executablePath;
var args;
if ( browser.chromium ) {
args = [ ...DEFAULT_CHROMIUM_ARGS ];
if ( !chromiumSandbox && process.platform === "linux" ) {
args.push( "--no-sandbox" );
}
if ( userDataDir ) {
if ( userDataDir === true ) {
this.#tmpUserDataDir = new TmpDir();
this.#userDataDir = this.#tmpUserDataDir.path;
}
else {
this.#userDataDir = userDataDir;
}
}
if ( this.#userDataDir ) {
args.push( "--user-data-dir=" + this.#userDataDir );
}
if ( newWindow ) {
args.push( "--new-window" );
}
if ( width || height ) {
args.push( `--window-size=${ width || 100_000 },${ height || 100_000 }` );
}
else if ( maximized ) {
args.push( "--start-maximized" );
}
if ( incognito ) {
if ( browser.browser === "msedge" ) {
args.push( "-inprivate" );
}
else {
args.push( "--incognito" );
}
}
// NOTE https://stackoverflow.com/questions/78912977/chromium-based-msedge-or-chrome-headless-printing-exit-code-21
if ( headless ) {
if ( headless === true ) {
args.push( "--headless" );
}
else {
args.push( "--headless=" + headless );
}
}
if ( app ) {
args.push( "--app=" + url );
}
else {
args.push( url );
}
this.#proc = childProcess.spawn( command, args, {
"stdio": "ignore",
signal,
detached,
} );
}
this.#proc.once( "error", this.#onError.bind( this ) );
this.#proc.once( "close", this.#onClose.bind( this ) );
}
// static
static get availableBrowsers () {
if ( !this.#availableBrowsers ) {
this.#availableBrowsers = {};
for ( const browser in BROWSERS ) {
// browser is not available for this platform
if ( !BROWSERS[ browser ].paths[ process.platform ] ) continue;
let executablePaths;
if ( path.isAbsolute( BROWSERS[ browser ].paths[ process.platform ] ) ) {
executablePaths = [ BROWSERS[ browser ].paths[ process.platform ] ];
}
else {
executablePaths = PREFIXES[ process.platform ].map( prefix => path.join( prefix, BROWSERS[ browser ].paths[ process.platform ] ) );
}
for ( const executablePath of executablePaths ) {
if ( fs.existsSync( executablePath ) ) {
this.#availableBrowsers[ browser ] = {
browser,
"chromium": BROWSERS[ browser ].chromium,
"channel": BROWSERS[ browser ].channel,
"headless": BROWSERS[ browser ].headless,
executablePath,
};
if ( !BROWSERS[ browser ].headless ) {
this.#defaultBrowser ??= browser;
}
if ( BROWSERS[ browser ].chromium ) {
this.#defaultChromiumBrowser ??= browser;
if ( BROWSERS[ browser ].headless ) {
this.#defaultChromiumHeadlessBrowser ??= browser;
}
}
break;
}
}
}
}
return this.#availableBrowsers;
}
static get defaultBrowser () {
return (
this.availableBrowsers[ "chrome-for-testing" ] || //
this.availableBrowsers[ PREFERRED_BROWSER ] ||
this.availableBrowsers[ this.#defaultBrowser ]
);
}
static get defaultChromiumBrowser () {
return (
this.availableBrowsers[ "chrome-for-testing" ] || //
this.availableBrowsers[ PREFERRED_CHROMIUM_BROWSER ] ||
this.availableBrowsers[ this.#defaultChromiumBrowser ]
);
}
static get defaultChromiumHeadlessBrowser () {
return (
this.availableBrowsers[ "chrome-headless-shell" ] || //
this.availableBrowsers[ this.#defaultChromiumHeadlessBrowser ] ||
this.defaultChromiumBrowser
);
}
static getBrowser ( { browser, chromium, headless } = {} ) {
if ( browser ) {
return this.availableBrowsers[ browser ];
}
else if ( headless ) {
return this.defaultChromiumHeadlessBrowser;
}
else if ( chromium ) {
return this.defaultChromiumsBrowser;
}
else {
return this.defaultChromiumBrowser || this.defaultBrowser;
}
}
static async installChrome ( { chromeForTesting, chromeHeadlessShell, dependencies, log } = {} ) {
var res;
// chrome for testing
if ( chromeForTesting ) {
res = await this.#installProduct( "google-chrome-for-testing", CHROME_FOR_TESTING, log );
if ( !res.ok ) return res;
}
// chrome headless shell
if ( chromeHeadlessShell ) {
res = await this.#installProduct( "google-chrome-headless-shell", CHROME_HEADLESS_SHELL, log );
if ( !res.ok ) return res;
}
// dependencies
if ( dependencies && process.platform === "linux" ) {
res = await childProcess.spawnSync( "apt-get", [ "update", "-y" ], {
"stdio": log
? "inherit"
: "ignore",
} );
if ( res.status ) return result( [ 500, "Failed to install dependencies" ] );
res = await childProcess.spawnSync( "apt-get", [ "install", "-y", ...DEPENDENCIES[ "ubuntu-24.04" ] ], {
"stdio": log
? "inherit"
: "ignore",
} );
if ( res.status ) return result( [ 500, "Failed to install dependencies" ] );
}
return result( 200 );
}
static async #installProduct ( product, resource, log ) {
if ( log ) {
process.stdout.write( `Updating "${ product }" ... ` );
}
const res = await resource.update();
if ( log ) {
console.log( res.statusText );
}
// ok
if ( res.ok ) {
this.#clear();
}
// error
else if ( !res.is3xx ) {
return res;
}
return result( 200 );
}
static #clear () {
this.#availableBrowsers = null;
this.#defaultBrowser = null;
this.#defaultChromiumBrowser = null;
this.#defaultChromiumHeadlessBrowser = null;
}
// properties
get userDataDir () {
return this.#userDataDir;
}
get hasRef () {
return this.#ref;
}
isStarted () {
return !!this.#proc;
}
// public
ref () {
this.#ref = true;
this.#proc?.ref();
return this;
}
unref () {
this.#ref = false;
this.#proc?.unref();
return this;
}
close () {
if ( !this.#proc ) return;
this.#proc.kill();
this.#proc = null;
}
// private
#onError ( e ) {
if ( e.code !== "ABORT_ERR" ) throw e;
}
#onClose () {
this.#proc = null;
this.#tmpUserDataDir = null;
this.emit( "close" );
}
}