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.
Files changed (223) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts +41 -3
  3. package/dist/aria-snapshot.d.ts.map +1 -1
  4. package/dist/aria-snapshot.js +134 -55
  5. package/dist/aria-snapshot.js.map +1 -1
  6. package/dist/aria-snapshot.test.js +5 -2
  7. package/dist/aria-snapshot.test.js.map +1 -1
  8. package/dist/aria-snapshot.unit.test.js +83 -41
  9. package/dist/aria-snapshot.unit.test.js.map +1 -1
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  13. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  14. package/dist/bippy.js +1 -1
  15. package/dist/cdp-log.d.ts +1 -1
  16. package/dist/cdp-log.d.ts.map +1 -1
  17. package/dist/cdp-log.js +1 -1
  18. package/dist/cdp-log.js.map +1 -1
  19. package/dist/cdp-relay.d.ts.map +1 -1
  20. package/dist/cdp-relay.js +492 -298
  21. package/dist/cdp-relay.js.map +1 -1
  22. package/dist/cdp-session.d.ts.map +1 -1
  23. package/dist/cdp-session.js.map +1 -1
  24. package/dist/cdp-types.d.ts.map +1 -1
  25. package/dist/cdp-types.js +7 -7
  26. package/dist/cdp-types.js.map +1 -1
  27. package/dist/clean-html.d.ts.map +1 -1
  28. package/dist/clean-html.js +4 -5
  29. package/dist/clean-html.js.map +1 -1
  30. package/dist/cli.js +45 -27
  31. package/dist/cli.js.map +1 -1
  32. package/dist/create-logger.d.ts.map +1 -1
  33. package/dist/create-logger.js +3 -1
  34. package/dist/create-logger.js.map +1 -1
  35. package/dist/debugger-examples-types.d.ts.map +1 -1
  36. package/dist/debugger.d.ts.map +1 -1
  37. package/dist/debugger.js +1 -3
  38. package/dist/debugger.js.map +1 -1
  39. package/dist/diff-utils.d.ts.map +1 -1
  40. package/dist/diff-utils.js +1 -4
  41. package/dist/diff-utils.js.map +1 -1
  42. package/dist/editor-api.md +12 -2
  43. package/dist/editor-examples.d.ts +1 -1
  44. package/dist/editor-examples.d.ts.map +1 -1
  45. package/dist/editor-examples.js +1 -1
  46. package/dist/editor-examples.js.map +1 -1
  47. package/dist/editor.d.ts +1 -1
  48. package/dist/editor.d.ts.map +1 -1
  49. package/dist/editor.js +1 -1
  50. package/dist/editor.js.map +1 -1
  51. package/dist/executor.d.ts +26 -3
  52. package/dist/executor.d.ts.map +1 -1
  53. package/dist/executor.js +297 -64
  54. package/dist/executor.js.map +1 -1
  55. package/dist/executor.unit.test.js +38 -1
  56. package/dist/executor.unit.test.js.map +1 -1
  57. package/dist/extension-connection.test.js +139 -36
  58. package/dist/extension-connection.test.js.map +1 -1
  59. package/dist/ffmpeg.d.ts +148 -0
  60. package/dist/ffmpeg.d.ts.map +1 -0
  61. package/dist/ffmpeg.js +523 -0
  62. package/dist/ffmpeg.js.map +1 -0
  63. package/dist/ghost-browser.d.ts.map +1 -1
  64. package/dist/ghost-browser.js.map +1 -1
  65. package/dist/ghost-cursor-client.js +287 -0
  66. package/dist/ghost-cursor.d.ts +27 -0
  67. package/dist/ghost-cursor.d.ts.map +1 -0
  68. package/dist/ghost-cursor.js +63 -0
  69. package/dist/ghost-cursor.js.map +1 -0
  70. package/dist/htmlrewrite.d.ts.map +1 -1
  71. package/dist/htmlrewrite.js +17 -55
  72. package/dist/htmlrewrite.js.map +1 -1
  73. package/dist/htmlrewrite.test.js.map +1 -1
  74. package/dist/kill-port.d.ts.map +1 -1
  75. package/dist/kill-port.js +1 -3
  76. package/dist/kill-port.js.map +1 -1
  77. package/dist/locator-selector.test.d.ts +2 -0
  78. package/dist/locator-selector.test.d.ts.map +1 -0
  79. package/dist/locator-selector.test.js +96 -0
  80. package/dist/locator-selector.test.js.map +1 -0
  81. package/dist/mcp-client.js.map +1 -1
  82. package/dist/mcp.d.ts.map +1 -1
  83. package/dist/mcp.js +8 -3
  84. package/dist/mcp.js.map +1 -1
  85. package/dist/on-mouse-action.test.d.ts +2 -0
  86. package/dist/on-mouse-action.test.d.ts.map +1 -0
  87. package/dist/on-mouse-action.test.js +155 -0
  88. package/dist/on-mouse-action.test.js.map +1 -0
  89. package/dist/page-markdown.js +4 -4
  90. package/dist/page-markdown.js.map +1 -1
  91. package/dist/prompt.md +450 -377
  92. package/dist/protocol.d.ts +4 -0
  93. package/dist/protocol.d.ts.map +1 -1
  94. package/dist/readability.js +16 -2
  95. package/dist/recording-ghost-cursor.d.ts +41 -0
  96. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  97. package/dist/recording-ghost-cursor.js +79 -0
  98. package/dist/recording-ghost-cursor.js.map +1 -0
  99. package/dist/recording-relay.d.ts.map +1 -1
  100. package/dist/recording-relay.js +8 -8
  101. package/dist/recording-relay.js.map +1 -1
  102. package/dist/relay-client.d.ts +17 -4
  103. package/dist/relay-client.d.ts.map +1 -1
  104. package/dist/relay-client.js +45 -11
  105. package/dist/relay-client.js.map +1 -1
  106. package/dist/relay-core.test.d.ts.map +1 -1
  107. package/dist/relay-core.test.js +515 -26
  108. package/dist/relay-core.test.js.map +1 -1
  109. package/dist/relay-navigation.test.d.ts.map +1 -1
  110. package/dist/relay-navigation.test.js +169 -31
  111. package/dist/relay-navigation.test.js.map +1 -1
  112. package/dist/relay-session.test.d.ts.map +1 -1
  113. package/dist/relay-session.test.js +113 -65
  114. package/dist/relay-session.test.js.map +1 -1
  115. package/dist/relay-state.d.ts +158 -0
  116. package/dist/relay-state.d.ts.map +1 -0
  117. package/dist/relay-state.js +306 -0
  118. package/dist/relay-state.js.map +1 -0
  119. package/dist/relay-state.test.d.ts +2 -0
  120. package/dist/relay-state.test.d.ts.map +1 -0
  121. package/dist/relay-state.test.js +472 -0
  122. package/dist/relay-state.test.js.map +1 -0
  123. package/dist/scoped-fs.d.ts.map +1 -1
  124. package/dist/scoped-fs.js.map +1 -1
  125. package/dist/screen-recording.d.ts +66 -4
  126. package/dist/screen-recording.d.ts.map +1 -1
  127. package/dist/screen-recording.js +150 -13
  128. package/dist/screen-recording.js.map +1 -1
  129. package/dist/screen-recording.test.d.ts +2 -0
  130. package/dist/screen-recording.test.d.ts.map +1 -0
  131. package/dist/screen-recording.test.js +102 -0
  132. package/dist/screen-recording.test.js.map +1 -0
  133. package/dist/selector-generator.js +1 -1
  134. package/dist/snapshot-tools.test.js +71 -28
  135. package/dist/snapshot-tools.test.js.map +1 -1
  136. package/dist/start-relay-server.d.ts +1 -1
  137. package/dist/start-relay-server.d.ts.map +1 -1
  138. package/dist/start-relay-server.js +1 -1
  139. package/dist/start-relay-server.js.map +1 -1
  140. package/dist/styles-api.md +8 -1
  141. package/dist/styles-examples.d.ts +1 -1
  142. package/dist/styles-examples.d.ts.map +1 -1
  143. package/dist/styles-examples.js +1 -1
  144. package/dist/styles-examples.js.map +1 -1
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +1 -3
  147. package/dist/styles.js.map +1 -1
  148. package/dist/test-declarations.d.ts.map +1 -1
  149. package/dist/test-utils.d.ts +1 -1
  150. package/dist/test-utils.d.ts.map +1 -1
  151. package/dist/test-utils.js +7 -5
  152. package/dist/test-utils.js.map +1 -1
  153. package/dist/utils.d.ts.map +1 -1
  154. package/dist/utils.js.map +1 -1
  155. package/dist/wait-for-page-load.d.ts.map +1 -1
  156. package/dist/wait-for-page-load.js +1 -1
  157. package/dist/wait-for-page-load.js.map +1 -1
  158. package/package.json +4 -3
  159. package/src/a11y-client.ts +5 -4
  160. package/src/aria-snapshot.test.ts +5 -2
  161. package/src/aria-snapshot.ts +306 -117
  162. package/src/aria-snapshot.unit.test.ts +199 -141
  163. package/src/aria-snapshots/github-interactive.txt +2 -0
  164. package/src/aria-snapshots/github-raw.txt +5 -1
  165. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  166. package/src/aria-snapshots/hackernews-raw.txt +265 -269
  167. package/src/assets/aria-labels-example.png +0 -0
  168. package/src/assets/aria-labels-github.png +0 -0
  169. package/src/assets/aria-labels-hacker-news.png +0 -0
  170. package/src/assets/aria-labels-old-reddit.png +0 -0
  171. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  172. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  173. package/src/cdp-log.ts +4 -1
  174. package/src/cdp-relay.ts +1059 -737
  175. package/src/cdp-session.ts +12 -3
  176. package/src/cdp-types.ts +51 -51
  177. package/src/clean-html.ts +4 -5
  178. package/src/cli.ts +82 -55
  179. package/src/create-logger.ts +5 -3
  180. package/src/debugger-examples-types.ts +4 -1
  181. package/src/debugger.ts +1 -5
  182. package/src/diff-utils.ts +2 -5
  183. package/src/editor-examples.ts +11 -1
  184. package/src/editor.ts +10 -2
  185. package/src/executor.ts +374 -73
  186. package/src/executor.unit.test.ts +48 -1
  187. package/src/extension-connection.test.ts +612 -488
  188. package/src/ffmpeg.ts +769 -0
  189. package/src/ghost-browser.ts +4 -6
  190. package/src/ghost-cursor-client.ts +369 -0
  191. package/src/ghost-cursor.ts +110 -0
  192. package/src/htmlrewrite.test.ts +6 -2
  193. package/src/htmlrewrite.ts +348 -386
  194. package/src/kill-port.ts +1 -3
  195. package/src/locator-selector.test.ts +115 -0
  196. package/src/mcp-client.ts +1 -1
  197. package/src/mcp.ts +21 -15
  198. package/src/on-mouse-action.test.ts +196 -0
  199. package/src/page-markdown.ts +7 -7
  200. package/src/protocol.ts +73 -57
  201. package/src/recording-ghost-cursor.ts +113 -0
  202. package/src/recording-relay.ts +20 -12
  203. package/src/relay-client.ts +85 -18
  204. package/src/relay-core.test.ts +1117 -578
  205. package/src/relay-navigation.test.ts +648 -483
  206. package/src/relay-session.test.ts +984 -929
  207. package/src/relay-state.test.ts +570 -0
  208. package/src/relay-state.ts +497 -0
  209. package/src/resource.md +21 -49
  210. package/src/scoped-fs.ts +9 -3
  211. package/src/screen-recording.test.ts +111 -0
  212. package/src/screen-recording.ts +256 -31
  213. package/src/skill.md +476 -396
  214. package/src/snapshot-tools.test.ts +580 -528
  215. package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
  216. package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
  217. package/src/start-relay-server.ts +14 -11
  218. package/src/styles-examples.ts +8 -1
  219. package/src/styles.ts +20 -21
  220. package/src/test-declarations.ts +6 -6
  221. package/src/test-utils.ts +104 -91
  222. package/src/utils.ts +2 -1
  223. 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 extensionConnections = new Map<string, ExtensionConnection>()
