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