Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Allow using Firefox / WebDriver BiDi protocol during conversion #597

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"@types/express": "^4.17.21",
"@types/jest": "^29.5.13",
"@types/markdown-it": "^14.1.2",
"@types/node": "~16.18.108",
"@types/node": "~18.19.50",
"@types/pug": "^2.0.10",
"@types/supertest": "^6.0.2",
"@types/which": "^3.0.4",
Expand Down Expand Up @@ -162,6 +162,7 @@
"cosmiconfig": "^9.0.0",
"puppeteer-core": "23.3.0",
"serve-index": "^1.9.1",
"sharp": "^0.33.5",
"tmp": "^0.2.3",
"ws": "^8.18.0",
"yargs": "^17.7.2"
Expand Down
119 changes: 111 additions & 8 deletions src/browser/browser.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,50 @@
import { EventEmitter } from 'node:events'
import { launch } from 'puppeteer-core'
import type {
Browser as PuppeteerBrowser,
ProtocolType,
PuppeteerLaunchOptions,
Page,
} from 'puppeteer-core'
import type TypedEventEmitter from 'typed-emitter'
import { isWSL } from '../utils/wsl'

export type BrowserKind = 'chrome' | 'firefox'
export type BrowserProtocol = 'webdriver-bidi' | 'cdp'
export type BrowserPurpose = 'convert' | 'preview'
export type BrowserProtocol = ProtocolType

export interface BrowserOptions {
purpose: BrowserPurpose
path: string
timeout?: number
}

// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- TypedEventEmitter is only compatible with type
type BrowserEvents = {
close: (browser: PuppeteerBrowser) => void
disconnect: (browser: PuppeteerBrowser) => void
launch: (browser: PuppeteerBrowser) => void
}