118
- const extensionKeyIndex = new Map<string, string>()
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 extensionConnections.keys().next().value || null
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
- ): ExtensionConnection | null => {
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 = extensionConnections.get(extensionId)
136
- if (direct) {
109
+ const direct = extensions.get(extensionId)
110
+ if (direct?.ws) {
137
111
  return direct
138
112
  }
139
- const mappedId = extensionKeyIndex.get(extensionId)
140
- if (mappedId) {
141
- return extensionConnections.get(mappedId) || null
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
- const fallbackId = getDefaultExtensionId()
151
- if (fallbackId) {
152
- return extensionConnections.get(fallbackId) || null
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
- connection,
180
- frameId
180
+ extensionState,
181
+ frameId,
181
182
  }: {
182
- connection: ExtensionConnection
183
+ extensionState: relayState.ExtensionEntry
183
184
  frameId: string
184
- }): ConnectedTarget | undefined => {
185
- return Array.from(connection.connectedTargets.values()).find((target) => {
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 connection = extensionConnections.get(extensionId)
192
- if (!connection) {
192
+ const ext = store.getState().extensions.get(extensionId)
193
+ if (!ext) {
193
194
  return
194
195
  }
195
- if (connection.pingInterval) {
196
- clearInterval(connection.pingInterval)
196
+ if (ext.pingInterval) {
197
+ clearInterval(ext.pingInterval)
197
198
  }
198
- connection.pingInterval = setInterval(() => {
199
- connection.ws.send(JSON.stringify({ method: 'ping' }))
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 connection = extensionConnections.get(extensionId)
205
- if (!connection || !connection.pingInterval) {
209
+ const ext = store.getState().extensions.get(extensionId)
210
+ if (!ext || !ext.pingInterval) {
206
211
  return
207
212
  }
208
- clearInterval(connection.pingInterval)
209
- connection.pingInterval = null
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 clients = Array.from(playwrightClients.values())
337
- for (const client of clients) {
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 connection = getExtensionConnection(extensionId)
376
- if (!connection) {
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
- connection.pendingRequests.delete(id)
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
- connection.pendingRequests.set(id, {
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 && extensionConnections.size === 1
421
- const connection = getExtensionConnection(extensionId, { allowFallback: allowDefault })
422
- if (!connection) {
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
- if (!recordingRelays.has(connection.id)) {
506
+ const connId = conn.id
507
+ if (!recordingRelays.has(connId)) {
426
508
  recordingRelays.set(
427
- connection.id,
509
+ connId,
428
510
  new RecordingRelay(
429
- (params) => sendToExtension({ extensionId: connection.id, ...params }),
430
- () => extensionConnections.has(connection.id),
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(connection.id) || null
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 connection = getExtensionConnection(extensionId)
445
- if (!connection) {
526
+ const conn = getExtensionConnection(extensionId)
527
+ if (!conn) {
446
528
  return
447
529
  }
448
- if (connection.connectedTargets.size > 0) {
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
- connection.connectedTargets.set(result.sessionId, {
462
- sessionId: result.sessionId,
463
- targetId: result.targetInfo.targetId,
464
- targetInfo: result.targetInfo,
465
- frameIds: new Set()
466
- })
467
- logger?.log(pc.blue(`Auto-created tab, now have ${connection.connectedTargets.size} targets, url: ${result.targetInfo.url}`))
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: any
484
- sessionId?: string
485
- source?: 'playwriter'
655
+ method: CDPCommand['method'] | (string & {})
656
+ params: CDPCommand['params']
657
+ sessionId?: CDPCommand['sessionId']
658
+ source?: CDPCommand['source']
486
659
  }) {
487
- const extension = getExtensionConnection(extensionId)
488
- const connectedTargets = extension?.connectedTargets || new Map<string, ConnectedTarget>()
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 (extension) {
512
- await maybeAutoCreateInitialTab(extension.id)
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: extension?.id || 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 targetId = params?.targetId
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 targetId = params?.targetId
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: extension?.id || 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: extension?.id || 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: extension?.id || 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(pc.yellow(`IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`))
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: extension?.id || 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: extension?.id || 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('*', cors({
649
- origin: (origin) => {
650
- if (!origin.startsWith('chrome-extension://')) {
651
- return null
652
- }
653
- const extensionId = origin.replace('chrome-extension://', '')
654
- if (!EXTENSION_IDS.includes(extensionId)) {
655
- return null
656
- }
657
- return origin
658
- },
659
- allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'],
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 = extensionConnections.size > 0
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(extensionConnections.values()).map((extension) => {
886
+ const extensions = Array.from(store.getState().extensions.values()).map((ext) => {
693
887
  return {
694
- extensionId: extension.id,
695
- stableKey: extension.stableKey,
696
- browser: extension.info.browser || null,
697
- profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
698
- activeTargets: extension.connectedTargets.size,
699
- playwriterVersion: extension.info?.version || null,
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
- 'Browser': `Playwriter/${VERSION}`,
906
+ Browser: `Playwriter/${VERSION}`,
713
907
  'Protocol-Version': '1.3',
714
- 'webSocketDebuggerUrl': getCdpWsUrl(c)
908
+ webSocketDebuggerUrl: getCdpWsUrl(c),
715
909
  })
716
910
  })
717
911
  .on(['GET', 'PUT'], '/json/version/', (c) => {
718
912
  return c.json({
719
- 'Browser': `Playwriter/${VERSION}`,
913
+ Browser: `Playwriter/${VERSION}`,
720
914
  'Protocol-Version': '1.3',
721
- 'webSocketDebuggerUrl': getCdpWsUrl(c)
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('/cdp/:clientId?', (c, next) => {
802
- const origin = c.req.header('origin')
803
-
804
- // Validate Origin header if present (Node.js clients don't send it)
805
- if (origin) {
806
- if (origin.startsWith('chrome-extension://')) {
807
- const extensionId = origin.replace('chrome-extension://', '')
808
- if (!EXTENSION_IDS.includes(extensionId)) {
809
- logger?.log(pc.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`))
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
- if (token) {
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 providedToken = url.searchParams.get('token')
821
- if (providedToken !== token) {
822
- return c.text('Unauthorized', 401)
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
- return {
836
- async onOpen(_event, ws) {
837
- if (playwrightClients.has(clientId)) {
838
- logger?.log(pc.yellow(`Rejecting duplicate Playwright clientId: ${clientId}`))
839
- ws.close(4004, 'Duplicate Playwright clientId')
840
- return
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
- if (!clientExtensionId) {
844
- const reason = requestedExtensionId
845
- ? `Unknown extensionId: ${requestedExtensionId}`
846
- : 'Multiple extensions connected. Specify extensionId.'
847
- logger?.log(pc.yellow(`Rejecting Playwright client ${clientId}: ${reason}`))
848
- ws.close(4003, reason)
849
- return
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
- // Add client first so it can receive Target.attachedToTarget events
853
- playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId })
854
- const extensionConnection = getExtensionConnection(clientExtensionId)
855
- const targetCount = extensionConnection?.connectedTargets.size || 0
856
- logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`))
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
- async onMessage(event, ws) {
860
- let message: CDPCommand
1069
+ async onMessage(event, ws) {
1070
+ let message: CDPCommand
861
1071
 
862
- try {
863
- message = JSON.parse(event.data.toString())
864
- } catch {
865
- return
866
- }
1072
+ try {
1073
+ message = JSON.parse(event.data.toString())
1074
+ } catch {
1075
+ return
1076
+ }
867
1077
 
868
- const { id, sessionId, method, params, source } = message
1078
+ const { id, sessionId, method, params, source } = message
869
1079
 
870
- logCdpJson({
871
- timestamp: new Date().toISOString(),
872
- direction: 'from-playwright',
873
- clientId,
874
- message,
875
- })
1080
+ logCdpJson({
1081
+ timestamp: new Date().toISOString(),
1082
+ direction: 'from-playwright',
1083
+ clientId,
1084
+ message,
1085
+ })
876
1086
 
877
- logCdpMessage({
878
- direction: 'from-playwright',
879
- clientId,
880
- method,
881
- sessionId,
882
- id
883
- })
1087
+ logCdpMessage({
1088
+ direction: 'from-playwright',
1089
+ clientId,
1090
+ method,
1091
+ sessionId,
1092
+ id,
1093
+ })
884
1094
 
885
- emitter.emit('cdp:command', { clientId, command: message })
1095
+ emitter.emit('cdp:command', { clientId, command: message })
886
1096
 
887
- const extensionConnection = getExtensionConnection(clientExtensionId)
888
- if (!extensionConnection) {
889
- sendToPlaywright({
890
- message: {
891
- id,
892
- sessionId,
893
- error: { message: 'Extension not connected' }
894
- },
895
- clientId
896
- })
897
- return
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
- try {
901
- const result: any = await routeCdpCommand({ extensionId: extensionConnection.id, method, params, sessionId, source })
1111
+ try {
1112
+ const result = await routeCdpCommand({
1113
+ extensionId: extensionConn.id,
1114
+ method,
1115
+ params,
1116
+ sessionId,
1117
+ source,
1118
+ })
902
1119
 
903
- if (method === 'Target.setAutoAttach' && !sessionId) {
904
- for (const target of extensionConnection.connectedTargets.values()) {
905
- // Skip restricted targets (extensions, chrome:// URLs, non-page types)
906
- if (isRestrictedTarget(target.targetInfo)) {
907
- continue
908
- }
909
- const attachedPayload = {
910
- method: 'Target.attachedToTarget',
911
- params: {
912
- sessionId: target.sessionId,
913
- targetInfo: {
914
- ...target.targetInfo,
915
- attached: true
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
- waitingForDebugger: false
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
- } satisfies CDPEventFor<'Target.attachedToTarget'>
920
- if (!target.targetInfo.url) {
921
- logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), JSON.stringify(attachedPayload))
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
- if (method === 'Target.setDiscoverTargets' && (params as any)?.discover) {
933
- for (const target of extensionConnection.connectedTargets.values()) {
934
- // Skip restricted targets (extensions, chrome:// URLs, non-page types)
935
- if (isRestrictedTarget(target.targetInfo)) {
936
- continue
937
- }
938
- const targetCreatedPayload = {
939
- method: 'Target.targetCreated',
940
- params: {
941
- targetInfo: {
942
- ...target.targetInfo,
943
- attached: true
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
- } satisfies CDPEventFor<'Target.targetCreated'>
947
- if (!target.targetInfo.url) {
948
- logger?.error(pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), JSON.stringify(targetCreatedPayload))
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
- if (method === 'Target.attachToTarget' && result?.sessionId) {
960
- const targetId = params?.targetId
961
- const target = Array.from(extensionConnection.connectedTargets.values()).find(t => t.targetId === targetId)
962
- if (target) {
963
- const attachedPayload = {
964
- method: 'Target.attachedToTarget',
965
- params: {
966
- sessionId: result.sessionId,
967
- targetInfo: {
968
- ...target.targetInfo,
969
- attached: true
970
- },
971
- waitingForDebugger: false
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
- const response: CDPResponseBase = { id, sessionId, result }
987
- sendToPlaywright({ message: response, clientId })
988
- emitter.emit('cdp:response', { clientId, response, command: message })
989
- } catch (e) {
990
- logger?.error('Error handling CDP command:', method, params, e)
991
- const errorResponse: CDPResponseBase = {
992
- id,
993
- sessionId,
994
- error: { message: (e as Error).message }
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
- sendToPlaywright({ message: errorResponse, clientId })
997
- emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
998
- }
999
- },
1246
+ },
1000
1247
 
1001
- onClose() {
1002
- playwrightClients.delete(clientId)
1003
- logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${playwrightClients.size} remaining)`))
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
- onError(event) {
1007
- logger?.error(`Playwright WebSocket error [${clientId}]:`, event)
1253
+ onError(event) {
1254
+ logger?.error(`Playwright WebSocket error [${clientId}]:`, event)
1255
+ },
1008
1256
  }
1009
- }
1010
- }))
1257
+ }),
1258
+ )
1011
1259
 
1012
- const getExtensionInfoFromRequest = (c: { req: { query: (name: string) => string | undefined } }): ExtensionInfo => {
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('/extension', (c, next) => {
1026
- // 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
1027
- // This prevents attackers on the network from hijacking the browser session
1028
- // even if the server is exposed via 0.0.0.0.
1029
- const info = getConnInfo(c)
1030
- const remoteAddress = info.remote.address
1031
- const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1'
1032
-
1033
- if (!isLocalhost) {
1034
- logger?.log(pc.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`))
1035
- return c.text('Forbidden - Extension must be local', 403)
1036
- }
1037
-
1038
- // 2. Origin Validation: Prevent browser-based attacks (CSRF).
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
- return next()
1054
- }, upgradeWebSocket((c) => {
1055
- const incomingExtensionInfo = getExtensionInfoFromRequest(c)
1056
- const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
1057
- return {
1058
- onOpen(_event, ws) {
1059
- const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId)
1060
- const existingId = extensionKeyIndex.get(stableKey)
1061
- if (existingId && existingId !== connectionId) {
1062
- logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingId} -> ${connectionId})`))
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
- const connection: ExtensionConnection = {
1070
- id: connectionId,
1071
- ws,
1072
- info: incomingExtensionInfo,
1073
- stableKey,
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
- async onMessage(event, ws) {
1086
- const connection = extensionConnections.get(connectionId)
1087
- if (!connection) {
1088
- ws.close(1000, 'Extension not registered')
1089
- return
1090
- }
1091
- // Handle binary data (recording chunks)
1092
- if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
1093
- const buffer = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
1094
- const relay = getRecordingRelay(connectionId)
1095
- if (relay) {
1096
- relay.handleBinaryData(buffer)
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
- let message: ExtensionMessage
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
- try {
1104
- message = JSON.parse(event.data.toString())
1105
- } catch {
1106
- ws.close(1000, 'Invalid JSON')
1107
- return
1108
- }
1331
+ startExtensionPing(connectionId)
1332
+ logger?.log(`Extension connected (${connectionId})`)
1333
+ },
1109
1334
 
1110
- if (message.id !== undefined) {
1111
- const pending = connection.pendingRequests.get(message.id)
1112
- if (!pending) {
1113
- logger?.log('Unexpected response with id:', message.id)
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
- connection.pendingRequests.delete(message.id)
1118
-
1119
- if (message.error) {
1120
- pending.reject(new Error(message.error))
1121
- } else {
1122
- pending.resolve(message.result)
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
- if (extensionEvent.method !== 'forwardCDPEvent') {
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
- const { method, params, sessionId } = extensionEvent.params
1360
+ if (message.id !== undefined) {
1361
+ const pending = (() => {
1362
+ let pendingRequest: relayState.ExtensionPendingRequest | null = null
1150
1363
 
1151
- logCdpJson({
1152
- timestamp: new Date().toISOString(),
1153
- direction: 'from-extension',
1154
- message: { method, params, sessionId },
1155
- })
1364
+ store.setState((s) => {
1365
+ const extensionEntry = s.extensions.get(connectionId)
1366
+ if (!extensionEntry) {
1367
+ return s
1368
+ }
1156
1369
 
1157
- logCdpMessage({
1158
- direction: 'from-extension',
1159
- method,
1160
- sessionId,
1161
- params
1162
- })
1370
+ const nextPendingRequest = extensionEntry.pendingRequests.get(message.id)
1371
+ if (!nextPendingRequest) {
1372
+ return s
1373
+ }
1163
1374
 
1164
- const cdpEvent: CDPEventBase = { method, sessionId, params }
1165
- emitter.emit('cdp:event', { event: cdpEvent, sessionId })
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
- method: 'forwardCDPCommand',
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
- logger?.log(pc.gray(`[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`))
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 (!targetParams.targetInfo.url) {
1197
- logger?.error(pc.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'), JSON.stringify({ method, params: targetParams, sessionId }))
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
- logger?.log(pc.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId }))
1200
-
1201
- // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
1202
- const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId)
1203
- const existingTarget = connection.connectedTargets.get(targetParams.sessionId)
1204
-
1205
- // Always update our local state with latest target info
1206
- connection.connectedTargets.set(targetParams.sessionId, {
1207
- sessionId: targetParams.sessionId,
1208
- targetId: targetParams.targetInfo.targetId,
1209
- targetInfo: targetParams.targetInfo,
1210
- frameIds: existingTarget?.frameIds ?? new Set()
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
- // Only forward to Playwright if this is a new target to avoid duplicates
1214
- if (!alreadyConnected) {
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
- // Iframe targets must be routed to the parent page sessionId so Playwright attaches them under the right page.
1218
- // - iframeOwnerSessionId: derived parent session via parentFrameId -> page sessionId (frameId tracking).
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
- } else if (method === 'Target.detachedFromTarget') {
1232
- const detachParams = params as Protocol.Target.DetachedFromTargetEvent
1233
- connection.connectedTargets.delete(detachParams.sessionId)
1234
-
1235
- sendToPlaywright({
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
- sendToPlaywright({
1254
- message: {
1255
- method: 'Target.targetCrashed',
1256
- params: crashParams
1257
- } as CDPEventBase,
1258
- source: 'extension',
1259
- extensionId: connectionId,
1260
- })
1261
- } else if (method === 'Target.targetInfoChanged') {
1262
- const infoParams = params as Protocol.Target.TargetInfoChangedEvent
1263
- for (const target of connection.connectedTargets.values()) {
1264
- if (target.targetId === infoParams.targetInfo.targetId) {
1265
- target.targetInfo = infoParams.targetInfo
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
- sendToPlaywright({
1271
- message: {
1272
- method: 'Target.targetInfoChanged',
1273
- params: infoParams
1274
- } as CDPEventBase,
1275
- source: 'extension',
1276
- extensionId: connectionId,
1277
- })
1278
- } else if (method === 'Page.frameAttached') {
1279
- const frameParams = params as Protocol.Page.FrameAttachedEvent
1280
- if (sessionId) {
1281
- const target = connection.connectedTargets.get(sessionId)
1282
- if (target) {
1283
- target.frameIds.add(frameParams.frameId)
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
- sendToPlaywright({
1288
- message: {
1289
- sessionId,
1290
- method,
1291
- params
1292
- } as CDPEventBase,
1293
- source: 'extension',
1294
- extensionId: connectionId,
1295
- })
1296
- } else if (method === 'Page.frameDetached') {
1297
- const frameParams = params as Protocol.Page.FrameDetachedEvent
1298
- const ownerTarget = getPageTargetForFrameId({ connection, frameId: frameParams.frameId })
1299
- if (ownerTarget) {
1300
- ownerTarget.frameIds.delete(frameParams.frameId)
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
- sendToPlaywright({
1304
- message: {
1305
- sessionId,
1306
- method,
1307
- params
1308
- } as CDPEventBase,
1309
- source: 'extension',
1310
- extensionId: connectionId,
1311
- })
1312
- } else if (method === 'Page.frameNavigated') {
1313
- const frameParams = params as Protocol.Page.FrameNavigatedEvent
1314
- if (sessionId) {
1315
- const target = connection.connectedTargets.get(sessionId)
1316
- if (target) {
1317
- target.frameIds.add(frameParams.frame.id)
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
- if (!frameParams.frame.parentId && sessionId) {
1321
- const target = connection.connectedTargets.get(sessionId)
1322
- if (target) {
1323
- target.targetInfo = {
1324
- ...target.targetInfo,
1325
- url: frameParams.frame.url,
1326
- title: frameParams.frame.name || target.targetInfo.title,
1327
- }
1328
- logger?.log(pc.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url)
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
- sendToPlaywright({
1333
- message: {
1334
- sessionId,
1335
- method,
1336
- params
1337
- } as CDPEventBase,
1338
- source: 'extension',
1339
- extensionId: connectionId,
1340
- })
1341
- } else if (method === 'Page.navigatedWithinDocument') {
1342
- const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent
1343
- if (sessionId) {
1344
- const target = connection.connectedTargets.get(sessionId)
1345
- if (target) {
1346
- target.targetInfo = {
1347
- ...target.targetInfo,
1348
- url: navParams.url,
1349
- }
1350
- logger?.log(pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url)
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
- sendToPlaywright({
1355
- message: {
1356
- sessionId,
1357
- method,
1358
- params
1359
- } as CDPEventBase,
1360
- source: 'extension',
1361
- extensionId: connectionId,
1362
- })
1363
- } else {
1364
- sendToPlaywright({
1365
- message: {
1366
- sessionId,
1367
- method,
1368
- params
1369
- } as CDPEventBase,
1370
- source: 'extension',
1371
- extensionId: connectionId,
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
- // Cancel any active recordings BEFORE removing connection (cancelRecording checks isExtensionConnected)
1382
- const recordingRelay = recordingRelays.get(connectionId)
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
- const connection = extensionConnections.get(connectionId)
1391
- if (connection) {
1392
- for (const pending of connection.pendingRequests.values()) {
1393
- pending.reject(new Error('Extension connection closed'))
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
- if (connection) {
1400
- const mappedId = extensionKeyIndex.get(connection.stableKey)
1401
- if (mappedId === connectionId) {
1402
- extensionKeyIndex.delete(connection.stableKey)
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
- for (const [clientId, client] of playwrightClients.entries()) {
1408
- if (client.extensionId !== connectionId) {
1409
- continue
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
- onError(event) {
1417
- logger?.error('Extension WebSocket error:', event)
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 (c: Parameters<Parameters<typeof app.use>[1]>[0], next: () => Promise<void>) => {
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({ text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], isError: true }, 404)
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 && extensionConnections.size === 1
1564
- const extension = getExtensionConnection(extensionId, { allowFallback: allowDefault })
1565
- if (!extension) {
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: extension.stableKey,
1577
- browser: extension.info.browser || null,
1578
- profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
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 { outputPath?: string; sessionId?: string | number; frameRate?: number; audio?: boolean; videoBitsPerSecond?: number; audioBitsPerSecond?: number }
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 manager = await getExecutorManager()
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 = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions) as StartRecordingBody
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 : (result.error?.includes('required') ? 400 : 500)
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 manager = await getExecutorManager()
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 = sessionId ? { sessionId } : {}
1985
+ const stopParams: StopRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {}
1666
1986
  const result = await relay.stopRecording(stopParams)
1667
- const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500)
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 normalizedSessionId = sessionId || undefined
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 = normalizedSessionId ? { sessionId: normalizedSessionId } : {}
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 manager = await getExecutorManager()
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 = sessionId ? { sessionId } : {}
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
- playwrightClients.clear()
1723
- for (const extension of extensionConnections.values()) {
1724
- extension.ws.close(1000, 'Server stopped')
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
- extensionConnections.clear()
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
  }