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

Allow using Firefox / WebDriver BiDi protocol during conversion #597

Merged
merged 23 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0b8dac7
Use new browser manager for conversion
yhatt Sep 23, 2024
03f72b8
Refactor tmp file clean up to use using statement
yhatt Sep 24, 2024
833cab5
Make compatible image conversion with Firefox
yhatt Sep 24, 2024
a7338f2
Warn about incompatible renderings on first PDF conversion with Firefox
yhatt Sep 24, 2024
b3bf0b0
Merge branch 'main' into browser-manager
yhatt Sep 25, 2024
d2c6441
Make the converter using new manager compatible with the behavior of v3
yhatt Sep 25, 2024
685d793
Fix SEGFAULT error by `new Intl.Segmenter().segment()` in the standal…
yhatt Sep 25, 2024
fde9ffe
Make compatible the standalone binary with sharp image processor
yhatt Sep 25, 2024
cfd5e4b
Fix ESLint
yhatt Sep 25, 2024
8a1e878
Refactor resolution of browser manager configuration
yhatt Sep 25, 2024
624a83d
Fix existing tests
yhatt Sep 26, 2024
9d87156
Add lacked unit tests for browser class and manager class
yhatt Sep 26, 2024
f8a8999
Fix ESLint
yhatt Sep 26, 2024
6df9c48
Add test for `CHROME_PATH` and `FIREFOX_PATH` environment variable
yhatt Sep 26, 2024
c2e7560
Use new browser manager in the preview window, and remove classic Pup…
yhatt Sep 26, 2024
23a983c
Remove tests for classic implementations, and rewrote the test suited…
yhatt Sep 26, 2024
69d6cc3
Fix ESLint
yhatt Sep 26, 2024
21d0ea1
Disable container detection if running inside CI environment
yhatt Sep 26, 2024
81bf08e
Skip some incompatible tests in Windows platform
yhatt Sep 26, 2024
36c95af
Add missing tests for preview
yhatt Sep 27, 2024
d98d27e
Revert preview test about window size
yhatt Sep 27, 2024
a81dfc3
Add lacked tests
yhatt Sep 27, 2024
50d4d16
[ci skip] Update CHANGELOG.md
yhatt Sep 27, 2024
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
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