fix: scope CDP tab sessions and simplify recording sessionId routing
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.72
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Use runtime-scoped root CDP tab session IDs**: Root tab sessions now use `pw-tab-<scope>-<n>` instead of `pw-tab-<n>`, where scope is a random value generated once per extension runtime. This prevents session ID collisions across multiple connected Chrome profiles.
|
||||
|
||||
## 0.0.71
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwriter",
|
||||
"version": "0.0.71",
|
||||
"version": "0.0.72",
|
||||
"description": "Automate your Browser using Cursor, Claude, VS Code. More capable and context efficient than Playwright MCP.",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mcp-extension",
|
||||
"version": "0.0.71",
|
||||
"version": "0.0.72",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
@@ -66,6 +66,15 @@ async function detectBrowserName(): Promise<string> {
|
||||
}
|
||||
|
||||
let identityPromise: Promise<ExtensionIdentity> | null = null
|
||||
const tabSessionScope = (() => {
|
||||
const values = new Uint32Array(2)
|
||||
crypto.getRandomValues(values)
|
||||
return Array.from(values)
|
||||
.map((value) => {
|
||||
return value.toString(36)
|
||||
})
|
||||
.join('')
|
||||
})()
|
||||
|
||||
async function getExtensionIdentity(): Promise<ExtensionIdentity> {
|
||||
if (identityPromise) {
|
||||
@@ -1070,7 +1079,7 @@ async function attachTab(
|
||||
}
|
||||
|
||||
const attachOrder = nextSessionId
|
||||
const sessionId = `pw-tab-${nextSessionId++}`
|
||||
const sessionId = `pw-tab-${tabSessionScope}-${nextSessionId++}`
|
||||
|
||||
store.setState((state) => {
|
||||
const newTabs = new Map(state.tabs)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.69
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Scope CDP tab session IDs by extension runtime**: Switched root tab IDs to `pw-tab-<scope>-<n>` so concurrent extension connections do not reuse the same `pw-tab-1`, `pw-tab-2`, etc. The scope is generated once per extension runtime to avoid cross-profile collisions and ambiguous recording-route resolution.
|
||||
- **Standardize recording routes on CDP `sessionId`**: Recording HTTP routes now treat `sessionId` as a CDP tab session ID (`pw-tab-*`) only, removing executor-target branching from the recording path.
|
||||
|
||||
## 0.0.68
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "playwriter",
|
||||
"description": "",
|
||||
"version": "0.0.68",
|
||||
"version": "0.0.69",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
+24
-39
@@ -429,9 +429,9 @@ export async function startPlayWriterCDPRelayServer({
|
||||
|
||||
const recordingRelays = new Map<string, RecordingRelay>()
|
||||
|
||||
// Find which extension connection owns a CDP session ID (pw-tab-*).
|
||||
// Used by recording routes where sessionId is a CDP tab session, not
|
||||
// an executor session. Searches all connected extensions' targets.
|
||||
// Find which extension connection owns a CDP tab session ID (pw-tab-*).
|
||||
// Used by recording routes where sessionId identifies the target tab.
|
||||
// Searches all connected extensions' targets.
|
||||
const findExtensionIdByCdpSession = (cdpSessionId: string): string | null => {
|
||||
for (const [connectionId, connection] of extensionConnections.entries()) {
|
||||
if (connection.connectedTargets.has(cdpSessionId)) {
|
||||
@@ -441,29 +441,21 @@ export async function startPlayWriterCDPRelayServer({
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve extensionId from a recording route's sessionId parameter.
|
||||
// sessionId can be either:
|
||||
// - CDP session ID (pw-tab-*) — look up which extension owns that tab
|
||||
// - Executor session ID (numeric) — look up which extension the executor is bound to
|
||||
// - null — fall back to default extension
|
||||
const resolveRecordingExtensionId = async (sessionId: string | null): Promise<{
|
||||
// Resolve recording route session ID (CDP tab session) to extension connection.
|
||||
const resolveRecordingRoute = async ({
|
||||
sessionId,
|
||||
}: {
|
||||
sessionId: string | null
|
||||
}): Promise<{
|
||||
extensionId: string | null
|
||||
error?: string
|
||||
sessionId: string | null
|
||||
}> => {
|
||||
if (!sessionId) {
|
||||
return { extensionId: null }
|
||||
return { extensionId: null, sessionId: null }
|
||||
}
|
||||
if (sessionId.startsWith('pw-tab-')) {
|
||||
const extensionId = findExtensionIdByCdpSession(sessionId)
|
||||
return { extensionId }
|
||||
}
|
||||
// Numeric executor session — resolve extension from executor metadata
|
||||
const manager = await getExecutorManager()
|
||||
const executor = manager.getSession(sessionId)
|
||||
if (!executor) {
|
||||
return { extensionId: null, error: `Session ${sessionId} not found` }
|
||||
}
|
||||
return { extensionId: executor.getSessionMetadata().extensionId || null }
|
||||
|
||||
const extensionId = findExtensionIdByCdpSession(sessionId)
|
||||
return { extensionId, sessionId }
|
||||
}
|
||||
|
||||
const getRecordingRelay = (extensionId?: string | null): RecordingRelay | null => {
|
||||
@@ -1766,15 +1758,14 @@ export async function startPlayWriterCDPRelayServer({
|
||||
}
|
||||
const sessionId = normalizeSessionId(body.sessionId)
|
||||
const { sessionId: _sessionId, ...recordingOptions } = body
|
||||
const { extensionId, error } = await resolveRecordingExtensionId(sessionId)
|
||||
if (error) {
|
||||
return c.json({ success: false, error }, 404)
|
||||
}
|
||||
const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
|
||||
const relay = getRecordingRelay(extensionId)
|
||||
if (!relay) {
|
||||
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
||||
}
|
||||
const recordingParams = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions) as StartRecordingBody
|
||||
const recordingParams = (resolvedSessionId
|
||||
? { ...recordingOptions, sessionId: resolvedSessionId }
|
||||
: recordingOptions) as StartRecordingBody
|
||||
const result = await relay.startRecording(recordingParams)
|
||||
const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500
|
||||
return c.json(result, status)
|
||||
@@ -1783,15 +1774,12 @@ export async function startPlayWriterCDPRelayServer({
|
||||
app.post('/recording/stop', async (c) => {
|
||||
const body = (await c.req.json()) as { sessionId?: string | number }
|
||||
const sessionId = normalizeSessionId(body.sessionId)
|
||||
const { extensionId, error } = await resolveRecordingExtensionId(sessionId)
|
||||
if (error) {
|
||||
return c.json({ success: false, error }, 404)
|
||||
}
|
||||
const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
|
||||
const relay = getRecordingRelay(extensionId)
|
||||
if (!relay) {
|
||||
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
||||
}
|
||||
const stopParams: StopRecordingParams = sessionId ? { sessionId } : {}
|
||||
const stopParams: StopRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
|
||||
const result = await relay.stopRecording(stopParams)
|
||||
const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500
|
||||
return c.json(result, status)
|
||||
@@ -1799,12 +1787,12 @@ export async function startPlayWriterCDPRelayServer({
|
||||
|
||||
app.get('/recording/status', async (c) => {
|
||||
const sessionId = normalizeSessionId(c.req.query('sessionId'))
|
||||
const { extensionId } = await resolveRecordingExtensionId(sessionId)
|
||||
const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
|
||||
const relay = getRecordingRelay(extensionId)
|
||||
if (!relay) {
|
||||
return c.json({ isRecording: false })
|
||||
}
|
||||
const isRecordingParams: IsRecordingParams = sessionId ? { sessionId } : {}
|
||||
const isRecordingParams: IsRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
|
||||
const result = await relay.isRecording(isRecordingParams)
|
||||
return c.json(result)
|
||||
})
|
||||
@@ -1812,15 +1800,12 @@ export async function startPlayWriterCDPRelayServer({
|
||||
app.post('/recording/cancel', async (c) => {
|
||||
const body = (await c.req.json()) as { sessionId?: string | number }
|
||||
const sessionId = normalizeSessionId(body.sessionId)
|
||||
const { extensionId, error } = await resolveRecordingExtensionId(sessionId)
|
||||
if (error) {
|
||||
return c.json({ success: false, error }, 404)
|
||||
}
|
||||
const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
|
||||
const relay = getRecordingRelay(extensionId)
|
||||
if (!relay) {
|
||||
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
||||
}
|
||||
const cancelParams: CancelRecordingParams = sessionId ? { sessionId } : {}
|
||||
const cancelParams: CancelRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
|
||||
const result = await relay.cancelRecording(cancelParams)
|
||||
return c.json(result)
|
||||
})
|
||||
|
||||
@@ -954,7 +954,7 @@ export class PlaywrightExecutor {
|
||||
) => {
|
||||
return async (options: T = {} as T) => {
|
||||
const targetPage = options.page || page
|
||||
// Use Playwright's exposed sessionId directly
|
||||
// Use Playwright's exposed tab session ID directly.
|
||||
const sessionId = options.sessionId || targetPage.sessionId() || undefined
|
||||
return fn({ page: targetPage, sessionId, relayPort, ...options })
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export type ExtensionMessage =
|
||||
|
||||
// Recording command messages (MCP -> Extension via relay)
|
||||
export type StartRecordingParams = {
|
||||
/** CDP tab session ID (pw-tab-*) to identify which tab to record. */
|
||||
sessionId?: string
|
||||
frameRate?: number
|
||||
audio?: boolean
|
||||
@@ -99,14 +100,17 @@ export type StartRecordingBody = StartRecordingParams & {
|
||||
}
|
||||
|
||||
export type StopRecordingParams = {
|
||||
/** CDP tab session ID (pw-tab-*) to identify which tab to stop recording. */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export type IsRecordingParams = {
|
||||
/** CDP tab session ID (pw-tab-*) to identify which tab to check. */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export type CancelRecordingParams = {
|
||||
/** CDP tab session ID (pw-tab-*) to identify which tab to cancel. */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
* Recording happens in the extension context, so it survives page navigation.
|
||||
*
|
||||
* This module communicates with the relay server which forwards commands to the extension.
|
||||
* SessionId (pw-tab-X format) is used to identify which tab to record.
|
||||
* sessionId (pw-tab-* format) is used to identify which tab to record.
|
||||
*/
|
||||
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@xmorse/playwright-core'
|
||||
import type { StartRecordingResult, StopRecordingResult, IsRecordingResult, CancelRecordingResult } from './protocol.js'
|
||||
import type {
|
||||
StartRecordingResult,
|
||||
StopRecordingResult,
|
||||
IsRecordingResult,
|
||||
CancelRecordingResult,
|
||||
} from './protocol.js'
|
||||
import { EXTENSION_IDS } from './utils.js'
|
||||
|
||||
/**
|
||||
@@ -49,7 +54,7 @@ function isActiveTabPermissionError(error: string): boolean {
|
||||
export interface StartRecordingOptions {
|
||||
/** Target page to record */
|
||||
page: Page
|
||||
/** Session ID (pw-tab-X format) to identify which tab to record */
|
||||
/** CDP tab session ID (pw-tab-* format) to identify which tab to record */
|
||||
sessionId?: string
|
||||
/** Frame rate (default: 30) */
|
||||
frameRate?: number
|
||||
@@ -68,7 +73,7 @@ export interface StartRecordingOptions {
|
||||
export interface StopRecordingOptions {
|
||||
/** Target page that is being recorded */
|
||||
page: Page
|
||||
/** Session ID (pw-tab-X format) to identify which tab to stop recording */
|
||||
/** CDP tab session ID (pw-tab-* format) to identify which tab to stop recording */
|
||||
sessionId?: string
|
||||
/** Relay server port (default: 19988) */
|
||||
relayPort?: number
|
||||
@@ -173,10 +178,11 @@ export async function isRecording(options: {
|
||||
}): Promise<RecordingState> {
|
||||
const { sessionId, relayPort = 19988 } = options
|
||||
|
||||
const url = sessionId
|
||||
? `http://127.0.0.1:${relayPort}/recording/status?sessionId=${encodeURIComponent(sessionId)}`
|
||||
: `http://127.0.0.1:${relayPort}/recording/status`
|
||||
const response = await fetch(url)
|
||||
const url = new URL(`http://127.0.0.1:${relayPort}/recording/status`)
|
||||
if (sessionId) {
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
}
|
||||
const response = await fetch(url.toString())
|
||||
const result = (await response.json()) as IsRecordingResult
|
||||
|
||||
return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
|
||||
@@ -185,7 +191,11 @@ export async function isRecording(options: {
|
||||
/**
|
||||
* Cancel recording without saving.
|
||||
*/
|
||||
export async function cancelRecording(options: { page: Page; sessionId?: string; relayPort?: number }): Promise<void> {
|
||||
export async function cancelRecording(options: {
|
||||
page: Page
|
||||
sessionId?: string
|
||||
relayPort?: number
|
||||
}): Promise<void> {
|
||||
const { sessionId, relayPort = 19988 } = options
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {
|
||||
|
||||
Reference in New Issue
Block a user