feat: add CDP discovery endpoints (/json/version, /json/list)

Standard Chrome DevTools Protocol HTTP discovery endpoints that allow
tools like Playwright to connect using just the HTTP URL without needing
to call getCdpUrl() first.

- /json/version returns browser info and webSocketDebuggerUrl
- /json/list returns array of connected targets
- Supports GET/PUT methods and trailing slashes for compatibility
This commit is contained in:
Tommy D. Rossi
2026-01-12 17:08:35 +01:00
parent 9c323c5940
commit 481cb4ddc8
4 changed files with 239 additions and 0 deletions
+19
View File
@@ -90,6 +90,25 @@ await browser.close()
server.close()
```
### Standard CDP Connection
Start the relay server, then connect with just the HTTP URL:
```bash
npx -y playwriter serve --host 127.0.0.1
```
Or programmatically:
```typescript
import { startPlayWriterCDPRelayServer } from 'playwriter'
await startPlayWriterCDPRelayServer()
const browser = await chromium.connectOverCDP('http://127.0.0.1:19988')
```
This works with any CDP-compatible tool - no special configuration needed.
### Visual Aria Ref Labels
Playwriter includes Vimium-style visual labels that overlay interactive elements, making it easy for AI agents to identify and click elements from screenshots. The `screenshotWithAccessibilityLabels` function is available in the MCP context.
+8
View File
@@ -8,6 +8,14 @@
- Non-contiguous sections are separated by `---`
- Provides better context for understanding search results
- **CDP discovery endpoints**: Added standard Chrome DevTools Protocol HTTP discovery endpoints
- `/json/version` - Returns browser info and `webSocketDebuggerUrl`
- `/json/list` - Returns list of debuggable targets
- `/json` - Alias for `/json/list`
- Supports both GET and PUT methods (Chrome 66+ compatibility)
- Handles trailing slash variants (Playwright compatibility)
- Allows `chromium.connectOverCDP('http://127.0.0.1:19988')` without needing to call `getCdpUrl` first
## 0.0.43
### Features
+83
View File
@@ -363,6 +363,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
const app = new Hono()
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
// Helper to build WebSocket URL from request host
const getCdpWsUrl = (reqHost: string | undefined) => {
const hostHeader = reqHost || `${host}:${port}`
const wsUrl = `ws://${hostHeader}/cdp`
return token ? `${wsUrl}?token=${token}` : wsUrl
}
app.get('/', (c) => {
return c.text('OK')
})
@@ -375,6 +382,82 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
return c.json({ connected: extensionWs !== null })
})
// CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API
// These allow tools like Playwright to discover the WebSocket URL automatically
// Spec: https://chromium.googlesource.com/chromium/src/+/main/content/browser/devtools/devtools_http_handler.cc
app
.on(['GET', 'PUT'], '/json/version', (c) => {
return c.json({
'Browser': `Playwriter/${VERSION}`,
'Protocol-Version': '1.3',
'webSocketDebuggerUrl': getCdpWsUrl(c.req.header('host'))
})
})
.on(['GET', 'PUT'], '/json/version/', (c) => {
return c.json({
'Browser': `Playwriter/${VERSION}`,
'Protocol-Version': '1.3',
'webSocketDebuggerUrl': getCdpWsUrl(c.req.header('host'))
})
})
.on(['GET', 'PUT'], '/json/list', (c) => {
const wsUrl = getCdpWsUrl(c.req.header('host'))
return c.json(
Array.from(connectedTargets.values()).map(t => ({
id: t.targetId,
type: t.targetInfo.type,
title: t.targetInfo.title,
description: t.targetInfo.title,
url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
}))
)
})
.on(['GET', 'PUT'], '/json/list/', (c) => {
const wsUrl = getCdpWsUrl(c.req.header('host'))
return c.json(
Array.from(connectedTargets.values()).map(t => ({
id: t.targetId,
type: t.targetInfo.type,
title: t.targetInfo.title,
description: t.targetInfo.title,
url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
}))
)
})
.on(['GET', 'PUT'], '/json', (c) => {
const wsUrl = getCdpWsUrl(c.req.header('host'))
return c.json(
Array.from(connectedTargets.values()).map(t => ({
id: t.targetId,
type: t.targetInfo.type,
title: t.targetInfo.title,
description: t.targetInfo.title,
url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
}))
)
})
.on(['GET', 'PUT'], '/json/', (c) => {
const wsUrl = getCdpWsUrl(c.req.header('host'))
return c.json(
Array.from(connectedTargets.values()).map(t => ({
id: t.targetId,
type: t.targetInfo.type,
title: t.targetInfo.title,
description: t.targetInfo.title,
url: t.targetInfo.url,
webSocketDebuggerUrl: wsUrl,
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
}))
)
})
app.post('/mcp-log', async (c) => {
try {
const { level, args } = await c.req.json()
+129
View File
@@ -2527,6 +2527,65 @@ describe('MCP Server Tests', () => {
await page.close()
}, 60000)
it('should expose CDP discovery endpoints /json/version and /json/list', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
// Enable extension on a page
const page = await browserContext.newPage()
await page.goto('https://example.com')
await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 200))
// Test /json/version
const versionRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`)
expect(versionRes.status).toBe(200)
const versionJson = await versionRes.json() as { webSocketDebuggerUrl: string }
expect(versionJson).toMatchObject({
'Browser': expect.stringContaining('Playwriter/'),
'Protocol-Version': '1.3',
'webSocketDebuggerUrl': expect.stringContaining('ws://'),
})
expect(versionJson.webSocketDebuggerUrl).toContain(`127.0.0.1:${TEST_PORT}/cdp`)
// Test /json/version/ (trailing slash - Playwright uses this)
const versionSlashRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version/`)
expect(versionSlashRes.status).toBe(200)
// Test /json/list
const listRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/list`)
expect(listRes.status).toBe(200)
const listJson = await listRes.json() as Array<{ url?: string }>
expect(Array.isArray(listJson)).toBe(true)
expect(listJson.length).toBeGreaterThan(0)
// Find the example.com page (there may be other pages like about:blank)
const examplePage = listJson.find((t) => t.url?.includes('example.com'))
expect(examplePage).toBeDefined()
expect(examplePage).toMatchObject({
id: expect.any(String),
type: 'page',
url: expect.stringContaining('example.com'),
webSocketDebuggerUrl: expect.stringContaining('ws://'),
})
// Test /json (alias for /json/list)
const jsonRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json`)
expect(jsonRes.status).toBe(200)
const jsonData = await jsonRes.json()
expect(Array.isArray(jsonData)).toBe(true)
// Test PUT method (Chrome 66+ prefers PUT)
const putRes = await fetch(`http://127.0.0.1:${TEST_PORT}/json/version`, { method: 'PUT' })
expect(putRes.status).toBe(200)
await page.close()
}, 60000)
})
@@ -3465,6 +3524,76 @@ describe('CDP Session Tests', () => {
}, 60000)
})
describe('Service Worker Target Tests', () => {
let testCtx: TestContext | null = null
beforeAll(async () => {
testCtx = await setupTestContext({ tempDirPrefix: 'pw-sw-test-' })
}, 600000)
afterAll(async () => {
await cleanupTestContext(testCtx)
testCtx = null
})
const getBrowserContext = () => {
if (!testCtx?.browserContext) throw new Error('Browser not initialized')
return testCtx.browserContext
}
it('should not disconnect when page has service worker (issue #14)', async () => {
// This test reproduces issue #14: pages with service workers cause disconnection loops.
// The problem is that Target.setAutoAttach attaches to service workers, and when
// Playwright tries to enable Network/Runtime on the SW session, the extension can't
// find a matching tab, causing errors and disconnection.
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
// Discord has a service worker - navigate there
const page = await browserContext.newPage()
await page.goto('https://discord.com/login', { waitUntil: 'load' })
await page.bringToFront()
// Attach extension to the page
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
// Connect via Playwright CDP - this triggers Target.setAutoAttach which
// will also attach to the service worker
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
// Track disconnection events
let disconnected = false
browser.on('disconnected', () => {
disconnected = true
console.log('Browser disconnected!')
})
// Wait for potential disconnection - issue says ~10s loop
// We wait 15 seconds to be sure
console.log('Waiting 15 seconds to check for disconnection loop...')
await new Promise(r => setTimeout(r, 15000))
// Should still be connected - this is what we're testing
expect(disconnected).toBe(false)
expect(browser.isConnected()).toBe(true)
// Verify we can still interact with the page
const pages = browser.contexts()[0].pages()
const discordPage = pages.find(p => p.url().includes('discord.com'))
expect(discordPage).toBeDefined()
const url = await discordPage!.evaluate(() => window.location.href)
expect(url).toContain('discord.com')
await browser.close()
await page.close()
}, 60000)
})
describe('Auto-enable Tests', () => {
let testCtx: TestContext | null = null