import { mkdirSync } from 'fs'
import { chromium } from 'playwright'
/**
* Chrome 生命週期管理器
*
* 設計原則:state 由 Playwright 的 context.isClosed() 衍生,不自製 state machine。
* caller 看 snapshot 拿 'open' / 'closed' 兩態;操作 API 只在 open 狀態可用,否則拋
* chromeState-tagged Error 給 errResponse 統一處理。
*
* lifecycle 操作(open / close / shutdown)走 _opLock 序列化,避免並發 race。
*/
const TIMEOUT_LAUNCH = 20000
const TIMEOUT_GOTO = 15000
const TIMEOUT_CLOSE_GRACEFUL = 5000
const TIMEOUT_CLEANUP_GRACE = 30000
class ChromeManager {
constructor() {
this.context = null
this.createdAt = null
this.lastError = null
this.shuttingDown = false
this._opLock = Promise.resolve()
}
isOpen() {
return this.context !== null && !this.context.isClosed()
}
snapshot() {
if (this.isOpen()) {
try {
return {
state: 'open',
url: this.context.pages()[0]?.url() || '',
createdAt: this.createdAt,
}
}
catch {
// page.url() 拋 → 視為 closed
}
}
const r = { state: 'closed' }
if (this.lastError) r.lastError = this.lastError
return r
}
// 業務 handler 用:取 page;非 open 拋帶標籤錯誤給 errResponse 處理
// caller 可能用 evaluate 關掉 default page;對齊 _launch / reuse 的 fallback 用 newPage 補回
async withPage(fn) {
if (!this.isOpen()) {
const err = new Error('chrome closed')
err.chromeState = 'closed'
throw err
}
const page = this.context.pages()[0] || await this.context.newPage()
return fn({ page })
}
async open(opts = {}) {
if (this.shuttingDown) {
const err = new Error('shutdown_in_progress')
err.chromeState = 'closed'
throw err
}
return this._enqueue(async () => {
const mode = opts.mode
// 已 open 且非 replace:reuse;caller 給不同 url 自動 navigate
if (this.isOpen() && mode !== 'replace') {
// caller 可能用 evaluate 關掉 default page;對齊 _launch 的 fallback
const page = this.context.pages()[0] || await this.context.newPage()
const currentUrl = page.url()
let gotoOk = true
let gotoError = null
if (opts.url && opts.url !== currentUrl) {
try {
await this._withDeadline(
page.goto(opts.url, { waitUntil: 'commit', timeout: TIMEOUT_GOTO }),
TIMEOUT_GOTO + 1000,
'reuse-navigate',
)
}
catch (err) {
gotoOk = false
gotoError = err.message
}
}
return {
state: 'open',
url: page.url(),
reused: true,
gotoOk,
gotoError,
}
}
if (this.isOpen() && mode === 'replace') {
await this._closeInternal()
}
// 非 open(含 isClosed 已 true 的殘骸 context)→ 清掉 reference 後重開
if (this.context && !this.isOpen()) {
this.context = null
}
return this._launch(opts)
})
}
async close() {
return this._enqueue(async () => {
if (!this.isOpen() && !this.context) {
return { closed: false, reason: 'closed' }
}
await this._closeInternal()
return { closed: true }
})
}
async shutdown() {
this.shuttingDown = true
return this._enqueue(async () => {
if (this.context) await this._closeInternal()
return { shutdown: true }
})
}
// ── 內部 ─────────────────────────────────────────────
_enqueue(fn) {
const next = this._opLock.then(() => fn(), () => fn())
this._opLock = next.catch(() => undefined)
return next
}
async _launch(opts) {
const { url, window, userDataDir, opt = {} } = opts
if (!userDataDir) throw new Error('userDataDir required')
const disableGpu = opt.disableGpu === true
const disableSandbox = opt.disableSandbox === true
const hasWindow = window && window.width && window.height
const args = [
'--disable-dev-shm-usage',
'--disable-infobars',
'--test-type',
'--hide-crash-restore-bubble',
'--disable-notifications',
'--disable-features=Translate',
'--lang=zh-TW',
'--force-device-scale-factor=1',
]
if (disableSandbox) args.push('--no-sandbox')
if (disableGpu) args.push('--disable-gpu')
let viewport = null
if (hasWindow) {
const x = window.x || 0
const y = window.y || 0
args.push(`--window-position=${x},${y}`)
args.push(`--window-size=${window.width},${window.height}`)
viewport = { width: window.width, height: window.height }
}
else {
args.push('--start-maximized')
}
mkdirSync(userDataDir, { recursive: true })
const ctxOpts = {
channel: 'chrome',
headless: false,
ignoreDefaultArgs: ['--enable-automation'],
args,
viewport,
locale: 'zh-TW',
}
if (viewport) ctxOpts.deviceScaleFactor = 1
// launch 不能 cancel;race 輸了要等底層真結束才釋放 _opLock,避免 retry 撞 SingletonLock
const launchP = chromium.launchPersistentContext(userDataDir, ctxOpts)
let context
try {
context = await this._withDeadline(launchP, TIMEOUT_LAUNCH, 'launchPersistentContext')
}
catch (err) {
this.lastError = err.message
if (/launchPersistentContext timeout/.test(err.message)) {
const cleanup = launchP.then(
(ctx) => ctx.close().catch(() => {}),
() => {},
)
await Promise.race([
cleanup,
new Promise((r) => setTimeout(r, TIMEOUT_CLEANUP_GRACE)),
])
}
throw err
}
// 異常死亡偵測:caller 沒主動 close 就轉 closed,記下 reason
context.once('close', () => {
// 只在我們仍持有此 context 且非主動 close 時記錯
if (this.context === context && !this.shuttingDown) {
this.lastError = this.lastError || 'context closed unexpectedly'
}
})
this.context = context
this.createdAt = new Date().toISOString()
this.lastError = null
const page = context.pages()[0] || await context.newPage()
let gotoOk = true
let gotoError = null
if (url) {
try {
await this._withDeadline(
page.goto(url, { waitUntil: 'commit', timeout: TIMEOUT_GOTO }),
TIMEOUT_GOTO + 1000,
'page.goto',
)
}
catch (err) {
gotoOk = false
gotoError = err.message
console.error('[chromeManager] goto failed:', err.message)
}
}
return {
state: 'open',
url: page.url(),
reused: false,
gotoOk,
gotoError,
}
}
async _closeInternal() {
if (!this.context) return
const ctx = this.context
const closeP = ctx.close()
try {
await this._withDeadline(closeP, TIMEOUT_CLOSE_GRACEFUL, 'context.close')
this.lastError = null
}
catch (err) {
console.error('[chromeManager] context.close failed; chromium may leak:', err.message)
this.lastError = `close_failed: ${err.message}`
// race 輸了等底層真關完(或 30s grace),避免 _opLock 釋放後撞 SingletonLock
await Promise.race([
closeP.catch(() => {}),
new Promise((r) => setTimeout(r, TIMEOUT_CLEANUP_GRACE)),
])
}
finally {
this.context = null
this.createdAt = null
}
}
async _withDeadline(promise, ms, label) {
let timer
const timeoutP = new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timeout ${ms}ms`)), ms)
})
try {
return await Promise.race([promise, timeoutP])
}
finally {
if (timer) clearTimeout(timer)
}
}
}
export default ChromeManager