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",
|
"name": "Playwriter",
|
||||||
"version": "0.0.69",
|
"version": "0.0.69",
|
||||||
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
|
"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>"],
|
"host_permissions": ["<all_urls>"],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js",
|
"service_worker": "background.js",
|
||||||
|
|||||||
@@ -14,13 +14,83 @@ import {
|
|||||||
cleanupRecordingForTab,
|
cleanupRecordingForTab,
|
||||||
} from './recording'
|
} from './recording'
|
||||||
|
|
||||||
const RELAY_PORT = process.env.PLAYWRITER_PORT
|
const RELAY_HOST = '127.0.0.1'
|
||||||
const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}/extension`
|
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> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
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 childSessions: Map<string, { tabId: number; targetId?: string }> = new Map()
|
||||||
let nextSessionId = 1
|
let nextSessionId = 1
|
||||||
let tabGroupQueue: Promise<void> = Promise.resolve()
|
let tabGroupQueue: Promise<void> = Promise.resolve()
|
||||||
@@ -106,14 +176,14 @@ class ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async connect(): Promise<void> {
|
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)
|
// 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
|
// Using fewer attempts since maintainLoop retries every 3 seconds anyway
|
||||||
const maxAttempts = 5
|
const maxAttempts = 5
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
try {
|
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')
|
logger.debug('Server is available')
|
||||||
break
|
break
|
||||||
} catch {
|
} catch {
|
||||||
@@ -125,8 +195,19 @@ class ConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Creating WebSocket connection to:', RELAY_URL)
|
const identity = await getExtensionIdentity()
|
||||||
const socket = new WebSocket(RELAY_URL)
|
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) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let settled = false
|
let settled = false
|
||||||
@@ -389,7 +470,7 @@ class ConnectionManager {
|
|||||||
// Slot is free when: no extension connected, OR connected but no active tabs.
|
// Slot is free when: no extension connected, OR connected but no active tabs.
|
||||||
if (store.getState().connectionState === 'extension-replaced') {
|
if (store.getState().connectionState === 'extension-replaced') {
|
||||||
try {
|
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 data = await response.json() as { connected: boolean; activeTargets: number }
|
||||||
const slotAvailable = !data.connected || data.activeTargets === 0
|
const slotAvailable = !data.connected || data.activeTargets === 0
|
||||||
if (slotAvailable) {
|
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
|
// Memory monitoring - helps debug service worker termination issues
|
||||||
let lastMemoryUsage = 0
|
let lastMemoryUsage = 0
|
||||||
|
|||||||
+353
-124
@@ -54,6 +54,23 @@ function isRestrictedTarget(targetInfo: Protocol.Target.TargetInfo): boolean {
|
|||||||
type PlaywrightClient = {
|
type PlaywrightClient = {
|
||||||
id: string
|
id: string
|
||||||
ws: WSContext
|
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
|
cdpLogger?: CdpLogger
|
||||||
} = {}): Promise<RelayServer> {
|
} = {}): Promise<RelayServer> {
|
||||||
const emitter = new EventEmitter()
|
const emitter = new EventEmitter()
|
||||||
const connectedTargets = new Map<string, ConnectedTarget>()
|
const extensionConnections = new Map<string, ExtensionConnection>()
|
||||||
|
|
||||||
const resolvedCdpLogger = cdpLogger || createCdpLogger()
|
const resolvedCdpLogger = cdpLogger || createCdpLogger()
|
||||||
const logCdpJson = (entry: CdpLogEntry) => {
|
const logCdpJson = (entry: CdpLogEntry) => {
|
||||||
resolvedCdpLogger.log(entry)
|
resolvedCdpLogger.log(entry)
|
||||||
}
|
}
|
||||||
const playwrightClients = new Map<string, PlaywrightClient>()
|
const playwrightClients = new Map<string, PlaywrightClient>()
|
||||||
let extensionWs: WSContext | null = null
|
|
||||||
|
|
||||||
const extensionPendingRequests = new Map<number, {
|
const getDefaultExtensionId = (): string | null => {
|
||||||
resolve: (result: any) => void
|
return extensionConnections.keys().next().value || null
|
||||||
reject: (error: Error) => void
|
}
|
||||||
}>()
|
|
||||||
let extensionMessageId = 0
|
|
||||||
let extensionPingInterval: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function startExtensionPing() {
|
const getExtensionConnection = (extensionId?: string | null): ExtensionConnection | null => {
|
||||||
if (extensionPingInterval) {
|
if (extensionId && extensionConnections.has(extensionId)) {
|
||||||
clearInterval(extensionPingInterval)
|
return extensionConnections.get(extensionId) || null
|
||||||
}
|
}
|
||||||
extensionPingInterval = setInterval(() => {
|
const fallbackId = getDefaultExtensionId()
|
||||||
extensionWs?.send(JSON.stringify({ method: 'ping' }))
|
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)
|
}, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopExtensionPing() {
|
const stopExtensionPing = (extensionId: string): void => {
|
||||||
if (extensionPingInterval) {
|
const connection = extensionConnections.get(extensionId)
|
||||||
clearInterval(extensionPingInterval)
|
if (!connection || !connection.pingInterval) {
|
||||||
extensionPingInterval = null
|
return
|
||||||
}
|
}
|
||||||
|
clearInterval(connection.pingInterval)
|
||||||
|
connection.pingInterval = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function logCdpMessage({
|
function logCdpMessage({
|
||||||
@@ -179,11 +209,13 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
function sendToPlaywright({
|
function sendToPlaywright({
|
||||||
message,
|
message,
|
||||||
clientId,
|
clientId,
|
||||||
source = 'extension'
|
source = 'extension',
|
||||||
|
extensionId,
|
||||||
}: {
|
}: {
|
||||||
message: CDPResponseBase | CDPEventBase
|
message: CDPResponseBase | CDPEventBase
|
||||||
clientId?: string
|
clientId?: string
|
||||||
source?: 'extension' | 'server'
|
source?: 'extension' | 'server'
|
||||||
|
extensionId?: string | null
|
||||||
}) {
|
}) {
|
||||||
const messageToSend = source === 'server' && 'method' in message
|
const messageToSend = source === 'server' && 'method' in message
|
||||||
? { ...message, __serverGenerated: true }
|
? { ...message, __serverGenerated: true }
|
||||||
@@ -231,9 +263,11 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
safeSend(client)
|
safeSend(client)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Copy the clients array to avoid issues if a client disconnects during iteration
|
|
||||||
const clients = Array.from(playwrightClients.values())
|
const clients = Array.from(playwrightClients.values())
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
|
if (extensionId && client.extensionId !== extensionId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
safeSend(client)
|
safeSend(client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,12 +291,23 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
return { method: record.method, sessionId, params: record.params }
|
return { method: record.method, sessionId, params: record.params }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendToExtension({ method, params, timeout = 30000 }: { method: string; params?: unknown; timeout?: number }): Promise<unknown> {
|
async function sendToExtension({
|
||||||
if (!extensionWs) {
|
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')
|
throw new Error('Extension not connected')
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = ++extensionMessageId
|
const id = ++connection.messageId
|
||||||
const message = { id, method, params }
|
const message = { id, method, params }
|
||||||
|
|
||||||
const forwardCdpParams = method === 'forwardCDPCommand' ? getForwardCdpParams(params) : undefined
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
extensionPendingRequests.delete(id)
|
connection.pendingRequests.delete(id)
|
||||||
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`))
|
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`))
|
||||||
}, timeout)
|
}, timeout)
|
||||||
|
|
||||||
extensionPendingRequests.set(id, {
|
connection.pendingRequests.set(id, {
|
||||||
resolve: (result) => {
|
resolve: (result) => {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
resolve(result)
|
resolve(result)
|
||||||
@@ -299,48 +344,76 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recording relay for screen recording functionality
|
const recordingRelays = new Map<string, RecordingRelay>()
|
||||||
const recordingRelay = new RecordingRelay(
|
|
||||||
sendToExtension,
|
const getRecordingRelay = (extensionId?: string | null): RecordingRelay | null => {
|
||||||
() => extensionWs !== null,
|
const connection = getExtensionConnection(extensionId)
|
||||||
logger,
|
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.
|
// 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.
|
// 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) {
|
if (!process.env.PLAYWRITER_AUTO_ENABLE) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!extensionWs) {
|
const connection = getExtensionConnection(extensionId)
|
||||||
|
if (!connection) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (connectedTargets.size > 0) {
|
if (connection.connectedTargets.size > 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger?.log(pc.blue('Auto-creating initial tab for Playwright client'))
|
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
|
success: boolean
|
||||||
tabId: number
|
tabId: number
|
||||||
sessionId: string
|
sessionId: string
|
||||||
targetInfo: Protocol.Target.TargetInfo
|
targetInfo: Protocol.Target.TargetInfo
|
||||||
}
|
}
|
||||||
if (result.success && result.sessionId && result.targetInfo) {
|
if (result.success && result.sessionId && result.targetInfo) {
|
||||||
connectedTargets.set(result.sessionId, {
|
connection.connectedTargets.set(result.sessionId, {
|
||||||
sessionId: result.sessionId,
|
sessionId: result.sessionId,
|
||||||
targetId: result.targetInfo.targetId,
|
targetId: result.targetInfo.targetId,
|
||||||
targetInfo: result.targetInfo
|
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) {
|
} catch (e) {
|
||||||
logger?.error('Failed to auto-create initial tab:', 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) {
|
switch (method) {
|
||||||
case 'Browser.getVersion': {
|
case 'Browser.getVersion': {
|
||||||
return {
|
return {
|
||||||
@@ -363,10 +436,13 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
await maybeAutoCreateInitialTab()
|
if (extension) {
|
||||||
|
await maybeAutoCreateInitialTab(extension.id)
|
||||||
|
}
|
||||||
// Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
|
// Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
|
||||||
// Playwright relies on these (with parentFrameId) when reconnecting over CDP.
|
// Playwright relies on these (with parentFrameId) when reconnecting over CDP.
|
||||||
await sendToExtension({
|
await sendToExtension({
|
||||||
|
extensionId: extension?.id || extensionId,
|
||||||
method: 'forwardCDPCommand',
|
method: 'forwardCDPCommand',
|
||||||
params: { method, params, source }
|
params: { method, params, source }
|
||||||
})
|
})
|
||||||
@@ -427,6 +503,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
|
|
||||||
case 'Target.createTarget': {
|
case 'Target.createTarget': {
|
||||||
return await sendToExtension({
|
return await sendToExtension({
|
||||||
|
extensionId: extension?.id || extensionId,
|
||||||
method: 'forwardCDPCommand',
|
method: 'forwardCDPCommand',
|
||||||
params: { method, params, source }
|
params: { method, params, source }
|
||||||
})
|
})
|
||||||
@@ -434,6 +511,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
|
|
||||||
case 'Target.closeTarget': {
|
case 'Target.closeTarget': {
|
||||||
return await sendToExtension({
|
return await sendToExtension({
|
||||||
|
extensionId: extension?.id || extensionId,
|
||||||
method: 'forwardCDPCommand',
|
method: 'forwardCDPCommand',
|
||||||
params: { method, params, source }
|
params: { method, params, source }
|
||||||
})
|
})
|
||||||
@@ -442,6 +520,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
// Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects
|
// Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects
|
||||||
case 'ghost-browser': {
|
case 'ghost-browser': {
|
||||||
return await sendToExtension({
|
return await sendToExtension({
|
||||||
|
extensionId: extension?.id || extensionId,
|
||||||
method: 'ghost-browser',
|
method: 'ghost-browser',
|
||||||
params
|
params
|
||||||
})
|
})
|
||||||
@@ -472,6 +551,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = await sendToExtension({
|
const result = await sendToExtension({
|
||||||
|
extensionId: extension?.id || extensionId,
|
||||||
method: 'forwardCDPCommand',
|
method: 'forwardCDPCommand',
|
||||||
params: { sessionId, method, params, source }
|
params: { sessionId, method, params, source }
|
||||||
})
|
})
|
||||||
@@ -483,6 +563,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await sendToExtension({
|
return await sendToExtension({
|
||||||
|
extensionId: extension?.id || extensionId,
|
||||||
method: 'forwardCDPCommand',
|
method: 'forwardCDPCommand',
|
||||||
params: { sessionId, method, params, source }
|
params: { sessionId, method, params, source }
|
||||||
})
|
})
|
||||||
@@ -521,12 +602,31 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get('/extension/status', (c) => {
|
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({
|
return c.json({
|
||||||
connected: extensionWs !== null,
|
connected,
|
||||||
activeTargets: connectedTargets.size
|
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
|
// CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API
|
||||||
// Allows tools like Playwright to discover the WebSocket URL via http://host:port
|
// 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
|
// 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) => {
|
.on(['GET', 'PUT'], '/json/list', (c) => {
|
||||||
const wsUrl = getCdpWsUrl(c)
|
const wsUrl = getCdpWsUrl(c)
|
||||||
|
const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map()
|
||||||
return c.json(
|
return c.json(
|
||||||
Array.from(connectedTargets.values()).map(t => ({
|
Array.from(defaultTargets.values()).map(t => ({
|
||||||
id: t.targetId,
|
id: t.targetId,
|
||||||
type: t.targetInfo.type,
|
type: t.targetInfo.type,
|
||||||
title: t.targetInfo.title,
|
title: t.targetInfo.title,
|
||||||
@@ -562,8 +663,9 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
})
|
})
|
||||||
.on(['GET', 'PUT'], '/json/list/', (c) => {
|
.on(['GET', 'PUT'], '/json/list/', (c) => {
|
||||||
const wsUrl = getCdpWsUrl(c)
|
const wsUrl = getCdpWsUrl(c)
|
||||||
|
const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map()
|
||||||
return c.json(
|
return c.json(
|
||||||
Array.from(connectedTargets.values()).map(t => ({
|
Array.from(defaultTargets.values()).map(t => ({
|
||||||
id: t.targetId,
|
id: t.targetId,
|
||||||
type: t.targetInfo.type,
|
type: t.targetInfo.type,
|
||||||
title: t.targetInfo.title,
|
title: t.targetInfo.title,
|
||||||
@@ -576,8 +678,9 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
})
|
})
|
||||||
.on(['GET', 'PUT'], '/json', (c) => {
|
.on(['GET', 'PUT'], '/json', (c) => {
|
||||||
const wsUrl = getCdpWsUrl(c)
|
const wsUrl = getCdpWsUrl(c)
|
||||||
|
const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map()
|
||||||
return c.json(
|
return c.json(
|
||||||
Array.from(connectedTargets.values()).map(t => ({
|
Array.from(defaultTargets.values()).map(t => ({
|
||||||
id: t.targetId,
|
id: t.targetId,
|
||||||
type: t.targetInfo.type,
|
type: t.targetInfo.type,
|
||||||
title: t.targetInfo.title,
|
title: t.targetInfo.title,
|
||||||
@@ -590,8 +693,9 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
})
|
})
|
||||||
.on(['GET', 'PUT'], '/json/', (c) => {
|
.on(['GET', 'PUT'], '/json/', (c) => {
|
||||||
const wsUrl = getCdpWsUrl(c)
|
const wsUrl = getCdpWsUrl(c)
|
||||||
|
const defaultTargets = getExtensionConnection(null)?.connectedTargets || new Map()
|
||||||
return c.json(
|
return c.json(
|
||||||
Array.from(connectedTargets.values()).map(t => ({
|
Array.from(defaultTargets.values()).map(t => ({
|
||||||
id: t.targetId,
|
id: t.targetId,
|
||||||
type: t.targetInfo.type,
|
type: t.targetInfo.type,
|
||||||
title: t.targetInfo.title,
|
title: t.targetInfo.title,
|
||||||
@@ -646,6 +750,10 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
return next()
|
return next()
|
||||||
}, upgradeWebSocket((c) => {
|
}, upgradeWebSocket((c) => {
|
||||||
const clientId = c.req.param('clientId') || 'default'
|
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 {
|
return {
|
||||||
async onOpen(_event, ws) {
|
async onOpen(_event, ws) {
|
||||||
@@ -656,8 +764,10 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add client first so it can receive Target.attachedToTarget events
|
// Add client first so it can receive Target.attachedToTarget events
|
||||||
playwrightClients.set(clientId, { id: clientId, ws })
|
playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId || null })
|
||||||
logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionWs}) (${connectedTargets.size} pages)`))
|
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) {
|
async onMessage(event, ws) {
|
||||||
@@ -688,7 +798,8 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
|
|
||||||
emitter.emit('cdp:command', { clientId, command: message })
|
emitter.emit('cdp:command', { clientId, command: message })
|
||||||
|
|
||||||
if (!extensionWs) {
|
const extensionConnection = getExtensionConnection(clientExtensionId)
|
||||||
|
if (!extensionConnection) {
|
||||||
sendToPlaywright({
|
sendToPlaywright({
|
||||||
message: {
|
message: {
|
||||||
id,
|
id,
|
||||||
@@ -701,10 +812,10 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
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)
|
// Skip restricted targets (extensions, chrome:// URLs, non-page types)
|
||||||
if (isRestrictedTarget(target.targetInfo)) {
|
if (isRestrictedTarget(target.targetInfo)) {
|
||||||
continue
|
continue
|
||||||
@@ -733,7 +844,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'Target.setDiscoverTargets' && (params as any)?.discover) {
|
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)
|
// Skip restricted targets (extensions, chrome:// URLs, non-page types)
|
||||||
if (isRestrictedTarget(target.targetInfo)) {
|
if (isRestrictedTarget(target.targetInfo)) {
|
||||||
continue
|
continue
|
||||||
@@ -761,7 +872,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
|
|
||||||
if (method === 'Target.attachToTarget' && result?.sessionId) {
|
if (method === 'Target.attachToTarget' && result?.sessionId) {
|
||||||
const targetId = params?.targetId
|
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) {
|
if (target) {
|
||||||
const attachedPayload = {
|
const attachedPayload = {
|
||||||
method: 'Target.attachedToTarget',
|
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) => {
|
app.get('/extension', (c, next) => {
|
||||||
// 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
|
// 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
|
||||||
// This prevents attackers on the network from hijacking the browser session
|
// This prevents attackers on the network from hijacking the browser session
|
||||||
@@ -841,44 +963,38 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}, upgradeWebSocket(() => {
|
}, upgradeWebSocket((c) => {
|
||||||
|
const incomingExtensionInfo = getExtensionInfoFromRequest(c)
|
||||||
|
const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
return {
|
return {
|
||||||
onOpen(_event, ws) {
|
onOpen(_event, ws) {
|
||||||
if (extensionWs) {
|
const connection: ExtensionConnection = {
|
||||||
// Only allow replacement if the existing extension has no active tabs.
|
id: connectionId,
|
||||||
// This prevents a new extension from hijacking an actively-used session.
|
ws,
|
||||||
// If the existing extension is idle (no tabs), allow the new one to take over.
|
info: incomingExtensionInfo,
|
||||||
if (connectedTargets.size > 0) {
|
connectedTargets: new Map(),
|
||||||
logger?.log(pc.yellow(`Rejecting new extension - existing one has ${connectedTargets.size} active tab(s)`))
|
pendingRequests: new Map(),
|
||||||
ws.close(4002, 'Extension Already In Use')
|
messageId: 0,
|
||||||
return
|
pingInterval: null,
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
extensionConnections.set(connectionId, connection)
|
||||||
extensionWs = ws
|
startExtensionPing(connectionId)
|
||||||
startExtensionPing()
|
logger?.log(`Extension connected (${connectionId})`)
|
||||||
logger?.log('Extension connected with clean state')
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async onMessage(event, ws) {
|
async onMessage(event, ws) {
|
||||||
|
const connection = extensionConnections.get(connectionId)
|
||||||
|
if (!connection) {
|
||||||
|
ws.close(1000, 'Extension not registered')
|
||||||
|
return
|
||||||
|
}
|
||||||
// Handle binary data (recording chunks)
|
// Handle binary data (recording chunks)
|
||||||
if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
|
if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
|
||||||
const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,13 +1008,13 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.id !== undefined) {
|
if (message.id !== undefined) {
|
||||||
const pending = extensionPendingRequests.get(message.id)
|
const pending = connection.pendingRequests.get(message.id)
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
logger?.log('Unexpected response with id:', message.id)
|
logger?.log('Unexpected response with id:', message.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
extensionPendingRequests.delete(message.id)
|
connection.pendingRequests.delete(message.id)
|
||||||
|
|
||||||
if (message.error) {
|
if (message.error) {
|
||||||
pending.reject(new Error(message.error))
|
pending.reject(new Error(message.error))
|
||||||
@@ -914,9 +1030,15 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`)
|
const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`)
|
||||||
logFunc?.(prefix, ...args)
|
logFunc?.(prefix, ...args)
|
||||||
} else if (message.method === 'recordingData') {
|
} 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') {
|
} else if (message.method === 'recordingCancelled') {
|
||||||
recordingRelay.handleRecordingCancelled(message as RecordingCancelledMessage)
|
const relay = getRecordingRelay(connectionId)
|
||||||
|
if (relay) {
|
||||||
|
relay.handleRecordingCancelled(message as RecordingCancelledMessage)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const extensionEvent = message as ExtensionEventMessage
|
const extensionEvent = message as ExtensionEventMessage
|
||||||
|
|
||||||
@@ -949,6 +1071,7 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
if (isRestrictedTarget(targetParams.targetInfo)) {
|
if (isRestrictedTarget(targetParams.targetInfo)) {
|
||||||
if (targetParams.waitingForDebugger && targetParams.sessionId) {
|
if (targetParams.waitingForDebugger && targetParams.sessionId) {
|
||||||
void sendToExtension({
|
void sendToExtension({
|
||||||
|
extensionId: connectionId,
|
||||||
method: 'forwardCDPCommand',
|
method: 'forwardCDPCommand',
|
||||||
params: {
|
params: {
|
||||||
sessionId: targetParams.sessionId,
|
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 }))
|
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)
|
// 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
|
// Always update our local state with latest target info
|
||||||
connectedTargets.set(targetParams.sessionId, {
|
connection.connectedTargets.set(targetParams.sessionId, {
|
||||||
sessionId: targetParams.sessionId,
|
sessionId: targetParams.sessionId,
|
||||||
targetId: targetParams.targetInfo.targetId,
|
targetId: targetParams.targetInfo.targetId,
|
||||||
targetInfo: targetParams.targetInfo
|
targetInfo: targetParams.targetInfo
|
||||||
@@ -987,25 +1110,27 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
method: 'Target.attachedToTarget',
|
method: 'Target.attachedToTarget',
|
||||||
params: targetParams
|
params: targetParams
|
||||||
} as CDPEventBase,
|
} as CDPEventBase,
|
||||||
source: 'extension'
|
source: 'extension',
|
||||||
|
extensionId: connectionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (method === 'Target.detachedFromTarget') {
|
} else if (method === 'Target.detachedFromTarget') {
|
||||||
const detachParams = params as Protocol.Target.DetachedFromTargetEvent
|
const detachParams = params as Protocol.Target.DetachedFromTargetEvent
|
||||||
connectedTargets.delete(detachParams.sessionId)
|
connection.connectedTargets.delete(detachParams.sessionId)
|
||||||
|
|
||||||
sendToPlaywright({
|
sendToPlaywright({
|
||||||
message: {
|
message: {
|
||||||
method: 'Target.detachedFromTarget',
|
method: 'Target.detachedFromTarget',
|
||||||
params: detachParams
|
params: detachParams
|
||||||
} as CDPEventBase,
|
} as CDPEventBase,
|
||||||
source: 'extension'
|
source: 'extension',
|
||||||
|
extensionId: connectionId,
|
||||||
})
|
})
|
||||||
} else if (method === 'Target.targetCrashed') {
|
} else if (method === 'Target.targetCrashed') {
|
||||||
const crashParams = params as Protocol.Target.TargetCrashedEvent
|
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) {
|
if (target.targetId === crashParams.targetId) {
|
||||||
connectedTargets.delete(sid)
|
connection.connectedTargets.delete(sid)
|
||||||
logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId)
|
logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1016,11 +1141,12 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
method: 'Target.targetCrashed',
|
method: 'Target.targetCrashed',
|
||||||
params: crashParams
|
params: crashParams
|
||||||
} as CDPEventBase,
|
} as CDPEventBase,
|
||||||
source: 'extension'
|
source: 'extension',
|
||||||
|
extensionId: connectionId,
|
||||||
})
|
})
|
||||||
} else if (method === 'Target.targetInfoChanged') {
|
} else if (method === 'Target.targetInfoChanged') {
|
||||||
const infoParams = params as Protocol.Target.TargetInfoChangedEvent
|
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) {
|
if (target.targetId === infoParams.targetInfo.targetId) {
|
||||||
target.targetInfo = infoParams.targetInfo
|
target.targetInfo = infoParams.targetInfo
|
||||||
break
|
break
|
||||||
@@ -1032,12 +1158,13 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
method: 'Target.targetInfoChanged',
|
method: 'Target.targetInfoChanged',
|
||||||
params: infoParams
|
params: infoParams
|
||||||
} as CDPEventBase,
|
} as CDPEventBase,
|
||||||
source: 'extension'
|
source: 'extension',
|
||||||
|
extensionId: connectionId,
|
||||||
})
|
})
|
||||||
} else if (method === 'Page.frameNavigated') {
|
} else if (method === 'Page.frameNavigated') {
|
||||||
const frameParams = params as Protocol.Page.FrameNavigatedEvent
|
const frameParams = params as Protocol.Page.FrameNavigatedEvent
|
||||||
if (!frameParams.frame.parentId && sessionId) {
|
if (!frameParams.frame.parentId && sessionId) {
|
||||||
const target = connectedTargets.get(sessionId)
|
const target = connection.connectedTargets.get(sessionId)
|
||||||
if (target) {
|
if (target) {
|
||||||
target.targetInfo = {
|
target.targetInfo = {
|
||||||
...target.targetInfo,
|
...target.targetInfo,
|
||||||
@@ -1054,12 +1181,13 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
method,
|
method,
|
||||||
params
|
params
|
||||||
} as CDPEventBase,
|
} as CDPEventBase,
|
||||||
source: 'extension'
|
source: 'extension',
|
||||||
|
extensionId: connectionId,
|
||||||
})
|
})
|
||||||
} else if (method === 'Page.navigatedWithinDocument') {
|
} else if (method === 'Page.navigatedWithinDocument') {
|
||||||
const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent
|
const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const target = connectedTargets.get(sessionId)
|
const target = connection.connectedTargets.get(sessionId)
|
||||||
if (target) {
|
if (target) {
|
||||||
target.targetInfo = {
|
target.targetInfo = {
|
||||||
...target.targetInfo,
|
...target.targetInfo,
|
||||||
@@ -1075,7 +1203,8 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
method,
|
method,
|
||||||
params
|
params
|
||||||
} as CDPEventBase,
|
} as CDPEventBase,
|
||||||
source: 'extension'
|
source: 'extension',
|
||||||
|
extensionId: connectionId,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
sendToPlaywright({
|
sendToPlaywright({
|
||||||
@@ -1084,35 +1213,36 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
method,
|
method,
|
||||||
params
|
params
|
||||||
} as CDPEventBase,
|
} as CDPEventBase,
|
||||||
source: 'extension'
|
source: 'extension',
|
||||||
|
extensionId: connectionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onClose(event, ws) {
|
onClose(event, ws) {
|
||||||
logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'}`)
|
logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`)
|
||||||
stopExtensionPing()
|
stopExtensionPing(connectionId)
|
||||||
|
|
||||||
// If this is an old connection closing after we've already established a new one,
|
const connection = extensionConnections.get(connectionId)
|
||||||
// don't clear the global state
|
if (connection) {
|
||||||
if (extensionWs && extensionWs !== ws) {
|
for (const pending of connection.pendingRequests.values()) {
|
||||||
logger?.log('Old extension connection closed, keeping new one active')
|
pending.reject(new Error('Extension connection closed'))
|
||||||
return
|
}
|
||||||
|
connection.pendingRequests.clear()
|
||||||
|
connection.connectedTargets.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pending of extensionPendingRequests.values()) {
|
extensionConnections.delete(connectionId)
|
||||||
pending.reject(new Error('Extension connection closed'))
|
recordingRelays.delete(connectionId)
|
||||||
}
|
|
||||||
extensionPendingRequests.clear()
|
|
||||||
|
|
||||||
extensionWs = null
|
for (const [clientId, client] of playwrightClients.entries()) {
|
||||||
connectedTargets.clear()
|
if (client.extensionId !== connectionId) {
|
||||||
|
continue
|
||||||
for (const client of playwrightClients.values()) {
|
}
|
||||||
client.ws.close(1000, 'Extension disconnected')
|
client.ws.close(1000, 'Extension disconnected')
|
||||||
|
playwrightClients.delete(clientId)
|
||||||
}
|
}
|
||||||
playwrightClients.clear()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onError(event) {
|
onError(event) {
|
||||||
@@ -1153,7 +1283,16 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manager = await getExecutorManager()
|
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)
|
const result = await executor.execute(code, timeout)
|
||||||
|
|
||||||
// Increment session counter after each execution to avoid conflicts
|
// Increment session counter after each execution to avoid conflicts
|
||||||
@@ -1176,7 +1315,16 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manager = await getExecutorManager()
|
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()
|
const { page, context } = await executor.reset()
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -1199,6 +1347,48 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
return c.json({ next: nextSessionNumber })
|
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) => {
|
app.post('/cli/session/delete', async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json() as { sessionId: string }
|
const body = await c.req.json() as { sessionId: string }
|
||||||
@@ -1214,7 +1404,6 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
return c.json({ error: `Session ${sessionId} not found` }, 404)
|
return c.json({ error: `Session ${sessionId} not found` }, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger?.error('Delete session endpoint error:', error)
|
logger?.error('Delete session endpoint error:', error)
|
||||||
@@ -1228,27 +1417,64 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
|
|
||||||
app.post('/recording/start', async (c) => {
|
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 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)
|
const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500)
|
||||||
return c.json(result, status)
|
return c.json(result, status)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/recording/stop', async (c) => {
|
app.post('/recording/stop', async (c) => {
|
||||||
const body = await c.req.json() as { sessionId?: string }
|
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)
|
const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500)
|
||||||
return c.json(result, status)
|
return c.json(result, status)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/recording/status', async (c) => {
|
app.get('/recording/status', async (c) => {
|
||||||
const sessionId = c.req.query('sessionId')
|
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)
|
return c.json(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/recording/cancel', async (c) => {
|
app.post('/recording/cancel', async (c) => {
|
||||||
const body = await c.req.json() as { sessionId?: string }
|
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)
|
return c.json(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1271,7 +1497,10 @@ export async function startPlayWriterCDPRelayServer({
|
|||||||
client.ws.close(1000, 'Server stopped')
|
client.ws.close(1000, 'Server stopped')
|
||||||
}
|
}
|
||||||
playwrightClients.clear()
|
playwrightClients.clear()
|
||||||
extensionWs?.close(1000, 'Server stopped')
|
for (const extension of extensionConnections.values()) {
|
||||||
|
extension.ws.close(1000, 'Server stopped')
|
||||||
|
}
|
||||||
|
extensionConnections.clear()
|
||||||
server.close()
|
server.close()
|
||||||
emitter.removeAllListeners()
|
emitter.removeAllListeners()
|
||||||
},
|
},
|
||||||
|
|||||||
+192
-42
@@ -46,6 +46,54 @@ async function getServerUrl(host?: string): Promise<string> {
|
|||||||
return `http://${serverHost}:${RELAY_PORT}`
|
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: {
|
async function executeCode(options: {
|
||||||
code: string
|
code: string
|
||||||
timeout: number
|
timeout: number
|
||||||
@@ -57,19 +105,6 @@ async function executeCode(options: {
|
|||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
const sessionId = options.sessionId || process.env.PLAYWRITER_SESSION
|
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
|
// Session is required
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
console.error('Error: -s/--session is required.')
|
console.error('Error: -s/--session is required.')
|
||||||
@@ -77,6 +112,19 @@ async function executeCode(options: {
|
|||||||
process.exit(1)
|
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
|
// Build request URL with token if provided
|
||||||
const executeUrl = `${serverUrl}/cli/execute`
|
const executeUrl = `${serverUrl}/cli/execute`
|
||||||
|
|
||||||
@@ -131,17 +179,81 @@ async function executeCode(options: {
|
|||||||
cli
|
cli
|
||||||
.command('session new', 'Create a new session and print the session ID')
|
.command('session new', 'Create a new session and print the session ID')
|
||||||
.option('--host <host>', 'Remote relay server host')
|
.option('--host <host>', 'Remote relay server host')
|
||||||
.action(async (options: { host?: string }) => {
|
.option('--browser <name>', 'Browser to use when multiple browsers are connected')
|
||||||
const serverUrl = await getServerUrl(options.host)
|
.action(async (options: { host?: string; browser?: string }) => {
|
||||||
|
|
||||||
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
||||||
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
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 {
|
try {
|
||||||
const res = await fetch(`${serverUrl}/cli/session/suggest`)
|
const serverUrl = await getServerUrl(options.host)
|
||||||
const { next } = await res.json() as { next: number }
|
const extensionId = selectedExtension.extensionId === 'default' ? null : selectedExtension.extensionId
|
||||||
console.log(`Session ${next} created. Use with: playwriter -s ${next} -e "..."`)
|
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) {
|
} catch (error: any) {
|
||||||
console.error(`Error: ${error.message}`)
|
console.error(`Error: ${error.message}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
@@ -152,43 +264,81 @@ cli
|
|||||||
.command('session list', 'List all active sessions')
|
.command('session list', 'List all active sessions')
|
||||||
.option('--host <host>', 'Remote relay server host')
|
.option('--host <host>', 'Remote relay server host')
|
||||||
.action(async (options: { host?: string }) => {
|
.action(async (options: { host?: string }) => {
|
||||||
const serverUrl = await getServerUrl(options.host)
|
|
||||||
|
|
||||||
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
||||||
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
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 {
|
try {
|
||||||
const res = await fetch(`${serverUrl}/cli/sessions`)
|
const response = await fetch(`${serverUrl}/cli/sessions`, {
|
||||||
const { sessions } = await res.json() as {
|
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<{
|
sessions: Array<{
|
||||||
id: string
|
id: string
|
||||||
stateKeys: string[]
|
stateKeys: string[]
|
||||||
|
browser: string | null
|
||||||
|
profile: { email: string; id: string } | null
|
||||||
|
extensionId: string | null
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
sessions = result.sessions
|
||||||
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)
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Error: ${error.message}`)
|
console.error(`Error: ${error.message}`)
|
||||||
process.exit(1)
|
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
|
cli
|
||||||
|
|||||||
@@ -181,10 +181,18 @@ export interface CdpConfig {
|
|||||||
host?: string
|
host?: string
|
||||||
port?: number
|
port?: number
|
||||||
token?: string
|
token?: string
|
||||||
|
extensionId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMetadata {
|
||||||
|
extensionId: string | null
|
||||||
|
browser: string | null
|
||||||
|
profile: { email: string; id: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutorOptions {
|
export interface ExecutorOptions {
|
||||||
cdpConfig: CdpConfig
|
cdpConfig: CdpConfig
|
||||||
|
sessionMetadata?: SessionMetadata
|
||||||
logger?: ExecutorLogger
|
logger?: ExecutorLogger
|
||||||
/** Working directory for scoped fs access */
|
/** Working directory for scoped fs access */
|
||||||
cwd?: string
|
cwd?: string
|
||||||
@@ -216,10 +224,12 @@ export class PlaywrightExecutor {
|
|||||||
|
|
||||||
private cdpConfig: CdpConfig
|
private cdpConfig: CdpConfig
|
||||||
private logger: ExecutorLogger
|
private logger: ExecutorLogger
|
||||||
|
private sessionMetadata: SessionMetadata
|
||||||
|
|
||||||
constructor(options: ExecutorOptions) {
|
constructor(options: ExecutorOptions) {
|
||||||
this.cdpConfig = options.cdpConfig
|
this.cdpConfig = options.cdpConfig
|
||||||
this.logger = options.logger || { log: console.log, error: console.error }
|
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.
|
// 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.scopedFs = new ScopedFS(options.cwd ? [options.cwd, '/tmp', os.tmpdir()] : undefined)
|
||||||
this.sandboxedRequire = this.createSandboxedRequire(require)
|
this.sandboxedRequire = this.createSandboxedRequire(require)
|
||||||
@@ -320,8 +330,31 @@ export class PlaywrightExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkExtensionStatus(): Promise<{ connected: boolean; activeTargets: number }> {
|
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 {
|
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`, {
|
const response = await fetch(`http://${host}:${port}/extension/status`, {
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
})
|
})
|
||||||
@@ -910,6 +943,10 @@ export class PlaywrightExecutor {
|
|||||||
getStateKeys(): string[] {
|
getStateKeys(): string[] {
|
||||||
return Object.keys(this.userState)
|
return Object.keys(this.userState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSessionMetadata(): SessionMetadata {
|
||||||
|
return this.sessionMetadata
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -917,19 +954,25 @@ export class PlaywrightExecutor {
|
|||||||
*/
|
*/
|
||||||
export class ExecutorManager {
|
export class ExecutorManager {
|
||||||
private executors = new Map<string, PlaywrightExecutor>()
|
private executors = new Map<string, PlaywrightExecutor>()
|
||||||
private cdpConfig: CdpConfig
|
private cdpConfig: CdpConfig | ((sessionId: string) => CdpConfig)
|
||||||
private logger: ExecutorLogger
|
private logger: ExecutorLogger
|
||||||
|
|
||||||
constructor(options: { cdpConfig: CdpConfig; logger?: ExecutorLogger }) {
|
constructor(options: { cdpConfig: CdpConfig | ((sessionId: string) => CdpConfig); logger?: ExecutorLogger }) {
|
||||||
this.cdpConfig = options.cdpConfig
|
this.cdpConfig = options.cdpConfig
|
||||||
this.logger = options.logger || { log: console.log, error: console.error }
|
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)
|
let executor = this.executors.get(sessionId)
|
||||||
if (!executor) {
|
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({
|
executor = new PlaywrightExecutor({
|
||||||
cdpConfig: this.cdpConfig,
|
cdpConfig,
|
||||||
|
sessionMetadata,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
cwd,
|
cwd,
|
||||||
})
|
})
|
||||||
@@ -942,11 +985,25 @@ export class ExecutorManager {
|
|||||||
return this.executors.delete(sessionId)
|
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]) => {
|
return [...this.executors.entries()].map(([id, executor]) => {
|
||||||
|
const metadata = executor.getSessionMetadata()
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
stateKeys: executor.getStateKeys(),
|
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)
|
'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 id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}`
|
||||||
const queryString = token ? `?token=${token}` : ''
|
const params = new URLSearchParams()
|
||||||
return `ws://${host}:${port}/cdp/${id}${queryString}`
|
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'
|
const LOG_BASE_DIR = os.platform() === 'win32' ? os.tmpdir() : '/tmp'
|
||||||
|
|||||||
Reference in New Issue
Block a user