feat: first extension with active tabs keeps connection instead of being replaced
- Server rejects new extension (code 4002) if existing has active tabs - Idle extensions (no tabs) can still be replaced - Extension properly enters polling mode when rejected during handshake - /extension/status now returns activeTargets count for smarter reconnection
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.69
|
||||
|
||||
### Features
|
||||
|
||||
- **First extension keeps connection**: When multiple Playwriter extensions are installed, the actively-used one (with tabs) now keeps the connection. New extensions are rejected with code 4002 instead of taking over.
|
||||
- **Smarter reconnection**: Extension now polls `/extension/status` for `activeTargets` count and only attempts reconnection when the other extension has no active tabs.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Proper state handling for 4002 rejection**: Fixed issue where extension would keep retrying forever when rejected during WebSocket handshake. Now correctly enters `extension-replaced` polling state.
|
||||
|
||||
## 0.0.68
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwriter MCP",
|
||||
"version": "0.0.67",
|
||||
"version": "0.0.69",
|
||||
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
|
||||
"permissions": ["debugger", "tabGroups", "contextMenus", "tabs"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mcp-extension",
|
||||
"version": "0.0.67",
|
||||
"version": "0.0.69",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
+52
-11
@@ -110,7 +110,12 @@ class ConnectionManager {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`WebSocket closed: ${event.reason || event.code}`))
|
||||
// Normalize 4002 rejection to consistent error message for callers to detect
|
||||
if (event.code === 4002 || event.reason === 'Extension Already In Use') {
|
||||
reject(new Error('Extension Already In Use'))
|
||||
} else {
|
||||
reject(new Error(`WebSocket closed: ${event.reason || event.code}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -221,9 +226,10 @@ class ConnectionManager {
|
||||
this.ws = null
|
||||
|
||||
// Only one extension can connect to the relay server at a time.
|
||||
// This happens when another Playwriter extension (dev/prod, or another Chrome profile) connects.
|
||||
// Code 4001: Another extension replaced this one (this extension was idle)
|
||||
// Code 4002: This extension tried to connect but another is actively in use
|
||||
if (reason === 'Extension Replaced' || code === 4001) {
|
||||
logger.debug('Disconnected: another Playwriter extension connected to the relay server')
|
||||
logger.debug('Disconnected: another Playwriter extension connected (this one was idle)')
|
||||
store.setState({
|
||||
tabs: new Map(),
|
||||
connectionState: 'extension-replaced',
|
||||
@@ -232,6 +238,16 @@ class ConnectionManager {
|
||||
return
|
||||
}
|
||||
|
||||
if (reason === 'Extension Already In Use' || code === 4002) {
|
||||
logger.debug('Rejected: another Playwriter extension is actively in use')
|
||||
store.setState({
|
||||
tabs: new Map(),
|
||||
connectionState: 'extension-replaced',
|
||||
errorText: 'Another Playwriter extension is actively in use',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// For normal disconnects, set tabs to 'connecting' state and let maintain loop handle reconnect
|
||||
store.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
@@ -249,16 +265,18 @@ class ConnectionManager {
|
||||
continue
|
||||
}
|
||||
|
||||
// When another Playwriter extension took over, poll until slot is free
|
||||
// When another Playwriter extension took over, poll until slot is free.
|
||||
// Slot is free when: no extension connected, OR connected but no active tabs.
|
||||
if (store.getState().connectionState === 'extension-replaced') {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${RELAY_PORT}/extension/status`, { method: 'GET', signal: AbortSignal.timeout(2000) })
|
||||
const data = await response.json()
|
||||
if (!data.connected) {
|
||||
const data = await response.json() as { connected: boolean; activeTargets: number }
|
||||
const slotAvailable = !data.connected || data.activeTargets === 0
|
||||
if (slotAvailable) {
|
||||
store.setState({ connectionState: 'idle', errorText: undefined })
|
||||
logger.debug('Extension slot is free, cleared error state')
|
||||
logger.debug('Extension slot is free (connected:', data.connected, 'activeTargets:', data.activeTargets, '), cleared error state')
|
||||
} else {
|
||||
logger.debug('Extension slot still taken, will retry...')
|
||||
logger.debug('Extension slot still taken (activeTargets:', data.activeTargets, '), will retry...')
|
||||
}
|
||||
} catch {
|
||||
logger.debug('Server not available, will retry...')
|
||||
@@ -317,7 +335,15 @@ class ConnectionManager {
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.debug('Connection attempt failed:', error.message)
|
||||
store.setState({ connectionState: 'idle' })
|
||||
// Check if rejected because another extension is actively in use
|
||||
if (error.message === 'Extension Already In Use') {
|
||||
store.setState({
|
||||
connectionState: 'extension-replaced',
|
||||
errorText: 'Another Playwriter extension is actively in use',
|
||||
})
|
||||
} else {
|
||||
store.setState({ connectionState: 'idle' })
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(3000)
|
||||
@@ -828,13 +854,28 @@ async function connectTab(tabId: number): Promise<void> {
|
||||
// Distinguish between WS connection errors and tab-specific errors
|
||||
// WS errors: keep in 'connecting' state, maintainLoop will retry when WS is available
|
||||
// Tab errors: show 'error' state (e.g., restricted page, debugger attach failed)
|
||||
// Extension in use: set global 'extension-replaced' state to enter polling mode
|
||||
const isExtensionInUse =
|
||||
error.message === 'Extension Already In Use' ||
|
||||
error.message === 'Another Playwriter extension is already connected'
|
||||
|
||||
const isWsError =
|
||||
error.message === 'Server not available' ||
|
||||
error.message === 'Connection timeout' ||
|
||||
error.message === 'Another Playwriter extension is already connected' ||
|
||||
error.message.startsWith('WebSocket')
|
||||
|
||||
if (isWsError) {
|
||||
if (isExtensionInUse) {
|
||||
logger.debug(`Another extension is in use, entering polling mode`)
|
||||
store.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
newTabs.delete(tabId)
|
||||
return {
|
||||
tabs: newTabs,
|
||||
connectionState: 'extension-replaced',
|
||||
errorText: 'Another Playwriter extension is actively in use',
|
||||
}
|
||||
})
|
||||
} else if (isWsError) {
|
||||
logger.debug(`WS connection failed, keeping tab ${tabId} in connecting state for retry`)
|
||||
// Tab stays in 'connecting' state - maintainLoop will retry when WS becomes available
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.51
|
||||
|
||||
### Features
|
||||
|
||||
- **First extension keeps connection**: When multiple Playwriter extensions are installed (e.g., dev and prod), the first one with active tabs now keeps the connection instead of being replaced by newer connections. Idle extensions (no tabs) can still be replaced.
|
||||
- **Smarter extension slot detection**: `/extension/status` endpoint now returns `activeTargets` count, allowing extensions to know when the slot becomes available (no active tabs).
|
||||
|
||||
## 0.0.50
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "playwriter",
|
||||
"description": "",
|
||||
"version": "0.0.50",
|
||||
"version": "0.0.51",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -451,7 +451,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
||||
})
|
||||
|
||||
app.get('/extension/status', (c) => {
|
||||
return c.json({ connected: extensionWs !== null })
|
||||
return c.json({
|
||||
connected: extensionWs !== null,
|
||||
activeTargets: connectedTargets.size
|
||||
})
|
||||
})
|
||||
|
||||
// CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API
|
||||
@@ -765,11 +768,19 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
if (extensionWs) {
|
||||
logger?.log(pc.yellow('Closing existing extension connection to replace with new one'))
|
||||
// Only allow replacement if the existing extension has no active tabs.
|
||||
// This prevents a new extension from hijacking an actively-used session.
|
||||
// If the existing extension is idle (no tabs), allow the new one to take over.
|
||||
if (connectedTargets.size > 0) {
|
||||
logger?.log(pc.yellow(`Rejecting new extension - existing one has ${connectedTargets.size} active tab(s)`))
|
||||
ws.close(4002, 'Extension Already In Use')
|
||||
return
|
||||
}
|
||||
|
||||
logger?.log(pc.yellow('Replacing idle extension connection (no active tabs)'))
|
||||
extensionWs.close(4001, 'Extension Replaced')
|
||||
|
||||
// Clear state from the old connection to prevent leaks
|
||||
connectedTargets.clear()
|
||||
for (const pending of extensionPendingRequests.values()) {
|
||||
pending.reject(new Error('Extension connection replaced'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user