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:
Tommy D. Rossi
2026-02-04 16:43:09 +01:00
parent c623c91891
commit 44514f00c8
6 changed files with 719 additions and 184 deletions
+1 -1
View File
@@ -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",
+89 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
+63 -6
View File
@@ -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
View File
@@ -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'