下沉浏览器会话基础运行时逻辑

This commit is contained in:
yml
2026-05-04 17:59:35 +08:00
parent 6cca81cd0a
commit 42979d9581
4 changed files with 310 additions and 228 deletions
@@ -0,0 +1,17 @@
// @ts-check
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { runtimeConfig } from '../../config/runtime.js'
import { resolveDefaultViewport } from './session-browser-config.js'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..')
export const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions')
export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
export const DEFAULT_VIEWPORT = resolveDefaultViewport()
export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
@@ -0,0 +1,90 @@
// @ts-check
import { chromium } from 'playwright'
import { runtimeConfig } from '../../config/runtime.js'
import {
buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase,
resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase,
} from './session-browser-config.js'
import { CHROME_PATH } from './session-service-config.js'
let browserPromise = null
export class SessionServiceError extends Error {
constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) {
super(message)
this.name = 'SessionServiceError'
this.statusCode = statusCode
this.errorCode = errorCode
}
}
export async function warmupTencentBrowser() {
const browserLaunch = resolveBrowserLaunchOptions()
if (!browserLaunch.prewarm) {
return {
warmed: false,
reason: 'disabled',
browserLaunch,
}
}
await ensureBrowser()
return {
warmed: true,
reason: 'ready',
browserLaunch,
}
}
export async function ensureBrowser() {
if (!browserPromise) {
const launchOptions = resolveBrowserLaunchOptions()
browserPromise = chromium.launch(buildChromiumLaunchOptions(launchOptions))
.then((browser) => {
browser.on('disconnected', () => {
browserPromise = null
})
return browser
})
.catch((error) => {
browserPromise = null
throw error
})
}
return browserPromise
}
export function resolveBrowserLaunchOptions() {
return resolveBrowserLaunchOptionsBase({
browserConfig: runtimeConfig.browser,
chromePath: CHROME_PATH,
})
}
export async function closeBrowserIfIdle({ activeSessionCount = 0 } = {}) {
if (activeSessionCount > 0 || !browserPromise) {
return
}
if (resolveBrowserLaunchOptions().keepAlive) {
return
}
try {
const browser = await browserPromise
await browser.close()
} catch {
// ignore browser close failures
} finally {
browserPromise = null
}
}
function buildChromiumLaunchOptions(launchOptions) {
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
}
@@ -0,0 +1,159 @@
// @ts-check
import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import {
DATA_ROOT,
REDEEM_SUCCESS_AUTO_CLOSE_MS,
} from './session-service-config.js'
import { SessionServiceError } from './session-service-runtime.js'
import {
resolveInitialQrSessionState,
SESSION_TTL_MS,
} from './session-state.js'
const sessions = new Map()
export async function registerTencentBrowserSession({ loginType, browserContext, page }) {
const sessionId = createSessionId()
const sessionDir = path.join(DATA_ROOT, sessionId)
await fs.mkdir(sessionDir, { recursive: true })
const now = new Date().toISOString()
const session = {
sessionId,
loginType,
status: 'created',
notice: '浏览器会话已创建',
createdAt: now,
updatedAt: now,
expiresAt: Date.now() + SESSION_TTL_MS,
autoCloseAt: 0,
autoCloseTimer: null,
sessionDir,
browserContext,
page,
qrImageBase64: '',
qrImagePath: '',
qrUpdatedAt: '',
lastState: null,
lastRedeem: null,
lastReview: null,
lastError: '',
presentationSyncAttempts: 0,
}
sessions.set(sessionId, session)
return session
}
export function listActiveTencentBrowserSessions() {
return [...sessions.values()]
}
export async function getRequiredSession(sessionId) {
const key = String(sessionId || '').trim()
if (!key) {
throw new SessionServiceError('缺少 sessionId', {
statusCode: 400,
errorCode: 'missing_session_id',
})
}
const session = sessions.get(key)
if (!session) {
throw new SessionServiceError('浏览器会话不存在或已过期', {
statusCode: 404,
errorCode: 'session_not_found',
})
}
return session
}
export function applyInitialQrSessionState(session) {
resetTencentSessionAutoClose(session)
Object.assign(session, resolveInitialQrSessionState(session.loginType))
}
export function armRedeemedSessionAutoClose(session, { closeSession }) {
resetTencentSessionAutoClose(session)
const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS
session.autoCloseAt = autoCloseAt
session.expiresAt = autoCloseAt
session.autoCloseTimer = setTimeout(() => {
void closeSession(session, { markClosed: true }).catch(() => {
// ignore delayed auto-close failures
})
}, REDEEM_SUCCESS_AUTO_CLOSE_MS)
}
export function resetTencentSessionAutoClose(session) {
if (session?.autoCloseTimer) {
clearTimeout(session.autoCloseTimer)
session.autoCloseTimer = null
}
if (session) {
session.autoCloseAt = 0
}
}
export async function cleanupExpiredTencentBrowserSessions({ closeSession }) {
const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now())
await Promise.all(expired.map((session) => closeSession(session, { markClosed: false })))
}
export async function closeSession(
session,
{
markClosed,
persistSessionState,
closeBrowserIfIdle,
},
) {
resetTencentSessionAutoClose(session)
sessions.delete(session.sessionId)
try {
await session.browserContext?.close()
} catch {
// ignore browser context close failures
}
session.browserContextClosed = true
session.updatedAt = new Date().toISOString()
if (markClosed) {
session.status = 'closed'
session.notice = '浏览器会话已关闭'
await persistSessionState(session)
}
await closeBrowserIfIdle()
}
export async function persistSessionState(session, { buildSessionPayload }) {
await fs.mkdir(session.sessionDir, { recursive: true })
await fs.writeFile(
path.join(session.sessionDir, 'session.json'),
JSON.stringify(buildSessionPayload(session), null, 2),
'utf8',
)
}
function createSessionId() {
let sessionId = ''
do {
sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}`
} while (sessions.has(sessionId))
return sessionId
}
+44 -228
View File
@@ -1,19 +1,8 @@
// @ts-nocheck
import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { runtimeConfig } from '../../config/runtime.js'
import {
buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase,
getLoginTypeLabel,
normalizeLoginType,
resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase,
resolveDefaultViewport,
} from './session-browser-config.js'
import { logBrowserSessionDebug } from './session-login-shared.js'
import {
@@ -58,63 +47,41 @@ import {
resolveSessionNotice,
resolveSessionStatus,
resolveTencentSessionExpiresAt,
SESSION_TTL_MS,
} from './session-state.js'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../../..')
const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions')
const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
const DEFAULT_VIEWPORT = resolveDefaultViewport()
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
const sessions = new Map()
let browserPromise = null
import {
ACTIVITY_URL,
DEFAULT_VIEWPORT,
REDEEM_SUCCESS_AUTO_CLOSE_MS,
SESSION_DEBUG_ENABLED,
} from './session-service-config.js'
import {
closeBrowserIfIdle,
ensureBrowser,
resolveBrowserLaunchOptions,
SessionServiceError,
warmupTencentBrowser,
} from './session-service-runtime.js'
import {
applyInitialQrSessionState,
armRedeemedSessionAutoClose,
cleanupExpiredTencentBrowserSessions,
closeSession as closeStoredSession,
getRequiredSession,
listActiveTencentBrowserSessions,
persistSessionState as persistStoredSessionState,
registerTencentBrowserSession,
resetTencentSessionAutoClose,
} from './session-service-store.js'
export { resolveTencentSessionExpiresAt }
class SessionServiceError extends Error {
constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) {
super(message)
this.name = 'SessionServiceError'
this.statusCode = statusCode
this.errorCode = errorCode
}
}
export async function warmupTencentBrowser() {
const browserLaunch = resolveBrowserLaunchOptions()
if (!browserLaunch.prewarm) {
return {
warmed: false,
reason: 'disabled',
browserLaunch,
}
}
await ensureBrowser()
return {
warmed: true,
reason: 'ready',
browserLaunch,
}
}
export { REDEEM_SUCCESS_AUTO_CLOSE_MS, warmupTencentBrowser }
export async function createTencentBrowserSession(payload = {}) {
await cleanupExpiredTencentBrowserSessions()
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
const loginType = normalizeLoginType(payload.loginType || payload.qrType)
const browser = await ensureBrowser()
const sessionId = createSessionId()
const sessionDir = path.join(DATA_ROOT, sessionId)
await fs.mkdir(sessionDir, { recursive: true })
const browserContext = await browser.newContext({
viewport: DEFAULT_VIEWPORT,
deviceScaleFactor: 1,
@@ -123,30 +90,12 @@ export async function createTencentBrowserSession(payload = {}) {
const page = await browserContext.newPage()
page.setDefaultTimeout(20_000)
const session = {
sessionId,
const session = await registerTencentBrowserSession({
loginType,
status: 'created',
notice: '浏览器会话已创建',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
expiresAt: Date.now() + SESSION_TTL_MS,
autoCloseAt: 0,
autoCloseTimer: null,
sessionDir,
browserContext,
page,
qrImageBase64: '',
qrImagePath: '',
qrUpdatedAt: '',
lastState: null,
lastRedeem: null,
lastReview: null,
lastError: '',
presentationSyncAttempts: 0,
}
sessions.set(sessionId, session)
})
const sessionId = session.sessionId
try {
await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' })
@@ -180,7 +129,7 @@ export async function getTencentBrowserSessionSummary(sessionId) {
}
export async function reloadTencentBrowserSession(sessionId) {
await cleanupExpiredTencentBrowserSessions()
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
const session = await getRequiredSession(sessionId)
@@ -222,7 +171,7 @@ export async function reloadTencentBrowserSession(sessionId) {
}
export async function refreshTencentBrowserSession(sessionId, { includeQrImage = true } = {}) {
await cleanupExpiredTencentBrowserSessions()
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
const session = await getRequiredSession(sessionId)
@@ -349,7 +298,7 @@ export async function redeemTencentBrowserSession(sessionId, payload = {}) {
activityUrl: ACTIVITY_URL,
})
armRedeemedSessionAutoClose(session)
armRedeemedSessionAutoClose(session, { closeSession: closeManagedSession })
await persistSessionState(session)
return {
@@ -360,7 +309,7 @@ export async function redeemTencentBrowserSession(sessionId, payload = {}) {
export async function closeTencentBrowserSession(sessionId) {
const session = await getRequiredSession(sessionId)
await closeSession(session, { markClosed: true })
await closeManagedSession(session, { markClosed: true })
return {
sessionId,
closed: true,
@@ -368,9 +317,9 @@ export async function closeTencentBrowserSession(sessionId) {
}
export async function closeAllTencentBrowserSessions({ markClosed = true } = {}) {
const activeSessions = [...sessions.values()]
const activeSessions = listActiveTencentBrowserSessions()
await Promise.all(activeSessions.map((session) => closeSession(session, { markClosed })))
await Promise.all(activeSessions.map((session) => closeManagedSession(session, { markClosed })))
return {
closedCount: activeSessions.length,
@@ -405,132 +354,8 @@ export async function getTencentBrowserSessionReviewScreenshotPath(sessionId) {
return screenshotPath
}
async function ensureBrowser() {
if (!browserPromise) {
const launchOptions = resolveBrowserLaunchOptions()
browserPromise = chromium.launch(buildChromiumLaunchOptions(launchOptions))
.then((browser) => {
browser.on('disconnected', () => {
browserPromise = null
})
return browser
})
.catch((error) => {
browserPromise = null
throw error
})
}
return browserPromise
}
function resolveBrowserLaunchOptions() {
return resolveBrowserLaunchOptionsBase({
browserConfig: runtimeConfig.browser,
chromePath: CHROME_PATH,
})
}
function buildChromiumLaunchOptions(launchOptions) {
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
}
function createSessionId() {
let sessionId = ''
do {
sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}`
} while (sessions.has(sessionId))
return sessionId
}
async function getRequiredSession(sessionId) {
const key = String(sessionId || '').trim()
if (!key) {
throw new SessionServiceError('缺少 sessionId', {
statusCode: 400,
errorCode: 'missing_session_id',
})
}
const session = sessions.get(key)
if (!session) {
throw new SessionServiceError('浏览器会话不存在或已过期', {
statusCode: 404,
errorCode: 'session_not_found',
})
}
return session
}
function applyInitialQrSessionState(session) {
resetTencentSessionAutoClose(session)
Object.assign(session, resolveInitialQrSessionState(session.loginType))
}
function armRedeemedSessionAutoClose(session) {
resetTencentSessionAutoClose(session)
const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS
session.autoCloseAt = autoCloseAt
session.expiresAt = autoCloseAt
session.autoCloseTimer = setTimeout(() => {
void closeSession(session, { markClosed: true }).catch(() => {
// ignore delayed auto-close failures
})
}, REDEEM_SUCCESS_AUTO_CLOSE_MS)
}
function resetTencentSessionAutoClose(session) {
if (session?.autoCloseTimer) {
clearTimeout(session.autoCloseTimer)
session.autoCloseTimer = null
}
if (session) {
session.autoCloseAt = 0
}
}
async function cleanupExpiredTencentBrowserSessions() {
const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now())
await Promise.all(expired.map((session) => closeSession(session, { markClosed: false })))
}
async function closeSession(session, { markClosed }) {
resetTencentSessionAutoClose(session)
sessions.delete(session.sessionId)
try {
await session.browserContext?.close()
} catch {
// ignore browser context close failures
}
session.browserContextClosed = true
session.updatedAt = new Date().toISOString()
if (markClosed) {
session.status = 'closed'
session.notice = '浏览器会话已关闭'
await persistSessionState(session)
}
await closeBrowserIfIdle()
}
async function persistSessionState(session) {
await fs.mkdir(session.sessionDir, { recursive: true })
await fs.writeFile(
path.join(session.sessionDir, 'session.json'),
JSON.stringify(buildSessionPayload(session), null, 2),
'utf8',
)
await persistStoredSessionState(session, { buildSessionPayload })
}
function buildSessionPayload(session, { includeQrImage = true } = {}) {
@@ -546,21 +371,12 @@ async function fillRedeemCodeInBrowser(page, code) {
await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady })
}
async function closeBrowserIfIdle() {
if (sessions.size > 0 || !browserPromise) {
return
}
if (resolveBrowserLaunchOptions().keepAlive) {
return
}
try {
const browser = await browserPromise
await browser.close()
} catch {
// ignore browser close failures
} finally {
browserPromise = null
}
async function closeManagedSession(session, { markClosed }) {
await closeStoredSession(session, {
markClosed,
persistSessionState,
closeBrowserIfIdle: () => closeBrowserIfIdle({
activeSessionCount: listActiveTencentBrowserSessions().length,
}),
})
}