export abstract class Browser {
const wslHostMatcher = /^\/mnt\/[a-z]\//

export abstract class Browser
extends (EventEmitter as new () => TypedEventEmitter<BrowserEvents>)
implements AsyncDisposable
{
static readonly kind: BrowserKind
static readonly protocol: BrowserProtocol

// ---

purpose: BrowserPurpose
path: string
protocolTimeout: number
puppeteer: PuppeteerBrowser | undefined
timeout: number

constructor(opts: BrowserOptions) {
this.purpose = opts.purpose
super()

this.path = opts.path
this.timeout = opts.timeout ?? 30000
this.protocolTimeout =
this.timeout === 0 ? 0 : Math.max(180_000, this.timeout)
}

get kind() {
Expand All @@ -25,4 +54,78 @@ export abstract class Browser {
get protocol() {
return (this.constructor as typeof Browser).protocol
}

async launch(opts: PuppeteerLaunchOptions = {}): Promise<PuppeteerBrowser> {
if (!this.puppeteer) {
const puppeteer = await this.launchPuppeteer(opts)

puppeteer.once('disconnected', () => {
this.emit('disconnect', puppeteer)
this.puppeteer = undefined
})

this.puppeteer = puppeteer
this.emit('launch', puppeteer)

return puppeteer
}
return this.puppeteer
}

async withPage<T>(fn: (page: Page) => T) {
const puppeteer = await this.launch()
const page = await puppeteer.newPage()

try {
return await fn(page)
} finally {
await page.close()
}
}

async close() {
if (this.puppeteer) {
const { puppeteer } = this

if (puppeteer.connected) {
await puppeteer.close()
this.emit('close', puppeteer)
}

this.puppeteer = undefined
}
}

async [Symbol.asyncDispose]() {
await this.close()
}

async browserInWSLHost(): Promise<boolean> {
return (
!!(await isWSL()) &&
wslHostMatcher.test(this.puppeteer?.process()?.spawnfile ?? this.path)
)
}

/** @internal Overload in subclass to customize launch behavior */
protected async launchPuppeteer(
opts: PuppeteerLaunchOptions
): Promise<PuppeteerBrowser> {
return await launch(this.generateLaunchOptions(opts))
}

/** @internal */
protected generateLaunchOptions(
mergeOptions: PuppeteerLaunchOptions = {}
): PuppeteerLaunchOptions {
return {
browser: this.kind,
executablePath: this.path,
headless: true,
protocol: this.protocol,
protocolTimeout: this.protocolTimeout,
timeout: this.timeout,
...mergeOptions,
}
}
}
32 changes: 28 additions & 4 deletions src/browser/browsers/chrome-cdp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
import { Browser } from '../browser'
import type { PuppeteerLaunchOptions } from 'puppeteer-core'
import macDockIcon from '../../assets/mac-dock-icon.png'
import { BrowserProtocol } from '../browser'
import { ChromeBrowser } from './chrome'

export class ChromeCdpBrowser extends Browser {
static readonly kind = 'chrome' as const
static readonly protocol = 'cdp' as const
export class ChromeCdpBrowser extends ChromeBrowser {
static readonly protocol: BrowserProtocol = 'cdp'

protected async launchPuppeteer(opts: PuppeteerLaunchOptions) {
const puppeteer = await super.launchPuppeteer(opts)

// macOS specific: Set Marp icon asynchrnously
if (process.platform === 'darwin') {
/* c8 ignore start */
puppeteer
.target()
.createCDPSession()
.then((session) => {
session
.send('Browser.setDockTile', { image: macDockIcon.slice(22) })
.catch(() => {
// no ops
})
})
/* c8 ignore stop */
}

return puppeteer
}
}
124 changes: 122 additions & 2 deletions src/browser/browsers/chrome.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,126 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { nanoid } from 'nanoid'
import { launch } from 'puppeteer-core'
import type {
Browser as PuppeteerBrowser,
PuppeteerLaunchOptions,
} from 'puppeteer-core'
import { CLIErrorCode, error, isError } from '../../error'
import { isInsideContainer } from '../../utils/container'
import { isWSL, resolveWindowsEnv } from '../../utils/wsl'
import { Browser } from '../browser'
import type { BrowserKind, BrowserProtocol, BrowserOptions } from '../browser'
import { isSnapBrowser } from '../finders/utils'

let wslTmp: string | undefined

export class ChromeBrowser extends Browser {
static readonly kind = 'chrome' as const
static readonly protocol = 'webdriver-bidi' as const
static readonly kind: BrowserKind = 'chrome'
static readonly protocol: BrowserProtocol = 'webDriverBiDi'

private _dataDirName: string

constructor(opts: BrowserOptions) {
super(opts)

this._dataDirName = `marp-cli-${nanoid(10)}`
}

protected async launchPuppeteer(
opts: PuppeteerLaunchOptions
): Promise<PuppeteerBrowser> {
const baseOpts = this.generateLaunchOptions({
args: await this.puppeteerArgs(),
headless: this.puppeteerHeadless(),
pipe: await this.puppeteerPipe(),
userDataDir: await this.createPuppeteerDataDir(),

// Escape hatch for force-extensions policy for Chrome enterprise
// https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-windows
// https://github.com/marp-team/marp-cli/issues/231
ignoreDefaultArgs: process.env.CHROME_ENABLE_EXTENSIONS
? ['--disable-extensions']
: undefined,
...opts,
})

const tryLaunch = async (
extraOpts: PuppeteerLaunchOptions = {}
): Promise<PuppeteerBrowser> => {
const finalizedOpts = { ...baseOpts, ...extraOpts }

try {
return await launch(finalizedOpts)
} catch (e: unknown) {
if (isError(e)) {
// Retry to launch with WebSocket connection if failed to connect to Chrome with pipe
// https://github.com/puppeteer/puppeteer/issues/6258
if (finalizedOpts.pipe) {
return await tryLaunch({ ...extraOpts, pipe: false })
}

// User-friendly warning when tried to spawn the snap browser within the snapd container
if (
/need to run as root or suid/im.test(e.message) &&
(await isSnapBrowser(this.path))
) {
error(
'Marp CLI has detected trying to spawn Chromium browser installed by snap, from the confined environment like another snap app. At least either of Chrome/Chromium or the shell environment must be non snap app.',
CLIErrorCode.CANNOT_SPAWN_SNAP_CHROMIUM
)
}
}
throw e
}
}

return await tryLaunch()
}

private async puppeteerArgs() {
const args = new Set(['--test-type'])

if (!(await this.puppeteerArgsEnableSandbox())) args.add('--no-sandbox')

return [...args]
}

private async puppeteerArgsEnableSandbox() {
if (process.env.CHROME_NO_SANDBOX) return false
if (isInsideContainer()) return false
if (await isWSL()) return false

return true
}

private async puppeteerPipe() {
if (await isWSL()) return false
if (await isSnapBrowser(this.path)) return false

return true
}

private puppeteerHeadless() {
const modeEnv = process.env.PUPPETEER_HEADLESS_MODE?.toLowerCase() ?? ''
return ['old', 'legacy', 'shell'].includes(modeEnv) ? 'shell' : true
}

private async createPuppeteerDataDir() {
const dataDir = await (async () => {
// In WSL environment, Marp CLI may use Chrome on Windows. If Chrome has
// located in host OS (Windows), we have to specify Windows path.
if (await this.browserInWSLHost()) {
if (wslTmp === undefined) wslTmp = await resolveWindowsEnv('TMP')
if (wslTmp !== undefined)
return path.win32.resolve(wslTmp, this._dataDirName)
}
return path.resolve(os.tmpdir(), this._dataDirName)
})()

// Ensure the data directory is created
await fs.promises.mkdir(dataDir, { recursive: true })
return dataDir
}
}
5 changes: 3 additions & 2 deletions src/browser/browsers/firefox.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Browser } from '../browser'
import type { BrowserKind, BrowserProtocol } from '../browser'

export class FirefoxBrowser extends Browser {
static readonly kind = 'firefox' as const
static readonly protocol = 'webdriver-bidi' as const
static readonly kind: BrowserKind = 'firefox'
static readonly protocol: BrowserProtocol = 'webDriverBiDi'
}
6 changes: 4 additions & 2 deletions src/browser/finder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ export type BrowserFinder = (

const finderMap = { chrome, edge, firefox } as const

export const autoFinders = ['chrome', 'edge', 'firefox'] as const
export type FinderName = keyof typeof finderMap

export const defaultFinders = ['chrome', 'edge', 'firefox'] as const

export const findBrowser = async (
finders: readonly (keyof typeof finderMap)[] = autoFinders,
finders: readonly FinderName[] = defaultFinders,
opts: BrowserFinderOptions = {}
) => {
const finderCount = finders.length
Expand Down
5 changes: 4 additions & 1 deletion src/browser/finders/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { error, CLIErrorCode } from '../../error'
import { ChromeBrowser } from '../browsers/chrome'
import { ChromeCdpBrowser } from '../browsers/chrome-cdp'
import type { BrowserFinder, BrowserFinderResult } from '../finder'
import { findExecutableBinary, getPlatform } from './utils'
import { findExecutableBinary, getPlatform, isExecutable } from './utils'

const chrome = (path: string): BrowserFinderResult => ({
path,
Expand All @@ -18,6 +18,9 @@ const chrome = (path: string): BrowserFinderResult => ({
export const chromeFinder: BrowserFinder = async ({ preferredPath } = {}) => {
if (preferredPath) return chrome(preferredPath)

if (process.env.CHROME_PATH && (await isExecutable(process.env.CHROME_PATH)))
return chrome(process.env.CHROME_PATH)

const platform = await getPlatform()
const installation = await (async () => {
switch (platform) {
Expand Down
13 changes: 12 additions & 1 deletion src/browser/finders/firefox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import path from 'node:path'
import { error, CLIErrorCode } from '../../error'
import { FirefoxBrowser } from '../browsers/firefox'
import type { BrowserFinder, BrowserFinderResult } from '../finder'
import { getPlatform, findExecutable, findExecutableBinary } from './utils'
import {
getPlatform,
findExecutable,
findExecutableBinary,
isExecutable,
} from './utils'

const firefox = (path: string): BrowserFinderResult => ({
path,
Expand All @@ -17,6 +22,12 @@ const winFirefoxDefault = ['Mozilla Firefox', 'firefox.exe'] // Firefox stable,
export const firefoxFinder: BrowserFinder = async ({ preferredPath } = {}) => {
if (preferredPath) return firefox(preferredPath)

if (
process.env.FIREFOX_PATH &&
(await isExecutable(process.env.FIREFOX_PATH))
)
return firefox(process.env.FIREFOX_PATH)

const platform = await getPlatform()
const installation = await (async () => {
switch (platform) {
Expand Down
Loading
Loading