Skip to content

Commit

Permalink
Merge pull request #597 from marp-team/browser-manager
Browse files Browse the repository at this point in the history
Allow using Firefox / WebDriver BiDi protocol during conversion
  • Loading branch information
yhatt authored Sep 27, 2024
2 parents 097398e + 50d4d16 commit c04622e
Show file tree
Hide file tree
Showing 43 changed files with 1,936 additions and 1,375 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

### Added

<!-- Allow using Firefox / WebDriver BiDi protocol during conversion ([#597](https://github.com/marp-team/marp-cli/pull/597)) -->

- CI testing against Node.js v22 ([#591](https://github.com/marp-team/marp-cli/pull/591))

### Changed
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
coverageProvider: 'v8',
coverageThreshold: { global: { lines: 95 } },
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
prettierPath: null,
setupFiles: ['./jest.setup.js'],
transform: {
...jsWithBabel.transform,
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@
},
"pkg": {
"scripts": "lib/**/*.js",
"assets": [
"node_modules/vm2/**/*"
]
"assets": ["tmp/icu/icudt*.dat", "node_modules/sharp/**/*", "node_modules/@img/**/*"]
},
"browserslist": [
"> 1% and last 3 versions",
Expand All @@ -61,7 +59,7 @@
"lint:css": "stylelint \"src/**/*.{css,scss}\"",
"prepack": "npm-run-all --parallel check:* lint:* test:coverage --parallel build types",
"preversion": "run-p check:* lint:* test:coverage",
"standalone": "node -e 'fs.rmSync(`bin`,{recursive:true,force:true})' && pkg --out-path ./bin .",
"standalone": "node -e 'fs.rmSync(`bin`,{recursive:true,force:true})' && pkg --options \"icu-data-dir=$(node ./scripts/icu.mjs)\" -C gzip --out-path ./bin .",
"standalone:pack": "node ./scripts/pack.js",
"test": "jest",
"test:coverage": "jest --coverage",
Expand All @@ -82,11 +80,12 @@
"@tsconfig/node20": "^20.1.4",
"@tsconfig/recommended": "^1.0.7",
"@types/cheerio": "^0.22.35",
"@types/debug": "^4.1.12",
"@types/dom-view-transitions": "^1.0.5",
"@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 +161,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
53 changes: 53 additions & 0 deletions scripts/icu.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* For baking the ICU data into the standalone binary, we need to download the compatible ICU data from the ICU repository. */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import yauzl from 'yauzl'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const icuDir = path.join(__dirname, '../tmp/icu')
await fs.promises.mkdir(icuDir, { recursive: true })

const zipFromBuffer = promisify(yauzl.fromBuffer)

// Get the ICU version and endianness
const [icuMajor, icuMinor] = process.versions.icu.split('.')
const icuEndianness = process.config.variables.icu_endianness.toLowerCase()

// Download the ICU data
const response = await fetch(
`https://github.com/unicode-org/icu/releases/download/release-${icuMajor}-${icuMinor}/icu4c-${icuMajor}_${icuMinor}-data-bin-${icuEndianness}.zip`
)

if (!response.ok) {
throw new Error(`Failed to download ICU data: ${response.statusText}`)
}

// Extract the ICU data
const zip = await zipFromBuffer(Buffer.from(await response.arrayBuffer()), {
lazyEntries: true,
})

const icuDat = await new Promise((res, rej) => {
zip.on('error', (err) => rej(err))
zip.on('entry', async (entry) => {
if (/icudt\d+.\.dat/.test(entry.fileName)) {
zip.openReadStream(entry, (err, readStream) => {
if (err) return rej(err)

const output = path.join(icuDir, entry.fileName)

readStream.pipe(fs.createWriteStream(output))
res(output)
})
} else {
zip.readEntry()
}
})
zip.on('end', () => rej(new Error('Failed to find ICU data in the archive')))
zip.readEntry()
})

// Print the relative path to the ICU data from the project root
console.log(path.relative(path.join(__dirname, '../'), icuDat))
122 changes: 114 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,81 @@ 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()

page.setDefaultTimeout(this.timeout)
page.setDefaultNavigationTimeout(this.timeout)

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,
}
}
}
28 changes: 24 additions & 4 deletions src/browser/browsers/chrome-cdp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
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') {
puppeteer
.target()
.createCDPSession()
.then((session) => {
session
.send('Browser.setDockTile', { image: macDockIcon.slice(22) })
.catch(() => void 0)
})
}

return puppeteer
}
}
Loading

0 comments on commit c04622e

Please sign in to comment.