playwriter 0.0.63 → 0.0.80

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