feat: add -e/--eval CLI flag for code execution
- Add -e/--eval flag to execute JavaScript code via relay server - Extract PlaywrightExecutor class to shared executor.ts module - Extract relay server utilities to relay-client.ts - Add /cli/execute, /cli/reset, /cli/sessions endpoints - Fix CDP session client ID collision by using fresh unique URLs for each connection
This commit is contained in:
@@ -1014,6 +1014,84 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CLI Execute Endpoints - For stateful code execution via CLI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Session counter for suggesting next session number
|
||||||
|
let nextSessionNumber = 1
|
||||||
|
|
||||||
|
// Lazy-load ExecutorManager to avoid circular imports and only when needed
|
||||||
|
let executorManager: import('./executor.js').ExecutorManager | null = null
|
||||||
|
|
||||||
|
const getExecutorManager = async () => {
|
||||||
|
if (!executorManager) {
|
||||||
|
const { ExecutorManager } = await import('./executor.js')
|
||||||
|
// Pass config instead of URL so executor can generate unique client IDs for each connection
|
||||||
|
executorManager = new ExecutorManager({
|
||||||
|
cdpConfig: { host: '127.0.0.1', port },
|
||||||
|
logger: logger || { log: console.log, error: console.error },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return executorManager
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/cli/execute', async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json() as { sessionId: string; code: string; timeout?: number; cwd?: string }
|
||||||
|
const { sessionId, code, timeout = 5000, cwd } = body
|
||||||
|
|
||||||
|
if (!sessionId || !code) {
|
||||||
|
return c.json({ error: 'sessionId and code are required' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = await getExecutorManager()
|
||||||
|
const executor = manager.getExecutor(sessionId, cwd)
|
||||||
|
const result = await executor.execute(code, timeout)
|
||||||
|
|
||||||
|
// Increment session counter after each execution to avoid conflicts
|
||||||
|
nextSessionNumber++
|
||||||
|
|
||||||
|
return c.json(result)
|
||||||
|
} catch (error: any) {
|
||||||
|
logger?.error('Execute endpoint error:', error)
|
||||||
|
return c.json({ text: `Server error: ${error.message}`, images: [], isError: true }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/cli/reset', async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json() as { sessionId: string; cwd?: string }
|
||||||
|
const { sessionId, cwd } = body
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: 'sessionId is required' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = await getExecutorManager()
|
||||||
|
const executor = manager.getExecutor(sessionId, cwd)
|
||||||
|
const { page, context } = await executor.reset()
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
pageUrl: page.url(),
|
||||||
|
pagesCount: context.pages().length,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
logger?.error('Reset endpoint error:', error)
|
||||||
|
return c.json({ error: error.message }, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/cli/sessions', async (c) => {
|
||||||
|
const manager = await getExecutorManager()
|
||||||
|
return c.json({ sessions: manager.listSessions() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/cli/session/suggest', (c) => {
|
||||||
|
return c.json({ next: nextSessionNumber })
|
||||||
|
})
|
||||||
|
|
||||||
const server = serve({ fetch: app.fetch, port, hostname: host })
|
const server = serve({ fetch: app.fetch, port, hostname: host })
|
||||||
injectWebSocket(server)
|
injectWebSocket(server)
|
||||||
|
|
||||||
|
|||||||
+145
-3
@@ -4,17 +4,32 @@ import { cac } from 'cac'
|
|||||||
import { startPlayWriterCDPRelayServer } from './cdp-relay.js'
|
import { startPlayWriterCDPRelayServer } from './cdp-relay.js'
|
||||||
import { createFileLogger } from './create-logger.js'
|
import { createFileLogger } from './create-logger.js'
|
||||||
import { VERSION } from './utils.js'
|
import { VERSION } from './utils.js'
|
||||||
|
import { ensureRelayServer, RELAY_PORT } from './relay-client.js'
|
||||||
import { killPortProcess } from 'kill-port-process'
|
import { killPortProcess } from 'kill-port-process'
|
||||||
|
|
||||||
const RELAY_PORT = 19988
|
|
||||||
|
|
||||||
const cli = cac('playwriter')
|
const cli = cac('playwriter')
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command('', 'Start the MCP server (default)')
|
.command('', 'Start the MCP server (default)')
|
||||||
.option('--host <host>', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)')
|
.option('--host <host>', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)')
|
||||||
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
||||||
.action(async (options: { host?: string; token?: string }) => {
|
.option('-e, --eval <code>', 'Execute JavaScript code and exit')
|
||||||
|
.option('--timeout <ms>', 'Execution timeout in milliseconds', { default: 5000 })
|
||||||
|
.option('-s, --session <name>', 'Session name (required for -e)')
|
||||||
|
.action(async (options: { host?: string; token?: string; eval?: string; timeout?: number; session?: string }) => {
|
||||||
|
// If -e flag is provided, execute code via relay server
|
||||||
|
if (options.eval) {
|
||||||
|
await executeCode({
|
||||||
|
code: options.eval,
|
||||||
|
timeout: options.timeout || 5000,
|
||||||
|
sessionId: options.session,
|
||||||
|
host: options.host,
|
||||||
|
token: options.token,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise start the MCP server
|
||||||
const { startMcp } = await import('./mcp.js')
|
const { startMcp } = await import('./mcp.js')
|
||||||
await startMcp({
|
await startMcp({
|
||||||
host: options.host,
|
host: options.host,
|
||||||
@@ -22,6 +37,133 @@ cli
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function executeCode(options: {
|
||||||
|
code: string
|
||||||
|
timeout: number
|
||||||
|
sessionId?: string
|
||||||
|
host?: string
|
||||||
|
token?: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const { code, timeout, host, token } = options
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const sessionId = options.sessionId || process.env.PLAYWRITER_SESSION
|
||||||
|
|
||||||
|
// Determine server URL
|
||||||
|
const serverHost = host || process.env.PLAYWRITER_HOST || '127.0.0.1'
|
||||||
|
const serverUrl = `http://${serverHost}:${RELAY_PORT}`
|
||||||
|
|
||||||
|
// Ensure relay server is running (only for local)
|
||||||
|
if (!host && !process.env.PLAYWRITER_HOST) {
|
||||||
|
await ensureRelayServer({ logger: console })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is required
|
||||||
|
if (!sessionId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${serverUrl}/cli/session/suggest`)
|
||||||
|
const { next } = await res.json() as { next: number }
|
||||||
|
console.error(`Error: -s/--session is required. Use -s ${next} for a new session.`)
|
||||||
|
} catch {
|
||||||
|
console.error(`Error: -s/--session is required. Use -s 1 for a new session.`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request URL with token if provided
|
||||||
|
const executeUrl = `${serverUrl}/cli/execute`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(executeUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token || process.env.PLAYWRITER_TOKEN ? { 'Authorization': `Bearer ${token || process.env.PLAYWRITER_TOKEN}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ sessionId, code, timeout, cwd }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
console.error(`Error: ${response.status} ${text}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as { text: string; images: Array<{ data: string; mimeType: string }>; isError: boolean }
|
||||||
|
|
||||||
|
// Print output
|
||||||
|
if (result.text) {
|
||||||
|
if (result.isError) {
|
||||||
|
console.error(result.text)
|
||||||
|
} else {
|
||||||
|
console.log(result.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: images are base64 encoded, we could save them to files if needed
|
||||||
|
if (result.images && result.images.length > 0) {
|
||||||
|
console.log(`\n${result.images.length} screenshot(s) captured`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.cause?.code === 'ECONNREFUSED') {
|
||||||
|
console.error('Error: Cannot connect to relay server. Make sure the Playwriter extension is running.')
|
||||||
|
} else {
|
||||||
|
console.error(`Error: ${error.message}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset command for CLI
|
||||||
|
cli
|
||||||
|
.command('reset', 'Reset the browser connection for current session')
|
||||||
|
.option('-s, --session <name>', 'Session name (required)')
|
||||||
|
.option('--host <host>', 'Remote relay server host')
|
||||||
|
.action(async (options: { session?: string; host?: string }) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const sessionId = options.session || process.env.PLAYWRITER_SESSION
|
||||||
|
const serverHost = options.host || process.env.PLAYWRITER_HOST || '127.0.0.1'
|
||||||
|
const serverUrl = `http://${serverHost}:${RELAY_PORT}`
|
||||||
|
|
||||||
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
||||||
|
await ensureRelayServer({ logger: console })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${serverUrl}/cli/session/suggest`)
|
||||||
|
const { next } = await res.json() as { next: number }
|
||||||
|
console.error(`Error: -s/--session is required. Use -s ${next} for a new session.`)
|
||||||
|
} catch {
|
||||||
|
console.error(`Error: -s/--session is required. Use -s 1 for a new session.`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverUrl}/cli/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, cwd }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
console.error(`Error: ${response.status} ${text}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as { success: boolean; pageUrl: string; pagesCount: number }
|
||||||
|
console.log(`Connection reset successfully. ${result.pagesCount} page(s) available. Current page URL: ${result.pageUrl}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error: ${error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command('serve', 'Start the CDP relay server for remote MCP connections')
|
.command('serve', 'Start the CDP relay server for remote MCP connections')
|
||||||
.option('--host <host>', 'Host to bind to', { default: '0.0.0.0' })
|
.option('--host <host>', 'Host to bind to', { default: '0.0.0.0' })
|
||||||
|
|||||||
@@ -0,0 +1,683 @@
|
|||||||
|
/**
|
||||||
|
* PlaywrightExecutor - Manages browser connection and code execution per session.
|
||||||
|
* Used by both MCP and CLI to execute Playwright code with persistent state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import os from 'node:os'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import vm from 'node:vm'
|
||||||
|
import { createPatch } from 'diff'
|
||||||
|
import { getCdpUrl } from './utils.js'
|
||||||
|
import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js'
|
||||||
|
import { getCDPSessionForPage, CDPSession, ICDPSession } from './cdp-session.js'
|
||||||
|
import { Debugger } from './debugger.js'
|
||||||
|
import { Editor } from './editor.js'
|
||||||
|
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
||||||
|
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
||||||
|
import { ScopedFS } from './scoped-fs.js'
|
||||||
|
import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js'
|
||||||
|
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
export class CodeExecutionTimeoutError extends Error {
|
||||||
|
constructor(timeout: number) {
|
||||||
|
super(`Code execution timed out after ${timeout}ms`)
|
||||||
|
this.name = 'CodeExecutionTimeoutError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usefulGlobals = {
|
||||||
|
setTimeout,
|
||||||
|
setInterval,
|
||||||
|
clearTimeout,
|
||||||
|
clearInterval,
|
||||||
|
URL,
|
||||||
|
URLSearchParams,
|
||||||
|
fetch,
|
||||||
|
Buffer,
|
||||||
|
TextEncoder,
|
||||||
|
TextDecoder,
|
||||||
|
crypto,
|
||||||
|
AbortController,
|
||||||
|
AbortSignal,
|
||||||
|
structuredClone,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe`
|
||||||
|
|
||||||
|
const MAX_LOGS_PER_PAGE = 5000
|
||||||
|
|
||||||
|
const ALLOWED_MODULES = new Set([
|
||||||
|
'path', 'node:path',
|
||||||
|
'url', 'node:url',
|
||||||
|
'querystring', 'node:querystring',
|
||||||
|
'punycode', 'node:punycode',
|
||||||
|
'crypto', 'node:crypto',
|
||||||
|
'buffer', 'node:buffer',
|
||||||
|
'string_decoder', 'node:string_decoder',
|
||||||
|
'util', 'node:util',
|
||||||
|
'assert', 'node:assert',
|
||||||
|
'events', 'node:events',
|
||||||
|
'timers', 'node:timers',
|
||||||
|
'stream', 'node:stream',
|
||||||
|
'zlib', 'node:zlib',
|
||||||
|
'http', 'node:http',
|
||||||
|
'https', 'node:https',
|
||||||
|
'http2', 'node:http2',
|
||||||
|
'os', 'node:os',
|
||||||
|
'fs', 'node:fs',
|
||||||
|
])
|
||||||
|
|
||||||
|
export interface ExecuteResult {
|
||||||
|
text: string
|
||||||
|
images: Array<{ data: string; mimeType: string }>
|
||||||
|
isError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutorLogger {
|
||||||
|
log(...args: any[]): void
|
||||||
|
error(...args: any[]): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CdpConfig {
|
||||||
|
host?: string
|
||||||
|
port?: number
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutorOptions {
|
||||||
|
cdpConfig: CdpConfig
|
||||||
|
logger?: ExecutorLogger
|
||||||
|
/** Working directory for scoped fs access */
|
||||||
|
cwd?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRegExp(value: any): value is RegExp {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaywrightExecutor {
|
||||||
|
private isConnected = false
|
||||||
|
private page: Page | null = null
|
||||||
|
private browser: Browser | null = null
|
||||||
|
private context: BrowserContext | null = null
|
||||||
|
|
||||||
|
private userState: Record<string, any> = {}
|
||||||
|
private browserLogs: Map<string, string[]> = new Map()
|
||||||
|
private lastSnapshots: WeakMap<Page, string> = new WeakMap()
|
||||||
|
private cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
|
||||||
|
private scopedFs: ScopedFS
|
||||||
|
private sandboxedRequire: NodeRequire
|
||||||
|
|
||||||
|
private cdpConfig: CdpConfig
|
||||||
|
private logger: ExecutorLogger
|
||||||
|
|
||||||
|
constructor(options: ExecutorOptions) {
|
||||||
|
this.cdpConfig = options.cdpConfig
|
||||||
|
this.logger = options.logger || { log: console.log, error: console.error }
|
||||||
|
// ScopedFS expects an array of allowed directories. If cwd is provided, use it; otherwise use defaults.
|
||||||
|
this.scopedFs = new ScopedFS(options.cwd ? [options.cwd, '/tmp', os.tmpdir()] : undefined)
|
||||||
|
this.sandboxedRequire = this.createSandboxedRequire(require)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSandboxedRequire(originalRequire: NodeRequire): NodeRequire {
|
||||||
|
const scopedFs = this.scopedFs
|
||||||
|
const sandboxedRequire = ((id: string) => {
|
||||||
|
if (!ALLOWED_MODULES.has(id)) {
|
||||||
|
const error = new Error(
|
||||||
|
`Module "${id}" is not allowed in the sandbox. ` +
|
||||||
|
`Only safe Node.js built-ins are permitted: ${[...ALLOWED_MODULES].filter((m) => !m.startsWith('node:')).join(', ')}`,
|
||||||
|
)
|
||||||
|
error.name = 'ModuleNotAllowedError'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (id === 'fs' || id === 'node:fs') {
|
||||||
|
return scopedFs
|
||||||
|
}
|
||||||
|
return originalRequire(id)
|
||||||
|
}) as NodeRequire
|
||||||
|
|
||||||
|
sandboxedRequire.resolve = originalRequire.resolve
|
||||||
|
sandboxedRequire.cache = originalRequire.cache
|
||||||
|
sandboxedRequire.extensions = originalRequire.extensions
|
||||||
|
sandboxedRequire.main = originalRequire.main
|
||||||
|
|
||||||
|
return sandboxedRequire
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> {
|
||||||
|
if (os.platform() !== 'darwin') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const options = (context as any)._options
|
||||||
|
if (!options || options.deviceScaleFactor === 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.deviceScaleFactor = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private async preserveSystemColorScheme(context: BrowserContext): Promise<void> {
|
||||||
|
const options = (context as any)._options
|
||||||
|
if (!options) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.colorScheme = 'no-override'
|
||||||
|
options.reducedMotion = 'no-override'
|
||||||
|
options.forcedColors = 'no-override'
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearUserState() {
|
||||||
|
Object.keys(this.userState).forEach((key) => delete this.userState[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearConnectionState() {
|
||||||
|
this.isConnected = false
|
||||||
|
this.browser = null
|
||||||
|
this.page = null
|
||||||
|
this.context = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupPageConsoleListener(page: Page) {
|
||||||
|
const targetId = (page as any)._guid as string | undefined
|
||||||
|
if (!targetId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.browserLogs.has(targetId)) {
|
||||||
|
this.browserLogs.set(targetId, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
page.on('framenavigated', (frame) => {
|
||||||
|
if (frame === page.mainFrame()) {
|
||||||
|
this.browserLogs.set(targetId, [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
page.on('close', () => {
|
||||||
|
this.browserLogs.delete(targetId)
|
||||||
|
})
|
||||||
|
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
try {
|
||||||
|
const logEntry = `[${msg.type()}] ${msg.text()}`
|
||||||
|
if (!this.browserLogs.has(targetId)) {
|
||||||
|
this.browserLogs.set(targetId, [])
|
||||||
|
}
|
||||||
|
const pageLogs = this.browserLogs.get(targetId)!
|
||||||
|
pageLogs.push(logEntry)
|
||||||
|
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
|
||||||
|
pageLogs.shift()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('[Executor] Failed to get console message text:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureConnection(): Promise<{ browser: Browser; page: Page }> {
|
||||||
|
if (this.isConnected && this.browser && this.page) {
|
||||||
|
return { browser: this.browser, page: this.page }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a fresh unique URL for each Playwright connection
|
||||||
|
const cdpUrl = getCdpUrl(this.cdpConfig)
|
||||||
|
const browser = await chromium.connectOverCDP(cdpUrl)
|
||||||
|
|
||||||
|
browser.on('disconnected', () => {
|
||||||
|
this.logger.log('Browser disconnected, clearing connection state')
|
||||||
|
this.clearConnectionState()
|
||||||
|
})
|
||||||
|
|
||||||
|
const contexts = browser.contexts()
|
||||||
|
const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
|
||||||
|
|
||||||
|
context.on('page', (page) => {
|
||||||
|
this.setupPageConsoleListener(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pages = context.pages()
|
||||||
|
if (pages.length === 0) {
|
||||||
|
throw new Error(NO_TABS_ERROR)
|
||||||
|
}
|
||||||
|
const page = pages[0]
|
||||||
|
|
||||||
|
context.pages().forEach((p) => this.setupPageConsoleListener(p))
|
||||||
|
|
||||||
|
await this.preserveSystemColorScheme(context)
|
||||||
|
await this.setDeviceScaleFactorForMacOS(context)
|
||||||
|
|
||||||
|
this.browser = browser
|
||||||
|
this.page = page
|
||||||
|
this.context = context
|
||||||
|
this.isConnected = true
|
||||||
|
|
||||||
|
return { browser, page }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentPage(timeout = 5000): Promise<Page> {
|
||||||
|
if (this.page && !this.page.isClosed()) {
|
||||||
|
return this.page
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.browser) {
|
||||||
|
const contexts = this.browser.contexts()
|
||||||
|
if (contexts.length > 0) {
|
||||||
|
const pages = contexts[0].pages().filter((p) => !p.isClosed())
|
||||||
|
if (pages.length > 0) {
|
||||||
|
const page = pages[0]
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout }).catch(() => {})
|
||||||
|
this.page = page
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(NO_TABS_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset(): Promise<{ page: Page; context: BrowserContext }> {
|
||||||
|
if (this.browser) {
|
||||||
|
try {
|
||||||
|
await this.browser.close()
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Error closing browser:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearConnectionState()
|
||||||
|
this.clearUserState()
|
||||||
|
|
||||||
|
// Generate a fresh unique URL for each Playwright connection
|
||||||
|
const cdpUrl = getCdpUrl(this.cdpConfig)
|
||||||
|
const browser = await chromium.connectOverCDP(cdpUrl)
|
||||||
|
|
||||||
|
browser.on('disconnected', () => {
|
||||||
|
this.logger.log('Browser disconnected, clearing connection state')
|
||||||
|
this.clearConnectionState()
|
||||||
|
})
|
||||||
|
|
||||||
|
const contexts = browser.contexts()
|
||||||
|
const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
|
||||||
|
|
||||||
|
context.on('page', (page) => {
|
||||||
|
this.setupPageConsoleListener(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pages = context.pages()
|
||||||
|
if (pages.length === 0) {
|
||||||
|
throw new Error(NO_TABS_ERROR)
|
||||||
|
}
|
||||||
|
const page = pages[0]
|
||||||
|
|
||||||
|
context.pages().forEach((p) => this.setupPageConsoleListener(p))
|
||||||
|
|
||||||
|
await this.preserveSystemColorScheme(context)
|
||||||
|
await this.setDeviceScaleFactorForMacOS(context)
|
||||||
|
|
||||||
|
this.browser = browser
|
||||||
|
this.page = page
|
||||||
|
this.context = context
|
||||||
|
this.isConnected = true
|
||||||
|
|
||||||
|
return { page, context }
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(code: string, timeout = 5000): Promise<ExecuteResult> {
|
||||||
|
const consoleLogs: Array<{ method: string; args: any[] }> = []
|
||||||
|
|
||||||
|
const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => {
|
||||||
|
if (logs.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
let text = `${prefix}:\n`
|
||||||
|
logs.forEach(({ method, args }) => {
|
||||||
|
const formattedArgs = args
|
||||||
|
.map((arg) => {
|
||||||
|
if (typeof arg === 'object') {
|
||||||
|
return JSON.stringify(arg, null, 2)
|
||||||
|
}
|
||||||
|
return String(arg)
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
text += `[${method}] ${formattedArgs}\n`
|
||||||
|
})
|
||||||
|
return text + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ensureConnection()
|
||||||
|
const page = await this.getCurrentPage(timeout)
|
||||||
|
const context = this.context || page.context()
|
||||||
|
|
||||||
|
this.logger.log('Executing code:', code)
|
||||||
|
|
||||||
|
const customConsole = {
|
||||||
|
log: (...args: any[]) => { consoleLogs.push({ method: 'log', args }) },
|
||||||
|
info: (...args: any[]) => { consoleLogs.push({ method: 'info', args }) },
|
||||||
|
warn: (...args: any[]) => { consoleLogs.push({ method: 'warn', args }) },
|
||||||
|
error: (...args: any[]) => { consoleLogs.push({ method: 'error', args }) },
|
||||||
|
debug: (...args: any[]) => { consoleLogs.push({ method: 'debug', args }) },
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibilitySnapshot = async (options: {
|
||||||
|
page: Page
|
||||||
|
search?: string | RegExp
|
||||||
|
showDiffSinceLastCall?: boolean
|
||||||
|
}) => {
|
||||||
|
const { page: targetPage, search, showDiffSinceLastCall = false } = options
|
||||||
|
if ((targetPage as any)._snapshotForAI) {
|
||||||
|
const snapshot = await (targetPage as any)._snapshotForAI()
|
||||||
|
const rawStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)
|
||||||
|
const snapshotStr = rawStr.toWellFormed?.() ?? rawStr
|
||||||
|
|
||||||
|
if (showDiffSinceLastCall) {
|
||||||
|
const previousSnapshot = this.lastSnapshots.get(targetPage)
|
||||||
|
if (!previousSnapshot) {
|
||||||
|
this.lastSnapshots.set(targetPage, snapshotStr)
|
||||||
|
return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.'
|
||||||
|
}
|
||||||
|
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { context: 3 })
|
||||||
|
if (patch.split('\n').length <= 4) {
|
||||||
|
return 'No changes detected since last snapshot'
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSnapshots.set(targetPage, snapshotStr)
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
return snapshotStr
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = snapshotStr.split('\n')
|
||||||
|
const matchIndices: number[] = []
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const isMatch = isRegExp(search) ? search.test(line) : line.includes(search)
|
||||||
|
if (isMatch) {
|
||||||
|
matchIndices.push(i)
|
||||||
|
if (matchIndices.length >= 10) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchIndices.length === 0) {
|
||||||
|
return 'No matches found'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTEXT_LINES = 5
|
||||||
|
const includedLines = new Set<number>()
|
||||||
|
for (const idx of matchIndices) {
|
||||||
|
const start = Math.max(0, idx - CONTEXT_LINES)
|
||||||
|
const end = Math.min(lines.length - 1, idx + CONTEXT_LINES)
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
includedLines.add(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedIndices = [...includedLines].sort((a, b) => a - b)
|
||||||
|
const result: string[] = []
|
||||||
|
for (let i = 0; i < sortedIndices.length; i++) {
|
||||||
|
const lineIdx = sortedIndices[i]
|
||||||
|
if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) {
|
||||||
|
result.push('---')
|
||||||
|
}
|
||||||
|
result.push(lines[lineIdx])
|
||||||
|
}
|
||||||
|
return result.join('\n')
|
||||||
|
}
|
||||||
|
throw new Error('accessibilitySnapshot is not available on this page')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLocatorStringForElement = async (element: any) => {
|
||||||
|
if (!element || typeof element.evaluate !== 'function') {
|
||||||
|
throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle')
|
||||||
|
}
|
||||||
|
const elementPage = element.page ? element.page() : page
|
||||||
|
const hasGenerator = await elementPage.evaluate(() => !!(globalThis as any).__selectorGenerator)
|
||||||
|
if (!hasGenerator) {
|
||||||
|
const scriptPath = path.join(__dirname, '..', 'dist', 'selector-generator.js')
|
||||||
|
const scriptContent = fs.readFileSync(scriptPath, 'utf-8')
|
||||||
|
const cdp = await getCDPSession({ page: elementPage })
|
||||||
|
await cdp.send('Runtime.evaluate', { expression: scriptContent })
|
||||||
|
}
|
||||||
|
return await element.evaluate((el: any) => {
|
||||||
|
const { createSelectorGenerator, toLocator } = (globalThis as any).__selectorGenerator
|
||||||
|
const generator = createSelectorGenerator(globalThis)
|
||||||
|
const result = generator(el)
|
||||||
|
return toLocator(result.selector, 'javascript')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPageTargetId = async (p: Page): Promise<string> => {
|
||||||
|
const guid = (p as any)._guid
|
||||||
|
if (guid) return guid
|
||||||
|
throw new Error('Could not get page identifier: _guid not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLatestLogs = async (options?: { page?: Page; count?: number; search?: string | RegExp }) => {
|
||||||
|
const { page: filterPage, count, search } = options || {}
|
||||||
|
let allLogs: string[] = []
|
||||||
|
|
||||||
|
if (filterPage) {
|
||||||
|
const targetId = await getPageTargetId(filterPage)
|
||||||
|
const pageLogs = this.browserLogs.get(targetId) || []
|
||||||
|
allLogs = [...pageLogs]
|
||||||
|
} else {
|
||||||
|
for (const pageLogs of this.browserLogs.values()) {
|
||||||
|
allLogs.push(...pageLogs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const matchIndices: number[] = []
|
||||||
|
for (let i = 0; i < allLogs.length; i++) {
|
||||||
|
const log = allLogs[i]
|
||||||
|
const isMatch = typeof search === 'string' ? log.includes(search) : isRegExp(search) && search.test(log)
|
||||||
|
if (isMatch) matchIndices.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTEXT_LINES = 5
|
||||||
|
const includedIndices = new Set<number>()
|
||||||
|
for (const idx of matchIndices) {
|
||||||
|
const start = Math.max(0, idx - CONTEXT_LINES)
|
||||||
|
const end = Math.min(allLogs.length - 1, idx + CONTEXT_LINES)
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
includedIndices.add(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedIndices = [...includedIndices].sort((a, b) => a - b)
|
||||||
|
const result: string[] = []
|
||||||
|
for (let i = 0; i < sortedIndices.length; i++) {
|
||||||
|
const logIdx = sortedIndices[i]
|
||||||
|
if (i > 0 && sortedIndices[i - 1] !== logIdx - 1) {
|
||||||
|
result.push('---')
|
||||||
|
}
|
||||||
|
result.push(allLogs[logIdx])
|
||||||
|
}
|
||||||
|
allLogs = result
|
||||||
|
}
|
||||||
|
|
||||||
|
return count !== undefined ? allLogs.slice(-count) : allLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAllLogs = () => {
|
||||||
|
this.browserLogs.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCDPSession = async (options: { page: Page }) => {
|
||||||
|
const cached = this.cdpSessionCache.get(options.page)
|
||||||
|
if (cached) return cached
|
||||||
|
// Generate a fresh unique URL for each CDP session to avoid client ID conflicts
|
||||||
|
const wsUrl = getCdpUrl(this.cdpConfig)
|
||||||
|
const session = await getCDPSessionForPage({ page: options.page, wsUrl })
|
||||||
|
this.cdpSessionCache.set(options.page, session)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDebugger = (options: { cdp: ICDPSession }) => new Debugger(options)
|
||||||
|
const createEditor = (options: { cdp: ICDPSession }) => new Editor(options)
|
||||||
|
|
||||||
|
const getStylesForLocatorFn = async (options: { locator: any }) => {
|
||||||
|
const cdp = await getCDPSession({ page: options.locator.page() })
|
||||||
|
return getStylesForLocator({ locator: options.locator, cdp })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReactSourceFn = async (options: { locator: any }) => {
|
||||||
|
const cdp = await getCDPSession({ page: options.locator.page() })
|
||||||
|
return getReactSource({ locator: options.locator, cdp })
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshotCollector: ScreenshotResult[] = []
|
||||||
|
|
||||||
|
const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => {
|
||||||
|
return screenshotWithAccessibilityLabels({
|
||||||
|
...options,
|
||||||
|
collector: screenshotCollector,
|
||||||
|
logger: { error: (...args) => { this.logger.error('[playwriter]', ...args) } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = this
|
||||||
|
|
||||||
|
let vmContextObj: any = {
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
state: this.userState,
|
||||||
|
console: customConsole,
|
||||||
|
accessibilitySnapshot,
|
||||||
|
getCleanHTML,
|
||||||
|
getLocatorStringForElement,
|
||||||
|
getLatestLogs,
|
||||||
|
clearAllLogs,
|
||||||
|
waitForPageLoad,
|
||||||
|
getCDPSession,
|
||||||
|
createDebugger,
|
||||||
|
createEditor,
|
||||||
|
getStylesForLocator: getStylesForLocatorFn,
|
||||||
|
formatStylesAsText,
|
||||||
|
getReactSource: getReactSourceFn,
|
||||||
|
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
||||||
|
resetPlaywright: async () => {
|
||||||
|
const { page: newPage, context: newContext } = await self.reset()
|
||||||
|
vmContextObj.page = newPage
|
||||||
|
vmContextObj.context = newContext
|
||||||
|
return { page: newPage, context: newContext }
|
||||||
|
},
|
||||||
|
require: this.sandboxedRequire,
|
||||||
|
import: (specifier: string) => import(specifier),
|
||||||
|
...usefulGlobals,
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmContext = vm.createContext(vmContextObj)
|
||||||
|
const wrappedCode = `(async () => { ${code} })()`
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
vm.runInContext(wrappedCode, vmContext, { timeout, displayErrors: true }),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new CodeExecutionTimeoutError(timeout)), timeout)),
|
||||||
|
])
|
||||||
|
|
||||||
|
let responseText = formatConsoleLogs(consoleLogs)
|
||||||
|
|
||||||
|
if (result !== undefined) {
|
||||||
|
responseText += 'Return value:\n'
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
responseText += result
|
||||||
|
} else {
|
||||||
|
responseText += JSON.stringify(result, null, 2)
|
||||||
|
}
|
||||||
|
} else if (consoleLogs.length === 0) {
|
||||||
|
responseText += 'Code executed successfully (no output)'
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const screenshot of screenshotCollector) {
|
||||||
|
responseText += `\nScreenshot saved to: ${screenshot.path}\n`
|
||||||
|
responseText += `Labels shown: ${screenshot.labelCount}\n\n`
|
||||||
|
responseText += `Accessibility snapshot:\n${screenshot.snapshot}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LENGTH = 6000
|
||||||
|
let finalText = responseText.trim()
|
||||||
|
if (finalText.length > MAX_LENGTH) {
|
||||||
|
finalText = finalText.slice(0, MAX_LENGTH) +
|
||||||
|
`\n\n[Truncated to ${MAX_LENGTH} characters. Better manage your logs or paginate them to read the full logs]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = screenshotCollector.map((s) => ({ data: s.base64, mimeType: s.mimeType }))
|
||||||
|
|
||||||
|
return { text: finalText, images, isError: false }
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorStack = error.stack || error.message
|
||||||
|
const isTimeoutError = error instanceof CodeExecutionTimeoutError || error.name === 'TimeoutError'
|
||||||
|
|
||||||
|
this.logger.error('Error in execute:', errorStack)
|
||||||
|
|
||||||
|
const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
|
||||||
|
const resetHint = isTimeoutError ? '' :
|
||||||
|
'\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]'
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
|
||||||
|
images: [],
|
||||||
|
isError: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get info about current connection state */
|
||||||
|
getStatus(): { connected: boolean; pageUrl: string | null; pagesCount: number } {
|
||||||
|
return {
|
||||||
|
connected: this.isConnected,
|
||||||
|
pageUrl: this.page?.url() || null,
|
||||||
|
pagesCount: this.context?.pages().length || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session manager for multiple executors, keyed by session ID (typically cwd hash)
|
||||||
|
*/
|
||||||
|
export class ExecutorManager {
|
||||||
|
private executors = new Map<string, PlaywrightExecutor>()
|
||||||
|
private cdpConfig: CdpConfig
|
||||||
|
private logger: ExecutorLogger
|
||||||
|
|
||||||
|
constructor(options: { cdpConfig: CdpConfig; logger?: ExecutorLogger }) {
|
||||||
|
this.cdpConfig = options.cdpConfig
|
||||||
|
this.logger = options.logger || { log: console.log, error: console.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
getExecutor(sessionId: string, cwd?: string): PlaywrightExecutor {
|
||||||
|
let executor = this.executors.get(sessionId)
|
||||||
|
if (!executor) {
|
||||||
|
executor = new PlaywrightExecutor({
|
||||||
|
cdpConfig: this.cdpConfig,
|
||||||
|
logger: this.logger,
|
||||||
|
cwd,
|
||||||
|
})
|
||||||
|
this.executors.set(sessionId, executor)
|
||||||
|
}
|
||||||
|
return executor
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteExecutor(sessionId: string): boolean {
|
||||||
|
return this.executors.delete(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
listSessions(): string[] {
|
||||||
|
return [...this.executors.keys()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+161
-1033
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Shared utilities for connecting to the relay server.
|
||||||
|
* Used by both MCP and CLI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { killPortProcess } from 'kill-port-process'
|
||||||
|
import { VERSION, sleep, LOG_FILE_PATH } from './utils.js'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
export const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988
|
||||||
|
|
||||||
|
export async function getRelayServerVersion(port: number = RELAY_PORT): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/version`, {
|
||||||
|
signal: AbortSignal.timeout(500),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { version: string }
|
||||||
|
return data.version
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killRelayServer(port: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await killPortProcess(port)
|
||||||
|
await sleep(500)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two semver versions. Returns:
|
||||||
|
* - negative if v1 < v2
|
||||||
|
* - 0 if v1 === v2
|
||||||
|
* - positive if v1 > v2
|
||||||
|
*/
|
||||||
|
function compareVersions(v1: string, v2: string): number {
|
||||||
|
const parts1 = v1.split('.').map(Number)
|
||||||
|
const parts2 = v2.split('.').map(Number)
|
||||||
|
const len = Math.max(parts1.length, parts2.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const p1 = parts1[i] || 0
|
||||||
|
const p2 = parts2[i] || 0
|
||||||
|
if (p1 !== p2) {
|
||||||
|
return p1 - p2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsureRelayServerOptions {
|
||||||
|
/** Optional logger for status messages */
|
||||||
|
logger?: {
|
||||||
|
log: (...args: any[]) => void
|
||||||
|
}
|
||||||
|
/** If true, will kill and restart server on version mismatch. Default: true */
|
||||||
|
restartOnVersionMismatch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the relay server is running. Starts it if not running.
|
||||||
|
* Optionally restarts on version mismatch.
|
||||||
|
*/
|
||||||
|
export async function ensureRelayServer(options: EnsureRelayServerOptions = {}): Promise<void> {
|
||||||
|
const { logger, restartOnVersionMismatch = true } = options
|
||||||
|
const serverVersion = await getRelayServerVersion(RELAY_PORT)
|
||||||
|
|
||||||
|
if (serverVersion === VERSION) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't restart if server version is higher than our version.
|
||||||
|
// This prevents older clients from killing a newer server.
|
||||||
|
if (serverVersion !== null && compareVersions(serverVersion, VERSION) > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverVersion !== null) {
|
||||||
|
if (restartOnVersionMismatch) {
|
||||||
|
logger?.log(`CDP relay server version mismatch (server: ${serverVersion}, client: ${VERSION}), restarting...`)
|
||||||
|
await killRelayServer(RELAY_PORT)
|
||||||
|
} else {
|
||||||
|
// Server is running but different version, just use it
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger?.log('CDP relay server not running, starting it...')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dev = process.env.PLAYWRITER_NODE_ENV === 'development'
|
||||||
|
const scriptPath = dev
|
||||||
|
? path.resolve(__dirname, '../src/start-relay-server.ts')
|
||||||
|
: path.resolve(__dirname, './start-relay-server.js')
|
||||||
|
|
||||||
|
const serverProcess = spawn(dev ? 'tsx' : process.execPath, [scriptPath], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: { ...process.env },
|
||||||
|
})
|
||||||
|
|
||||||
|
serverProcess.unref()
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await sleep(500)
|
||||||
|
const newVersion = await getRelayServerVersion(RELAY_PORT)
|
||||||
|
if (newVersion) {
|
||||||
|
logger?.log('CDP relay server started successfully')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to start CDP relay server after 5 seconds. Check logs at: ${LOG_FILE_PATH}`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user