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