feat: add multi-browser support
- Extension connections are now tracked per-connection with isolated state - Each connection has its own connectedTargets, pendingRequests, messageId - Sessions are bound to specific extensionId via SessionMetadata - New /extensions/status endpoint for listing all connected browsers - CLI session new shows browser selection when multiple connected - Browser detection via navigator.userAgentData (Ghost, Brave, Edge, etc) - Added identity permission for Chrome profile email detection
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"name": "Playwriter",
|
||||
"version": "0.0.69",
|
||||
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
|
||||
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen"],
|
||||
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs", "tabCapture", "offscreen", "identity", "identity.email"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
|
||||
@@ -14,13 +14,83 @@ import {
|
||||
cleanupRecordingForTab,
|
||||
} from './recording'
|
||||
|
||||
const RELAY_PORT = process.env.PLAYWRITER_PORT
|
||||
const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension`
|
||||
const RELAY_HOST = '127.0.0.1'
|
||||
const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988
|
||||
|
||||
type NavigatorWithUaData = Navigator & {
|
||||
userAgentData?: {
|
||||
brands: Array<{ brand: string; version: string }>
|
||||
}
|
||||
}
|
||||
|
||||
type ExtensionIdentity = {
|
||||
browser: string
|
||||
email: string
|
||||
id: string
|
||||
}
|
||||
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function detectBrowserName(): Promise<string> {
|
||||
if ((chrome as unknown as { ghostPublicAPI?: unknown }).ghostPublicAPI) {
|
||||
return 'Ghost'
|
||||
}
|
||||
|
||||
const navigatorWithUaData = navigator as NavigatorWithUaData
|
||||
const brands = navigatorWithUaData.userAgentData?.brands
|
||||
if (brands && brands.length > 0) {
|
||||
const brandNames = brands.map((brand) => {
|
||||
return brand.brand.trim().toLowerCase()
|
||||
})
|
||||
|
||||
if (brandNames.some((brand) => brand === 'brave')) return 'Brave'
|
||||
if (brandNames.some((brand) => brand === 'microsoft edge')) return 'Edge'
|
||||
if (brandNames.some((brand) => brand === 'opera')) return 'Opera'
|
||||
if (brandNames.some((brand) => brand === 'vivaldi')) return 'Vivaldi'
|
||||
if (brandNames.some((brand) => brand === 'google chrome')) return 'Chrome'
|
||||
if (brandNames.some((brand) => brand === 'chromium')) return 'Chromium'
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
if (ua.includes('edg/')) return 'Edge'
|
||||
if (ua.includes('opr/')) return 'Opera'
|
||||
if (ua.includes('vivaldi')) return 'Vivaldi'
|
||||
if (ua.includes('brave')) return 'Brave'
|
||||
if (ua.includes('chrome')) return 'Chrome'
|
||||
return 'Chromium'
|
||||
}
|
||||
|
||||
let identityPromise: Promise<ExtensionIdentity> | null = null
|
||||
|
||||
async function getExtensionIdentity(): Promise<ExtensionIdentity> {
|
||||
if (identityPromise) {
|
||||
return identityPromise
|
||||
}
|
||||
|
||||
identityPromise = (async () => {
|
||||
const browser = await detectBrowserName()
|
||||
try {
|
||||
const info = await chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' })
|
||||
return {
|
||||
browser,
|
||||
email: info.email || '',
|
||||
id: info.id || '',
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
browser,
|
||||
email: '',
|
||||
id: '',
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return identityPromise
|
||||
}
|
||||
|
||||
let childSessions: Map<string, { tabId: number; targetId?: string }> = new Map()
|
||||
let nextSessionId = 1
|
||||
let tabGroupQueue: Promise<void> = Promise.resolve()
|
||||
@@ -106,14 +176,14 @@ class ConnectionManager {
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
logger.debug(`Waiting for server at http://127.0.0.1:${RELAY_PORT}...`)
|
||||
logger.debug(`Waiting for server at http://${RELAY_HOST}:${RELAY_PORT}...`)
|
||||
|
||||
// Retry for up to 5 seconds with 1s intervals, then give up (maintain loop will retry later)
|
||||
// Using fewer attempts since maintainLoop retries every 3 seconds anyway
|
||||
const maxAttempts = 5
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${RELAY_PORT}`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
|
||||
await fetch(`http://${RELAY_HOST}:${RELAY_PORT}`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
|
||||
logger.debug('Server is available')
|
||||
break
|
||||
} catch {
|
||||
@@ -125,8 +195,19 @@ class ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Creating WebSocket connection to:', RELAY_URL)
|
||||
const socket = new WebSocket(RELAY_URL)
|
||||
const identity = await getExtensionIdentity()
|
||||
const relayUrl = new URL(`ws://${RELAY_HOST}:${RELAY_PORT}/extension`)
|
||||
if (identity.browser) {
|
||||
relayUrl.searchParams.set('browser', identity.browser)
|
||||
}
|
||||
if (identity.email) {
|
||||
relayUrl.searchParams.set('email', identity.email)
|
||||
}
|
||||
if (identity.id) {
|
||||
relayUrl.searchParams.set('id', identity.id)
|
||||
}
|
||||
logger.debug('Creating WebSocket connection to:', relayUrl)
|
||||
const socket = new WebSocket(relayUrl.toString())
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
@@ -389,7 +470,7 @@ class ConnectionManager {
|
||||
// Slot is free when: no extension connected, OR connected but no active tabs.
|
||||
if (store.getState().connectionState === 'extension-replaced') {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) })
|
||||
const response = await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) })
|
||||
const data = await response.json() as { connected: boolean; activeTargets: number }
|
||||
const slotAvailable = !data.connected || data.activeTargets === 0
|
||||
if (slotAvailable) {
|
||||
@@ -1369,7 +1450,7 @@ store.subscribe((state, prevState) => {
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(`Using relay URL: ${RELAY_URL}`)
|
||||
logger.debug(`Using relay host: ${RELAY_HOST}, port: ${RELAY_PORT}`)
|
||||
|
||||
// Memory monitoring - helps debug service worker termination issues
|
||||
let lastMemoryUsage = 0
|
||||
|
||||
+353
-124
@@ -54,6 +54,23 @@ function isRestrictedTarget(targetInfo: Protocol.Target.TargetInfo): boolean {
|
||||
type PlaywrightClient = {
|
||||
id: string
|
||||
ws: WSContext
|
||||
extensionId: string | null
|
||||
}
|
||||
|
||||
type ExtensionInfo = {
|
||||
browser?: string
|
||||
email?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
type ExtensionConnection = {
|
||||
id: string
|
||||
ws: WSContext
|
||||
info: ExtensionInfo
|
||||
connectedTargets: Map<string, ConnectedTarget>
|
||||
pendingRequests: Map<number, { resolve: (result: any) => void; reject: (error: Error) => void }>
|
||||
messageId: number
|
||||
pingInterval: ReturnType<typeof setInterval> | null
|
||||
}
|
||||
|
||||
|
||||
@@ -77,36 +94,49 @@ export async function startPlayWriterCDPRelayServer({
|
||||
cdpLogger?: CdpLogger
|
||||
} = {}): Promise<RelayServer> {
|
||||
const emitter = new EventEmitter()
|
||||
const connectedTargets = new Map<string, ConnectedTarget>()
|
||||
const extensionConnections = new Map<string, ExtensionConnection>()
|
||||
|
||||
const resolvedCdpLogger = cdpLogger || createCdpLogger()
|
||||
const logCdpJson = (entry: CdpLogEntry) => {
|
||||
resolvedCdpLogger.log(entry)
|
||||
}
|
||||
const playwrightClients = new Map<string, PlaywrightClient>()
|
||||
let extensionWs: WSContext | null = null
|
||||
|
||||
const extensionPendingRequests = new Map<number, {
|
||||
resolve: (result: any) => void
|
||||
reject: (error: Error) => void
|
||||
}>()
|
||||
let extensionMessageId = 0
|
||||
let extensionPingInterval: ReturnType<typeof setInterval> | null = null
|
||||
const getDefaultExtensionId = (): string | null => {
|
||||
return extensionConnections.keys().next().value || null
|
||||
}
|
||||
|
||||
function startExtensionPing() {
|
||||
if (extensionPingInterval) {
|
||||
clearInterval(extensionPingInterval)
|
||||
const getExtensionConnection = (extensionId?: string | null): ExtensionConnection | null => {
|
||||
if (extensionId && extensionConnections.has(extensionId)) {
|
||||
return extensionConnections.get(extensionId) || null
|
||||
}
|
||||
extensionPingInterval = setInterval(() => {
|
||||
extensionWs?.send(JSON.stringify({ method: 'ping' }))
|
||||
const fallbackId = getDefaultExtensionId()
|
||||
if (fallbackId) {
|
||||
return extensionConnections.get(fallbackId) || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const startExtensionPing = (extensionId: string): void => {
|
||||
const connection = extensionConnections.get(extensionId)
|
||||
if (!connection) {
|
||||
return
|
||||
}
|
||||
if (connection.pingInterval) {
|
||||
clearInterval(connection.pingInterval)
|
||||
}
|
||||
connection.pingInterval = setInterval(() => {
|
||||
connection.ws.send(JSON.stringify({ method: 'ping' }))
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopExtensionPing() {
|
||||
if (extensionPingInterval) {
|
||||
clearInterval(extensionPingInterval)
|
||||
extensionPingInterval = null
|
||||
const stopExtensionPing = (extensionId: string): void => {
|
||||
const connection = extensionConnections.get(extensionId)
|
||||
if (!connection || !connection.pingInterval) {
|
||||
return
|
||||
}
|
||||
clearInterval(connection.pingInterval)
|
||||
connection.pingInterval = null
|
||||
}
|
||||
|
||||
function logCdpMessage({
|
||||
@@ -179,11 +209,13 @@ export async function startPlayWriterCDPRelayServer({
|
||||
function sendToPlaywright({
|
||||
message,
|
||||
clientId,
|
||||
source = 'extension'
|
||||
source = 'extension',
|
||||
extensionId,
|
||||
}: {
|
||||
message: CDPResponseBase | CDPEventBase
|
||||
clientId?: string
|
||||
source?: 'extension' | 'server'
|
||||
extensionId?: string | null
|
||||
}) {
|
||||
const messageToSend = source === 'server' && 'method' in message
|
||||
? { ...message, __serverGenerated: true }
|
||||
@@ -231,9 +263,11 @@ export async function startPlayWriterCDPRelayServer({
|
||||
safeSend(client)
|
||||
}
|
||||
} else {
|
||||
// Copy the clients array to avoid issues if a client disconnects during iteration
|
||||
const clients = Array.from(playwrightClients.values())
|
||||
for (const client of clients) {
|
||||
if (extensionId && client.extensionId !== extensionId) {
|
||||
continue
|
||||
}
|
||||
safeSend(client)
|
||||
}
|
||||
}
|
||||
@@ -257,12 +291,23 @@ export async function startPlayWriterCDPRelayServer({
|
||||
return { method: record.method, sessionId, params: record.params }
|
||||
}
|
||||
|
||||
async function sendToExtension({ method, params, timeout = 30000 }: { method: string; params?: unknown; timeout?: number }): Promise<unknown> {
|
||||
if (!extensionWs) {
|
||||
async function sendToExtension({
|
||||
extensionId,
|
||||
method,
|
||||
params,
|
||||
timeout = 30000,
|
||||
}: {
|
||||
extensionId?: string | null
|
||||
method: string
|
||||
params?: unknown
|
||||
timeout?: number
|
||||
}): Promise<unknown> {
|
||||
const connection = getExtensionConnection(extensionId)
|
||||
if (!connection) {
|
||||
throw new Error('Extension not connected')
|
||||
}
|
||||
|
||||
const id = ++extensionMessageId
|
||||
const id = ++connection.messageId
|
||||
const message = { id, method, params }
|
||||
|
||||
const forwardCdpParams = method === 'forwardCDPCommand' ? getForwardCdpParams(params) : undefined
|
||||
@@ -278,15 +323,15 @@ export async function startPlayWriterCDPRelayServer({
|
||||
})
|
||||
}
|
||||
|
||||
extensionWs.send(JSON.stringify(message))
|
||||
connection.ws.send(JSON.stringify(message))
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
extensionPendingRequests.delete(id)
|
||||
connection.pendingRequests.delete(id)
|
||||
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`))
|
||||
}, timeout)
|
||||
|
||||
extensionPendingRequests.set(id, {
|
||||
connection.pendingRequests.set(id, {
|
||||
resolve: (result) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(result)
|
||||
@@ -299,48 +344,76 @@ export async function startPlayWriterCDPRelayServer({
|
||||
})
|
||||
}
|
||||
|
||||
// Recording relay for screen recording functionality
|
||||
const recordingRelay = new RecordingRelay(
|
||||
sendToExtension,
|
||||
() => extensionWs !== null,
|
||||
logger,
|
||||
)
|
||||
const recordingRelays = new Map<string, RecordingRelay>()
|
||||
|
||||
const getRecordingRelay = (extensionId?: string | null): RecordingRelay | null => {
|
||||
const connection = getExtensionConnection(extensionId)
|
||||
if (!connection) {
|
||||
return null
|
||||
}
|
||||
if (!recordingRelays.has(connection.id)) {
|
||||
recordingRelays.set(
|
||||
connection.id,
|
||||
new RecordingRelay(
|
||||
(params) => sendToExtension({ extensionId: connection.id, ...params }),
|
||||
() => extensionConnections.has(connection.id),
|
||||
logger,
|
||||
)
|
||||
)
|
||||
}
|
||||
return recordingRelays.get(connection.id) || null
|
||||
}
|
||||
|
||||
// Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist.
|
||||
// This allows Playwright to connect and immediately have a page to work with.
|
||||
async function maybeAutoCreateInitialTab(): Promise<void> {
|
||||
async function maybeAutoCreateInitialTab(extensionId: string): Promise<void> {
|
||||
if (!process.env.PLAYWRITER_AUTO_ENABLE) {
|
||||
return
|
||||
}
|
||||
if (!extensionWs) {
|
||||
const connection = getExtensionConnection(extensionId)
|
||||
if (!connection) {
|
||||
return
|
||||
}
|
||||
if (connectedTargets.size > 0) {
|
||||
if (connection.connectedTargets.size > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger?.log(pc.blue('Auto-creating initial tab for Playwright client'))
|
||||
const result = await sendToExtension({ method: 'createInitialTab', timeout: 10000 }) as {
|
||||
const result = await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 }) as {
|
||||
success: boolean
|
||||
tabId: number
|
||||
sessionId: string
|
||||
targetInfo: Protocol.Target.TargetInfo
|
||||
}
|
||||
if (result.success && result.sessionId && result.targetInfo) {
|
||||
connectedTargets.set(result.sessionId, {
|
||||
connection.connectedTargets.set(result.sessionId, {
|
||||
sessionId: result.sessionId,
|
||||
targetId: result.targetInfo.targetId,
|
||||
targetInfo: result.targetInfo
|
||||
})
|
||||
logger?.log(pc.blue(`Auto-created tab, now have ${connectedTargets.size} targets, url: ${result.targetInfo.url}`))
|
||||
logger?.log(pc.blue(`Auto-created tab, now have ${connection.connectedTargets.size} targets, url: ${result.targetInfo.url}`))
|
||||
}
|
||||
} catch (e) {
|
||||
logger?.error('Failed to auto-create initial tab:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function routeCdpCommand({ method, params, sessionId, source }: { method: string; params: any; sessionId?: string; source?: 'playwriter' }) {
|
||||
async function routeCdpCommand({
|
||||
extensionId,
|
||||
method,
|
||||
params,
|
||||
sessionId,
|
||||
source,
|
||||
}: {
|
||||
extensionId: string | null
|
||||
method: string
|
||||
params: any
|
||||
sessionId?: string
|
||||
source?: 'playwriter'
|
||||
}) {
|
||||
const extension = getExtensionConnection(extensionId)
|
||||
const connectedTargets = extension?.connectedTargets || new Map<string, ConnectedTarget>()
|
||||
switch (method) {
|
||||
case 'Browser.getVersion': {
|
||||
return {
|
||||
@@ -363,10 +436,13 @@ export async function startPlayWriterCDPRelayServer({
|
||||
if (sessionId) {
|
||||
break
|
||||
}
|
||||
await maybeAutoCreateInitialTab()
|
||||
if (extension) {
|
||||
await maybeAutoCreateInitialTab(extension.id)
|
||||
}
|
||||
// Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
|
||||
// Playwright relies on these (with parentFrameId) when reconnecting over CDP.
|
||||
await sendToExtension({
|
||||
extensionId: extension?.id || extensionId,
|
||||
method: 'forwardCDPCommand',
|
||||
params: { method, params, source }
|
||||
})
|
||||
@@ -427,6 +503,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
|
||||
case 'Target.createTarget': {
|
||||
return await sendToExtension({
|
||||
extensionId: extension?.id || extensionId,
|
||||
method: 'forwardCDPCommand',
|
||||
params: { method, params, source }
|
||||
})
|
||||
@@ -434,6 +511,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
|
||||
case 'Target.closeTarget': {
|
||||
return await sendToExtension({
|
||||
extensionId: extension?.id || extensionId,
|
||||
method: 'forwardCDPCommand',
|
||||
params: { method, params, source }
|
||||
})
|
||||
@@ -442,6 +520,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
// Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects
|
||||
case 'ghost-browser': {
|
||||
return await sendToExtension({
|
||||
extensionId: extension?.id || extensionId,
|
||||
method: 'ghost-browser',
|
||||
params
|
||||
})
|
||||
@@ -472,6 +551,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
})
|
||||
|
||||
const result = await sendToExtension({
|
||||
extensionId: extension?.id || extensionId,
|
||||
method: 'forwardCDPCommand',
|
||||
params: { sessionId, method, params, source }
|
||||
})
|
||||
@@ -483,6 +563,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
|
||||
return await sendToExtension({
|
||||
extensionId: extension?.id || extensionId,
|
||||
method: 'forwardCDPCommand',
|
||||
params: { sessionId, method, params, source }
|
||||
})
|
||||
@@ -521,12 +602,31 @@ export async function startPlayWriterCDPRelayServer({
|
||||
})
|
||||
|
||||
app.get('/extension/status', (c) => {
|
||||
const defaultExtension = getExtensionConnection(null)
|
||||
const connected = extensionConnections.size > 0
|
||||
const activeTargets = defaultExtension?.connectedTargets.size || 0
|
||||
const info = defaultExtension?.info
|
||||
|
||||
return c.json({
|
||||
connected: extensionWs !== null,
|
||||
activeTargets: connectedTargets.size
|
||||
connected,
|
||||
activeTargets,
|
||||
browser: info?.browser || null,
|
||||
profile: info ? { email: info.email || '', id: info.id || '' } : null,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/extensions/status', (c) => {
|
||||
const extensions = Array.from(extensionConnections.values()).map((extension) => {
|
||||
return {
|
||||
extensionId: extension.id,
|
||||
browser: extension.info.browser || null,
|
||||
profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
|
||||
activeTargets: extension.connectedTargets.size,
|
||||
}
|
||||
})
|
||||
return c.json({ extensions })
|
||||
})
|
||||
|
||||
// CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API
|
||||
// Allows tools like Playwright to discover the WebSocket URL via http://host:port
|
||||
// Spec: https://chromium.googlesource.com/chromium/src/+/main/content/browser/devtools/devtools_http_handler.cc
|
||||
@@ -548,8 +648,9 @@ export async function startPlayWriterCDPRelayServer({
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json/list', (c) => {
|
||||
const wsUrl = getCdpWsUrl(c)
|
||||
const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map()
|
||||
return c.json(
|
||||
Array.from(connectedTargets.values()).map(t => ({
|
||||
Array.from(defaultTargets.values()).map(t => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type,
|
||||
title: t.targetInfo.title,
|
||||
@@ -562,8 +663,9 @@ export async function startPlayWriterCDPRelayServer({
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json/list/', (c) => {
|
||||
const wsUrl = getCdpWsUrl(c)
|
||||
const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map()
|
||||
return c.json(
|
||||
Array.from(connectedTargets.values()).map(t => ({
|
||||
Array.from(defaultTargets.values()).map(t => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type,
|
||||
title: t.targetInfo.title,
|
||||
@@ -576,8 +678,9 @@ export async function startPlayWriterCDPRelayServer({
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json', (c) => {
|
||||
const wsUrl = getCdpWsUrl(c)
|
||||
const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map()
|
||||
return c.json(
|
||||
Array.from(connectedTargets.values()).map(t => ({
|
||||
Array.from(defaultTargets.values()).map(t => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type,
|
||||
title: t.targetInfo.title,
|
||||
@@ -590,8 +693,9 @@ export async function startPlayWriterCDPRelayServer({
|
||||
})
|
||||
.on(['GET', 'PUT'], '/json/', (c) => {
|
||||
const wsUrl = getCdpWsUrl(c)
|
||||
const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map()
|
||||
return c.json(
|
||||
Array.from(connectedTargets.values()).map(t => ({
|
||||
Array.from(defaultTargets.values()).map(t => ({
|
||||
id: t.targetId,
|
||||
type: t.targetInfo.type,
|
||||
title: t.targetInfo.title,
|
||||
@@ -646,6 +750,10 @@ export async function startPlayWriterCDPRelayServer({
|
||||
return next()
|
||||
}, upgradeWebSocket((c) => {
|
||||
const clientId = c.req.param('clientId') || 'default'
|
||||
const url = new URL(c.req.url, 'http://localhost')
|
||||
const requestedExtensionId = url.searchParams.get('extensionId')
|
||||
const resolvedExtension = getExtensionConnection(requestedExtensionId)
|
||||
const clientExtensionId = resolvedExtension?.id || null
|
||||
|
||||
return {
|
||||
async onOpen(_event, ws) {
|
||||
@@ -656,8 +764,10 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
|
||||
// Add client first so it can receive Target.attachedToTarget events
|
||||
playwrightClients.set(clientId, { id: clientId, ws })
|
||||
logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionWs}) (${connectedTargets.size} pages)`))
|
||||
playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId || null })
|
||||
const extensionConnection = getExtensionConnection(clientExtensionId)
|
||||
const targetCount = extensionConnection?.connectedTargets.size || 0
|
||||
logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`))
|
||||
},
|
||||
|
||||
async onMessage(event, ws) {
|
||||
@@ -688,7 +798,8 @@ export async function startPlayWriterCDPRelayServer({
|
||||
|
||||
emitter.emit('cdp:command', { clientId, command: message })
|
||||
|
||||
if (!extensionWs) {
|
||||
const extensionConnection = getExtensionConnection(clientExtensionId)
|
||||
if (!extensionConnection) {
|
||||
sendToPlaywright({
|
||||
message: {
|
||||
id,
|
||||
@@ -701,10 +812,10 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
|
||||
try {
|
||||
const result: any = await routeCdpCommand({ method, params, sessionId, source })
|
||||
const result: any = await routeCdpCommand({ extensionId: extensionConnection.id, method, params, sessionId, source })
|
||||
|
||||
if (method === 'Target.setAutoAttach' && !sessionId) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
for (const target of extensionConnection.connectedTargets.values()) {
|
||||
// Skip restricted targets (extensions, chrome:// URLs, non-page types)
|
||||
if (isRestrictedTarget(target.targetInfo)) {
|
||||
continue
|
||||
@@ -733,7 +844,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
|
||||
if (method === 'Target.setDiscoverTargets' && (params as any)?.discover) {
|
||||
for (const target of connectedTargets.values()) {
|
||||
for (const target of extensionConnection.connectedTargets.values()) {
|
||||
// Skip restricted targets (extensions, chrome:// URLs, non-page types)
|
||||
if (isRestrictedTarget(target.targetInfo)) {
|
||||
continue
|
||||
@@ -761,7 +872,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
|
||||
if (method === 'Target.attachToTarget' && result?.sessionId) {
|
||||
const targetId = params?.targetId
|
||||
const target = Array.from(connectedTargets.values()).find(t => t.targetId === targetId)
|
||||
const target = Array.from(extensionConnection.connectedTargets.values()).find(t => t.targetId === targetId)
|
||||
if (target) {
|
||||
const attachedPayload = {
|
||||
method: 'Target.attachedToTarget',
|
||||
@@ -812,6 +923,17 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
}))
|
||||
|
||||
const getExtensionInfoFromRequest = (c: { req: { query: (name: string) => string | undefined } }): ExtensionInfo => {
|
||||
const browser = c.req.query('browser')
|
||||
const email = c.req.query('email')
|
||||
const id = c.req.query('id')
|
||||
return {
|
||||
browser: browser || undefined,
|
||||
email: email || undefined,
|
||||
id: id || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/extension', (c, next) => {
|
||||
// 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
|
||||
// This prevents attackers on the network from hijacking the browser session
|
||||
@@ -841,44 +963,38 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
|
||||
return next()
|
||||
}, upgradeWebSocket(() => {
|
||||
}, upgradeWebSocket((c) => {
|
||||
const incomingExtensionInfo = getExtensionInfoFromRequest(c)
|
||||
const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
if (extensionWs) {
|
||||
// Only allow replacement if the existing extension has no active tabs.
|
||||
// This prevents a new extension from hijacking an actively-used session.
|
||||
// If the existing extension is idle (no tabs), allow the new one to take over.
|
||||
if (connectedTargets.size > 0) {
|
||||
logger?.log(pc.yellow(`Rejecting new extension - existing one has ${connectedTargets.size} active tab(s)`))
|
||||
ws.close(4002, 'Extension Already In Use')
|
||||
return
|
||||
}
|
||||
|
||||
logger?.log(pc.yellow('Replacing idle extension connection (no active tabs)'))
|
||||
extensionWs.close(4001, 'Extension Replaced')
|
||||
|
||||
// Clear state from the old connection to prevent leaks
|
||||
for (const pending of extensionPendingRequests.values()) {
|
||||
pending.reject(new Error('Extension connection replaced'))
|
||||
}
|
||||
extensionPendingRequests.clear()
|
||||
|
||||
for (const client of playwrightClients.values()) {
|
||||
client.ws.close(1000, 'Extension Replaced')
|
||||
}
|
||||
playwrightClients.clear()
|
||||
const connection: ExtensionConnection = {
|
||||
id: connectionId,
|
||||
ws,
|
||||
info: incomingExtensionInfo,
|
||||
connectedTargets: new Map(),
|
||||
pendingRequests: new Map(),
|
||||
messageId: 0,
|
||||
pingInterval: null,
|
||||
}
|
||||
|
||||
extensionWs = ws
|
||||
startExtensionPing()
|
||||
logger?.log('Extension connected with clean state')
|
||||
extensionConnections.set(connectionId, connection)
|
||||
startExtensionPing(connectionId)
|
||||
logger?.log(`Extension connected (${connectionId})`)
|
||||
},
|
||||
|
||||
async onMessage(event, ws) {
|
||||
const connection = extensionConnections.get(connectionId)
|
||||
if (!connection) {
|
||||
ws.close(1000, 'Extension not registered')
|
||||
return
|
||||
}
|
||||
// Handle binary data (recording chunks)
|
||||
if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
|
||||
const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
|
||||
recordingRelay.handleBinaryData(buffer)
|
||||
const relay = getRecordingRelay(connectionId)
|
||||
if (relay) {
|
||||
relay.handleBinaryData(buffer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -892,13 +1008,13 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
const pending = extensionPendingRequests.get(message.id)
|
||||
const pending = connection.pendingRequests.get(message.id)
|
||||
if (!pending) {
|
||||
logger?.log('Unexpected response with id:', message.id)
|
||||
return
|
||||
}
|
||||
|
||||
extensionPendingRequests.delete(message.id)
|
||||
connection.pendingRequests.delete(message.id)
|
||||
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error))
|
||||
@@ -914,9 +1030,15 @@ export async function startPlayWriterCDPRelayServer({
|
||||
const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`)
|
||||
logFunc?.(prefix, ...args)
|
||||
} else if (message.method === 'recordingData') {
|
||||
recordingRelay.handleRecordingData(message as RecordingDataMessage)
|
||||
const relay = getRecordingRelay(connectionId)
|
||||
if (relay) {
|
||||
relay.handleRecordingData(message as RecordingDataMessage)
|
||||
}
|
||||
} else if (message.method === 'recordingCancelled') {
|
||||
recordingRelay.handleRecordingCancelled(message as RecordingCancelledMessage)
|
||||
const relay = getRecordingRelay(connectionId)
|
||||
if (relay) {
|
||||
relay.handleRecordingCancelled(message as RecordingCancelledMessage)
|
||||
}
|
||||
} else {
|
||||
const extensionEvent = message as ExtensionEventMessage
|
||||
|
||||
@@ -949,6 +1071,7 @@ export async function startPlayWriterCDPRelayServer({
|
||||
if (isRestrictedTarget(targetParams.targetInfo)) {
|
||||
if (targetParams.waitingForDebugger && targetParams.sessionId) {
|
||||
void sendToExtension({
|
||||
extensionId: connectionId,
|
||||
method: 'forwardCDPCommand',
|
||||
params: {
|
||||
sessionId: targetParams.sessionId,
|
||||
@@ -971,10 +1094,10 @@ export async function startPlayWriterCDPRelayServer({
|
||||
logger?.log(pc.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId }))
|
||||
|
||||
// Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
|
||||
const alreadyConnected = connectedTargets.has(targetParams.sessionId)
|
||||
const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId)
|
||||
|
||||
// Always update our local state with latest target info
|
||||
connectedTargets.set(targetParams.sessionId, {
|
||||
connection.connectedTargets.set(targetParams.sessionId, {
|
||||
sessionId: targetParams.sessionId,
|
||||
targetId: targetParams.targetInfo.targetId,
|
||||
targetInfo: targetParams.targetInfo
|
||||
@@ -987,25 +1110,27 @@ export async function startPlayWriterCDPRelayServer({
|
||||
method: 'Target.attachedToTarget',
|
||||
params: targetParams
|
||||
} as CDPEventBase,
|
||||
source: 'extension'
|
||||
source: 'extension',
|
||||
extensionId: connectionId,
|
||||
})
|
||||
}
|
||||
} else if (method === 'Target.detachedFromTarget') {
|
||||
const detachParams = params as Protocol.Target.DetachedFromTargetEvent
|
||||
connectedTargets.delete(detachParams.sessionId)
|
||||
connection.connectedTargets.delete(detachParams.sessionId)
|
||||
|
||||
sendToPlaywright({
|
||||
message: {
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: detachParams
|
||||
} as CDPEventBase,
|
||||
source: 'extension'
|
||||
source: 'extension',
|
||||
extensionId: connectionId,
|
||||
})
|
||||
} else if (method === 'Target.targetCrashed') {
|
||||
const crashParams = params as Protocol.Target.TargetCrashedEvent
|
||||
for (const [sid, target] of connectedTargets.entries()) {
|
||||
for (const [sid, target] of connection.connectedTargets.entries()) {
|
||||
if (target.targetId === crashParams.targetId) {
|
||||
connectedTargets.delete(sid)
|
||||
connection.connectedTargets.delete(sid)
|
||||
logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId)
|
||||
break
|
||||
}
|
||||
@@ -1016,11 +1141,12 @@ export async function startPlayWriterCDPRelayServer({
|
||||
method: 'Target.targetCrashed',
|
||||
params: crashParams
|
||||
} as CDPEventBase,
|
||||
source: 'extension'
|
||||
source: 'extension',
|
||||
extensionId: connectionId,
|
||||
})
|
||||
} else if (method === 'Target.targetInfoChanged') {
|
||||
const infoParams = params as Protocol.Target.TargetInfoChangedEvent
|
||||
for (const target of connectedTargets.values()) {
|
||||
for (const target of connection.connectedTargets.values()) {
|
||||
if (target.targetId === infoParams.targetInfo.targetId) {
|
||||
target.targetInfo = infoParams.targetInfo
|
||||
break
|
||||
@@ -1032,12 +1158,13 @@ export async function startPlayWriterCDPRelayServer({
|
||||
method: 'Target.targetInfoChanged',
|
||||
params: infoParams
|
||||
} as CDPEventBase,
|
||||
source: 'extension'
|
||||
source: 'extension',
|
||||
extensionId: connectionId,
|
||||
})
|
||||
} else if (method === 'Page.frameNavigated') {
|
||||
const frameParams = params as Protocol.Page.FrameNavigatedEvent
|
||||
if (!frameParams.frame.parentId && sessionId) {
|
||||
const target = connectedTargets.get(sessionId)
|
||||
const target = connection.connectedTargets.get(sessionId)
|
||||
if (target) {
|
||||
target.targetInfo = {
|
||||
...target.targetInfo,
|
||||
@@ -1054,12 +1181,13 @@ export async function startPlayWriterCDPRelayServer({
|
||||
method,
|
||||
params
|
||||
} as CDPEventBase,
|
||||
source: 'extension'
|
||||
source: 'extension',
|
||||
extensionId: connectionId,
|
||||
})
|
||||
} else if (method === 'Page.navigatedWithinDocument') {
|
||||
const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent
|
||||
if (sessionId) {
|
||||
const target = connectedTargets.get(sessionId)
|
||||
const target = connection.connectedTargets.get(sessionId)
|
||||
if (target) {
|
||||
target.targetInfo = {
|
||||
...target.targetInfo,
|
||||
@@ -1075,7 +1203,8 @@ export async function startPlayWriterCDPRelayServer({
|
||||
method,
|
||||
params
|
||||
} as CDPEventBase,
|
||||
source: 'extension'
|
||||
source: 'extension',
|
||||
extensionId: connectionId,
|
||||
})
|
||||
} else {
|
||||
sendToPlaywright({
|
||||
@@ -1084,35 +1213,36 @@ export async function startPlayWriterCDPRelayServer({
|
||||
method,
|
||||
params
|
||||
} as CDPEventBase,
|
||||
source: 'extension'
|
||||
source: 'extension',
|
||||
extensionId: connectionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onClose(event, ws) {
|
||||
logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'}`)
|
||||
stopExtensionPing()
|
||||
logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`)
|
||||
stopExtensionPing(connectionId)
|
||||
|
||||
// If this is an old connection closing after we've already established a new one,
|
||||
// don't clear the global state
|
||||
if (extensionWs && extensionWs !== ws) {
|
||||
logger?.log('Old extension connection closed, keeping new one active')
|
||||
return
|
||||
const connection = extensionConnections.get(connectionId)
|
||||
if (connection) {
|
||||
for (const pending of connection.pendingRequests.values()) {
|
||||
pending.reject(new Error('Extension connection closed'))
|
||||
}
|
||||
connection.pendingRequests.clear()
|
||||
connection.connectedTargets.clear()
|
||||
}
|
||||
|
||||
for (const pending of extensionPendingRequests.values()) {
|
||||
pending.reject(new Error('Extension connection closed'))
|
||||
}
|
||||
extensionPendingRequests.clear()
|
||||
extensionConnections.delete(connectionId)
|
||||
recordingRelays.delete(connectionId)
|
||||
|
||||
extensionWs = null
|
||||
connectedTargets.clear()
|
||||
|
||||
for (const client of playwrightClients.values()) {
|
||||
for (const [clientId, client] of playwrightClients.entries()) {
|
||||
if (client.extensionId !== connectionId) {
|
||||
continue
|
||||
}
|
||||
client.ws.close(1000, 'Extension disconnected')
|
||||
playwrightClients.delete(clientId)
|
||||
}
|
||||
playwrightClients.clear()
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
@@ -1153,7 +1283,16 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
|
||||
const manager = await getExecutorManager()
|
||||
const executor = manager.getExecutor(sessionId, cwd)
|
||||
const defaultExtension = getExtensionConnection(null)
|
||||
const executor = manager.getExecutor({
|
||||
sessionId,
|
||||
cwd,
|
||||
sessionMetadata: {
|
||||
extensionId: defaultExtension?.id || null,
|
||||
browser: defaultExtension?.info.browser || null,
|
||||
profile: defaultExtension?.info ? { email: defaultExtension.info.email || '', id: defaultExtension.info.id || '' } : null,
|
||||
},
|
||||
})
|
||||
const result = await executor.execute(code, timeout)
|
||||
|
||||
// Increment session counter after each execution to avoid conflicts
|
||||
@@ -1176,7 +1315,16 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
|
||||
const manager = await getExecutorManager()
|
||||
const executor = manager.getExecutor(sessionId, cwd)
|
||||
const defaultExtension = getExtensionConnection(null)
|
||||
const executor = manager.getExecutor({
|
||||
sessionId,
|
||||
cwd,
|
||||
sessionMetadata: {
|
||||
extensionId: defaultExtension?.id || null,
|
||||
browser: defaultExtension?.info.browser || null,
|
||||
profile: defaultExtension?.info ? { email: defaultExtension.info.email || '', id: defaultExtension.info.id || '' } : null,
|
||||
},
|
||||
})
|
||||
const { page, context } = await executor.reset()
|
||||
|
||||
return c.json({
|
||||
@@ -1199,6 +1347,48 @@ export async function startPlayWriterCDPRelayServer({
|
||||
return c.json({ next: nextSessionNumber })
|
||||
})
|
||||
|
||||
app.post('/cli/session/new', async (c) => {
|
||||
const body = await c.req.json().catch(() => ({})) as { extensionId?: string | null }
|
||||
const sessionId = String(nextSessionNumber++)
|
||||
const extensionId = body.extensionId || null
|
||||
const extension = getExtensionConnection(extensionId)
|
||||
if (!extension) {
|
||||
return c.json({ error: 'Extension not connected' }, 404)
|
||||
}
|
||||
const manager = await getExecutorManager()
|
||||
const executor = manager.getExecutor({
|
||||
sessionId,
|
||||
sessionMetadata: {
|
||||
extensionId: extension.id,
|
||||
browser: extension.info.browser || null,
|
||||
profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
|
||||
},
|
||||
})
|
||||
const metadata = executor.getSessionMetadata()
|
||||
return c.json({
|
||||
id: sessionId,
|
||||
extensionId: metadata.extensionId,
|
||||
browser: metadata.browser,
|
||||
profile: metadata.profile,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/cli/session/:id', async (c) => {
|
||||
const sessionId = c.req.param('id')
|
||||
const manager = await getExecutorManager()
|
||||
const executor = manager.getSession(sessionId)
|
||||
if (!executor) {
|
||||
return c.json({ error: 'not found' }, 404)
|
||||
}
|
||||
const metadata = executor.getSessionMetadata()
|
||||
return c.json({
|
||||
id: sessionId,
|
||||
extensionId: metadata.extensionId,
|
||||
browser: metadata.browser,
|
||||
profile: metadata.profile,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/cli/session/delete', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json() as { sessionId: string }
|
||||
@@ -1214,7 +1404,6 @@ export async function startPlayWriterCDPRelayServer({
|
||||
if (!deleted) {
|
||||
return c.json({ error: `Session ${sessionId} not found` }, 404)
|
||||
}
|
||||
|
||||
return c.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger?.error('Delete session endpoint error:', error)
|
||||
@@ -1228,27 +1417,64 @@ export async function startPlayWriterCDPRelayServer({
|
||||
|
||||
app.post('/recording/start', async (c) => {
|
||||
const body = await c.req.json() as { outputPath?: string; sessionId?: string; frameRate?: number; audio?: boolean; videoBitsPerSecond?: number; audioBitsPerSecond?: number }
|
||||
const result = await recordingRelay.startRecording(body as { outputPath: string } & typeof body)
|
||||
const manager = await getExecutorManager()
|
||||
const executor = body.sessionId ? manager.getSession(body.sessionId) : null
|
||||
if (body.sessionId && !executor) {
|
||||
return c.json({ success: false, error: `Session ${body.sessionId} not found` }, 404)
|
||||
}
|
||||
const extensionId = executor?.getSessionMetadata().extensionId || null
|
||||
const relay = getRecordingRelay(extensionId)
|
||||
if (!relay) {
|
||||
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
||||
}
|
||||
const result = await relay.startRecording(body as { outputPath: string } & typeof body)
|
||||
const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500)
|
||||
return c.json(result, status)
|
||||
})
|
||||
|
||||
app.post('/recording/stop', async (c) => {
|
||||
const body = await c.req.json() as { sessionId?: string }
|
||||
const result = await recordingRelay.stopRecording(body)
|
||||
const manager = await getExecutorManager()
|
||||
const executor = body.sessionId ? manager.getSession(body.sessionId) : null
|
||||
if (body.sessionId && !executor) {
|
||||
return c.json({ success: false, error: `Session ${body.sessionId} not found` }, 404)
|
||||
}
|
||||
const extensionId = executor?.getSessionMetadata().extensionId || null
|
||||
const relay = getRecordingRelay(extensionId)
|
||||
if (!relay) {
|
||||
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
||||
}
|
||||
const result = await relay.stopRecording(body)
|
||||
const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500)
|
||||
return c.json(result, status)
|
||||
})
|
||||
|
||||
app.get('/recording/status', async (c) => {
|
||||
const sessionId = c.req.query('sessionId')
|
||||
const result = await recordingRelay.isRecording({ sessionId })
|
||||
const manager = await getExecutorManager()
|
||||
const executor = sessionId ? manager.getSession(sessionId) : null
|
||||
const extensionId = executor?.getSessionMetadata().extensionId || null
|
||||
const relay = getRecordingRelay(extensionId)
|
||||
if (!relay) {
|
||||
return c.json({ isRecording: false })
|
||||
}
|
||||
const result = await relay.isRecording({ sessionId })
|
||||
return c.json(result)
|
||||
})
|
||||
|
||||
app.post('/recording/cancel', async (c) => {
|
||||
const body = await c.req.json() as { sessionId?: string }
|
||||
const result = await recordingRelay.cancelRecording(body)
|
||||
const manager = await getExecutorManager()
|
||||
const executor = body.sessionId ? manager.getSession(body.sessionId) : null
|
||||
if (body.sessionId && !executor) {
|
||||
return c.json({ success: false, error: `Session ${body.sessionId} not found` }, 404)
|
||||
}
|
||||
const extensionId = executor?.getSessionMetadata().extensionId || null
|
||||
const relay = getRecordingRelay(extensionId)
|
||||
if (!relay) {
|
||||
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
||||
}
|
||||
const result = await relay.cancelRecording(body)
|
||||
return c.json(result)
|
||||
})
|
||||
|
||||
@@ -1271,7 +1497,10 @@ export async function startPlayWriterCDPRelayServer({
|
||||
client.ws.close(1000, 'Server stopped')
|
||||
}
|
||||
playwrightClients.clear()
|
||||
extensionWs?.close(1000, 'Server stopped')
|
||||
for (const extension of extensionConnections.values()) {
|
||||
extension.ws.close(1000, 'Server stopped')
|
||||
}
|
||||
extensionConnections.clear()
|
||||
server.close()
|
||||
emitter.removeAllListeners()
|
||||
},
|
||||
|
||||
+192
-42
@@ -46,6 +46,54 @@ async function getServerUrl(host?: string): Promise<string> {
|
||||
return `http://${serverHost}:${RELAY_PORT}`
|
||||
}
|
||||
|
||||
async function fetchExtensionsStatus(host?: string): Promise<Array<{
|
||||
extensionId: string
|
||||
browser: string | null
|
||||
profile: { email: string; id: string } | null
|
||||
activeTargets: number
|
||||
}>> {
|
||||
try {
|
||||
const serverUrl = await getServerUrl(host)
|
||||
const response = await fetch(`${serverUrl}/extensions/status`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const fallback = await fetch(`${serverUrl}/extension/status`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
})
|
||||
if (!fallback.ok) {
|
||||
return []
|
||||
}
|
||||
const fallbackData = await fallback.json() as {
|
||||
connected: boolean
|
||||
activeTargets: number
|
||||
browser: string | null
|
||||
profile: { email: string; id: string } | null
|
||||
}
|
||||
if (!fallbackData.connected) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
extensionId: 'default',
|
||||
browser: fallbackData.browser,
|
||||
profile: fallbackData.profile,
|
||||
activeTargets: fallbackData.activeTargets,
|
||||
}]
|
||||
}
|
||||
const data = await response.json() as {
|
||||
extensions: Array<{
|
||||
extensionId: string
|
||||
browser: string | null
|
||||
profile: { email: string; id: string } | null
|
||||
activeTargets: number
|
||||
}>
|
||||
}
|
||||
return data.extensions
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCode(options: {
|
||||
code: string
|
||||
timeout: number
|
||||
@@ -57,19 +105,6 @@ async function executeCode(options: {
|
||||
const cwd = process.cwd()
|
||||
const sessionId = options.sessionId || process.env.PLAYWRITER_SESSION
|
||||
|
||||
const serverUrl = await getServerUrl(host)
|
||||
|
||||
// Ensure relay server is running (only for local)
|
||||
if (!host && !process.env.PLAYWRITER_HOST) {
|
||||
const restarted = await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
||||
if (restarted){
|
||||
const connected = await waitForExtension({ logger: console, timeoutMs: 10000 })
|
||||
if (!connected) {
|
||||
console.error('Warning: Extension not connected. Commands may fail.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session is required
|
||||
if (!sessionId) {
|
||||
console.error('Error: -s/--session is required.')
|
||||
@@ -77,6 +112,19 @@ async function executeCode(options: {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const serverUrl = await getServerUrl(host)
|
||||
|
||||
// Ensure relay server is running (only for local)
|
||||
if (!host && !process.env.PLAYWRITER_HOST) {
|
||||
const restarted = await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
||||
if (restarted) {
|
||||
const connected = await waitForExtension({ logger: console, timeoutMs: 10000 })
|
||||
if (!connected) {
|
||||
console.error('Warning: Extension not connected. Commands may fail.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build request URL with token if provided
|
||||
const executeUrl = `${serverUrl}/cli/execute`
|
||||
|
||||
@@ -131,17 +179,81 @@ async function executeCode(options: {
|
||||
cli
|
||||
.command('session new', 'Create a new session and print the session ID')
|
||||
.option('--host <host>', 'Remote relay server host')
|
||||
.action(async (options: { host?: string }) => {
|
||||
const serverUrl = await getServerUrl(options.host)
|
||||
|
||||
.option('--browser <name>', 'Browser to use when multiple browsers are connected')
|
||||
.action(async (options: { host?: string; browser?: string }) => {
|
||||
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
||||
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
||||
}
|
||||
|
||||
const extensions = await fetchExtensionsStatus(options.host)
|
||||
if (extensions.length === 0) {
|
||||
console.error('No connected browsers detected. Click the Playwriter extension icon.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let selectedExtension: { extensionId: string; browser: string | null; profile: { email: string; id: string } | null } | null = null
|
||||
|
||||
if (extensions.length === 1) {
|
||||
selectedExtension = extensions[0]
|
||||
} else if (!options.browser) {
|
||||
console.log('Multiple browsers detected:\n')
|
||||
console.log('ID BROWSER PROFILE')
|
||||
console.log('------- ------- -------')
|
||||
for (const extension of extensions) {
|
||||
const label = extension.profile?.email || '(not signed in)'
|
||||
const shortId = extension.extensionId === 'default' ? 'default' : extension.extensionId.slice(0, 7)
|
||||
console.log(`${shortId.padEnd(7)} ${(extension.browser || 'Chrome').padEnd(7)} ${label}`)
|
||||
}
|
||||
console.log('\nRun again with --browser <email, browser, or id>.')
|
||||
process.exit(1)
|
||||
} else {
|
||||
const byEmail = extensions.find((extension) => extension.profile?.email === options.browser)
|
||||
const byId = extensions.find((extension) => extension.extensionId === options.browser)
|
||||
const byBrowser = extensions.filter((extension) => (extension.browser || 'Chrome') === options.browser)
|
||||
|
||||
selectedExtension = byEmail || byId || null
|
||||
if (!selectedExtension && byBrowser.length === 1) {
|
||||
selectedExtension = byBrowser[0]
|
||||
}
|
||||
|
||||
if (!selectedExtension) {
|
||||
if (byBrowser.length > 1) {
|
||||
console.error(`Browser name is ambiguous: ${options.browser}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.error(`Browser not found: ${options.browser}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedExtension) {
|
||||
console.error('Unable to determine browser identity.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
||||
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
||||
const connected = await waitForExtension({ logger: console, timeoutMs: 10000 })
|
||||
if (!connected) {
|
||||
console.error('Warning: Extension not connected. Commands may fail.')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/cli/session/suggest`)
|
||||
const { next } = await res.json() as { next: number }
|
||||
console.log(`Session ${next} created. Use with: playwriter -s ${next} -e "..."`)
|
||||
const serverUrl = await getServerUrl(options.host)
|
||||
const extensionId = selectedExtension.extensionId === 'default' ? null : selectedExtension.extensionId
|
||||
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ extensionId }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
console.error(`Error: ${response.status} ${text}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const result = await response.json() as { id: string; extensionId: string | null }
|
||||
console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`)
|
||||
} catch (error: any) {
|
||||
console.error(`Error: ${error.message}`)
|
||||
process.exit(1)
|
||||
@@ -152,43 +264,81 @@ cli
|
||||
.command('session list', 'List all active sessions')
|
||||
.option('--host <host>', 'Remote relay server host')
|
||||
.action(async (options: { host?: string }) => {
|
||||
const serverUrl = await getServerUrl(options.host)
|
||||
|
||||
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
||||
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
||||
}
|
||||
|
||||
const serverUrl = await getServerUrl(options.host)
|
||||
let sessions: Array<{
|
||||
id: string
|
||||
stateKeys: string[]
|
||||
browser: string | null
|
||||
profile: { email: string; id: string } | null
|
||||
extensionId: string | null
|
||||
}> = []
|
||||
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/cli/sessions`)
|
||||
const { sessions } = await res.json() as {
|
||||
const response = await fetch(`${serverUrl}/cli/sessions`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.error(`Error: ${response.status} ${await response.text()}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const result = await response.json() as {
|
||||
sessions: Array<{
|
||||
id: string
|
||||
stateKeys: string[]
|
||||
browser: string | null
|
||||
profile: { email: string; id: string } | null
|
||||
extensionId: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions')
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate column widths for aligned table
|
||||
const idWidth = Math.max(2, ...sessions.map((s) => { return String(s.id).length }))
|
||||
const stateWidth = Math.max(10, ...sessions.map((s) => { return s.stateKeys.join(', ').length || 1 }))
|
||||
|
||||
// Header
|
||||
console.log('ID'.padEnd(idWidth) + ' ' + 'State Keys')
|
||||
console.log('-'.repeat(idWidth + stateWidth + 2))
|
||||
|
||||
// Rows
|
||||
for (const session of sessions) {
|
||||
const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-'
|
||||
console.log(String(session.id).padEnd(idWidth) + ' ' + stateStr)
|
||||
}
|
||||
sessions = result.sessions
|
||||
} catch (error: any) {
|
||||
console.error(`Error: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions')
|
||||
return
|
||||
}
|
||||
|
||||
const idWidth = Math.max(2, ...sessions.map((session) => String(session.id).length))
|
||||
const browserWidth = Math.max(7, ...sessions.map((session) => (session.browser || 'Chrome').length))
|
||||
const profileWidth = Math.max(7, ...sessions.map((session) => (session.profile?.email || '').length || 1))
|
||||
const extensionWidth = Math.max(2, ...sessions.map((session) => (session.extensionId || '').length || 1))
|
||||
const stateWidth = Math.max(10, ...sessions.map((session) => session.stateKeys.join(', ').length || 1))
|
||||
|
||||
console.log(
|
||||
'ID'.padEnd(idWidth) +
|
||||
' ' +
|
||||
'BROWSER'.padEnd(browserWidth) +
|
||||
' ' +
|
||||
'PROFILE'.padEnd(profileWidth) +
|
||||
' ' +
|
||||
'EXT'.padEnd(extensionWidth) +
|
||||
' ' +
|
||||
'STATE KEYS'
|
||||
)
|
||||
console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8))
|
||||
|
||||
for (const session of sessions) {
|
||||
const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-'
|
||||
const profileLabel = session.profile?.email || '-'
|
||||
console.log(
|
||||
String(session.id).padEnd(idWidth) +
|
||||
' ' +
|
||||
(session.browser || 'Chrome').padEnd(browserWidth) +
|
||||
' ' +
|
||||
profileLabel.padEnd(profileWidth) +
|
||||
' ' +
|
||||
(session.extensionId || '-').padEnd(extensionWidth) +
|
||||
' ' +
|
||||
stateStr
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
cli
|
||||
|
||||
@@ -181,10 +181,18 @@ export interface CdpConfig {
|
||||
host?: string
|
||||
port?: number
|
||||
token?: string
|
||||
extensionId?: string | null
|
||||
}
|
||||
|
||||
export interface SessionMetadata {
|
||||
extensionId: string | null
|
||||
browser: string | null
|
||||
profile: { email: string; id: string } | null
|
||||
}
|
||||
|
||||
export interface ExecutorOptions {
|
||||
cdpConfig: CdpConfig
|
||||
sessionMetadata?: SessionMetadata
|
||||
logger?: ExecutorLogger
|
||||
/** Working directory for scoped fs access */
|
||||
cwd?: string
|
||||
@@ -216,10 +224,12 @@ export class PlaywrightExecutor {
|
||||
|
||||
private cdpConfig: CdpConfig
|
||||
private logger: ExecutorLogger
|
||||
private sessionMetadata: SessionMetadata
|
||||
|
||||
constructor(options: ExecutorOptions) {
|
||||
this.cdpConfig = options.cdpConfig
|
||||
this.logger = options.logger || { log: console.log, error: console.error }
|
||||
this.sessionMetadata = options.sessionMetadata || { extensionId: null, browser: null, profile: null }
|
||||
// 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)
|
||||
@@ -320,8 +330,31 @@ export class PlaywrightExecutor {
|
||||
}
|
||||
|
||||
private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number }> {
|
||||
const { host = '127.0.0.1', port = 19988 } = this.cdpConfig
|
||||
const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
|
||||
try {
|
||||
if (extensionId) {
|
||||
const response = await fetch(`http://${host}:${port}/extensions/status`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const fallback = await fetch(`http://${host}:${port}/extension/status`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
})
|
||||
if (!fallback.ok) {
|
||||
return { connected: false, activeTargets: 0 }
|
||||
}
|
||||
return (await fallback.json()) as { connected: boolean; activeTargets: number }
|
||||
}
|
||||
const data = await response.json() as {
|
||||
extensions: Array<{ extensionId: string; activeTargets: number }>
|
||||
}
|
||||
const extension = data.extensions.find((item) => item.extensionId === extensionId)
|
||||
if (!extension) {
|
||||
return { connected: false, activeTargets: 0 }
|
||||
}
|
||||
return { connected: true, activeTargets: extension.activeTargets }
|
||||
}
|
||||
|
||||
const response = await fetch(`http://${host}:${port}/extension/status`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
})
|
||||
@@ -910,6 +943,10 @@ export class PlaywrightExecutor {
|
||||
getStateKeys(): string[] {
|
||||
return Object.keys(this.userState)
|
||||
}
|
||||
|
||||
getSessionMetadata(): SessionMetadata {
|
||||
return this.sessionMetadata
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -917,19 +954,25 @@ export class PlaywrightExecutor {
|
||||
*/
|
||||
export class ExecutorManager {
|
||||
private executors = new Map<string, PlaywrightExecutor>()
|
||||
private cdpConfig: CdpConfig
|
||||
private cdpConfig: CdpConfig | ((sessionId: string) => CdpConfig)
|
||||
private logger: ExecutorLogger
|
||||
|
||||
constructor(options: { cdpConfig: CdpConfig; logger?: ExecutorLogger }) {
|
||||
constructor(options: { cdpConfig: CdpConfig | ((sessionId: string) => CdpConfig); logger?: ExecutorLogger }) {
|
||||
this.cdpConfig = options.cdpConfig
|
||||
this.logger = options.logger || { log: console.log, error: console.error }
|
||||
}
|
||||
|
||||
getExecutor(sessionId: string, cwd?: string): PlaywrightExecutor {
|
||||
getExecutor(options: { sessionId: string; cwd?: string; sessionMetadata?: SessionMetadata }): PlaywrightExecutor {
|
||||
const { sessionId, cwd, sessionMetadata } = options
|
||||
let executor = this.executors.get(sessionId)
|
||||
if (!executor) {
|
||||
const baseConfig = typeof this.cdpConfig === 'function' ? this.cdpConfig(sessionId) : this.cdpConfig
|
||||
const cdpConfig = sessionMetadata?.extensionId
|
||||
? { ...baseConfig, extensionId: sessionMetadata.extensionId }
|
||||
: baseConfig
|
||||
executor = new PlaywrightExecutor({
|
||||
cdpConfig: this.cdpConfig,
|
||||
cdpConfig,
|
||||
sessionMetadata,
|
||||
logger: this.logger,
|
||||
cwd,
|
||||
})
|
||||
@@ -942,11 +985,25 @@ export class ExecutorManager {
|
||||
return this.executors.delete(sessionId)
|
||||
}
|
||||
|
||||
listSessions(): Array<{ id: string; stateKeys: string[] }> {
|
||||
getSession(sessionId: string): PlaywrightExecutor | null {
|
||||
return this.executors.get(sessionId) || null
|
||||
}
|
||||
|
||||
listSessions(): Array<{
|
||||
id: string
|
||||
stateKeys: string[]
|
||||
extensionId: string | null
|
||||
browser: string | null
|
||||
profile: { email: string; id: string } | null
|
||||
}> {
|
||||
return [...this.executors.entries()].map(([id, executor]) => {
|
||||
const metadata = executor.getSessionMetadata()
|
||||
return {
|
||||
id,
|
||||
stateKeys: executor.getStateKeys(),
|
||||
extensionId: metadata.extensionId,
|
||||
browser: metadata.browser,
|
||||
profile: metadata.profile,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+21
-3
@@ -9,10 +9,28 @@ export const EXTENSION_IDS = [
|
||||
'pebbngnfojnignonigcnkdilknapkgid', // Dev extension (stable ID from manifest key)
|
||||
]
|
||||
|
||||
export function getCdpUrl({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) {
|
||||
export function getCdpUrl({
|
||||
port = 19988,
|
||||
host = '127.0.0.1',
|
||||
token,
|
||||
extensionId,
|
||||
}: {
|
||||
port?: number
|
||||
host?: string
|
||||
token?: string
|
||||
extensionId?: string | null
|
||||
} = {}) {
|
||||
const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}`
|
||||
const queryString = token ? `?token=${token}` : ''
|
||||
return `ws://${host}:${port}/cdp/${id}${queryString}`
|
||||
const params = new URLSearchParams()
|
||||
if (token) {
|
||||
params.set('token', token)
|
||||
}
|
||||
if (extensionId) {
|
||||
params.set('extensionId', extensionId)
|
||||
}
|
||||
const queryString = params.toString()
|
||||
const suffix = queryString ? `?${queryString}` : ''
|
||||
return `ws://${host}:${port}/cdp/${id}${suffix}`
|
||||
}
|
||||
|
||||
const LOG_BASE_DIR = os.platform() === 'win32' ? os.tmpdir() : '/tmp'
|
||||
|
||||
Reference in New Issue
Block a user