playwriter 0.0.63 → 0.0.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aria-snapshot.d.ts +41 -3
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +131 -54
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/aria-snapshot.test.js +5 -2
- package/dist/aria-snapshot.test.js.map +1 -1
- package/dist/aria-snapshot.unit.test.js +83 -41
- package/dist/aria-snapshot.unit.test.js.map +1 -1
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
- package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
- package/dist/bippy.js +1 -1
- package/dist/cdp-log.d.ts +1 -1
- package/dist/cdp-log.d.ts.map +1 -1
- package/dist/cdp-log.js +1 -1
- package/dist/cdp-log.js.map +1 -1
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +408 -298
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cdp-session.d.ts.map +1 -1
- package/dist/cdp-session.js.map +1 -1
- package/dist/cdp-types.d.ts.map +1 -1
- package/dist/cdp-types.js +7 -7
- package/dist/cdp-types.js.map +1 -1
- package/dist/clean-html.d.ts.map +1 -1
- package/dist/clean-html.js +4 -5
- package/dist/clean-html.js.map +1 -1
- package/dist/cli.js +45 -27
- package/dist/cli.js.map +1 -1
- package/dist/create-logger.d.ts.map +1 -1
- package/dist/create-logger.js +3 -1
- package/dist/create-logger.js.map +1 -1
- package/dist/debugger-examples-types.d.ts.map +1 -1
- package/dist/debugger.d.ts.map +1 -1
- package/dist/debugger.js +1 -3
- package/dist/debugger.js.map +1 -1
- package/dist/diff-utils.d.ts.map +1 -1
- package/dist/diff-utils.js +1 -4
- package/dist/diff-utils.js.map +1 -1
- package/dist/editor-api.md +12 -2
- package/dist/editor-examples.d.ts +1 -1
- package/dist/editor-examples.d.ts.map +1 -1
- package/dist/editor-examples.js +1 -1
- package/dist/editor-examples.js.map +1 -1
- package/dist/editor.d.ts +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +1 -1
- package/dist/editor.js.map +1 -1
- package/dist/executor.d.ts +26 -3
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +295 -64
- package/dist/executor.js.map +1 -1
- package/dist/executor.unit.test.js +38 -1
- package/dist/executor.unit.test.js.map +1 -1
- package/dist/extension-connection.test.js +139 -36
- package/dist/extension-connection.test.js.map +1 -1
- package/dist/ffmpeg.d.ts +148 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +523 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/ghost-browser.d.ts.map +1 -1
- package/dist/ghost-browser.js.map +1 -1
- package/dist/ghost-cursor-client.js +281 -0
- package/dist/ghost-cursor.d.ts +27 -0
- package/dist/ghost-cursor.d.ts.map +1 -0
- package/dist/ghost-cursor.js +63 -0
- package/dist/ghost-cursor.js.map +1 -0
- package/dist/htmlrewrite.d.ts.map +1 -1
- package/dist/htmlrewrite.js +17 -55
- package/dist/htmlrewrite.js.map +1 -1
- package/dist/htmlrewrite.test.js.map +1 -1
- package/dist/kill-port.d.ts.map +1 -1
- package/dist/kill-port.js +1 -3
- package/dist/kill-port.js.map +1 -1
- package/dist/locator-selector.test.d.ts +2 -0
- package/dist/locator-selector.test.d.ts.map +1 -0
- package/dist/locator-selector.test.js +96 -0
- package/dist/locator-selector.test.js.map +1 -0
- package/dist/mcp-client.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -3
- package/dist/mcp.js.map +1 -1
- package/dist/on-mouse-action.test.d.ts +2 -0
- package/dist/on-mouse-action.test.d.ts.map +1 -0
- package/dist/on-mouse-action.test.js +155 -0
- package/dist/on-mouse-action.test.js.map +1 -0
- package/dist/page-markdown.js +4 -4
- package/dist/page-markdown.js.map +1 -1
- package/dist/prompt.md +594 -255
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/readability.js +1 -1
- package/dist/recording-ghost-cursor.d.ts +41 -0
- package/dist/recording-ghost-cursor.d.ts.map +1 -0
- package/dist/recording-ghost-cursor.js +79 -0
- package/dist/recording-ghost-cursor.js.map +1 -0
- package/dist/recording-relay.d.ts.map +1 -1
- package/dist/recording-relay.js +8 -8
- package/dist/recording-relay.js.map +1 -1
- package/dist/relay-client.d.ts +17 -4
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +44 -10
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-core.test.d.ts.map +1 -1
- package/dist/relay-core.test.js +187 -26
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-navigation.test.d.ts.map +1 -1
- package/dist/relay-navigation.test.js +54 -31
- package/dist/relay-navigation.test.js.map +1 -1
- package/dist/relay-session.test.d.ts.map +1 -1
- package/dist/relay-session.test.js +113 -65
- package/dist/relay-session.test.js.map +1 -1
- package/dist/relay-state.d.ts +158 -0
- package/dist/relay-state.d.ts.map +1 -0
- package/dist/relay-state.js +306 -0
- package/dist/relay-state.js.map +1 -0
- package/dist/relay-state.test.d.ts +2 -0
- package/dist/relay-state.test.d.ts.map +1 -0
- package/dist/relay-state.test.js +472 -0
- package/dist/relay-state.test.js.map +1 -0
- package/dist/scoped-fs.d.ts.map +1 -1
- package/dist/scoped-fs.js.map +1 -1
- package/dist/screen-recording.d.ts +42 -4
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +88 -13
- package/dist/screen-recording.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/dist/snapshot-tools.test.js +71 -28
- package/dist/snapshot-tools.test.js.map +1 -1
- package/dist/start-relay-server.d.ts +1 -1
- package/dist/start-relay-server.d.ts.map +1 -1
- package/dist/start-relay-server.js +1 -1
- package/dist/start-relay-server.js.map +1 -1
- package/dist/styles-api.md +8 -1
- package/dist/styles-examples.d.ts +1 -1
- package/dist/styles-examples.d.ts.map +1 -1
- package/dist/styles-examples.js +1 -1
- package/dist/styles-examples.js.map +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +1 -3
- package/dist/styles.js.map +1 -1
- package/dist/test-declarations.d.ts.map +1 -1
- package/dist/test-utils.d.ts +1 -1
- package/dist/test-utils.d.ts.map +1 -1
- package/dist/test-utils.js +7 -5
- package/dist/test-utils.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/wait-for-page-load.d.ts.map +1 -1
- package/dist/wait-for-page-load.js +1 -1
- package/dist/wait-for-page-load.js.map +1 -1
- package/package.json +4 -3
- package/src/a11y-client.ts +5 -4
- package/src/aria-snapshot.test.ts +5 -2
- package/src/aria-snapshot.ts +303 -116
- package/src/aria-snapshot.unit.test.ts +199 -141
- package/src/aria-snapshots/github-raw.txt +1 -1
- package/src/aria-snapshots/hackernews-interactive.txt +240 -240
- package/src/aria-snapshots/hackernews-raw.txt +270 -270
- package/src/assets/aria-labels-example.png +0 -0
- package/src/assets/aria-labels-github.png +0 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/assets/aria-labels-old-reddit.png +0 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
- package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
- package/src/cdp-log.ts +4 -1
- package/src/cdp-relay.ts +949 -737
- package/src/cdp-session.ts +12 -3
- package/src/cdp-types.ts +51 -51
- package/src/clean-html.ts +4 -5
- package/src/cli.ts +82 -55
- package/src/create-logger.ts +5 -3
- package/src/debugger-examples-types.ts +4 -1
- package/src/debugger.ts +1 -5
- package/src/diff-utils.ts +2 -5
- package/src/editor-examples.ts +11 -1
- package/src/editor.ts +10 -2
- package/src/executor.ts +372 -73
- package/src/executor.unit.test.ts +48 -1
- package/src/extension-connection.test.ts +612 -488
- package/src/ffmpeg.ts +769 -0
- package/src/ghost-browser.ts +4 -6
- package/src/ghost-cursor-client.ts +368 -0
- package/src/ghost-cursor.ts +110 -0
- package/src/htmlrewrite.test.ts +6 -2
- package/src/htmlrewrite.ts +348 -386
- package/src/kill-port.ts +1 -3
- package/src/locator-selector.test.ts +115 -0
- package/src/mcp-client.ts +1 -1
- package/src/mcp.ts +21 -15
- package/src/on-mouse-action.test.ts +196 -0
- package/src/page-markdown.ts +7 -7
- package/src/protocol.ts +73 -57
- package/src/recording-ghost-cursor.ts +107 -0
- package/src/recording-relay.ts +20 -12
- package/src/relay-client.ts +84 -17
- package/src/relay-core.test.ts +761 -583
- package/src/relay-navigation.test.ts +517 -484
- package/src/relay-session.test.ts +984 -929
- package/src/relay-state.test.ts +570 -0
- package/src/relay-state.ts +497 -0
- package/src/resource.md +21 -49
- package/src/scoped-fs.ts +9 -3
- package/src/screen-recording.ts +175 -31
- package/src/skill.md +619 -271
- package/src/snapshot-tools.test.ts +580 -528
- package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
- package/src/start-relay-server.ts +14 -11
- package/src/styles-examples.ts +8 -1
- package/src/styles.ts +20 -21
- package/src/test-declarations.ts +6 -6
- package/src/test-utils.ts +104 -91
- package/src/utils.ts +2 -1
- package/src/wait-for-page-load.ts +6 -1
package/src/cdp-relay.ts
CHANGED
|
@@ -28,13 +28,7 @@ import { EventEmitter } from 'node:events'
|
|
|
28
28
|
import { VERSION, EXTENSION_IDS } from './utils.js'
|
|
29
29
|
import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js'
|
|
30
30
|
import { RecordingRelay } from './recording-relay.js'
|
|
31
|
-
|
|
32
|
-
type ConnectedTarget = {
|
|
33
|
-
sessionId: string
|
|
34
|
-
targetId: string
|
|
35
|
-
targetInfo: Protocol.Target.TargetInfo
|
|
36
|
-
frameIds: Set<string>
|
|
37
|
-
}
|
|
31
|
+
import * as relayState from './relay-state.js'
|
|
38
32
|
|
|
39
33
|
/**
|
|
40
34
|
* Checks if a target should be filtered out (not exposed to Playwright).
|
|
@@ -68,32 +62,6 @@ function isRestrictedTarget(targetInfo: Protocol.Target.TargetInfo): boolean {
|
|
|
68
62
|
return blockedPrefixes.some((prefix) => url.startsWith(prefix))
|
|
69
63
|
}
|
|
70
64
|
|
|
71
|
-
type PlaywrightClient = {
|
|
72
|
-
id: string
|
|
73
|
-
ws: WSContext
|
|
74
|
-
extensionId: string | null
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
type ExtensionInfo = {
|
|
78
|
-
browser?: string
|
|
79
|
-
email?: string
|
|
80
|
-
id?: string
|
|
81
|
-
/** playwriter package version the extension was built with (sent as ?v= query param) */
|
|
82
|
-
version?: string
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
type ExtensionConnection = {
|
|
86
|
-
id: string
|
|
87
|
-
ws: WSContext
|
|
88
|
-
info: ExtensionInfo
|
|
89
|
-
stableKey: string
|
|
90
|
-
connectedTargets: Map<string, ConnectedTarget>
|
|
91
|
-
pendingRequests: Map<number, { resolve: (result: any) => void; reject: (error: Error) => void }>
|
|
92
|
-
messageId: number
|
|
93
|
-
pingInterval: ReturnType<typeof setInterval> | null
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
65
|
export type RelayServer = {
|
|
98
66
|
close(): void
|
|
99
67
|
on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
|
|
@@ -114,31 +82,44 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
114
82
|
cdpLogger?: CdpLogger
|
|
115
83
|
} = {}): Promise<RelayServer> {
|
|
116
84
|
const emitter = new EventEmitter()
|
|
117
|
-
const
|
|
118
|
-
const extensionKeyIndex = new Map<string, string>()
|
|
85
|
+
const store = relayState.createRelayStore()
|
|
119
86
|
|
|
120
87
|
const resolvedCdpLogger = cdpLogger || createCdpLogger()
|
|
121
88
|
const logCdpJson = (entry: CdpLogEntry) => {
|
|
122
89
|
resolvedCdpLogger.log(entry)
|
|
123
90
|
}
|
|
124
|
-
const playwrightClients = new Map<string, PlaywrightClient>()
|
|
125
91
|
|
|
126
92
|
const getDefaultExtensionId = (): string | null => {
|
|
127
|
-
return
|
|
93
|
+
return store.getState().extensions.keys().next().value || null
|
|
128
94
|
}
|
|
129
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Resolve an extension by ID, stableKey, or fallback.
|
|
98
|
+
* Returns the unified ExtensionEntry which includes both state and I/O.
|
|
99
|
+
*/
|
|
130
100
|
const getExtensionConnection = (
|
|
131
101
|
extensionId?: string | null,
|
|
132
|
-
options: { allowFallback?: boolean } = {}
|
|
133
|
-
):
|
|
102
|
+
options: { allowFallback?: boolean } = {},
|
|
103
|
+
): relayState.ExtensionEntry | null => {
|
|
104
|
+
const currentRelayState = store.getState()
|
|
105
|
+
const { extensions } = currentRelayState
|
|
106
|
+
|
|
134
107
|
if (extensionId) {
|
|
135
|
-
const direct =
|
|
136
|
-
if (direct) {
|
|
108
|
+
const direct = extensions.get(extensionId)
|
|
109
|
+
if (direct?.ws) {
|
|
137
110
|
return direct
|
|
138
111
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
112
|
+
// Try stableKey lookup.
|
|
113
|
+
const byKey = relayState.findExtensionByStableKey(currentRelayState, extensionId)
|
|
114
|
+
if (byKey) {
|
|
115
|
+
const candidates = Array.from(extensions.values())
|
|
116
|
+
.filter((ext) => ext.stableKey === byKey.stableKey)
|
|
117
|
+
.reverse()
|
|
118
|
+
for (const candidate of candidates) {
|
|
119
|
+
if (candidate.ws) {
|
|
120
|
+
return candidate
|
|
121
|
+
}
|
|
122
|
+
}
|
|
142
123
|
}
|
|
143
124
|
return null
|
|
144
125
|
}
|
|
@@ -147,14 +128,33 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
147
128
|
return null
|
|
148
129
|
}
|
|
149
130
|
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
131
|
+
// Single extension — use it directly
|
|
132
|
+
if (extensions.size === 1) {
|
|
133
|
+
const fallbackId = getDefaultExtensionId()
|
|
134
|
+
if (fallbackId) {
|
|
135
|
+
const ext = extensions.get(fallbackId)
|
|
136
|
+
if (ext?.ws) {
|
|
137
|
+
return ext
|
|
138
|
+
}
|
|
139
|
+
}
|
|
153
140
|
}
|
|
141
|
+
|
|
142
|
+
// Multiple extensions — auto-select if exactly one has active targets.
|
|
143
|
+
// This handles the common case of multiple Chrome profiles with the extension
|
|
144
|
+
// installed, where only one profile has playwriter-enabled tabs. (#52)
|
|
145
|
+
if (extensions.size > 1) {
|
|
146
|
+
const activeExtensions = Array.from(extensions.values()).filter((ext) => {
|
|
147
|
+
return ext.connectedTargets.size > 0
|
|
148
|
+
})
|
|
149
|
+
if (activeExtensions.length === 1 && activeExtensions[0].ws) {
|
|
150
|
+
return activeExtensions[0]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
154
|
return null
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
const buildStableExtensionKey = (info: ExtensionInfo, connectionId: string): string => {
|
|
157
|
+
const buildStableExtensionKey = (info: relayState.ExtensionInfo, connectionId: string): string => {
|
|
158
158
|
if (info.id) {
|
|
159
159
|
return `profile:${info.id}`
|
|
160
160
|
}
|
|
@@ -176,37 +176,41 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
const getPageTargetForFrameId = ({
|
|
179
|
-
|
|
180
|
-
frameId
|
|
179
|
+
extensionState,
|
|
180
|
+
frameId,
|
|
181
181
|
}: {
|
|
182
|
-
|
|
182
|
+
extensionState: relayState.ExtensionEntry
|
|
183
183
|
frameId: string
|
|
184
|
-
}): ConnectedTarget | undefined => {
|
|
185
|
-
return Array.from(
|
|
184
|
+
}): relayState.ConnectedTarget | undefined => {
|
|
185
|
+
return Array.from(extensionState.connectedTargets.values()).find((target) => {
|
|
186
186
|
return target.targetInfo.type === 'page' && target.frameIds.has(frameId)
|
|
187
187
|
})
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
const startExtensionPing = (extensionId: string): void => {
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
191
|
+
const ext = store.getState().extensions.get(extensionId)
|
|
192
|
+
if (!ext) {
|
|
193
193
|
return
|
|
194
194
|
}
|
|
195
|
-
if (
|
|
196
|
-
clearInterval(
|
|
195
|
+
if (ext.pingInterval) {
|
|
196
|
+
clearInterval(ext.pingInterval)
|
|
197
197
|
}
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
|
|
199
|
+
const pingInterval = setInterval(() => {
|
|
200
|
+
const latestExt = store.getState().extensions.get(extensionId)
|
|
201
|
+
latestExt?.ws?.send(JSON.stringify({ method: 'ping' }))
|
|
200
202
|
}, 5000)
|
|
203
|
+
|
|
204
|
+
store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval }))
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
const stopExtensionPing = (extensionId: string): void => {
|
|
204
|
-
const
|
|
205
|
-
if (!
|
|
208
|
+
const ext = store.getState().extensions.get(extensionId)
|
|
209
|
+
if (!ext || !ext.pingInterval) {
|
|
206
210
|
return
|
|
207
211
|
}
|
|
208
|
-
clearInterval(
|
|
209
|
-
|
|
212
|
+
clearInterval(ext.pingInterval)
|
|
213
|
+
store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval: null }))
|
|
210
214
|
}
|
|
211
215
|
|
|
212
216
|
function logCdpMessage({
|
|
@@ -216,7 +220,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
216
220
|
sessionId,
|
|
217
221
|
params,
|
|
218
222
|
id,
|
|
219
|
-
source
|
|
223
|
+
source,
|
|
220
224
|
}: {
|
|
221
225
|
direction: 'to-playwright' | 'from-playwright' | 'from-extension'
|
|
222
226
|
clientId?: string
|
|
@@ -232,7 +236,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
232
236
|
'Network.responseReceivedExtraInfo',
|
|
233
237
|
'Network.dataReceived',
|
|
234
238
|
'Network.requestWillBeSent',
|
|
235
|
-
'Network.loadingFinished'
|
|
239
|
+
'Network.loadingFinished',
|
|
236
240
|
]
|
|
237
241
|
|
|
238
242
|
if (noisyEvents.includes(method)) {
|
|
@@ -287,9 +291,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
287
291
|
source?: 'extension' | 'server'
|
|
288
292
|
extensionId?: string | null
|
|
289
293
|
}) {
|
|
290
|
-
const messageToSend = source === 'server' && 'method' in message
|
|
291
|
-
? { ...message, __serverGenerated: true }
|
|
292
|
-
: message
|
|
294
|
+
const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message
|
|
293
295
|
|
|
294
296
|
logCdpJson({
|
|
295
297
|
timestamp: new Date().toISOString(),
|
|
@@ -306,7 +308,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
306
308
|
method: message.method,
|
|
307
309
|
sessionId: 'sessionId' in message ? message.sessionId : undefined,
|
|
308
310
|
params: 'params' in message ? message.params : undefined,
|
|
309
|
-
source
|
|
311
|
+
source,
|
|
310
312
|
})
|
|
311
313
|
}
|
|
312
314
|
|
|
@@ -318,7 +320,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
318
320
|
// 2. We might still have messages in flight or try to send
|
|
319
321
|
// This can cause "Assertion error" in Playwright's crConnection.js if a response
|
|
320
322
|
// arrives after callbacks were cleared. We wrap in try-catch to handle this gracefully.
|
|
321
|
-
const safeSend = (client: PlaywrightClient) => {
|
|
323
|
+
const safeSend = (client: relayState.PlaywrightClient) => {
|
|
322
324
|
try {
|
|
323
325
|
client.ws.send(messageStr)
|
|
324
326
|
} catch (e) {
|
|
@@ -328,13 +330,13 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
328
330
|
}
|
|
329
331
|
|
|
330
332
|
if (clientId) {
|
|
331
|
-
const client = playwrightClients.get(clientId)
|
|
333
|
+
const client = store.getState().playwrightClients.get(clientId)
|
|
332
334
|
if (client) {
|
|
333
335
|
safeSend(client)
|
|
334
336
|
}
|
|
335
337
|
} else {
|
|
336
|
-
const
|
|
337
|
-
for (const client of
|
|
338
|
+
const { playwrightClients } = store.getState()
|
|
339
|
+
for (const client of playwrightClients.values()) {
|
|
338
340
|
if (extensionId && client.extensionId !== extensionId) {
|
|
339
341
|
continue
|
|
340
342
|
}
|
|
@@ -372,12 +374,28 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
372
374
|
params?: unknown
|
|
373
375
|
timeout?: number
|
|
374
376
|
}): Promise<unknown> {
|
|
375
|
-
const
|
|
376
|
-
if (!
|
|
377
|
+
const conn = getExtensionConnection(extensionId)
|
|
378
|
+
if (!conn) {
|
|
379
|
+
throw new Error('Extension not connected')
|
|
380
|
+
}
|
|
381
|
+
const resolvedExtensionId = conn.id
|
|
382
|
+
|
|
383
|
+
let id = 0
|
|
384
|
+
store.setState((s) => {
|
|
385
|
+
const ext = s.extensions.get(resolvedExtensionId)
|
|
386
|
+
if (!ext) {
|
|
387
|
+
return s
|
|
388
|
+
}
|
|
389
|
+
id = ext.messageId + 1
|
|
390
|
+
const newExtensions = new Map(s.extensions)
|
|
391
|
+
newExtensions.set(resolvedExtensionId, { ...ext, messageId: id })
|
|
392
|
+
return { ...s, extensions: newExtensions }
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
if (!id) {
|
|
377
396
|
throw new Error('Extension not connected')
|
|
378
397
|
}
|
|
379
398
|
|
|
380
|
-
const id = ++connection.messageId
|
|
381
399
|
const message = { id, method, params }
|
|
382
400
|
|
|
383
401
|
const forwardCdpParams = method === 'forwardCDPCommand' ? getForwardCdpParams(params) : undefined
|
|
@@ -393,15 +411,18 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
393
411
|
})
|
|
394
412
|
}
|
|
395
413
|
|
|
396
|
-
connection.ws.send(JSON.stringify(message))
|
|
397
|
-
|
|
398
414
|
return new Promise((resolve, reject) => {
|
|
399
415
|
const timeoutId = setTimeout(() => {
|
|
400
|
-
|
|
416
|
+
store.setState((s) =>
|
|
417
|
+
relayState.removeExtensionPendingRequest(s, {
|
|
418
|
+
extensionId: resolvedExtensionId,
|
|
419
|
+
requestId: id,
|
|
420
|
+
}),
|
|
421
|
+
)
|
|
401
422
|
reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`))
|
|
402
423
|
}, timeout)
|
|
403
424
|
|
|
404
|
-
|
|
425
|
+
const pendingRequest = {
|
|
405
426
|
resolve: (result) => {
|
|
406
427
|
clearTimeout(timeoutId)
|
|
407
428
|
resolve(result)
|
|
@@ -409,30 +430,90 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
409
430
|
reject: (error) => {
|
|
410
431
|
clearTimeout(timeoutId)
|
|
411
432
|
reject(error)
|
|
412
|
-
}
|
|
413
|
-
}
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
store.setState((s) =>
|
|
437
|
+
relayState.addExtensionPendingRequest(s, {
|
|
438
|
+
extensionId: resolvedExtensionId,
|
|
439
|
+
requestId: id,
|
|
440
|
+
pendingRequest,
|
|
441
|
+
}),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
const latestExt = store.getState().extensions.get(resolvedExtensionId)
|
|
445
|
+
if (!latestExt?.ws) {
|
|
446
|
+
clearTimeout(timeoutId)
|
|
447
|
+
store.setState((s) =>
|
|
448
|
+
relayState.removeExtensionPendingRequest(s, {
|
|
449
|
+
extensionId: resolvedExtensionId,
|
|
450
|
+
requestId: id,
|
|
451
|
+
}),
|
|
452
|
+
)
|
|
453
|
+
reject(new Error('Extension not connected'))
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
latestExt.ws.send(JSON.stringify(message))
|
|
459
|
+
} catch (error) {
|
|
460
|
+
clearTimeout(timeoutId)
|
|
461
|
+
store.setState((s) =>
|
|
462
|
+
relayState.removeExtensionPendingRequest(s, {
|
|
463
|
+
extensionId: resolvedExtensionId,
|
|
464
|
+
requestId: id,
|
|
465
|
+
}),
|
|
466
|
+
)
|
|
467
|
+
const sendError = error instanceof Error ? error : new Error(String(error))
|
|
468
|
+
reject(new Error(`Extension send failed: ${method}`, { cause: sendError }))
|
|
469
|
+
}
|
|
414
470
|
})
|
|
415
471
|
}
|
|
416
472
|
|
|
417
473
|
const recordingRelays = new Map<string, RecordingRelay>()
|
|
418
474
|
|
|
475
|
+
// Find which extension connection owns a CDP tab session ID (pw-tab-*).
|
|
476
|
+
// Used by recording routes where sessionId identifies the target tab.
|
|
477
|
+
// Delegates to the pure derivation function from relay-state.ts.
|
|
478
|
+
const findExtensionIdByCdpSession = (cdpSessionId: string): string | null => {
|
|
479
|
+
return relayState.findExtensionIdByCdpSession(store.getState(), cdpSessionId)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Resolve recording route session ID (CDP tab session) to extension connection.
|
|
483
|
+
const resolveRecordingRoute = async ({
|
|
484
|
+
sessionId,
|
|
485
|
+
}: {
|
|
486
|
+
sessionId: string | null
|
|
487
|
+
}): Promise<{
|
|
488
|
+
extensionId: string | null
|
|
489
|
+
sessionId: string | null
|
|
490
|
+
}> => {
|
|
491
|
+
if (!sessionId) {
|
|
492
|
+
return { extensionId: null, sessionId: null }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const extensionId = findExtensionIdByCdpSession(sessionId)
|
|
496
|
+
return { extensionId, sessionId }
|
|
497
|
+
}
|
|
498
|
+
|
|
419
499
|
const getRecordingRelay = (extensionId?: string | null): RecordingRelay | null => {
|
|
420
|
-
const allowDefault = !extensionId &&
|
|
421
|
-
const
|
|
422
|
-
if (!
|
|
500
|
+
const allowDefault = !extensionId && store.getState().extensions.size === 1
|
|
501
|
+
const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault })
|
|
502
|
+
if (!conn) {
|
|
423
503
|
return null
|
|
424
504
|
}
|
|
425
|
-
|
|
505
|
+
const connId = conn.id
|
|
506
|
+
if (!recordingRelays.has(connId)) {
|
|
426
507
|
recordingRelays.set(
|
|
427
|
-
|
|
508
|
+
connId,
|
|
428
509
|
new RecordingRelay(
|
|
429
|
-
(params) => sendToExtension({ extensionId:
|
|
430
|
-
() =>
|
|
510
|
+
(params) => sendToExtension({ extensionId: connId, ...params }),
|
|
511
|
+
() => store.getState().extensions.has(connId),
|
|
431
512
|
logger,
|
|
432
|
-
)
|
|
513
|
+
),
|
|
433
514
|
)
|
|
434
515
|
}
|
|
435
|
-
return recordingRelays.get(
|
|
516
|
+
return recordingRelays.get(connId) || null
|
|
436
517
|
}
|
|
437
518
|
|
|
438
519
|
// Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist.
|
|
@@ -441,30 +522,35 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
441
522
|
if (!process.env.PLAYWRITER_AUTO_ENABLE) {
|
|
442
523
|
return
|
|
443
524
|
}
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
525
|
+
const conn = getExtensionConnection(extensionId)
|
|
526
|
+
if (!conn) {
|
|
446
527
|
return
|
|
447
528
|
}
|
|
448
|
-
if (
|
|
529
|
+
if (conn.connectedTargets.size > 0) {
|
|
449
530
|
return
|
|
450
531
|
}
|
|
451
532
|
|
|
452
533
|
try {
|
|
453
534
|
logger?.log(pc.blue('Auto-creating initial tab for Playwright client'))
|
|
454
|
-
const result = await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 }) as {
|
|
535
|
+
const result = (await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 })) as {
|
|
455
536
|
success: boolean
|
|
456
537
|
tabId: number
|
|
457
538
|
sessionId: string
|
|
458
539
|
targetInfo: Protocol.Target.TargetInfo
|
|
459
540
|
}
|
|
460
541
|
if (result.success && result.sessionId && result.targetInfo) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
542
|
+
store.setState((s) =>
|
|
543
|
+
relayState.addTarget(s, {
|
|
544
|
+
extensionId,
|
|
545
|
+
sessionId: result.sessionId,
|
|
546
|
+
targetId: result.targetInfo.targetId,
|
|
547
|
+
targetInfo: result.targetInfo,
|
|
548
|
+
}),
|
|
549
|
+
)
|
|
550
|
+
const updatedTargets = store.getState().extensions.get(extensionId)?.connectedTargets.size || 0
|
|
551
|
+
logger?.log(
|
|
552
|
+
pc.blue(`Auto-created tab, now have ${updatedTargets} targets, url: ${result.targetInfo.url}`),
|
|
553
|
+
)
|
|
468
554
|
}
|
|
469
555
|
} catch (e) {
|
|
470
556
|
logger?.error('Failed to auto-create initial tab:', e)
|
|
@@ -479,13 +565,14 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
479
565
|
source,
|
|
480
566
|
}: {
|
|
481
567
|
extensionId: string | null
|
|
482
|
-
method: string
|
|
483
|
-
params:
|
|
484
|
-
sessionId?:
|
|
485
|
-
source?: '
|
|
568
|
+
method: CDPCommand['method'] | (string & {})
|
|
569
|
+
params: CDPCommand['params']
|
|
570
|
+
sessionId?: CDPCommand['sessionId']
|
|
571
|
+
source?: CDPCommand['source']
|
|
486
572
|
}) {
|
|
487
|
-
const
|
|
488
|
-
const connectedTargets =
|
|
573
|
+
const conn = getExtensionConnection(extensionId)
|
|
574
|
+
const connectedTargets = conn?.connectedTargets || new Map<string, relayState.ConnectedTarget>()
|
|
575
|
+
const resolvedExtensionId = conn?.id || extensionId
|
|
489
576
|
switch (method) {
|
|
490
577
|
case 'Browser.getVersion': {
|
|
491
578
|
return {
|
|
@@ -493,7 +580,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
493
580
|
product: 'Chrome/Extension-Bridge',
|
|
494
581
|
revision: '1.0.0',
|
|
495
582
|
userAgent: 'CDP-Bridge-Server/1.0.0',
|
|
496
|
-
jsVersion: 'V8'
|
|
583
|
+
jsVersion: 'V8',
|
|
497
584
|
} satisfies Protocol.Browser.GetVersionResponse
|
|
498
585
|
}
|
|
499
586
|
|
|
@@ -508,15 +595,15 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
508
595
|
if (sessionId) {
|
|
509
596
|
break
|
|
510
597
|
}
|
|
511
|
-
if (
|
|
512
|
-
await maybeAutoCreateInitialTab(
|
|
598
|
+
if (conn) {
|
|
599
|
+
await maybeAutoCreateInitialTab(conn.id)
|
|
513
600
|
}
|
|
514
601
|
// Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
|
|
515
602
|
// Playwright relies on these (with parentFrameId) when reconnecting over CDP.
|
|
516
603
|
await sendToExtension({
|
|
517
|
-
extensionId:
|
|
604
|
+
extensionId: resolvedExtensionId,
|
|
518
605
|
method: 'forwardCDPCommand',
|
|
519
|
-
params: { method, params, source }
|
|
606
|
+
params: { method, params, source },
|
|
520
607
|
})
|
|
521
608
|
return {}
|
|
522
609
|
}
|
|
@@ -526,22 +613,23 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
526
613
|
}
|
|
527
614
|
|
|
528
615
|
case 'Target.attachToTarget': {
|
|
529
|
-
const
|
|
530
|
-
if (!targetId) {
|
|
616
|
+
const attachParams = params as Protocol.Target.AttachToTargetRequest
|
|
617
|
+
if (!attachParams?.targetId) {
|
|
531
618
|
throw new Error('targetId is required for Target.attachToTarget')
|
|
532
619
|
}
|
|
533
620
|
|
|
534
621
|
for (const target of connectedTargets.values()) {
|
|
535
|
-
if (target.targetId === targetId) {
|
|
622
|
+
if (target.targetId === attachParams.targetId) {
|
|
536
623
|
return { sessionId: target.sessionId } satisfies Protocol.Target.AttachToTargetResponse
|
|
537
624
|
}
|
|
538
625
|
}
|
|
539
626
|
|
|
540
|
-
throw new Error(`Target ${targetId} not found in connected targets`)
|
|
627
|
+
throw new Error(`Target ${attachParams.targetId} not found in connected targets`)
|
|
541
628
|
}
|
|
542
629
|
|
|
543
630
|
case 'Target.getTargetInfo': {
|
|
544
|
-
const
|
|
631
|
+
const infoReqParams = params as Protocol.Target.GetTargetInfoRequest | undefined
|
|
632
|
+
const targetId = infoReqParams?.targetId
|
|
545
633
|
|
|
546
634
|
if (targetId) {
|
|
547
635
|
for (const target of connectedTargets.values()) {
|
|
@@ -568,33 +656,33 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
568
656
|
.filter((t) => !isRestrictedTarget(t.targetInfo))
|
|
569
657
|
.map((t) => ({
|
|
570
658
|
...t.targetInfo,
|
|
571
|
-
attached: true
|
|
572
|
-
}))
|
|
659
|
+
attached: true,
|
|
660
|
+
})),
|
|
573
661
|
}
|
|
574
662
|
}
|
|
575
663
|
|
|
576
664
|
case 'Target.createTarget': {
|
|
577
665
|
return await sendToExtension({
|
|
578
|
-
extensionId:
|
|
666
|
+
extensionId: resolvedExtensionId,
|
|
579
667
|
method: 'forwardCDPCommand',
|
|
580
|
-
params: { method, params, source }
|
|
668
|
+
params: { method, params, source },
|
|
581
669
|
})
|
|
582
670
|
}
|
|
583
671
|
|
|
584
672
|
case 'Target.closeTarget': {
|
|
585
673
|
return await sendToExtension({
|
|
586
|
-
extensionId:
|
|
674
|
+
extensionId: resolvedExtensionId,
|
|
587
675
|
method: 'forwardCDPCommand',
|
|
588
|
-
params: { method, params, source }
|
|
676
|
+
params: { method, params, source },
|
|
589
677
|
})
|
|
590
678
|
}
|
|
591
679
|
|
|
592
680
|
// Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects
|
|
593
681
|
case 'ghost-browser': {
|
|
594
682
|
return await sendToExtension({
|
|
595
|
-
extensionId:
|
|
683
|
+
extensionId: resolvedExtensionId,
|
|
596
684
|
method: 'ghost-browser',
|
|
597
|
-
params
|
|
685
|
+
params,
|
|
598
686
|
})
|
|
599
687
|
}
|
|
600
688
|
|
|
@@ -616,16 +704,20 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
616
704
|
}
|
|
617
705
|
const timeout = setTimeout(() => {
|
|
618
706
|
emitter.off('cdp:event', handler)
|
|
619
|
-
logger?.log(
|
|
707
|
+
logger?.log(
|
|
708
|
+
pc.yellow(
|
|
709
|
+
`IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`,
|
|
710
|
+
),
|
|
711
|
+
)
|
|
620
712
|
resolve()
|
|
621
713
|
}, 3000)
|
|
622
714
|
emitter.on('cdp:event', handler)
|
|
623
715
|
})
|
|
624
716
|
|
|
625
717
|
const result = await sendToExtension({
|
|
626
|
-
extensionId:
|
|
718
|
+
extensionId: resolvedExtensionId,
|
|
627
719
|
method: 'forwardCDPCommand',
|
|
628
|
-
params: { sessionId, method, params, source }
|
|
720
|
+
params: { sessionId, method, params, source },
|
|
629
721
|
})
|
|
630
722
|
|
|
631
723
|
await contextCreatedPromise
|
|
@@ -635,9 +727,9 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
635
727
|
}
|
|
636
728
|
|
|
637
729
|
return await sendToExtension({
|
|
638
|
-
extensionId:
|
|
730
|
+
extensionId: resolvedExtensionId,
|
|
639
731
|
method: 'forwardCDPCommand',
|
|
640
|
-
params: { sessionId, method, params, source }
|
|
732
|
+
params: { sessionId, method, params, source },
|
|
641
733
|
})
|
|
642
734
|
}
|
|
643
735
|
|
|
@@ -645,19 +737,22 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
645
737
|
// CORS middleware for HTTP endpoints - only allows our specific extension IDs.
|
|
646
738
|
// This prevents other extensions from reading responses via fetch/XHR.
|
|
647
739
|
// WebSocket connections have their own separate origin validation.
|
|
648
|
-
app.use(
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
740
|
+
app.use(
|
|
741
|
+
'*',
|
|
742
|
+
cors({
|
|
743
|
+
origin: (origin) => {
|
|
744
|
+
if (!origin.startsWith('chrome-extension://')) {
|
|
745
|
+
return null
|
|
746
|
+
}
|
|
747
|
+
const extensionId = origin.replace('chrome-extension://', '')
|
|
748
|
+
if (!EXTENSION_IDS.includes(extensionId)) {
|
|
749
|
+
return null
|
|
750
|
+
}
|
|
751
|
+
return origin
|
|
752
|
+
},
|
|
753
|
+
allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'],
|
|
754
|
+
}),
|
|
755
|
+
)
|
|
661
756
|
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
|
662
757
|
|
|
663
758
|
const getCdpWsUrl = (c: { req: { header: (name: string) => string | undefined } }) => {
|
|
@@ -675,7 +770,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
675
770
|
|
|
676
771
|
app.get('/extension/status', (c) => {
|
|
677
772
|
const defaultExtension = getExtensionConnection(null, { allowFallback: true })
|
|
678
|
-
const connected =
|
|
773
|
+
const connected = store.getState().extensions.size > 0
|
|
679
774
|
const activeTargets = defaultExtension?.connectedTargets.size || 0
|
|
680
775
|
const info = defaultExtension?.info
|
|
681
776
|
|
|
@@ -689,14 +784,14 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
689
784
|
})
|
|
690
785
|
|
|
691
786
|
app.get('/extensions/status', (c) => {
|
|
692
|
-
const extensions = Array.from(
|
|
787
|
+
const extensions = Array.from(store.getState().extensions.values()).map((ext) => {
|
|
693
788
|
return {
|
|
694
|
-
extensionId:
|
|
695
|
-
stableKey:
|
|
696
|
-
browser:
|
|
697
|
-
profile:
|
|
698
|
-
activeTargets:
|
|
699
|
-
playwriterVersion:
|
|
789
|
+
extensionId: ext.id,
|
|
790
|
+
stableKey: ext.stableKey,
|
|
791
|
+
browser: ext.info.browser || null,
|
|
792
|
+
profile: ext.info ? { email: ext.info.email || '', id: ext.info.id || '' } : null,
|
|
793
|
+
activeTargets: ext.connectedTargets.size,
|
|
794
|
+
playwriterVersion: ext.info?.version || null,
|
|
700
795
|
}
|
|
701
796
|
})
|
|
702
797
|
return c.json({ extensions })
|
|
@@ -709,76 +804,76 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
709
804
|
app
|
|
710
805
|
.on(['GET', 'PUT'], '/json/version', (c) => {
|
|
711
806
|
return c.json({
|
|
712
|
-
|
|
807
|
+
Browser: `Playwriter/${VERSION}`,
|
|
713
808
|
'Protocol-Version': '1.3',
|
|
714
|
-
|
|
809
|
+
webSocketDebuggerUrl: getCdpWsUrl(c),
|
|
715
810
|
})
|
|
716
811
|
})
|
|
717
812
|
.on(['GET', 'PUT'], '/json/version/', (c) => {
|
|
718
813
|
return c.json({
|
|
719
|
-
|
|
814
|
+
Browser: `Playwriter/${VERSION}`,
|
|
720
815
|
'Protocol-Version': '1.3',
|
|
721
|
-
|
|
816
|
+
webSocketDebuggerUrl: getCdpWsUrl(c),
|
|
722
817
|
})
|
|
723
818
|
})
|
|
724
819
|
.on(['GET', 'PUT'], '/json/list', (c) => {
|
|
725
820
|
const wsUrl = getCdpWsUrl(c)
|
|
726
821
|
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
|
|
727
822
|
return c.json(
|
|
728
|
-
Array.from(defaultTargets.values()).map(t => ({
|
|
823
|
+
Array.from(defaultTargets.values()).map((t) => ({
|
|
729
824
|
id: t.targetId,
|
|
730
825
|
type: t.targetInfo.type,
|
|
731
826
|
title: t.targetInfo.title,
|
|
732
827
|
description: t.targetInfo.title,
|
|
733
828
|
url: t.targetInfo.url,
|
|
734
829
|
webSocketDebuggerUrl: wsUrl,
|
|
735
|
-
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}
|
|
736
|
-
}))
|
|
830
|
+
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
|
|
831
|
+
})),
|
|
737
832
|
)
|
|
738
833
|
})
|
|
739
834
|
.on(['GET', 'PUT'], '/json/list/', (c) => {
|
|
740
835
|
const wsUrl = getCdpWsUrl(c)
|
|
741
836
|
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
|
|
742
837
|
return c.json(
|
|
743
|
-
Array.from(defaultTargets.values()).map(t => ({
|
|
838
|
+
Array.from(defaultTargets.values()).map((t) => ({
|
|
744
839
|
id: t.targetId,
|
|
745
840
|
type: t.targetInfo.type,
|
|
746
841
|
title: t.targetInfo.title,
|
|
747
842
|
description: t.targetInfo.title,
|
|
748
843
|
url: t.targetInfo.url,
|
|
749
844
|
webSocketDebuggerUrl: wsUrl,
|
|
750
|
-
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}
|
|
751
|
-
}))
|
|
845
|
+
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
|
|
846
|
+
})),
|
|
752
847
|
)
|
|
753
848
|
})
|
|
754
849
|
.on(['GET', 'PUT'], '/json', (c) => {
|
|
755
850
|
const wsUrl = getCdpWsUrl(c)
|
|
756
851
|
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
|
|
757
852
|
return c.json(
|
|
758
|
-
Array.from(defaultTargets.values()).map(t => ({
|
|
853
|
+
Array.from(defaultTargets.values()).map((t) => ({
|
|
759
854
|
id: t.targetId,
|
|
760
855
|
type: t.targetInfo.type,
|
|
761
856
|
title: t.targetInfo.title,
|
|
762
857
|
description: t.targetInfo.title,
|
|
763
858
|
url: t.targetInfo.url,
|
|
764
859
|
webSocketDebuggerUrl: wsUrl,
|
|
765
|
-
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}
|
|
766
|
-
}))
|
|
860
|
+
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
|
|
861
|
+
})),
|
|
767
862
|
)
|
|
768
863
|
})
|
|
769
864
|
.on(['GET', 'PUT'], '/json/', (c) => {
|
|
770
865
|
const wsUrl = getCdpWsUrl(c)
|
|
771
866
|
const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map()
|
|
772
867
|
return c.json(
|
|
773
|
-
Array.from(defaultTargets.values()).map(t => ({
|
|
868
|
+
Array.from(defaultTargets.values()).map((t) => ({
|
|
774
869
|
id: t.targetId,
|
|
775
870
|
type: t.targetInfo.type,
|
|
776
871
|
title: t.targetInfo.title,
|
|
777
872
|
description: t.targetInfo.title,
|
|
778
873
|
url: t.targetInfo.url,
|
|
779
874
|
webSocketDebuggerUrl: wsUrl,
|
|
780
|
-
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}
|
|
781
|
-
}))
|
|
875
|
+
devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
|
|
876
|
+
})),
|
|
782
877
|
)
|
|
783
878
|
})
|
|
784
879
|
|
|
@@ -798,218 +893,274 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
798
893
|
// Browsers always send Origin header for WebSocket connections, but Node.js clients don't.
|
|
799
894
|
// We only allow our specific extension IDs to prevent malicious websites or extensions
|
|
800
895
|
// from connecting to the local WebSocket server.
|
|
801
|
-
app.get(
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
if (
|
|
807
|
-
|
|
808
|
-
if (
|
|
809
|
-
|
|
896
|
+
app.get(
|
|
897
|
+
'/cdp/:clientId?',
|
|
898
|
+
(c, next) => {
|
|
899
|
+
const origin = c.req.header('origin')
|
|
900
|
+
|
|
901
|
+
// Validate Origin header if present (Node.js clients don't send it)
|
|
902
|
+
if (origin) {
|
|
903
|
+
if (origin.startsWith('chrome-extension://')) {
|
|
904
|
+
const extensionId = origin.replace('chrome-extension://', '')
|
|
905
|
+
if (!EXTENSION_IDS.includes(extensionId)) {
|
|
906
|
+
logger?.log(pc.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`))
|
|
907
|
+
return c.text('Forbidden', 403)
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
logger?.log(pc.red(`Rejecting /cdp WebSocket from origin: ${origin}`))
|
|
810
911
|
return c.text('Forbidden', 403)
|
|
811
912
|
}
|
|
812
|
-
} else {
|
|
813
|
-
logger?.log(pc.red(`Rejecting /cdp WebSocket from origin: ${origin}`))
|
|
814
|
-
return c.text('Forbidden', 403)
|
|
815
913
|
}
|
|
816
|
-
}
|
|
817
914
|
|
|
818
|
-
|
|
915
|
+
if (token) {
|
|
916
|
+
const url = new URL(c.req.url, 'http://localhost')
|
|
917
|
+
const providedToken = url.searchParams.get('token')
|
|
918
|
+
if (providedToken !== token) {
|
|
919
|
+
return c.text('Unauthorized', 401)
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return next()
|
|
923
|
+
},
|
|
924
|
+
upgradeWebSocket((c) => {
|
|
925
|
+
const clientId = c.req.param('clientId') || 'default'
|
|
819
926
|
const url = new URL(c.req.url, 'http://localhost')
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
927
|
+
const requestedExtensionId = url.searchParams.get('extensionId')
|
|
928
|
+
// When extensionId is explicit, resolve directly. Otherwise use fallback which
|
|
929
|
+
// handles single-extension and uniquely-active-extension cases (#52).
|
|
930
|
+
const resolvedExtension = requestedExtensionId
|
|
931
|
+
? getExtensionConnection(requestedExtensionId)
|
|
932
|
+
: getExtensionConnection(null, { allowFallback: true })
|
|
933
|
+
const clientExtensionId = resolvedExtension?.id || null
|
|
934
|
+
|
|
935
|
+
const getBoundExtensionIdForClient = (): string | null => {
|
|
936
|
+
const client = store.getState().playwrightClients.get(clientId)
|
|
937
|
+
return client?.extensionId || null
|
|
823
938
|
}
|
|
824
|
-
}
|
|
825
|
-
return next()
|
|
826
|
-
}, upgradeWebSocket((c) => {
|
|
827
|
-
const clientId = c.req.param('clientId') || 'default'
|
|
828
|
-
const url = new URL(c.req.url, 'http://localhost')
|
|
829
|
-
const requestedExtensionId = url.searchParams.get('extensionId')
|
|
830
|
-
const resolvedExtension = getExtensionConnection(requestedExtensionId)
|
|
831
|
-
const allowDefault = !requestedExtensionId && extensionConnections.size === 1
|
|
832
|
-
const defaultExtension = allowDefault ? getExtensionConnection(null, { allowFallback: true }) : null
|
|
833
|
-
const clientExtensionId = resolvedExtension?.id || defaultExtension?.id || null
|
|
834
939
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
940
|
+
return {
|
|
941
|
+
async onOpen(_event, ws) {
|
|
942
|
+
if (store.getState().playwrightClients.has(clientId)) {
|
|
943
|
+
logger?.log(pc.yellow(`Rejecting duplicate Playwright clientId: ${clientId}`))
|
|
944
|
+
ws.close(4004, 'Duplicate Playwright clientId')
|
|
945
|
+
return
|
|
946
|
+
}
|
|
842
947
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
948
|
+
if (!clientExtensionId) {
|
|
949
|
+
const reason = requestedExtensionId
|
|
950
|
+
? `Unknown extensionId: ${requestedExtensionId}`
|
|
951
|
+
: 'Multiple extensions connected. Specify extensionId.'
|
|
952
|
+
logger?.log(pc.yellow(`Rejecting Playwright client ${clientId}: ${reason}`))
|
|
953
|
+
ws.close(4003, reason)
|
|
954
|
+
return
|
|
955
|
+
}
|
|
851
956
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
957
|
+
// Add client first so it can receive Target.attachedToTarget events
|
|
958
|
+
store.setState((s) => {
|
|
959
|
+
return relayState.addPlaywrightClient(s, { id: clientId, extensionId: clientExtensionId, ws })
|
|
960
|
+
})
|
|
961
|
+
const extensionConnection = getExtensionConnection(clientExtensionId)
|
|
962
|
+
const targetCount = extensionConnection?.connectedTargets.size || 0
|
|
963
|
+
logger?.log(
|
|
964
|
+
pc.green(
|
|
965
|
+
`Playwright client connected: ${clientId} (${store.getState().playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`,
|
|
966
|
+
),
|
|
967
|
+
)
|
|
968
|
+
},
|
|
858
969
|
|
|
859
|
-
|
|
860
|
-
|
|
970
|
+
async onMessage(event, ws) {
|
|
971
|
+
let message: CDPCommand
|
|
861
972
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
973
|
+
try {
|
|
974
|
+
message = JSON.parse(event.data.toString())
|
|
975
|
+
} catch {
|
|
976
|
+
return
|
|
977
|
+
}
|
|
867
978
|
|
|
868
|
-
|
|
979
|
+
const { id, sessionId, method, params, source } = message
|
|
869
980
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
981
|
+
logCdpJson({
|
|
982
|
+
timestamp: new Date().toISOString(),
|
|
983
|
+
direction: 'from-playwright',
|
|
984
|
+
clientId,
|
|
985
|
+
message,
|
|
986
|
+
})
|
|
876
987
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
988
|
+
logCdpMessage({
|
|
989
|
+
direction: 'from-playwright',
|
|
990
|
+
clientId,
|
|
991
|
+
method,
|
|
992
|
+
sessionId,
|
|
993
|
+
id,
|
|
994
|
+
})
|
|
884
995
|
|
|
885
|
-
|
|
996
|
+
emitter.emit('cdp:command', { clientId, command: message })
|
|
886
997
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
998
|
+
const boundExtensionId = getBoundExtensionIdForClient()
|
|
999
|
+
const extensionConn = getExtensionConnection(boundExtensionId)
|
|
1000
|
+
if (!extensionConn) {
|
|
1001
|
+
sendToPlaywright({
|
|
1002
|
+
message: {
|
|
1003
|
+
id,
|
|
1004
|
+
sessionId,
|
|
1005
|
+
error: { message: 'Extension not connected' },
|
|
1006
|
+
},
|
|
1007
|
+
clientId,
|
|
1008
|
+
})
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
899
1011
|
|
|
900
|
-
|
|
901
|
-
|
|
1012
|
+
try {
|
|
1013
|
+
const result = await routeCdpCommand({
|
|
1014
|
+
extensionId: extensionConn.id,
|
|
1015
|
+
method,
|
|
1016
|
+
params,
|
|
1017
|
+
sessionId,
|
|
1018
|
+
source,
|
|
1019
|
+
})
|
|
902
1020
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1021
|
+
if (method === 'Target.setAutoAttach' && !sessionId) {
|
|
1022
|
+
// Re-read state after async routeCdpCommand — targets may have changed
|
|
1023
|
+
const freshExt = store.getState().extensions.get(extensionConn.id)
|
|
1024
|
+
const freshTargets = freshExt?.connectedTargets || new Map()
|
|
1025
|
+
for (const target of freshTargets.values()) {
|
|
1026
|
+
// Skip restricted targets (extensions, chrome:// URLs, non-page types)
|
|
1027
|
+
if (isRestrictedTarget(target.targetInfo)) {
|
|
1028
|
+
continue
|
|
1029
|
+
}
|
|
1030
|
+
const attachedPayload = {
|
|
1031
|
+
method: 'Target.attachedToTarget',
|
|
1032
|
+
params: {
|
|
1033
|
+
sessionId: target.sessionId,
|
|
1034
|
+
targetInfo: {
|
|
1035
|
+
...target.targetInfo,
|
|
1036
|
+
attached: true,
|
|
1037
|
+
},
|
|
1038
|
+
waitingForDebugger: false,
|
|
916
1039
|
},
|
|
917
|
-
|
|
1040
|
+
} satisfies CDPEventFor<'Target.attachedToTarget'>
|
|
1041
|
+
if (!target.targetInfo.url) {
|
|
1042
|
+
logger?.error(
|
|
1043
|
+
pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'),
|
|
1044
|
+
JSON.stringify(attachedPayload),
|
|
1045
|
+
)
|
|
918
1046
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1047
|
+
logger?.log(
|
|
1048
|
+
pc.magenta('[Server] Target.attachedToTarget full payload:'),
|
|
1049
|
+
JSON.stringify(attachedPayload),
|
|
1050
|
+
)
|
|
1051
|
+
sendToPlaywright({
|
|
1052
|
+
message: attachedPayload,
|
|
1053
|
+
clientId,
|
|
1054
|
+
source: 'server',
|
|
1055
|
+
})
|
|
922
1056
|
}
|
|
923
|
-
logger?.log(pc.magenta('[Server] Target.attachedToTarget full payload:'), JSON.stringify(attachedPayload))
|
|
924
|
-
sendToPlaywright({
|
|
925
|
-
message: attachedPayload,
|
|
926
|
-
clientId,
|
|
927
|
-
source: 'server'
|
|
928
|
-
})
|
|
929
1057
|
}
|
|
930
|
-
}
|
|
931
1058
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
method: 'Target.targetCreated',
|
|
940
|
-
params: {
|
|
941
|
-
targetInfo: {
|
|
942
|
-
...target.targetInfo,
|
|
943
|
-
attached: true
|
|
944
|
-
}
|
|
1059
|
+
if (method === 'Target.setDiscoverTargets' && (params as Protocol.Target.SetDiscoverTargetsRequest)?.discover) {
|
|
1060
|
+
const freshExt2 = store.getState().extensions.get(extensionConn.id)
|
|
1061
|
+
const freshTargets2 = freshExt2?.connectedTargets || new Map()
|
|
1062
|
+
for (const target of freshTargets2.values()) {
|
|
1063
|
+
// Skip restricted targets (extensions, chrome:// URLs, non-page types)
|
|
1064
|
+
if (isRestrictedTarget(target.targetInfo)) {
|
|
1065
|
+
continue
|
|
945
1066
|
}
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1067
|
+
const targetCreatedPayload = {
|
|
1068
|
+
method: 'Target.targetCreated',
|
|
1069
|
+
params: {
|
|
1070
|
+
targetInfo: {
|
|
1071
|
+
...target.targetInfo,
|
|
1072
|
+
attached: true,
|
|
1073
|
+
},
|
|
1074
|
+
},
|
|
1075
|
+
} satisfies CDPEventFor<'Target.targetCreated'>
|
|
1076
|
+
if (!target.targetInfo.url) {
|
|
1077
|
+
logger?.error(
|
|
1078
|
+
pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'),
|
|
1079
|
+
JSON.stringify(targetCreatedPayload),
|
|
1080
|
+
)
|
|
1081
|
+
}
|
|
1082
|
+
logger?.log(
|
|
1083
|
+
pc.magenta('[Server] Target.targetCreated full payload:'),
|
|
1084
|
+
JSON.stringify(targetCreatedPayload),
|
|
1085
|
+
)
|
|
1086
|
+
sendToPlaywright({
|
|
1087
|
+
message: targetCreatedPayload,
|
|
1088
|
+
clientId,
|
|
1089
|
+
source: 'server',
|
|
1090
|
+
})
|
|
949
1091
|
}
|
|
950
|
-
logger?.log(pc.magenta('[Server] Target.targetCreated full payload:'), JSON.stringify(targetCreatedPayload))
|
|
951
|
-
sendToPlaywright({
|
|
952
|
-
message: targetCreatedPayload,
|
|
953
|
-
clientId,
|
|
954
|
-
source: 'server'
|
|
955
|
-
})
|
|
956
1092
|
}
|
|
957
|
-
}
|
|
958
1093
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1094
|
+
if (method === 'Target.attachToTarget') {
|
|
1095
|
+
const attachResponse = result as Protocol.Target.AttachToTargetResponse | undefined
|
|
1096
|
+
const attachRequestParams = params as Protocol.Target.AttachToTargetRequest | undefined
|
|
1097
|
+
if (attachResponse?.sessionId) {
|
|
1098
|
+
const freshExt3 = store.getState().extensions.get(extensionConn.id)
|
|
1099
|
+
const freshTargets3 = freshExt3?.connectedTargets || new Map()
|
|
1100
|
+
const target = Array.from(freshTargets3.values()).find((t) => {
|
|
1101
|
+
return t.targetId === attachRequestParams?.targetId
|
|
1102
|
+
})
|
|
1103
|
+
if (target) {
|
|
1104
|
+
const attachedPayload = {
|
|
1105
|
+
method: 'Target.attachedToTarget',
|
|
1106
|
+
params: {
|
|
1107
|
+
sessionId: attachResponse.sessionId,
|
|
1108
|
+
targetInfo: {
|
|
1109
|
+
...target.targetInfo,
|
|
1110
|
+
attached: true,
|
|
1111
|
+
},
|
|
1112
|
+
waitingForDebugger: false,
|
|
1113
|
+
},
|
|
1114
|
+
} satisfies CDPEventFor<'Target.attachedToTarget'>
|
|
1115
|
+
if (!target.targetInfo.url) {
|
|
1116
|
+
logger?.error(
|
|
1117
|
+
pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'),
|
|
1118
|
+
JSON.stringify(attachedPayload),
|
|
1119
|
+
)
|
|
1120
|
+
}
|
|
1121
|
+
logger?.log(
|
|
1122
|
+
pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'),
|
|
1123
|
+
JSON.stringify(attachedPayload),
|
|
1124
|
+
)
|
|
1125
|
+
sendToPlaywright({
|
|
1126
|
+
message: attachedPayload,
|
|
1127
|
+
clientId,
|
|
1128
|
+
source: 'server',
|
|
1129
|
+
})
|
|
972
1130
|
}
|
|
973
|
-
} satisfies CDPEventFor<'Target.attachedToTarget'>
|
|
974
|
-
if (!target.targetInfo.url) {
|
|
975
|
-
logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload))
|
|
976
1131
|
}
|
|
977
|
-
logger?.log(pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload))
|
|
978
|
-
sendToPlaywright({
|
|
979
|
-
message: attachedPayload,
|
|
980
|
-
clientId,
|
|
981
|
-
source: 'server'
|
|
982
|
-
})
|
|
983
1132
|
}
|
|
984
|
-
}
|
|
985
1133
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1134
|
+
const response: CDPResponseBase = { id, sessionId, result }
|
|
1135
|
+
sendToPlaywright({ message: response, clientId })
|
|
1136
|
+
emitter.emit('cdp:response', { clientId, response, command: message })
|
|
1137
|
+
} catch (e) {
|
|
1138
|
+
logger?.error('Error handling CDP command:', method, params, e)
|
|
1139
|
+
const errorResponse: CDPResponseBase = {
|
|
1140
|
+
id,
|
|
1141
|
+
sessionId,
|
|
1142
|
+
error: { message: (e as Error).message },
|
|
1143
|
+
}
|
|
1144
|
+
sendToPlaywright({ message: errorResponse, clientId })
|
|
1145
|
+
emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
|
|
995
1146
|
}
|
|
996
|
-
|
|
997
|
-
emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
|
|
998
|
-
}
|
|
999
|
-
},
|
|
1147
|
+
},
|
|
1000
1148
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1149
|
+
onClose() {
|
|
1150
|
+
store.setState((s) => relayState.removePlaywrightClient(s, { clientId }))
|
|
1151
|
+
logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${store.getState().playwrightClients.size} remaining)`))
|
|
1152
|
+
},
|
|
1005
1153
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1154
|
+
onError(event) {
|
|
1155
|
+
logger?.error(`Playwright WebSocket error [${clientId}]:`, event)
|
|
1156
|
+
},
|
|
1008
1157
|
}
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1158
|
+
}),
|
|
1159
|
+
)
|
|
1011
1160
|
|
|
1012
|
-
const getExtensionInfoFromRequest = (c: {
|
|
1161
|
+
const getExtensionInfoFromRequest = (c: {
|
|
1162
|
+
req: { query: (name: string) => string | undefined }
|
|
1163
|
+
}): relayState.ExtensionInfo => {
|
|
1013
1164
|
const browser = c.req.query('browser')
|
|
1014
1165
|
const email = c.req.query('email')
|
|
1015
1166
|
const id = c.req.query('id')
|
|
@@ -1022,402 +1173,456 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1022
1173
|
}
|
|
1023
1174
|
}
|
|
1024
1175
|
|
|
1025
|
-
app.get(
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
// Browsers cannot spoof the Origin header, so this ensures the connection
|
|
1040
|
-
// is coming from our specific Chrome Extension, not a malicious website.
|
|
1041
|
-
const origin = c.req.header('origin')
|
|
1042
|
-
if (!origin || !origin.startsWith('chrome-extension://')) {
|
|
1043
|
-
logger?.log(pc.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`))
|
|
1044
|
-
return c.text('Forbidden', 403)
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
const extensionId = origin.replace('chrome-extension://', '')
|
|
1048
|
-
if (!EXTENSION_IDS.includes(extensionId)) {
|
|
1049
|
-
logger?.log(pc.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`))
|
|
1050
|
-
return c.text('Forbidden', 403)
|
|
1051
|
-
}
|
|
1176
|
+
app.get(
|
|
1177
|
+
'/extension',
|
|
1178
|
+
(c, next) => {
|
|
1179
|
+
// 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
|
|
1180
|
+
// This prevents attackers on the network from hijacking the browser session
|
|
1181
|
+
// even if the server is exposed via 0.0.0.0.
|
|
1182
|
+
const info = getConnInfo(c)
|
|
1183
|
+
const remoteAddress = info.remote.address
|
|
1184
|
+
const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1'
|
|
1185
|
+
|
|
1186
|
+
if (!isLocalhost) {
|
|
1187
|
+
logger?.log(pc.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`))
|
|
1188
|
+
return c.text('Forbidden - Extension must be local', 403)
|
|
1189
|
+
}
|
|
1052
1190
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
const existingConnection = extensionConnections.get(existingId)
|
|
1064
|
-
if (existingConnection) {
|
|
1065
|
-
existingConnection.ws.close(4001, 'Extension Replaced')
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1191
|
+
// 2. Origin Validation: Prevent browser-based attacks (CSRF).
|
|
1192
|
+
// Browsers cannot spoof the Origin header, so this ensures the connection
|
|
1193
|
+
// is coming from our specific Chrome Extension, not a malicious website.
|
|
1194
|
+
const origin = c.req.header('origin')
|
|
1195
|
+
if (!origin || !origin.startsWith('chrome-extension://')) {
|
|
1196
|
+
logger?.log(
|
|
1197
|
+
pc.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`),
|
|
1198
|
+
)
|
|
1199
|
+
return c.text('Forbidden', 403)
|
|
1200
|
+
}
|
|
1068
1201
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
connectedTargets: new Map(),
|
|
1075
|
-
pendingRequests: new Map(),
|
|
1076
|
-
messageId: 0,
|
|
1077
|
-
pingInterval: null,
|
|
1078
|
-
}
|
|
1079
|
-
extensionConnections.set(connectionId, connection)
|
|
1080
|
-
extensionKeyIndex.set(stableKey, connectionId)
|
|
1081
|
-
startExtensionPing(connectionId)
|
|
1082
|
-
logger?.log(`Extension connected (${connectionId})`)
|
|
1083
|
-
},
|
|
1202
|
+
const extensionId = origin.replace('chrome-extension://', '')
|
|
1203
|
+
if (!EXTENSION_IDS.includes(extensionId)) {
|
|
1204
|
+
logger?.log(pc.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`))
|
|
1205
|
+
return c.text('Forbidden', 403)
|
|
1206
|
+
}
|
|
1084
1207
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1208
|
+
return next()
|
|
1209
|
+
},
|
|
1210
|
+
upgradeWebSocket((c) => {
|
|
1211
|
+
const incomingExtensionInfo = getExtensionInfoFromRequest(c)
|
|
1212
|
+
const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|
1213
|
+
return {
|
|
1214
|
+
onOpen(_event, ws) {
|
|
1215
|
+
const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId)
|
|
1216
|
+
|
|
1217
|
+
// Check for existing connection with same stableKey and close it
|
|
1218
|
+
const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey)
|
|
1219
|
+
if (existingExt && existingExt.id !== connectionId) {
|
|
1220
|
+
logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingExt.id} -> ${connectionId})`))
|
|
1221
|
+
if (existingExt.ws) {
|
|
1222
|
+
existingExt.ws.close(4001, 'Extension Replaced')
|
|
1223
|
+
}
|
|
1097
1224
|
}
|
|
1098
|
-
return
|
|
1099
|
-
}
|
|
1100
1225
|
|
|
1101
|
-
|
|
1226
|
+
// State transition: add extension with ws handle included.
|
|
1227
|
+
// Existing same-stableKey entry stays until old socket onClose.
|
|
1228
|
+
store.setState((s) => {
|
|
1229
|
+
return relayState.addExtension(s, { id: connectionId, info: incomingExtensionInfo, stableKey, ws })
|
|
1230
|
+
})
|
|
1102
1231
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
}
|
|
1106
|
-
ws.close(1000, 'Invalid JSON')
|
|
1107
|
-
return
|
|
1108
|
-
}
|
|
1232
|
+
startExtensionPing(connectionId)
|
|
1233
|
+
logger?.log(`Extension connected (${connectionId})`)
|
|
1234
|
+
},
|
|
1109
1235
|
|
|
1110
|
-
|
|
1111
|
-
const
|
|
1112
|
-
if (!
|
|
1113
|
-
|
|
1236
|
+
async onMessage(event, ws) {
|
|
1237
|
+
const ext = store.getState().extensions.get(connectionId)
|
|
1238
|
+
if (!ext) {
|
|
1239
|
+
ws.close(1000, 'Extension not registered')
|
|
1114
1240
|
return
|
|
1115
1241
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
} else if (message.method === 'pong') {
|
|
1125
|
-
// Keep-alive response, nothing to do
|
|
1126
|
-
} else if (message.method === 'log') {
|
|
1127
|
-
const { level, args } = message.params
|
|
1128
|
-
const logFn = (logger as Record<string, unknown>)?.[level] as ((...args: unknown[]) => void) | undefined
|
|
1129
|
-
const logFunc = logFn || logger?.log
|
|
1130
|
-
const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`)
|
|
1131
|
-
logFunc?.(prefix, ...args)
|
|
1132
|
-
} else if (message.method === 'recordingData') {
|
|
1133
|
-
const relay = getRecordingRelay(connectionId)
|
|
1134
|
-
if (relay) {
|
|
1135
|
-
relay.handleRecordingData(message as RecordingDataMessage)
|
|
1136
|
-
}
|
|
1137
|
-
} else if (message.method === 'recordingCancelled') {
|
|
1138
|
-
const relay = getRecordingRelay(connectionId)
|
|
1139
|
-
if (relay) {
|
|
1140
|
-
relay.handleRecordingCancelled(message as RecordingCancelledMessage)
|
|
1242
|
+
// Handle binary data (recording chunks)
|
|
1243
|
+
if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
|
|
1244
|
+
const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
|
|
1245
|
+
const relay = getRecordingRelay(connectionId)
|
|
1246
|
+
if (relay) {
|
|
1247
|
+
relay.handleBinaryData(buffer)
|
|
1248
|
+
}
|
|
1249
|
+
return
|
|
1141
1250
|
}
|
|
1142
|
-
} else {
|
|
1143
|
-
const extensionEvent = message as ExtensionEventMessage
|
|
1144
1251
|
|
|
1145
|
-
|
|
1252
|
+
let message: ExtensionMessage
|
|
1253
|
+
|
|
1254
|
+
try {
|
|
1255
|
+
message = JSON.parse(event.data.toString())
|
|
1256
|
+
} catch {
|
|
1257
|
+
ws.close(1000, 'Invalid JSON')
|
|
1146
1258
|
return
|
|
1147
1259
|
}
|
|
1148
1260
|
|
|
1149
|
-
|
|
1261
|
+
if (message.id !== undefined) {
|
|
1262
|
+
const pending = (() => {
|
|
1263
|
+
let pendingRequest: relayState.ExtensionPendingRequest | null = null
|
|
1150
1264
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1265
|
+
store.setState((s) => {
|
|
1266
|
+
const extensionEntry = s.extensions.get(connectionId)
|
|
1267
|
+
if (!extensionEntry) {
|
|
1268
|
+
return s
|
|
1269
|
+
}
|
|
1156
1270
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
params
|
|
1162
|
-
})
|
|
1271
|
+
const nextPendingRequest = extensionEntry.pendingRequests.get(message.id)
|
|
1272
|
+
if (!nextPendingRequest) {
|
|
1273
|
+
return s
|
|
1274
|
+
}
|
|
1163
1275
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
if (method === 'Target.attachedToTarget') {
|
|
1168
|
-
const targetParams = params as Protocol.Target.AttachedToTargetEvent
|
|
1169
|
-
const incomingSessionId = sessionId
|
|
1170
|
-
const iframeParentFrameId = targetParams.targetInfo.parentFrameId
|
|
1171
|
-
const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId
|
|
1172
|
-
? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId
|
|
1173
|
-
: undefined
|
|
1174
|
-
|
|
1175
|
-
// Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.)
|
|
1176
|
-
if (isRestrictedTarget(targetParams.targetInfo)) {
|
|
1177
|
-
if (targetParams.waitingForDebugger && targetParams.sessionId) {
|
|
1178
|
-
void sendToExtension({
|
|
1276
|
+
pendingRequest = nextPendingRequest
|
|
1277
|
+
return relayState.removeExtensionPendingRequest(s, {
|
|
1179
1278
|
extensionId: connectionId,
|
|
1180
|
-
|
|
1181
|
-
params: {
|
|
1182
|
-
sessionId: targetParams.sessionId,
|
|
1183
|
-
method: 'Runtime.runIfWaitingForDebugger',
|
|
1184
|
-
params: {},
|
|
1185
|
-
source: 'server',
|
|
1186
|
-
},
|
|
1187
|
-
}).catch((error) => {
|
|
1188
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
1189
|
-
logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), message)
|
|
1279
|
+
requestId: message.id,
|
|
1190
1280
|
})
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
return pendingRequest
|
|
1284
|
+
})() as relayState.ExtensionPendingRequest | null
|
|
1285
|
+
|
|
1286
|
+
if (!pending) {
|
|
1287
|
+
logger?.log('Unexpected response with id:', message.id)
|
|
1193
1288
|
return
|
|
1194
1289
|
}
|
|
1195
1290
|
|
|
1196
|
-
if (
|
|
1197
|
-
|
|
1291
|
+
if (message.error) {
|
|
1292
|
+
pending.reject(new Error(message.error))
|
|
1293
|
+
} else {
|
|
1294
|
+
pending.resolve(message.result)
|
|
1295
|
+
}
|
|
1296
|
+
} else if (message.method === 'pong') {
|
|
1297
|
+
// Keep-alive response, nothing to do
|
|
1298
|
+
} else if (message.method === 'log') {
|
|
1299
|
+
const { level, args } = message.params
|
|
1300
|
+
const logFn = (logger as Record<string, unknown>)?.[level] as ((...args: unknown[]) => void) | undefined
|
|
1301
|
+
const logFunc = logFn || logger?.log
|
|
1302
|
+
const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`)
|
|
1303
|
+
logFunc?.(prefix, ...args)
|
|
1304
|
+
} else if (message.method === 'recordingData') {
|
|
1305
|
+
const relay = getRecordingRelay(connectionId)
|
|
1306
|
+
if (relay) {
|
|
1307
|
+
relay.handleRecordingData(message as RecordingDataMessage)
|
|
1308
|
+
}
|
|
1309
|
+
} else if (message.method === 'recordingCancelled') {
|
|
1310
|
+
const relay = getRecordingRelay(connectionId)
|
|
1311
|
+
if (relay) {
|
|
1312
|
+
relay.handleRecordingCancelled(message as RecordingCancelledMessage)
|
|
1313
|
+
}
|
|
1314
|
+
} else {
|
|
1315
|
+
const extensionEvent = message as ExtensionEventMessage
|
|
1316
|
+
|
|
1317
|
+
if (extensionEvent.method !== 'forwardCDPEvent') {
|
|
1318
|
+
return
|
|
1198
1319
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1320
|
+
|
|
1321
|
+
const { method, params, sessionId } = extensionEvent.params
|
|
1322
|
+
|
|
1323
|
+
logCdpJson({
|
|
1324
|
+
timestamp: new Date().toISOString(),
|
|
1325
|
+
direction: 'from-extension',
|
|
1326
|
+
message: { method, params, sessionId },
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
logCdpMessage({
|
|
1330
|
+
direction: 'from-extension',
|
|
1331
|
+
method,
|
|
1332
|
+
sessionId,
|
|
1333
|
+
params,
|
|
1211
1334
|
})
|
|
1212
1335
|
|
|
1213
|
-
|
|
1214
|
-
|
|
1336
|
+
const cdpEvent: CDPEventBase = { method, sessionId, params }
|
|
1337
|
+
emitter.emit('cdp:event', { event: cdpEvent, sessionId })
|
|
1338
|
+
|
|
1339
|
+
if (method === 'Target.attachedToTarget') {
|
|
1340
|
+
const targetParams = params as Protocol.Target.AttachedToTargetEvent
|
|
1341
|
+
const incomingSessionId = sessionId
|
|
1342
|
+
const iframeParentFrameId = targetParams.targetInfo.parentFrameId
|
|
1343
|
+
// Read current extension state for iframe parent lookup
|
|
1344
|
+
const currentExtState = store.getState().extensions.get(connectionId)
|
|
1345
|
+
const iframeOwnerSessionId =
|
|
1346
|
+
targetParams.targetInfo.type === 'iframe' && iframeParentFrameId && currentExtState
|
|
1347
|
+
? getPageTargetForFrameId({ extensionState: currentExtState, frameId: iframeParentFrameId })?.sessionId
|
|
1348
|
+
: undefined
|
|
1349
|
+
|
|
1350
|
+
// Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.)
|
|
1351
|
+
if (isRestrictedTarget(targetParams.targetInfo)) {
|
|
1352
|
+
if (targetParams.waitingForDebugger && targetParams.sessionId) {
|
|
1353
|
+
void sendToExtension({
|
|
1354
|
+
extensionId: connectionId,
|
|
1355
|
+
method: 'forwardCDPCommand',
|
|
1356
|
+
params: {
|
|
1357
|
+
sessionId: targetParams.sessionId,
|
|
1358
|
+
method: 'Runtime.runIfWaitingForDebugger',
|
|
1359
|
+
params: {},
|
|
1360
|
+
source: 'server',
|
|
1361
|
+
},
|
|
1362
|
+
}).catch((error) => {
|
|
1363
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
1364
|
+
logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), msg)
|
|
1365
|
+
})
|
|
1366
|
+
}
|
|
1367
|
+
logger?.log(
|
|
1368
|
+
pc.gray(
|
|
1369
|
+
`[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`,
|
|
1370
|
+
),
|
|
1371
|
+
)
|
|
1372
|
+
return
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (!targetParams.targetInfo.url) {
|
|
1376
|
+
logger?.error(
|
|
1377
|
+
pc.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'),
|
|
1378
|
+
JSON.stringify({ method, params: targetParams, sessionId }),
|
|
1379
|
+
)
|
|
1380
|
+
}
|
|
1381
|
+
logger?.log(
|
|
1382
|
+
pc.yellow('[Extension] Target.attachedToTarget full payload:'),
|
|
1383
|
+
JSON.stringify({ method, params: targetParams, sessionId }),
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
// Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
|
|
1387
|
+
const alreadyConnected = currentExtState?.connectedTargets.has(targetParams.sessionId) ?? false
|
|
1388
|
+
|
|
1389
|
+
// State transition: add/update target
|
|
1390
|
+
store.setState((s) =>
|
|
1391
|
+
relayState.addTarget(s, {
|
|
1392
|
+
extensionId: connectionId,
|
|
1393
|
+
sessionId: targetParams.sessionId,
|
|
1394
|
+
targetId: targetParams.targetInfo.targetId,
|
|
1395
|
+
targetInfo: targetParams.targetInfo,
|
|
1396
|
+
}),
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
// Only forward to Playwright if this is a new target to avoid duplicates
|
|
1400
|
+
if (!alreadyConnected) {
|
|
1401
|
+
sendToPlaywright({
|
|
1402
|
+
message: {
|
|
1403
|
+
// Iframe targets must be routed to the parent page sessionId so Playwright attaches them under the right page.
|
|
1404
|
+
// - iframeOwnerSessionId: derived parent session via parentFrameId -> page sessionId (frameId tracking).
|
|
1405
|
+
// - incomingSessionId: extension event sessionId for the parent tab.
|
|
1406
|
+
// The frameId mapping is racy: Target.attachedToTarget can arrive before Page.frameAttached/Page.frameNavigated populate frameIds.
|
|
1407
|
+
// When iframeOwnerSessionId is missing we must fall back to incomingSessionId, otherwise Playwright receives the attach on the root
|
|
1408
|
+
// session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations.
|
|
1409
|
+
sessionId: iframeOwnerSessionId ?? incomingSessionId,
|
|
1410
|
+
method: 'Target.attachedToTarget',
|
|
1411
|
+
params: targetParams,
|
|
1412
|
+
} as CDPEventBase,
|
|
1413
|
+
source: 'extension',
|
|
1414
|
+
extensionId: connectionId,
|
|
1415
|
+
})
|
|
1416
|
+
}
|
|
1417
|
+
} else if (method === 'Target.detachedFromTarget') {
|
|
1418
|
+
const detachParams = params as Protocol.Target.DetachedFromTargetEvent
|
|
1419
|
+
store.setState((s) =>
|
|
1420
|
+
relayState.removeTarget(s, { extensionId: connectionId, sessionId: detachParams.sessionId }),
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1215
1423
|
sendToPlaywright({
|
|
1216
1424
|
message: {
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
// - incomingSessionId: extension event sessionId for the parent tab.
|
|
1220
|
-
// The frameId mapping is racy: Target.attachedToTarget can arrive before Page.frameAttached/Page.frameNavigated populate frameIds.
|
|
1221
|
-
// When iframeOwnerSessionId is missing we must fall back to incomingSessionId, otherwise Playwright receives the attach on the root
|
|
1222
|
-
// session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations.
|
|
1223
|
-
sessionId: iframeOwnerSessionId ?? incomingSessionId,
|
|
1224
|
-
method: 'Target.attachedToTarget',
|
|
1225
|
-
params: targetParams
|
|
1425
|
+
method: 'Target.detachedFromTarget',
|
|
1426
|
+
params: detachParams,
|
|
1226
1427
|
} as CDPEventBase,
|
|
1227
1428
|
source: 'extension',
|
|
1228
1429
|
extensionId: connectionId,
|
|
1229
1430
|
})
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
message: {
|
|
1237
|
-
method: 'Target.detachedFromTarget',
|
|
1238
|
-
params: detachParams
|
|
1239
|
-
} as CDPEventBase,
|
|
1240
|
-
source: 'extension',
|
|
1241
|
-
extensionId: connectionId,
|
|
1242
|
-
})
|
|
1243
|
-
} else if (method === 'Target.targetCrashed') {
|
|
1244
|
-
const crashParams = params as Protocol.Target.TargetCrashedEvent
|
|
1245
|
-
for (const [sid, target] of connection.connectedTargets.entries()) {
|
|
1246
|
-
if (target.targetId === crashParams.targetId) {
|
|
1247
|
-
connection.connectedTargets.delete(sid)
|
|
1248
|
-
logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId)
|
|
1249
|
-
break
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1431
|
+
} else if (method === 'Target.targetCrashed') {
|
|
1432
|
+
const crashParams = params as Protocol.Target.TargetCrashedEvent
|
|
1433
|
+
store.setState((s) =>
|
|
1434
|
+
relayState.removeTargetByCrash(s, { extensionId: connectionId, targetId: crashParams.targetId }),
|
|
1435
|
+
)
|
|
1436
|
+
logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId)
|
|
1252
1437
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
break
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1438
|
+
sendToPlaywright({
|
|
1439
|
+
message: {
|
|
1440
|
+
method: 'Target.targetCrashed',
|
|
1441
|
+
params: crashParams,
|
|
1442
|
+
} as CDPEventBase,
|
|
1443
|
+
source: 'extension',
|
|
1444
|
+
extensionId: connectionId,
|
|
1445
|
+
})
|
|
1446
|
+
} else if (method === 'Target.targetInfoChanged') {
|
|
1447
|
+
const infoParams = params as Protocol.Target.TargetInfoChangedEvent
|
|
1448
|
+
store.setState((s) =>
|
|
1449
|
+
relayState.updateTargetInfo(s, { extensionId: connectionId, targetInfo: infoParams.targetInfo }),
|
|
1450
|
+
)
|
|
1269
1451
|
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1452
|
+
sendToPlaywright({
|
|
1453
|
+
message: {
|
|
1454
|
+
method: 'Target.targetInfoChanged',
|
|
1455
|
+
params: infoParams,
|
|
1456
|
+
} as CDPEventBase,
|
|
1457
|
+
source: 'extension',
|
|
1458
|
+
extensionId: connectionId,
|
|
1459
|
+
})
|
|
1460
|
+
} else if (method === 'Page.frameAttached') {
|
|
1461
|
+
const frameParams = params as Protocol.Page.FrameAttachedEvent
|
|
1462
|
+
if (sessionId) {
|
|
1463
|
+
store.setState((s) =>
|
|
1464
|
+
relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frameId }),
|
|
1465
|
+
)
|
|
1284
1466
|
}
|
|
1285
|
-
}
|
|
1286
1467
|
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
}
|
|
1468
|
+
sendToPlaywright({
|
|
1469
|
+
message: {
|
|
1470
|
+
sessionId,
|
|
1471
|
+
method,
|
|
1472
|
+
params,
|
|
1473
|
+
} as CDPEventBase,
|
|
1474
|
+
source: 'extension',
|
|
1475
|
+
extensionId: connectionId,
|
|
1476
|
+
})
|
|
1477
|
+
} else if (method === 'Page.frameDetached') {
|
|
1478
|
+
const frameParams = params as Protocol.Page.FrameDetachedEvent
|
|
1479
|
+
store.setState((s) =>
|
|
1480
|
+
relayState.removeFrameId(s, { extensionId: connectionId, frameId: frameParams.frameId }),
|
|
1481
|
+
)
|
|
1302
1482
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1483
|
+
sendToPlaywright({
|
|
1484
|
+
message: {
|
|
1485
|
+
sessionId,
|
|
1486
|
+
method,
|
|
1487
|
+
params,
|
|
1488
|
+
} as CDPEventBase,
|
|
1489
|
+
source: 'extension',
|
|
1490
|
+
extensionId: connectionId,
|
|
1491
|
+
})
|
|
1492
|
+
} else if (method === 'Page.frameNavigated') {
|
|
1493
|
+
const frameParams = params as Protocol.Page.FrameNavigatedEvent
|
|
1494
|
+
if (sessionId) {
|
|
1495
|
+
store.setState((s) =>
|
|
1496
|
+
relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frame.id }),
|
|
1497
|
+
)
|
|
1318
1498
|
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
logger?.log(
|
|
1499
|
+
if (!frameParams.frame.parentId && sessionId) {
|
|
1500
|
+
store.setState((s) =>
|
|
1501
|
+
relayState.updateTargetUrl(s, {
|
|
1502
|
+
extensionId: connectionId,
|
|
1503
|
+
sessionId,
|
|
1504
|
+
url: frameParams.frame.url,
|
|
1505
|
+
title: frameParams.frame.name || undefined,
|
|
1506
|
+
}),
|
|
1507
|
+
)
|
|
1508
|
+
logger?.log(
|
|
1509
|
+
pc.magenta('[Server] Updated target URL from Page.frameNavigated:'),
|
|
1510
|
+
frameParams.frame.url,
|
|
1511
|
+
)
|
|
1329
1512
|
}
|
|
1330
|
-
}
|
|
1331
1513
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1514
|
+
sendToPlaywright({
|
|
1515
|
+
message: {
|
|
1516
|
+
sessionId,
|
|
1517
|
+
method,
|
|
1518
|
+
params,
|
|
1519
|
+
} as CDPEventBase,
|
|
1520
|
+
source: 'extension',
|
|
1521
|
+
extensionId: connectionId,
|
|
1522
|
+
})
|
|
1523
|
+
} else if (method === 'Page.navigatedWithinDocument') {
|
|
1524
|
+
const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent
|
|
1525
|
+
if (sessionId) {
|
|
1526
|
+
store.setState((s) =>
|
|
1527
|
+
relayState.updateTargetUrl(s, { extensionId: connectionId, sessionId, url: navParams.url }),
|
|
1528
|
+
)
|
|
1529
|
+
logger?.log(
|
|
1530
|
+
pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'),
|
|
1531
|
+
navParams.url,
|
|
1532
|
+
)
|
|
1351
1533
|
}
|
|
1352
|
-
}
|
|
1353
1534
|
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1535
|
+
sendToPlaywright({
|
|
1536
|
+
message: {
|
|
1537
|
+
sessionId,
|
|
1538
|
+
method,
|
|
1539
|
+
params,
|
|
1540
|
+
} as CDPEventBase,
|
|
1541
|
+
source: 'extension',
|
|
1542
|
+
extensionId: connectionId,
|
|
1543
|
+
})
|
|
1544
|
+
} else {
|
|
1545
|
+
sendToPlaywright({
|
|
1546
|
+
message: {
|
|
1547
|
+
sessionId,
|
|
1548
|
+
method,
|
|
1549
|
+
params,
|
|
1550
|
+
} as CDPEventBase,
|
|
1551
|
+
source: 'extension',
|
|
1552
|
+
extensionId: connectionId,
|
|
1553
|
+
})
|
|
1554
|
+
}
|
|
1373
1555
|
}
|
|
1374
|
-
}
|
|
1375
|
-
},
|
|
1376
|
-
|
|
1377
|
-
onClose(event, ws) {
|
|
1378
|
-
logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`)
|
|
1379
|
-
stopExtensionPing(connectionId)
|
|
1556
|
+
},
|
|
1380
1557
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
if (recordingRelay) {
|
|
1384
|
-
recordingRelay.cancelRecording({}).catch(() => {
|
|
1385
|
-
// Ignore errors during cleanup
|
|
1386
|
-
})
|
|
1387
|
-
}
|
|
1388
|
-
recordingRelays.delete(connectionId)
|
|
1558
|
+
onClose(event) {
|
|
1559
|
+
logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`)
|
|
1389
1560
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1561
|
+
// Cancel recordings BEFORE removing extension state (cancelRecording checks isExtensionConnected)
|
|
1562
|
+
const recordingRelay = recordingRelays.get(connectionId)
|
|
1563
|
+
if (recordingRelay) {
|
|
1564
|
+
recordingRelay.cancelRecording({}).catch(() => {
|
|
1565
|
+
// Ignore errors during cleanup
|
|
1566
|
+
})
|
|
1567
|
+
}
|
|
1568
|
+
recordingRelays.delete(connectionId)
|
|
1569
|
+
|
|
1570
|
+
// Reject all pending I/O requests (state cleanup happens in removeExtension below)
|
|
1571
|
+
const closingExt = store.getState().extensions.get(connectionId)
|
|
1572
|
+
if (closingExt) {
|
|
1573
|
+
stopExtensionPing(connectionId)
|
|
1574
|
+
for (const pending of closingExt.pendingRequests.values()) {
|
|
1575
|
+
pending.reject(new Error('Extension connection closed'))
|
|
1576
|
+
}
|
|
1394
1577
|
}
|
|
1395
|
-
connection.pendingRequests.clear()
|
|
1396
|
-
connection.connectedTargets.clear()
|
|
1397
|
-
}
|
|
1398
1578
|
|
|
1399
|
-
|
|
1400
|
-
const
|
|
1401
|
-
|
|
1402
|
-
|
|
1579
|
+
const currentRelayState = store.getState()
|
|
1580
|
+
const closingExtension = currentRelayState.extensions.get(connectionId)
|
|
1581
|
+
const successorCandidates = closingExtension
|
|
1582
|
+
? Array.from(currentRelayState.extensions.values())
|
|
1583
|
+
.reverse()
|
|
1584
|
+
.filter((ext) => {
|
|
1585
|
+
return ext.id !== connectionId && ext.stableKey === closingExtension.stableKey && Boolean(ext.ws)
|
|
1586
|
+
})
|
|
1587
|
+
: []
|
|
1588
|
+
const successorExtension = closingExtension
|
|
1589
|
+
? successorCandidates[0]
|
|
1590
|
+
: undefined
|
|
1591
|
+
|
|
1592
|
+
if (successorExtension) {
|
|
1593
|
+
logger?.log(
|
|
1594
|
+
pc.yellow(
|
|
1595
|
+
`Rebinding clients from ${connectionId} to ${successorExtension.id} (stableKey: ${successorExtension.stableKey})`,
|
|
1596
|
+
),
|
|
1597
|
+
)
|
|
1598
|
+
store.setState((s) => {
|
|
1599
|
+
return relayState.rebindClientsToExtension(s, {
|
|
1600
|
+
fromExtensionId: connectionId,
|
|
1601
|
+
toExtensionId: successorExtension.id,
|
|
1602
|
+
})
|
|
1603
|
+
})
|
|
1403
1604
|
}
|
|
1404
|
-
}
|
|
1405
|
-
extensionConnections.delete(connectionId)
|
|
1406
1605
|
|
|
1407
|
-
|
|
1408
|
-
if (
|
|
1409
|
-
|
|
1606
|
+
// Close playwright clients bound to this extension when no successor exists.
|
|
1607
|
+
if (!successorExtension) {
|
|
1608
|
+
const { playwrightClients } = store.getState()
|
|
1609
|
+
for (const client of playwrightClients.values()) {
|
|
1610
|
+
if (client.extensionId === connectionId) {
|
|
1611
|
+
client.ws.close(1000, 'Extension disconnected')
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1410
1614
|
}
|
|
1411
|
-
client.ws.close(1000, 'Extension disconnected')
|
|
1412
|
-
playwrightClients.delete(clientId)
|
|
1413
|
-
}
|
|
1414
|
-
},
|
|
1415
1615
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1616
|
+
// State transition: remove extension + its bound clients atomically
|
|
1617
|
+
store.setState((s) => relayState.removeExtension(s, { extensionId: connectionId }))
|
|
1618
|
+
},
|
|
1619
|
+
|
|
1620
|
+
onError(event) {
|
|
1621
|
+
logger?.error('Extension WebSocket error:', event)
|
|
1622
|
+
},
|
|
1418
1623
|
}
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1624
|
+
}),
|
|
1625
|
+
)
|
|
1421
1626
|
|
|
1422
1627
|
// ============================================================================
|
|
1423
1628
|
// CLI Execute Endpoints - For stateful code execution via CLI
|
|
@@ -1457,7 +1662,10 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1457
1662
|
// preflight as a fallback, which our CORS policy already blocks.
|
|
1458
1663
|
// 3. When token mode is enabled (remote access), require the token.
|
|
1459
1664
|
// ============================================================================
|
|
1460
|
-
const privilegedRouteMiddleware = async (
|
|
1665
|
+
const privilegedRouteMiddleware = async (
|
|
1666
|
+
c: Parameters<Parameters<typeof app.use>[1]>[0],
|
|
1667
|
+
next: () => Promise<void>,
|
|
1668
|
+
) => {
|
|
1461
1669
|
// Block cross-origin browser requests via Sec-Fetch-Site header.
|
|
1462
1670
|
// Browsers always set this forbidden header; it cannot be spoofed.
|
|
1463
1671
|
// Non-browser clients (Node.js, curl, MCP) don't send it.
|
|
@@ -1497,7 +1705,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1497
1705
|
|
|
1498
1706
|
app.post('/cli/execute', async (c) => {
|
|
1499
1707
|
try {
|
|
1500
|
-
const body = await c.req.json() as { sessionId: string | number; code: string; timeout?: number }
|
|
1708
|
+
const body = (await c.req.json()) as { sessionId: string | number; code: string; timeout?: number }
|
|
1501
1709
|
const sessionId = normalizeSessionId(body.sessionId)
|
|
1502
1710
|
const { code, timeout = 10000 } = body
|
|
1503
1711
|
|
|
@@ -1508,7 +1716,10 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1508
1716
|
const manager = await getExecutorManager()
|
|
1509
1717
|
const existingExecutor = manager.getSession(sessionId)
|
|
1510
1718
|
if (!existingExecutor) {
|
|
1511
|
-
return c.json(
|
|
1719
|
+
return c.json(
|
|
1720
|
+
{ text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], isError: true },
|
|
1721
|
+
404,
|
|
1722
|
+
)
|
|
1512
1723
|
}
|
|
1513
1724
|
const result = await existingExecutor.execute(code, timeout)
|
|
1514
1725
|
|
|
@@ -1521,7 +1732,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1521
1732
|
|
|
1522
1733
|
app.post('/cli/reset', async (c) => {
|
|
1523
1734
|
try {
|
|
1524
|
-
const body = await c.req.json() as { sessionId: string | number }
|
|
1735
|
+
const body = (await c.req.json()) as { sessionId: string | number }
|
|
1525
1736
|
const sessionId = normalizeSessionId(body.sessionId)
|
|
1526
1737
|
|
|
1527
1738
|
if (!sessionId) {
|
|
@@ -1556,13 +1767,13 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1556
1767
|
})
|
|
1557
1768
|
|
|
1558
1769
|
app.post('/cli/session/new', async (c) => {
|
|
1559
|
-
const body = await c.req.json().catch(() => ({})) as { extensionId?: string | null; cwd?: string }
|
|
1770
|
+
const body = (await c.req.json().catch(() => ({}))) as { extensionId?: string | null; cwd?: string }
|
|
1560
1771
|
const sessionId = String(nextSessionNumber++)
|
|
1561
1772
|
const extensionId = body.extensionId || null
|
|
1562
1773
|
const cwd = body.cwd
|
|
1563
|
-
const allowDefault = !extensionId &&
|
|
1564
|
-
const
|
|
1565
|
-
if (!
|
|
1774
|
+
const allowDefault = !extensionId && store.getState().extensions.size === 1
|
|
1775
|
+
const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault })
|
|
1776
|
+
if (!conn) {
|
|
1566
1777
|
const error = extensionId
|
|
1567
1778
|
? `Extension not connected: ${extensionId}`
|
|
1568
1779
|
: 'Multiple extensions connected. Specify extensionId.'
|
|
@@ -1573,9 +1784,9 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1573
1784
|
sessionId,
|
|
1574
1785
|
cwd,
|
|
1575
1786
|
sessionMetadata: {
|
|
1576
|
-
extensionId:
|
|
1577
|
-
browser:
|
|
1578
|
-
profile:
|
|
1787
|
+
extensionId: conn.stableKey,
|
|
1788
|
+
browser: conn.info.browser || null,
|
|
1789
|
+
profile: conn.info ? { email: conn.info.email || '', id: conn.info.id || '' } : null,
|
|
1579
1790
|
},
|
|
1580
1791
|
})
|
|
1581
1792
|
const metadata = executor.getSessionMetadata()
|
|
@@ -1605,7 +1816,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1605
1816
|
|
|
1606
1817
|
app.post('/cli/session/delete', async (c) => {
|
|
1607
1818
|
try {
|
|
1608
|
-
const body = await c.req.json() as { sessionId: string | number }
|
|
1819
|
+
const body = (await c.req.json()) as { sessionId: string | number }
|
|
1609
1820
|
const sessionId = normalizeSessionId(body.sessionId)
|
|
1610
1821
|
|
|
1611
1822
|
if (!sessionId) {
|
|
@@ -1630,73 +1841,64 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1630
1841
|
// ============================================================================
|
|
1631
1842
|
|
|
1632
1843
|
app.post('/recording/start', async (c) => {
|
|
1633
|
-
const body = await c.req.json() as {
|
|
1844
|
+
const body = (await c.req.json()) as {
|
|
1845
|
+
outputPath?: string
|
|
1846
|
+
sessionId?: string | number
|
|
1847
|
+
frameRate?: number
|
|
1848
|
+
audio?: boolean
|
|
1849
|
+
videoBitsPerSecond?: number
|
|
1850
|
+
audioBitsPerSecond?: number
|
|
1851
|
+
}
|
|
1634
1852
|
const sessionId = normalizeSessionId(body.sessionId)
|
|
1635
1853
|
const { sessionId: _sessionId, ...recordingOptions } = body
|
|
1636
|
-
const
|
|
1637
|
-
const executor = sessionId ? manager.getSession(sessionId) : null
|
|
1638
|
-
if (sessionId && !executor) {
|
|
1639
|
-
return c.json({ success: false, error: `Session ${sessionId} not found` }, 404)
|
|
1640
|
-
}
|
|
1641
|
-
const extensionId = executor?.getSessionMetadata().extensionId || null
|
|
1854
|
+
const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
|
|
1642
1855
|
const relay = getRecordingRelay(extensionId)
|
|
1643
1856
|
if (!relay) {
|
|
1644
1857
|
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
|
1645
1858
|
}
|
|
1646
|
-
const recordingParams = (
|
|
1859
|
+
const recordingParams = (resolvedSessionId
|
|
1860
|
+
? { ...recordingOptions, sessionId: resolvedSessionId }
|
|
1861
|
+
: recordingOptions) as StartRecordingBody
|
|
1647
1862
|
const result = await relay.startRecording(recordingParams)
|
|
1648
|
-
const status = result.success ? 200 :
|
|
1863
|
+
const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500
|
|
1649
1864
|
return c.json(result, status)
|
|
1650
1865
|
})
|
|
1651
1866
|
|
|
1652
1867
|
app.post('/recording/stop', async (c) => {
|
|
1653
|
-
const body = await c.req.json() as { sessionId?: string | number }
|
|
1868
|
+
const body = (await c.req.json()) as { sessionId?: string | number }
|
|
1654
1869
|
const sessionId = normalizeSessionId(body.sessionId)
|
|
1655
|
-
const
|
|
1656
|
-
const executor = sessionId ? manager.getSession(sessionId) : null
|
|
1657
|
-
if (sessionId && !executor) {
|
|
1658
|
-
return c.json({ success: false, error: `Session ${sessionId} not found` }, 404)
|
|
1659
|
-
}
|
|
1660
|
-
const extensionId = executor?.getSessionMetadata().extensionId || null
|
|
1870
|
+
const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
|
|
1661
1871
|
const relay = getRecordingRelay(extensionId)
|
|
1662
1872
|
if (!relay) {
|
|
1663
1873
|
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
|
1664
1874
|
}
|
|
1665
|
-
const stopParams: StopRecordingParams =
|
|
1875
|
+
const stopParams: StopRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
|
|
1666
1876
|
const result = await relay.stopRecording(stopParams)
|
|
1667
|
-
const status = result.success ? 200 :
|
|
1877
|
+
const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500
|
|
1668
1878
|
return c.json(result, status)
|
|
1669
1879
|
})
|
|
1670
1880
|
|
|
1671
1881
|
app.get('/recording/status', async (c) => {
|
|
1672
1882
|
const sessionId = normalizeSessionId(c.req.query('sessionId'))
|
|
1673
|
-
const
|
|
1674
|
-
const manager = await getExecutorManager()
|
|
1675
|
-
const executor = normalizedSessionId ? manager.getSession(normalizedSessionId) : null
|
|
1676
|
-
const extensionId = executor?.getSessionMetadata().extensionId || null
|
|
1883
|
+
const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
|
|
1677
1884
|
const relay = getRecordingRelay(extensionId)
|
|
1678
1885
|
if (!relay) {
|
|
1679
1886
|
return c.json({ isRecording: false })
|
|
1680
1887
|
}
|
|
1681
|
-
const isRecordingParams: IsRecordingParams =
|
|
1888
|
+
const isRecordingParams: IsRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
|
|
1682
1889
|
const result = await relay.isRecording(isRecordingParams)
|
|
1683
1890
|
return c.json(result)
|
|
1684
1891
|
})
|
|
1685
1892
|
|
|
1686
1893
|
app.post('/recording/cancel', async (c) => {
|
|
1687
|
-
const body = await c.req.json() as { sessionId?: string | number }
|
|
1894
|
+
const body = (await c.req.json()) as { sessionId?: string | number }
|
|
1688
1895
|
const sessionId = normalizeSessionId(body.sessionId)
|
|
1689
|
-
const
|
|
1690
|
-
const executor = sessionId ? manager.getSession(sessionId) : null
|
|
1691
|
-
if (sessionId && !executor) {
|
|
1692
|
-
return c.json({ success: false, error: `Session ${sessionId} not found` }, 404)
|
|
1693
|
-
}
|
|
1694
|
-
const extensionId = executor?.getSessionMetadata().extensionId || null
|
|
1896
|
+
const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId })
|
|
1695
1897
|
const relay = getRecordingRelay(extensionId)
|
|
1696
1898
|
if (!relay) {
|
|
1697
1899
|
return c.json({ success: false, error: 'Extension not connected' }, 500)
|
|
1698
1900
|
}
|
|
1699
|
-
const cancelParams: CancelRecordingParams =
|
|
1901
|
+
const cancelParams: CancelRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
|
|
1700
1902
|
const result = await relay.cancelRecording(cancelParams)
|
|
1701
1903
|
return c.json(result)
|
|
1702
1904
|
})
|
|
@@ -1716,14 +1918,24 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1716
1918
|
|
|
1717
1919
|
return {
|
|
1718
1920
|
close() {
|
|
1921
|
+
const { extensions, playwrightClients } = store.getState()
|
|
1922
|
+
|
|
1719
1923
|
for (const client of playwrightClients.values()) {
|
|
1720
1924
|
client.ws.close(1000, 'Server stopped')
|
|
1721
1925
|
}
|
|
1722
|
-
|
|
1723
|
-
for (const
|
|
1724
|
-
|
|
1926
|
+
|
|
1927
|
+
for (const ext of extensions.values()) {
|
|
1928
|
+
if (ext.pingInterval) {
|
|
1929
|
+
clearInterval(ext.pingInterval)
|
|
1930
|
+
}
|
|
1931
|
+
ext.ws?.close(1000, 'Server stopped')
|
|
1725
1932
|
}
|
|
1726
|
-
|
|
1933
|
+
|
|
1934
|
+
// Reset store state
|
|
1935
|
+
store.setState({
|
|
1936
|
+
extensions: new Map(),
|
|
1937
|
+
playwrightClients: new Map(),
|
|
1938
|
+
})
|
|
1727
1939
|
server.close()
|
|
1728
1940
|
emitter.removeAllListeners()
|
|
1729
1941
|
},
|
|
@@ -1732,6 +1944,6 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1732
1944
|
},
|
|
1733
1945
|
off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
|
|
1734
1946
|
emitter.off(event, listener as (...args: unknown[]) => void)
|
|
1735
|
-
}
|
|
1947
|
+
},
|
|
1736
1948
|
}
|
|
1737
1949
|
}
|