playwriter 0.0.63 → 0.0.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts +41 -3
  3. package/dist/aria-snapshot.d.ts.map +1 -1
  4. package/dist/aria-snapshot.js +134 -55
  5. package/dist/aria-snapshot.js.map +1 -1
  6. package/dist/aria-snapshot.test.js +5 -2
  7. package/dist/aria-snapshot.test.js.map +1 -1
  8. package/dist/aria-snapshot.unit.test.js +83 -41
  9. package/dist/aria-snapshot.unit.test.js.map +1 -1
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  13. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  14. package/dist/bippy.js +1 -1
  15. package/dist/cdp-log.d.ts +1 -1
  16. package/dist/cdp-log.d.ts.map +1 -1
  17. package/dist/cdp-log.js +1 -1
  18. package/dist/cdp-log.js.map +1 -1
  19. package/dist/cdp-relay.d.ts.map +1 -1
  20. package/dist/cdp-relay.js +492 -298
  21. package/dist/cdp-relay.js.map +1 -1
  22. package/dist/cdp-session.d.ts.map +1 -1
  23. package/dist/cdp-session.js.map +1 -1
  24. package/dist/cdp-types.d.ts.map +1 -1
  25. package/dist/cdp-types.js +7 -7
  26. package/dist/cdp-types.js.map +1 -1
  27. package/dist/clean-html.d.ts.map +1 -1
  28. package/dist/clean-html.js +4 -5
  29. package/dist/clean-html.js.map +1 -1
  30. package/dist/cli.js +45 -27
  31. package/dist/cli.js.map +1 -1
  32. package/dist/create-logger.d.ts.map +1 -1
  33. package/dist/create-logger.js +3 -1
  34. package/dist/create-logger.js.map +1 -1
  35. package/dist/debugger-examples-types.d.ts.map +1 -1
  36. package/dist/debugger.d.ts.map +1 -1
  37. package/dist/debugger.js +1 -3
  38. package/dist/debugger.js.map +1 -1
  39. package/dist/diff-utils.d.ts.map +1 -1
  40. package/dist/diff-utils.js +1 -4
  41. package/dist/diff-utils.js.map +1 -1
  42. package/dist/editor-api.md +12 -2
  43. package/dist/editor-examples.d.ts +1 -1
  44. package/dist/editor-examples.d.ts.map +1 -1
  45. package/dist/editor-examples.js +1 -1
  46. package/dist/editor-examples.js.map +1 -1
  47. package/dist/editor.d.ts +1 -1
  48. package/dist/editor.d.ts.map +1 -1
  49. package/dist/editor.js +1 -1
  50. package/dist/editor.js.map +1 -1
  51. package/dist/executor.d.ts +26 -3
  52. package/dist/executor.d.ts.map +1 -1
  53. package/dist/executor.js +297 -64
  54. package/dist/executor.js.map +1 -1
  55. package/dist/executor.unit.test.js +38 -1
  56. package/dist/executor.unit.test.js.map +1 -1
  57. package/dist/extension-connection.test.js +139 -36
  58. package/dist/extension-connection.test.js.map +1 -1
  59. package/dist/ffmpeg.d.ts +148 -0
  60. package/dist/ffmpeg.d.ts.map +1 -0
  61. package/dist/ffmpeg.js +523 -0
  62. package/dist/ffmpeg.js.map +1 -0
  63. package/dist/ghost-browser.d.ts.map +1 -1
  64. package/dist/ghost-browser.js.map +1 -1
  65. package/dist/ghost-cursor-client.js +287 -0
  66. package/dist/ghost-cursor.d.ts +27 -0
  67. package/dist/ghost-cursor.d.ts.map +1 -0
  68. package/dist/ghost-cursor.js +63 -0
  69. package/dist/ghost-cursor.js.map +1 -0
  70. package/dist/htmlrewrite.d.ts.map +1 -1
  71. package/dist/htmlrewrite.js +17 -55
  72. package/dist/htmlrewrite.js.map +1 -1
  73. package/dist/htmlrewrite.test.js.map +1 -1
  74. package/dist/kill-port.d.ts.map +1 -1
  75. package/dist/kill-port.js +1 -3
  76. package/dist/kill-port.js.map +1 -1
  77. package/dist/locator-selector.test.d.ts +2 -0
  78. package/dist/locator-selector.test.d.ts.map +1 -0
  79. package/dist/locator-selector.test.js +96 -0
  80. package/dist/locator-selector.test.js.map +1 -0
  81. package/dist/mcp-client.js.map +1 -1
  82. package/dist/mcp.d.ts.map +1 -1
  83. package/dist/mcp.js +8 -3
  84. package/dist/mcp.js.map +1 -1
  85. package/dist/on-mouse-action.test.d.ts +2 -0
  86. package/dist/on-mouse-action.test.d.ts.map +1 -0
  87. package/dist/on-mouse-action.test.js +155 -0
  88. package/dist/on-mouse-action.test.js.map +1 -0
  89. package/dist/page-markdown.js +4 -4
  90. package/dist/page-markdown.js.map +1 -1
  91. package/dist/prompt.md +450 -377
  92. package/dist/protocol.d.ts +4 -0
  93. package/dist/protocol.d.ts.map +1 -1
  94. package/dist/readability.js +16 -2
  95. package/dist/recording-ghost-cursor.d.ts +41 -0
  96. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  97. package/dist/recording-ghost-cursor.js +79 -0
  98. package/dist/recording-ghost-cursor.js.map +1 -0
  99. package/dist/recording-relay.d.ts.map +1 -1
  100. package/dist/recording-relay.js +8 -8
  101. package/dist/recording-relay.js.map +1 -1
  102. package/dist/relay-client.d.ts +17 -4
  103. package/dist/relay-client.d.ts.map +1 -1
  104. package/dist/relay-client.js +45 -11
  105. package/dist/relay-client.js.map +1 -1
  106. package/dist/relay-core.test.d.ts.map +1 -1
  107. package/dist/relay-core.test.js +515 -26
  108. package/dist/relay-core.test.js.map +1 -1
  109. package/dist/relay-navigation.test.d.ts.map +1 -1
  110. package/dist/relay-navigation.test.js +169 -31
  111. package/dist/relay-navigation.test.js.map +1 -1
  112. package/dist/relay-session.test.d.ts.map +1 -1
  113. package/dist/relay-session.test.js +113 -65
  114. package/dist/relay-session.test.js.map +1 -1
  115. package/dist/relay-state.d.ts +158 -0
  116. package/dist/relay-state.d.ts.map +1 -0
  117. package/dist/relay-state.js +306 -0
  118. package/dist/relay-state.js.map +1 -0
  119. package/dist/relay-state.test.d.ts +2 -0
  120. package/dist/relay-state.test.d.ts.map +1 -0
  121. package/dist/relay-state.test.js +472 -0
  122. package/dist/relay-state.test.js.map +1 -0
  123. package/dist/scoped-fs.d.ts.map +1 -1
  124. package/dist/scoped-fs.js.map +1 -1
  125. package/dist/screen-recording.d.ts +66 -4
  126. package/dist/screen-recording.d.ts.map +1 -1
  127. package/dist/screen-recording.js +150 -13
  128. package/dist/screen-recording.js.map +1 -1
  129. package/dist/screen-recording.test.d.ts +2 -0
  130. package/dist/screen-recording.test.d.ts.map +1 -0
  131. package/dist/screen-recording.test.js +102 -0
  132. package/dist/screen-recording.test.js.map +1 -0
  133. package/dist/selector-generator.js +1 -1
  134. package/dist/snapshot-tools.test.js +71 -28
  135. package/dist/snapshot-tools.test.js.map +1 -1
  136. package/dist/start-relay-server.d.ts +1 -1
  137. package/dist/start-relay-server.d.ts.map +1 -1
  138. package/dist/start-relay-server.js +1 -1
  139. package/dist/start-relay-server.js.map +1 -1
  140. package/dist/styles-api.md +8 -1
  141. package/dist/styles-examples.d.ts +1 -1
  142. package/dist/styles-examples.d.ts.map +1 -1
  143. package/dist/styles-examples.js +1 -1
  144. package/dist/styles-examples.js.map +1 -1
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +1 -3
  147. package/dist/styles.js.map +1 -1
  148. package/dist/test-declarations.d.ts.map +1 -1
  149. package/dist/test-utils.d.ts +1 -1
  150. package/dist/test-utils.d.ts.map +1 -1
  151. package/dist/test-utils.js +7 -5
  152. package/dist/test-utils.js.map +1 -1
  153. package/dist/utils.d.ts.map +1 -1
  154. package/dist/utils.js.map +1 -1
  155. package/dist/wait-for-page-load.d.ts.map +1 -1
  156. package/dist/wait-for-page-load.js +1 -1
  157. package/dist/wait-for-page-load.js.map +1 -1
  158. package/package.json +4 -3
  159. package/src/a11y-client.ts +5 -4
  160. package/src/aria-snapshot.test.ts +5 -2
  161. package/src/aria-snapshot.ts +306 -117
  162. package/src/aria-snapshot.unit.test.ts +199 -141
  163. package/src/aria-snapshots/github-interactive.txt +2 -0
  164. package/src/aria-snapshots/github-raw.txt +5 -1
  165. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  166. package/src/aria-snapshots/hackernews-raw.txt +265 -269
  167. package/src/assets/aria-labels-example.png +0 -0
  168. package/src/assets/aria-labels-github.png +0 -0
  169. package/src/assets/aria-labels-hacker-news.png +0 -0
  170. package/src/assets/aria-labels-old-reddit.png +0 -0
  171. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  172. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  173. package/src/cdp-log.ts +4 -1
  174. package/src/cdp-relay.ts +1059 -737
  175. package/src/cdp-session.ts +12 -3
  176. package/src/cdp-types.ts +51 -51
  177. package/src/clean-html.ts +4 -5
  178. package/src/cli.ts +82 -55
  179. package/src/create-logger.ts +5 -3
  180. package/src/debugger-examples-types.ts +4 -1
  181. package/src/debugger.ts +1 -5
  182. package/src/diff-utils.ts +2 -5
  183. package/src/editor-examples.ts +11 -1
  184. package/src/editor.ts +10 -2
  185. package/src/executor.ts +374 -73
  186. package/src/executor.unit.test.ts +48 -1
  187. package/src/extension-connection.test.ts +612 -488
  188. package/src/ffmpeg.ts +769 -0
  189. package/src/ghost-browser.ts +4 -6
  190. package/src/ghost-cursor-client.ts +369 -0
  191. package/src/ghost-cursor.ts +110 -0
  192. package/src/htmlrewrite.test.ts +6 -2
  193. package/src/htmlrewrite.ts +348 -386
  194. package/src/kill-port.ts +1 -3
  195. package/src/locator-selector.test.ts +115 -0
  196. package/src/mcp-client.ts +1 -1
  197. package/src/mcp.ts +21 -15
  198. package/src/on-mouse-action.test.ts +196 -0
  199. package/src/page-markdown.ts +7 -7
  200. package/src/protocol.ts +73 -57
  201. package/src/recording-ghost-cursor.ts +113 -0
  202. package/src/recording-relay.ts +20 -12
  203. package/src/relay-client.ts +85 -18
  204. package/src/relay-core.test.ts +1117 -578
  205. package/src/relay-navigation.test.ts +648 -483
  206. package/src/relay-session.test.ts +984 -929
  207. package/src/relay-state.test.ts +570 -0
  208. package/src/relay-state.ts +497 -0
  209. package/src/resource.md +21 -49
  210. package/src/scoped-fs.ts +9 -3
  211. package/src/screen-recording.test.ts +111 -0
  212. package/src/screen-recording.ts +256 -31
  213. package/src/skill.md +476 -396
  214. package/src/snapshot-tools.test.ts +580 -528
  215. package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
  216. package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
  217. package/src/start-relay-server.ts +14 -11
  218. package/src/styles-examples.ts +8 -1
  219. package/src/styles.ts +20 -21
  220. package/src/test-declarations.ts +6 -6
  221. package/src/test-utils.ts +104 -91
  222. package/src/utils.ts +2 -1
  223. package/src/wait-for-page-load.ts +6 -1
package/dist/cdp-relay.js CHANGED
@@ -13,6 +13,7 @@ import { EventEmitter } from 'node:events';
13
13
  import { VERSION, EXTENSION_IDS } from './utils.js';
14
14
  import { createCdpLogger } from './cdp-log.js';
15
15
  import { RecordingRelay } from './recording-relay.js';
16
+ import * as relayState from './relay-state.js';
16
17
  /**
17
18
  * Checks if a target should be filtered out (not exposed to Playwright).
18
19
  * Filters extension pages, service workers, and other restricted targets,
@@ -42,34 +43,64 @@ function isRestrictedTarget(targetInfo) {
42
43
  }
43
44
  export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.0.0.1', token, logger, cdpLogger, } = {}) {
44
45
  const emitter = new EventEmitter();
45
- const extensionConnections = new Map();
46
- const extensionKeyIndex = new Map();
46
+ const store = relayState.createRelayStore();
47
+ const extensionDownloadBehavior = new Map();
47
48
  const resolvedCdpLogger = cdpLogger || createCdpLogger();
48
49
  const logCdpJson = (entry) => {
49
50
  resolvedCdpLogger.log(entry);
50
51
  };
51
- const playwrightClients = new Map();
52
52
  const getDefaultExtensionId = () => {
53
- return extensionConnections.keys().next().value || null;
53
+ return store.getState().extensions.keys().next().value || null;
54
54
  };
55
+ /**
56
+ * Resolve an extension by ID, stableKey, or fallback.
57
+ * Returns the unified ExtensionEntry which includes both state and I/O.
58
+ */
55
59
  const getExtensionConnection = (extensionId, options = {}) => {
60
+ const currentRelayState = store.getState();
61
+ const { extensions } = currentRelayState;
56
62
  if (extensionId) {
57
- const direct = extensionConnections.get(extensionId);
58
- if (direct) {
63
+ const direct = extensions.get(extensionId);
64
+ if (direct?.ws) {
59
65
  return direct;
60
66
  }
61
- const mappedId = extensionKeyIndex.get(extensionId);
62
- if (mappedId) {
63
- return extensionConnections.get(mappedId) || null;
67
+ // Try stableKey lookup.
68
+ const byKey = relayState.findExtensionByStableKey(currentRelayState, extensionId);
69
+ if (byKey) {
70
+ const candidates = Array.from(extensions.values())
71
+ .filter((ext) => ext.stableKey === byKey.stableKey)
72
+ .reverse();
73
+ for (const candidate of candidates) {
74
+ if (candidate.ws) {
75
+ return candidate;
76
+ }
77
+ }
64
78
  }
65
79
  return null;
66
80
  }
67
81
  if (!options.allowFallback) {
68
82
  return null;
69
83
  }
70
- const fallbackId = getDefaultExtensionId();
71
- if (fallbackId) {
72
- return extensionConnections.get(fallbackId) || null;
84
+ // Single extension — use it directly
85
+ if (extensions.size === 1) {
86
+ const fallbackId = getDefaultExtensionId();
87
+ if (fallbackId) {
88
+ const ext = extensions.get(fallbackId);
89
+ if (ext?.ws) {
90
+ return ext;
91
+ }
92
+ }
93
+ }
94
+ // Multiple extensions — auto-select if exactly one has active targets.
95
+ // This handles the common case of multiple Chrome profiles with the extension
96
+ // installed, where only one profile has playwriter-enabled tabs. (#52)
97
+ if (extensions.size > 1) {
98
+ const activeExtensions = Array.from(extensions.values()).filter((ext) => {
99
+ return ext.connectedTargets.size > 0;
100
+ });
101
+ if (activeExtensions.length === 1 && activeExtensions[0].ws) {
102
+ return activeExtensions[0];
103
+ }
73
104
  }
74
105
  return null;
75
106
  };
@@ -92,39 +123,41 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
92
123
  const normalized = String(value);
93
124
  return normalized ? normalized : null;
94
125
  };
95
- const getPageTargetForFrameId = ({ connection, frameId }) => {
96
- return Array.from(connection.connectedTargets.values()).find((target) => {
126
+ const getPageTargetForFrameId = ({ extensionState, frameId, }) => {
127
+ return Array.from(extensionState.connectedTargets.values()).find((target) => {
97
128
  return target.targetInfo.type === 'page' && target.frameIds.has(frameId);
98
129
  });
99
130
  };
100
131
  const startExtensionPing = (extensionId) => {
101
- const connection = extensionConnections.get(extensionId);
102
- if (!connection) {
132
+ const ext = store.getState().extensions.get(extensionId);
133
+ if (!ext) {
103
134
  return;
104
135
  }
105
- if (connection.pingInterval) {
106
- clearInterval(connection.pingInterval);
136
+ if (ext.pingInterval) {
137
+ clearInterval(ext.pingInterval);
107
138
  }
108
- connection.pingInterval = setInterval(() => {
109
- connection.ws.send(JSON.stringify({ method: 'ping' }));
139
+ const pingInterval = setInterval(() => {
140
+ const latestExt = store.getState().extensions.get(extensionId);
141
+ latestExt?.ws?.send(JSON.stringify({ method: 'ping' }));
110
142
  }, 5000);
143
+ store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval }));
111
144
  };
112
145
  const stopExtensionPing = (extensionId) => {
113
- const connection = extensionConnections.get(extensionId);
114
- if (!connection || !connection.pingInterval) {
146
+ const ext = store.getState().extensions.get(extensionId);
147
+ if (!ext || !ext.pingInterval) {
115
148
  return;
116
149
  }
117
- clearInterval(connection.pingInterval);
118
- connection.pingInterval = null;
150
+ clearInterval(ext.pingInterval);
151
+ store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval: null }));
119
152
  };
120
- function logCdpMessage({ direction, clientId, method, sessionId, params, id, source }) {
153
+ function logCdpMessage({ direction, clientId, method, sessionId, params, id, source, }) {
121
154
  const noisyEvents = [
122
155
  'Network.requestWillBeSentExtraInfo',
123
156
  'Network.responseReceived',
124
157
  'Network.responseReceivedExtraInfo',
125
158
  'Network.dataReceived',
126
159
  'Network.requestWillBeSent',
127
- 'Network.loadingFinished'
160
+ 'Network.loadingFinished',
128
161
  ];
129
162
  if (noisyEvents.includes(method)) {
130
163
  return;
@@ -163,9 +196,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
163
196
  }
164
197
  }
165
198
  function sendToPlaywright({ message, clientId, source = 'extension', extensionId, }) {
166
- const messageToSend = source === 'server' && 'method' in message
167
- ? { ...message, __serverGenerated: true }
168
- : message;
199
+ const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message;
169
200
  logCdpJson({
170
201
  timestamp: new Date().toISOString(),
171
202
  direction: 'to-playwright',
@@ -180,7 +211,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
180
211
  method: message.method,
181
212
  sessionId: 'sessionId' in message ? message.sessionId : undefined,
182
213
  params: 'params' in message ? message.params : undefined,
183
- source
214
+ source,
184
215
  });
185
216
  }
186
217
  const messageStr = JSON.stringify(messageToSend);
@@ -200,14 +231,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
200
231
  }
201
232
  };
202
233
  if (clientId) {
203
- const client = playwrightClients.get(clientId);
234
+ const client = store.getState().playwrightClients.get(clientId);
204
235
  if (client) {
205
236
  safeSend(client);
206
237
  }
207
238
  }
208
239
  else {
209
- const clients = Array.from(playwrightClients.values());
210
- for (const client of clients) {
240
+ const { playwrightClients } = store.getState();
241
+ for (const client of playwrightClients.values()) {
211
242
  if (extensionId && client.extensionId !== extensionId) {
212
243
  continue;
213
244
  }
@@ -227,11 +258,25 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
227
258
  return { method: record.method, sessionId, params: record.params };
228
259
  }
229
260
  async function sendToExtension({ extensionId, method, params, timeout = 30000, }) {
230
- const connection = getExtensionConnection(extensionId);
231
- if (!connection) {
261
+ const conn = getExtensionConnection(extensionId);
262
+ if (!conn) {
263
+ throw new Error('Extension not connected');
264
+ }
265
+ const resolvedExtensionId = conn.id;
266
+ let id = 0;
267
+ store.setState((s) => {
268
+ const ext = s.extensions.get(resolvedExtensionId);
269
+ if (!ext) {
270
+ return s;
271
+ }
272
+ id = ext.messageId + 1;
273
+ const newExtensions = new Map(s.extensions);
274
+ newExtensions.set(resolvedExtensionId, { ...ext, messageId: id });
275
+ return { ...s, extensions: newExtensions };
276
+ });
277
+ if (!id) {
232
278
  throw new Error('Extension not connected');
233
279
  }
234
- const id = ++connection.messageId;
235
280
  const message = { id, method, params };
236
281
  const forwardCdpParams = method === 'forwardCDPCommand' ? getForwardCdpParams(params) : undefined;
237
282
  if (forwardCdpParams) {
@@ -245,13 +290,15 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
245
290
  },
246
291
  });
247
292
  }
248
- connection.ws.send(JSON.stringify(message));
249
293
  return new Promise((resolve, reject) => {
250
294
  const timeoutId = setTimeout(() => {
251
- connection.pendingRequests.delete(id);
295
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
296
+ extensionId: resolvedExtensionId,
297
+ requestId: id,
298
+ }));
252
299
  reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
253
300
  }, timeout);
254
- connection.pendingRequests.set(id, {
301
+ const pendingRequest = {
255
302
  resolve: (result) => {
256
303
  clearTimeout(timeoutId);
257
304
  resolve(result);
@@ -259,21 +306,63 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
259
306
  reject: (error) => {
260
307
  clearTimeout(timeoutId);
261
308
  reject(error);
262
- }
263
- });
309
+ },
310
+ };
311
+ store.setState((s) => relayState.addExtensionPendingRequest(s, {
312
+ extensionId: resolvedExtensionId,
313
+ requestId: id,
314
+ pendingRequest,
315
+ }));
316
+ const latestExt = store.getState().extensions.get(resolvedExtensionId);
317
+ if (!latestExt?.ws) {
318
+ clearTimeout(timeoutId);
319
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
320
+ extensionId: resolvedExtensionId,
321
+ requestId: id,
322
+ }));
323
+ reject(new Error('Extension not connected'));
324
+ return;
325
+ }
326
+ try {
327
+ latestExt.ws.send(JSON.stringify(message));
328
+ }
329
+ catch (error) {
330
+ clearTimeout(timeoutId);
331
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
332
+ extensionId: resolvedExtensionId,
333
+ requestId: id,
334
+ }));
335
+ const sendError = error instanceof Error ? error : new Error(String(error));
336
+ reject(new Error(`Extension send failed: ${method}`, { cause: sendError }));
337
+ }
264
338
  });
265
339
  }
266
340
  const recordingRelays = new Map();
341
+ // Find which extension connection owns a CDP tab session ID (pw-tab-*).
342
+ // Used by recording routes where sessionId identifies the target tab.
343
+ // Delegates to the pure derivation function from relay-state.ts.
344
+ const findExtensionIdByCdpSession = (cdpSessionId) => {
345
+ return relayState.findExtensionIdByCdpSession(store.getState(), cdpSessionId);
346
+ };
347
+ // Resolve recording route session ID (CDP tab session) to extension connection.
348
+ const resolveRecordingRoute = async ({ sessionId, }) => {
349
+ if (!sessionId) {
350
+ return { extensionId: null, sessionId: null };
351
+ }
352
+ const extensionId = findExtensionIdByCdpSession(sessionId);
353
+ return { extensionId, sessionId };
354
+ };
267
355
  const getRecordingRelay = (extensionId) => {
268
- const allowDefault = !extensionId && extensionConnections.size === 1;
269
- const connection = getExtensionConnection(extensionId, { allowFallback: allowDefault });
270
- if (!connection) {
356
+ const allowDefault = !extensionId && store.getState().extensions.size === 1;
357
+ const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault });
358
+ if (!conn) {
271
359
  return null;
272
360
  }
273
- if (!recordingRelays.has(connection.id)) {
274
- recordingRelays.set(connection.id, new RecordingRelay((params) => sendToExtension({ extensionId: connection.id, ...params }), () => extensionConnections.has(connection.id), logger));
361
+ const connId = conn.id;
362
+ if (!recordingRelays.has(connId)) {
363
+ recordingRelays.set(connId, new RecordingRelay((params) => sendToExtension({ extensionId: connId, ...params }), () => store.getState().extensions.has(connId), logger));
275
364
  }
276
- return recordingRelays.get(connection.id) || null;
365
+ return recordingRelays.get(connId) || null;
277
366
  };
278
367
  // Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist.
279
368
  // This allows Playwright to connect and immediately have a page to work with.
@@ -281,33 +370,97 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
281
370
  if (!process.env.PLAYWRITER_AUTO_ENABLE) {
282
371
  return;
283
372
  }
284
- const connection = getExtensionConnection(extensionId);
285
- if (!connection) {
373
+ const conn = getExtensionConnection(extensionId);
374
+ if (!conn) {
286
375
  return;
287
376
  }
288
- if (connection.connectedTargets.size > 0) {
377
+ if (conn.connectedTargets.size > 0) {
289
378
  return;
290
379
  }
291
380
  try {
292
381
  logger?.log(pc.blue('Auto-creating initial tab for Playwright client'));
293
- const result = await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 });
382
+ const result = (await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 }));
294
383
  if (result.success && result.sessionId && result.targetInfo) {
295
- connection.connectedTargets.set(result.sessionId, {
384
+ store.setState((s) => relayState.addTarget(s, {
385
+ extensionId,
296
386
  sessionId: result.sessionId,
297
387
  targetId: result.targetInfo.targetId,
298
388
  targetInfo: result.targetInfo,
299
- frameIds: new Set()
300
- });
301
- logger?.log(pc.blue(`Auto-created tab, now have ${connection.connectedTargets.size} targets, url: ${result.targetInfo.url}`));
389
+ }));
390
+ const updatedTargets = store.getState().extensions.get(extensionId)?.connectedTargets.size || 0;
391
+ logger?.log(pc.blue(`Auto-created tab, now have ${updatedTargets} targets, url: ${result.targetInfo.url}`));
302
392
  }
303
393
  }
304
394
  catch (e) {
305
395
  logger?.error('Failed to auto-create initial tab:', e);
306
396
  }
307
397
  }
398
+ function getPageTargetSessionIds({ extensionId }) {
399
+ const extensionState = store.getState().extensions.get(extensionId);
400
+ if (!extensionState) {
401
+ return [];
402
+ }
403
+ return Array.from(extensionState.connectedTargets.values())
404
+ .filter((target) => {
405
+ return target.targetInfo.type === 'page';
406
+ })
407
+ .map((target) => {
408
+ return target.sessionId;
409
+ });
410
+ }
411
+ function maybeEmitBrowserDownloadCompatEvent({ method, params, extensionId, }) {
412
+ const browserEventMethod = method === 'Page.downloadWillBegin'
413
+ ? 'Browser.downloadWillBegin'
414
+ : method === 'Page.downloadProgress'
415
+ ? 'Browser.downloadProgress'
416
+ : null;
417
+ if (!browserEventMethod) {
418
+ return;
419
+ }
420
+ sendToPlaywright({
421
+ message: {
422
+ method: browserEventMethod,
423
+ params,
424
+ },
425
+ source: 'server',
426
+ extensionId,
427
+ });
428
+ }
429
+ async function applyDownloadBehaviorToTargets({ extensionId, behavior, source, targetSessionIds, }) {
430
+ const pageBehavior = behavior.behavior === 'allowAndName' ? 'allow' : behavior.behavior;
431
+ const pageParams = (() => {
432
+ if (pageBehavior === 'allow' && behavior.downloadPath) {
433
+ return { behavior: pageBehavior, downloadPath: behavior.downloadPath };
434
+ }
435
+ return { behavior: pageBehavior };
436
+ })();
437
+ const sessions = targetSessionIds || getPageTargetSessionIds({ extensionId });
438
+ if (sessions.length === 0) {
439
+ return;
440
+ }
441
+ await Promise.all(sessions.map(async (targetSessionId) => {
442
+ try {
443
+ await sendToExtension({
444
+ extensionId,
445
+ method: 'forwardCDPCommand',
446
+ params: {
447
+ sessionId: targetSessionId,
448
+ method: 'Page.setDownloadBehavior',
449
+ params: pageParams,
450
+ source,
451
+ },
452
+ });
453
+ }
454
+ catch (error) {
455
+ const message = error instanceof Error ? error.message : String(error);
456
+ logger?.log(pc.yellow(`[Server] Failed to apply Page.setDownloadBehavior to ${targetSessionId}: ${message}`));
457
+ }
458
+ }));
459
+ }
308
460
  async function routeCdpCommand({ extensionId, method, params, sessionId, source, }) {
309
- const extension = getExtensionConnection(extensionId);
310
- const connectedTargets = extension?.connectedTargets || new Map();
461
+ const conn = getExtensionConnection(extensionId);
462
+ const connectedTargets = conn?.connectedTargets || new Map();
463
+ const resolvedExtensionId = conn?.id || extensionId;
311
464
  switch (method) {
312
465
  case 'Browser.getVersion': {
313
466
  return {
@@ -315,10 +468,22 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
315
468
  product: 'Chrome/Extension-Bridge',
316
469
  revision: '1.0.0',
317
470
  userAgent: 'CDP-Bridge-Server/1.0.0',
318
- jsVersion: 'V8'
471
+ jsVersion: 'V8',
319
472
  };
320
473
  }
321
474
  case 'Browser.setDownloadBehavior': {
475
+ const downloadBehaviorParams = params;
476
+ if (!downloadBehaviorParams?.behavior) {
477
+ throw new Error('behavior is required for Browser.setDownloadBehavior');
478
+ }
479
+ if (resolvedExtensionId) {
480
+ extensionDownloadBehavior.set(resolvedExtensionId, downloadBehaviorParams);
481
+ await applyDownloadBehaviorToTargets({
482
+ extensionId: resolvedExtensionId,
483
+ behavior: downloadBehaviorParams,
484
+ source,
485
+ });
486
+ }
322
487
  return {};
323
488
  }
324
489
  // Target.setAutoAttach is a CDP command Playwright sends on first connection.
@@ -328,15 +493,15 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
328
493
  if (sessionId) {
329
494
  break;
330
495
  }
331
- if (extension) {
332
- await maybeAutoCreateInitialTab(extension.id);
496
+ if (conn) {
497
+ await maybeAutoCreateInitialTab(conn.id);
333
498
  }
334
499
  // Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
335
500
  // Playwright relies on these (with parentFrameId) when reconnecting over CDP.
336
501
  await sendToExtension({
337
- extensionId: extension?.id || extensionId,
502
+ extensionId: resolvedExtensionId,
338
503
  method: 'forwardCDPCommand',
339
- params: { method, params, source }
504
+ params: { method, params, source },
340
505
  });
341
506
  return {};
342
507
  }
@@ -344,19 +509,20 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
344
509
  return {};
345
510
  }
346
511
  case 'Target.attachToTarget': {
347
- const targetId = params?.targetId;
348
- if (!targetId) {
512
+ const attachParams = params;
513
+ if (!attachParams?.targetId) {
349
514
  throw new Error('targetId is required for Target.attachToTarget');
350
515
  }
351
516
  for (const target of connectedTargets.values()) {
352
- if (target.targetId === targetId) {
517
+ if (target.targetId === attachParams.targetId) {
353
518
  return { sessionId: target.sessionId };
354
519
  }
355
520
  }
356
- throw new Error(`Target ${targetId} not found in connected targets`);
521
+ throw new Error(`Target ${attachParams.targetId} not found in connected targets`);
357
522
  }
358
523
  case 'Target.getTargetInfo': {
359
- const targetId = params?.targetId;
524
+ const infoReqParams = params;
525
+ const targetId = infoReqParams?.targetId;
360
526
  if (targetId) {
361
527
  for (const target of connectedTargets.values()) {
362
528
  if (target.targetId === targetId) {
@@ -379,30 +545,30 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
379
545
  .filter((t) => !isRestrictedTarget(t.targetInfo))
380
546
  .map((t) => ({
381
547
  ...t.targetInfo,
382
- attached: true
383
- }))
548
+ attached: true,
549
+ })),
384
550
  };
385
551
  }
386
552
  case 'Target.createTarget': {
387
553
  return await sendToExtension({
388
- extensionId: extension?.id || extensionId,
554
+ extensionId: resolvedExtensionId,
389
555
  method: 'forwardCDPCommand',
390
- params: { method, params, source }
556
+ params: { method, params, source },
391
557
  });
392
558
  }
393
559
  case 'Target.closeTarget': {
394
560
  return await sendToExtension({
395
- extensionId: extension?.id || extensionId,
561
+ extensionId: resolvedExtensionId,
396
562
  method: 'forwardCDPCommand',
397
- params: { method, params, source }
563
+ params: { method, params, source },
398
564
  });
399
565
  }
400
566
  // Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects
401
567
  case 'ghost-browser': {
402
568
  return await sendToExtension({
403
- extensionId: extension?.id || extensionId,
569
+ extensionId: resolvedExtensionId,
404
570
  method: 'ghost-browser',
405
- params
571
+ params,
406
572
  });
407
573
  }
408
574
  case 'Runtime.enable': {
@@ -428,18 +594,18 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
428
594
  emitter.on('cdp:event', handler);
429
595
  });
430
596
  const result = await sendToExtension({
431
- extensionId: extension?.id || extensionId,
597
+ extensionId: resolvedExtensionId,
432
598
  method: 'forwardCDPCommand',
433
- params: { sessionId, method, params, source }
599
+ params: { sessionId, method, params, source },
434
600
  });
435
601
  await contextCreatedPromise;
436
602
  return result;
437
603
  }
438
604
  }
439
605
  return await sendToExtension({
440
- extensionId: extension?.id || extensionId,
606
+ extensionId: resolvedExtensionId,
441
607
  method: 'forwardCDPCommand',
442
- params: { sessionId, method, params, source }
608
+ params: { sessionId, method, params, source },
443
609
  });
444
610
  }
445
611
  const app = new Hono();
@@ -472,7 +638,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
472
638
  });
473
639
  app.get('/extension/status', (c) => {
474
640
  const defaultExtension = getExtensionConnection(null, { allowFallback: true });
475
- const connected = extensionConnections.size > 0;
641
+ const connected = store.getState().extensions.size > 0;
476
642
  const activeTargets = defaultExtension?.connectedTargets.size || 0;
477
643
  const info = defaultExtension?.info;
478
644
  return c.json({
@@ -484,14 +650,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
484
650
  });
485
651
  });
486
652
  app.get('/extensions/status', (c) => {
487
- const extensions = Array.from(extensionConnections.values()).map((extension) => {
653
+ const extensions = Array.from(store.getState().extensions.values()).map((ext) => {
488
654
  return {
489
- extensionId: extension.id,
490
- stableKey: extension.stableKey,
491
- browser: extension.info.browser || null,
492
- profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
493
- activeTargets: extension.connectedTargets.size,
494
- playwriterVersion: extension.info?.version || null,
655
+ extensionId: ext.id,
656
+ stableKey: ext.stableKey,
657
+ browser: ext.info.browser || null,
658
+ profile: ext.info ? { email: ext.info.email || '', id: ext.info.id || '' } : null,
659
+ activeTargets: ext.connectedTargets.size,
660
+ playwriterVersion: ext.info?.version || null,
495
661
  };
496
662
  });
497
663
  return c.json({ extensions });
@@ -502,68 +668,68 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
502
668
  app
503
669
  .on(['GET', 'PUT'], '/json/version', (c) => {
504
670
  return c.json({
505
- 'Browser': `Playwriter/${VERSION}`,
671
+ Browser: `Playwriter/${VERSION}`,
506
672
  'Protocol-Version': '1.3',
507
- 'webSocketDebuggerUrl': getCdpWsUrl(c)
673
+ webSocketDebuggerUrl: getCdpWsUrl(c),
508
674
  });
509
675
  })
510
676
  .on(['GET', 'PUT'], '/json/version/', (c) => {
511
677
  return c.json({
512
- 'Browser': `Playwriter/${VERSION}`,
678
+ Browser: `Playwriter/${VERSION}`,
513
679
  'Protocol-Version': '1.3',
514
- 'webSocketDebuggerUrl': getCdpWsUrl(c)
680
+ webSocketDebuggerUrl: getCdpWsUrl(c),
515
681
  });
516
682
  })
517
683
  .on(['GET', 'PUT'], '/json/list', (c) => {
518
684
  const wsUrl = getCdpWsUrl(c);
519
685
  const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
520
- return c.json(Array.from(defaultTargets.values()).map(t => ({
686
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
521
687
  id: t.targetId,
522
688
  type: t.targetInfo.type,
523
689
  title: t.targetInfo.title,
524
690
  description: t.targetInfo.title,
525
691
  url: t.targetInfo.url,
526
692
  webSocketDebuggerUrl: wsUrl,
527
- devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
693
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
528
694
  })));
529
695
  })
530
696
  .on(['GET', 'PUT'], '/json/list/', (c) => {
531
697
  const wsUrl = getCdpWsUrl(c);
532
698
  const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
533
- return c.json(Array.from(defaultTargets.values()).map(t => ({
699
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
534
700
  id: t.targetId,
535
701
  type: t.targetInfo.type,
536
702
  title: t.targetInfo.title,
537
703
  description: t.targetInfo.title,
538
704
  url: t.targetInfo.url,
539
705
  webSocketDebuggerUrl: wsUrl,
540
- devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
706
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
541
707
  })));
542
708
  })
543
709
  .on(['GET', 'PUT'], '/json', (c) => {
544
710
  const wsUrl = getCdpWsUrl(c);
545
711
  const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
546
- return c.json(Array.from(defaultTargets.values()).map(t => ({
712
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
547
713
  id: t.targetId,
548
714
  type: t.targetInfo.type,
549
715
  title: t.targetInfo.title,
550
716
  description: t.targetInfo.title,
551
717
  url: t.targetInfo.url,
552
718
  webSocketDebuggerUrl: wsUrl,
553
- devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
719
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
554
720
  })));
555
721
  })
556
722
  .on(['GET', 'PUT'], '/json/', (c) => {
557
723
  const wsUrl = getCdpWsUrl(c);
558
724
  const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
559
- return c.json(Array.from(defaultTargets.values()).map(t => ({
725
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
560
726
  id: t.targetId,
561
727
  type: t.targetInfo.type,
562
728
  title: t.targetInfo.title,
563
729
  description: t.targetInfo.title,
564
730
  url: t.targetInfo.url,
565
731
  webSocketDebuggerUrl: wsUrl,
566
- devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
732
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
567
733
  })));
568
734
  });
569
735
  app.post('/mcp-log', async (c) => {
@@ -610,13 +776,19 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
610
776
  const clientId = c.req.param('clientId') || 'default';
611
777
  const url = new URL(c.req.url, 'http://localhost');
612
778
  const requestedExtensionId = url.searchParams.get('extensionId');
613
- const resolvedExtension = getExtensionConnection(requestedExtensionId);
614
- const allowDefault = !requestedExtensionId && extensionConnections.size === 1;
615
- const defaultExtension = allowDefault ? getExtensionConnection(null, { allowFallback: true }) : null;
616
- const clientExtensionId = resolvedExtension?.id || defaultExtension?.id || null;
779
+ // When extensionId is explicit, resolve directly. Otherwise use fallback which
780
+ // handles single-extension and uniquely-active-extension cases (#52).
781
+ const resolvedExtension = requestedExtensionId
782
+ ? getExtensionConnection(requestedExtensionId)
783
+ : getExtensionConnection(null, { allowFallback: true });
784
+ const clientExtensionId = resolvedExtension?.id || null;
785
+ const getBoundExtensionIdForClient = () => {
786
+ const client = store.getState().playwrightClients.get(clientId);
787
+ return client?.extensionId || null;
788
+ };
617
789
  return {
618
790
  async onOpen(_event, ws) {
619
- if (playwrightClients.has(clientId)) {
791
+ if (store.getState().playwrightClients.has(clientId)) {
620
792
  logger?.log(pc.yellow(`Rejecting duplicate Playwright clientId: ${clientId}`));
621
793
  ws.close(4004, 'Duplicate Playwright clientId');
622
794
  return;
@@ -630,10 +802,12 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
630
802
  return;
631
803
  }
632
804
  // Add client first so it can receive Target.attachedToTarget events
633
- playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId });
805
+ store.setState((s) => {
806
+ return relayState.addPlaywrightClient(s, { id: clientId, extensionId: clientExtensionId, ws });
807
+ });
634
808
  const extensionConnection = getExtensionConnection(clientExtensionId);
635
809
  const targetCount = extensionConnection?.connectedTargets.size || 0;
636
- logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`));
810
+ logger?.log(pc.green(`Playwright client connected: ${clientId} (${store.getState().playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`));
637
811
  },
638
812
  async onMessage(event, ws) {
639
813
  let message;
@@ -655,25 +829,35 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
655
829
  clientId,
656
830
  method,
657
831
  sessionId,
658
- id
832
+ id,
659
833
  });
660
834
  emitter.emit('cdp:command', { clientId, command: message });
661
- const extensionConnection = getExtensionConnection(clientExtensionId);
662
- if (!extensionConnection) {
835
+ const boundExtensionId = getBoundExtensionIdForClient();
836
+ const extensionConn = getExtensionConnection(boundExtensionId);
837
+ if (!extensionConn) {
663
838
  sendToPlaywright({
664
839
  message: {
665
840
  id,
666
841
  sessionId,
667
- error: { message: 'Extension not connected' }
842
+ error: { message: 'Extension not connected' },
668
843
  },
669
- clientId
844
+ clientId,
670
845
  });
671
846
  return;
672
847
  }
673
848
  try {
674
- const result = await routeCdpCommand({ extensionId: extensionConnection.id, method, params, sessionId, source });
849
+ const result = await routeCdpCommand({
850
+ extensionId: extensionConn.id,
851
+ method,
852
+ params,
853
+ sessionId,
854
+ source,
855
+ });
675
856
  if (method === 'Target.setAutoAttach' && !sessionId) {
676
- for (const target of extensionConnection.connectedTargets.values()) {
857
+ // Re-read state after async routeCdpCommand — targets may have changed
858
+ const freshExt = store.getState().extensions.get(extensionConn.id);
859
+ const freshTargets = freshExt?.connectedTargets || new Map();
860
+ for (const target of freshTargets.values()) {
677
861
  // Skip restricted targets (extensions, chrome:// URLs, non-page types)
678
862
  if (isRestrictedTarget(target.targetInfo)) {
679
863
  continue;
@@ -684,10 +868,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
684
868
  sessionId: target.sessionId,
685
869
  targetInfo: {
686
870
  ...target.targetInfo,
687
- attached: true
871
+ attached: true,
688
872
  },
689
- waitingForDebugger: false
690
- }
873
+ waitingForDebugger: false,
874
+ },
691
875
  };
692
876
  if (!target.targetInfo.url) {
693
877
  logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), JSON.stringify(attachedPayload));
@@ -696,12 +880,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
696
880
  sendToPlaywright({
697
881
  message: attachedPayload,
698
882
  clientId,
699
- source: 'server'
883
+ source: 'server',
700
884
  });
701
885
  }
702
886
  }
703
887
  if (method === 'Target.setDiscoverTargets' && params?.discover) {
704
- for (const target of extensionConnection.connectedTargets.values()) {
888
+ const freshExt2 = store.getState().extensions.get(extensionConn.id);
889
+ const freshTargets2 = freshExt2?.connectedTargets || new Map();
890
+ for (const target of freshTargets2.values()) {
705
891
  // Skip restricted targets (extensions, chrome:// URLs, non-page types)
706
892
  if (isRestrictedTarget(target.targetInfo)) {
707
893
  continue;
@@ -711,9 +897,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
711
897
  params: {
712
898
  targetInfo: {
713
899
  ...target.targetInfo,
714
- attached: true
715
- }
716
- }
900
+ attached: true,
901
+ },
902
+ },
717
903
  };
718
904
  if (!target.targetInfo.url) {
719
905
  logger?.error(pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), JSON.stringify(targetCreatedPayload));
@@ -722,34 +908,41 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
722
908
  sendToPlaywright({
723
909
  message: targetCreatedPayload,
724
910
  clientId,
725
- source: 'server'
911
+ source: 'server',
726
912
  });
727
913
  }
728
914
  }
729
- if (method === 'Target.attachToTarget' && result?.sessionId) {
730
- const targetId = params?.targetId;
731
- const target = Array.from(extensionConnection.connectedTargets.values()).find(t => t.targetId === targetId);
732
- if (target) {
733
- const attachedPayload = {
734
- method: 'Target.attachedToTarget',
735
- params: {
736
- sessionId: result.sessionId,
737
- targetInfo: {
738
- ...target.targetInfo,
739
- attached: true
915
+ if (method === 'Target.attachToTarget') {
916
+ const attachResponse = result;
917
+ const attachRequestParams = params;
918
+ if (attachResponse?.sessionId) {
919
+ const freshExt3 = store.getState().extensions.get(extensionConn.id);
920
+ const freshTargets3 = freshExt3?.connectedTargets || new Map();
921
+ const target = Array.from(freshTargets3.values()).find((t) => {
922
+ return t.targetId === attachRequestParams?.targetId;
923
+ });
924
+ if (target) {
925
+ const attachedPayload = {
926
+ method: 'Target.attachedToTarget',
927
+ params: {
928
+ sessionId: attachResponse.sessionId,
929
+ targetInfo: {
930
+ ...target.targetInfo,
931
+ attached: true,
932
+ },
933
+ waitingForDebugger: false,
740
934
  },
741
- waitingForDebugger: false
935
+ };
936
+ if (!target.targetInfo.url) {
937
+ logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload));
742
938
  }
743
- };
744
- if (!target.targetInfo.url) {
745
- logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload));
939
+ logger?.log(pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload));
940
+ sendToPlaywright({
941
+ message: attachedPayload,
942
+ clientId,
943
+ source: 'server',
944
+ });
746
945
  }
747
- logger?.log(pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload));
748
- sendToPlaywright({
749
- message: attachedPayload,
750
- clientId,
751
- source: 'server'
752
- });
753
946
  }
754
947
  }
755
948
  const response = { id, sessionId, result };
@@ -761,19 +954,19 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
761
954
  const errorResponse = {
762
955
  id,
763
956
  sessionId,
764
- error: { message: e.message }
957
+ error: { message: e.message },
765
958
  };
766
959
  sendToPlaywright({ message: errorResponse, clientId });
767
960
  emitter.emit('cdp:response', { clientId, response: errorResponse, command: message });
768
961
  }
769
962
  },
770
963
  onClose() {
771
- playwrightClients.delete(clientId);
772
- logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${playwrightClients.size} remaining)`));
964
+ store.setState((s) => relayState.removePlaywrightClient(s, { clientId }));
965
+ logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${store.getState().playwrightClients.size} remaining)`));
773
966
  },
774
967
  onError(event) {
775
968
  logger?.error(`Playwright WebSocket error [${clientId}]:`, event);
776
- }
969
+ },
777
970
  };
778
971
  }));
779
972
  const getExtensionInfoFromRequest = (c) => {
@@ -819,32 +1012,25 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
819
1012
  return {
820
1013
  onOpen(_event, ws) {
821
1014
  const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId);
822
- const existingId = extensionKeyIndex.get(stableKey);
823
- if (existingId && existingId !== connectionId) {
824
- logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingId} -> ${connectionId})`));
825
- const existingConnection = extensionConnections.get(existingId);
826
- if (existingConnection) {
827
- existingConnection.ws.close(4001, 'Extension Replaced');
1015
+ // Check for existing connection with same stableKey and close it
1016
+ const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey);
1017
+ if (existingExt && existingExt.id !== connectionId) {
1018
+ logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingExt.id} -> ${connectionId})`));
1019
+ if (existingExt.ws) {
1020
+ existingExt.ws.close(4001, 'Extension Replaced');
828
1021
  }
829
1022
  }
830
- const connection = {
831
- id: connectionId,
832
- ws,
833
- info: incomingExtensionInfo,
834
- stableKey,
835
- connectedTargets: new Map(),
836
- pendingRequests: new Map(),
837
- messageId: 0,
838
- pingInterval: null,
839
- };
840
- extensionConnections.set(connectionId, connection);
841
- extensionKeyIndex.set(stableKey, connectionId);
1023
+ // State transition: add extension with ws handle included.
1024
+ // Existing same-stableKey entry stays until old socket onClose.
1025
+ store.setState((s) => {
1026
+ return relayState.addExtension(s, { id: connectionId, info: incomingExtensionInfo, stableKey, ws });
1027
+ });
842
1028
  startExtensionPing(connectionId);
843
1029
  logger?.log(`Extension connected (${connectionId})`);
844
1030
  },
845
1031
  async onMessage(event, ws) {
846
- const connection = extensionConnections.get(connectionId);
847
- if (!connection) {
1032
+ const ext = store.getState().extensions.get(connectionId);
1033
+ if (!ext) {
848
1034
  ws.close(1000, 'Extension not registered');
849
1035
  return;
850
1036
  }
@@ -866,12 +1052,29 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
866
1052
  return;
867
1053
  }
868
1054
  if (message.id !== undefined) {
869
- const pending = connection.pendingRequests.get(message.id);
1055
+ const pending = (() => {
1056
+ let pendingRequest = null;
1057
+ store.setState((s) => {
1058
+ const extensionEntry = s.extensions.get(connectionId);
1059
+ if (!extensionEntry) {
1060
+ return s;
1061
+ }
1062
+ const nextPendingRequest = extensionEntry.pendingRequests.get(message.id);
1063
+ if (!nextPendingRequest) {
1064
+ return s;
1065
+ }
1066
+ pendingRequest = nextPendingRequest;
1067
+ return relayState.removeExtensionPendingRequest(s, {
1068
+ extensionId: connectionId,
1069
+ requestId: message.id,
1070
+ });
1071
+ });
1072
+ return pendingRequest;
1073
+ })();
870
1074
  if (!pending) {
871
1075
  logger?.log('Unexpected response with id:', message.id);
872
1076
  return;
873
1077
  }
874
- connection.pendingRequests.delete(message.id);
875
1078
  if (message.error) {
876
1079
  pending.reject(new Error(message.error));
877
1080
  }
@@ -916,16 +1119,19 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
916
1119
  direction: 'from-extension',
917
1120
  method,
918
1121
  sessionId,
919
- params
1122
+ params,
920
1123
  });
921
1124
  const cdpEvent = { method, sessionId, params };
922
1125
  emitter.emit('cdp:event', { event: cdpEvent, sessionId });
1126
+ maybeEmitBrowserDownloadCompatEvent({ method, params, extensionId: connectionId });
923
1127
  if (method === 'Target.attachedToTarget') {
924
1128
  const targetParams = params;
925
1129
  const incomingSessionId = sessionId;
926
1130
  const iframeParentFrameId = targetParams.targetInfo.parentFrameId;
927
- const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId
928
- ? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId
1131
+ // Read current extension state for iframe parent lookup
1132
+ const currentExtState = store.getState().extensions.get(connectionId);
1133
+ const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId && currentExtState
1134
+ ? getPageTargetForFrameId({ extensionState: currentExtState, frameId: iframeParentFrameId })?.sessionId
929
1135
  : undefined;
930
1136
  // Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.)
931
1137
  if (isRestrictedTarget(targetParams.targetInfo)) {
@@ -940,8 +1146,8 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
940
1146
  source: 'server',
941
1147
  },
942
1148
  }).catch((error) => {
943
- const message = error instanceof Error ? error.message : String(error);
944
- logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), message);
1149
+ const msg = error instanceof Error ? error.message : String(error);
1150
+ logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), msg);
945
1151
  });
946
1152
  }
947
1153
  logger?.log(pc.gray(`[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`));
@@ -952,15 +1158,22 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
952
1158
  }
953
1159
  logger?.log(pc.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId }));
954
1160
  // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
955
- const alreadyConnected = connection.connectedTargets.has(targetParams.sessionId);
956
- const existingTarget = connection.connectedTargets.get(targetParams.sessionId);
957
- // Always update our local state with latest target info
958
- connection.connectedTargets.set(targetParams.sessionId, {
1161
+ const alreadyConnected = currentExtState?.connectedTargets.has(targetParams.sessionId) ?? false;
1162
+ // State transition: add/update target
1163
+ store.setState((s) => relayState.addTarget(s, {
1164
+ extensionId: connectionId,
959
1165
  sessionId: targetParams.sessionId,
960
1166
  targetId: targetParams.targetInfo.targetId,
961
1167
  targetInfo: targetParams.targetInfo,
962
- frameIds: existingTarget?.frameIds ?? new Set()
963
- });
1168
+ }));
1169
+ const cachedDownloadBehavior = extensionDownloadBehavior.get(connectionId);
1170
+ if (cachedDownloadBehavior && targetParams.targetInfo.type === 'page') {
1171
+ void applyDownloadBehaviorToTargets({
1172
+ extensionId: connectionId,
1173
+ behavior: cachedDownloadBehavior,
1174
+ targetSessionIds: [targetParams.sessionId],
1175
+ });
1176
+ }
964
1177
  // Only forward to Playwright if this is a new target to avoid duplicates
965
1178
  if (!alreadyConnected) {
966
1179
  sendToPlaywright({
@@ -973,7 +1186,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
973
1186
  // session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations.
974
1187
  sessionId: iframeOwnerSessionId ?? incomingSessionId,
975
1188
  method: 'Target.attachedToTarget',
976
- params: targetParams
1189
+ params: targetParams,
977
1190
  },
978
1191
  source: 'extension',
979
1192
  extensionId: connectionId,
@@ -982,11 +1195,11 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
982
1195
  }
983
1196
  else if (method === 'Target.detachedFromTarget') {
984
1197
  const detachParams = params;
985
- connection.connectedTargets.delete(detachParams.sessionId);
1198
+ store.setState((s) => relayState.removeTarget(s, { extensionId: connectionId, sessionId: detachParams.sessionId }));
986
1199
  sendToPlaywright({
987
1200
  message: {
988
1201
  method: 'Target.detachedFromTarget',
989
- params: detachParams
1202
+ params: detachParams,
990
1203
  },
991
1204
  source: 'extension',
992
1205
  extensionId: connectionId,
@@ -994,17 +1207,12 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
994
1207
  }
995
1208
  else if (method === 'Target.targetCrashed') {
996
1209
  const crashParams = params;
997
- for (const [sid, target] of connection.connectedTargets.entries()) {
998
- if (target.targetId === crashParams.targetId) {
999
- connection.connectedTargets.delete(sid);
1000
- logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId);
1001
- break;
1002
- }
1003
- }
1210
+ store.setState((s) => relayState.removeTargetByCrash(s, { extensionId: connectionId, targetId: crashParams.targetId }));
1211
+ logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId);
1004
1212
  sendToPlaywright({
1005
1213
  message: {
1006
1214
  method: 'Target.targetCrashed',
1007
- params: crashParams
1215
+ params: crashParams,
1008
1216
  },
1009
1217
  source: 'extension',
1010
1218
  extensionId: connectionId,
@@ -1012,16 +1220,11 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1012
1220
  }
1013
1221
  else if (method === 'Target.targetInfoChanged') {
1014
1222
  const infoParams = params;
1015
- for (const target of connection.connectedTargets.values()) {
1016
- if (target.targetId === infoParams.targetInfo.targetId) {
1017
- target.targetInfo = infoParams.targetInfo;
1018
- break;
1019
- }
1020
- }
1223
+ store.setState((s) => relayState.updateTargetInfo(s, { extensionId: connectionId, targetInfo: infoParams.targetInfo }));
1021
1224
  sendToPlaywright({
1022
1225
  message: {
1023
1226
  method: 'Target.targetInfoChanged',
1024
- params: infoParams
1227
+ params: infoParams,
1025
1228
  },
1026
1229
  source: 'extension',
1027
1230
  extensionId: connectionId,
@@ -1030,16 +1233,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1030
1233
  else if (method === 'Page.frameAttached') {
1031
1234
  const frameParams = params;
1032
1235
  if (sessionId) {
1033
- const target = connection.connectedTargets.get(sessionId);
1034
- if (target) {
1035
- target.frameIds.add(frameParams.frameId);
1036
- }
1236
+ store.setState((s) => relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frameId }));
1037
1237
  }
1038
1238
  sendToPlaywright({
1039
1239
  message: {
1040
1240
  sessionId,
1041
1241
  method,
1042
- params
1242
+ params,
1043
1243
  },
1044
1244
  source: 'extension',
1045
1245
  extensionId: connectionId,
@@ -1047,15 +1247,12 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1047
1247
  }
1048
1248
  else if (method === 'Page.frameDetached') {
1049
1249
  const frameParams = params;
1050
- const ownerTarget = getPageTargetForFrameId({ connection, frameId: frameParams.frameId });
1051
- if (ownerTarget) {
1052
- ownerTarget.frameIds.delete(frameParams.frameId);
1053
- }
1250
+ store.setState((s) => relayState.removeFrameId(s, { extensionId: connectionId, frameId: frameParams.frameId }));
1054
1251
  sendToPlaywright({
1055
1252
  message: {
1056
1253
  sessionId,
1057
1254
  method,
1058
- params
1255
+ params,
1059
1256
  },
1060
1257
  source: 'extension',
1061
1258
  extensionId: connectionId,
@@ -1064,27 +1261,22 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1064
1261
  else if (method === 'Page.frameNavigated') {
1065
1262
  const frameParams = params;
1066
1263
  if (sessionId) {
1067
- const target = connection.connectedTargets.get(sessionId);
1068
- if (target) {
1069
- target.frameIds.add(frameParams.frame.id);
1070
- }
1264
+ store.setState((s) => relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frame.id }));
1071
1265
  }
1072
1266
  if (!frameParams.frame.parentId && sessionId) {
1073
- const target = connection.connectedTargets.get(sessionId);
1074
- if (target) {
1075
- target.targetInfo = {
1076
- ...target.targetInfo,
1077
- url: frameParams.frame.url,
1078
- title: frameParams.frame.name || target.targetInfo.title,
1079
- };
1080
- logger?.log(pc.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url);
1081
- }
1267
+ store.setState((s) => relayState.updateTargetUrl(s, {
1268
+ extensionId: connectionId,
1269
+ sessionId,
1270
+ url: frameParams.frame.url,
1271
+ title: frameParams.frame.name || undefined,
1272
+ }));
1273
+ logger?.log(pc.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url);
1082
1274
  }
1083
1275
  sendToPlaywright({
1084
1276
  message: {
1085
1277
  sessionId,
1086
1278
  method,
1087
- params
1279
+ params,
1088
1280
  },
1089
1281
  source: 'extension',
1090
1282
  extensionId: connectionId,
@@ -1093,20 +1285,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1093
1285
  else if (method === 'Page.navigatedWithinDocument') {
1094
1286
  const navParams = params;
1095
1287
  if (sessionId) {
1096
- const target = connection.connectedTargets.get(sessionId);
1097
- if (target) {
1098
- target.targetInfo = {
1099
- ...target.targetInfo,
1100
- url: navParams.url,
1101
- };
1102
- logger?.log(pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url);
1103
- }
1288
+ store.setState((s) => relayState.updateTargetUrl(s, { extensionId: connectionId, sessionId, url: navParams.url }));
1289
+ logger?.log(pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url);
1104
1290
  }
1105
1291
  sendToPlaywright({
1106
1292
  message: {
1107
1293
  sessionId,
1108
1294
  method,
1109
- params
1295
+ params,
1110
1296
  },
1111
1297
  source: 'extension',
1112
1298
  extensionId: connectionId,
@@ -1117,7 +1303,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1117
1303
  message: {
1118
1304
  sessionId,
1119
1305
  method,
1120
- params
1306
+ params,
1121
1307
  },
1122
1308
  source: 'extension',
1123
1309
  extensionId: connectionId,
@@ -1125,10 +1311,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1125
1311
  }
1126
1312
  }
1127
1313
  },
1128
- onClose(event, ws) {
1314
+ onClose(event) {
1129
1315
  logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`);
1130
- stopExtensionPing(connectionId);
1131
- // Cancel any active recordings BEFORE removing connection (cancelRecording checks isExtensionConnected)
1316
+ // Cancel recordings BEFORE removing extension state (cancelRecording checks isExtensionConnected)
1132
1317
  const recordingRelay = recordingRelays.get(connectionId);
1133
1318
  if (recordingRelay) {
1134
1319
  recordingRelay.cancelRecording({}).catch(() => {
@@ -1136,32 +1321,50 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1136
1321
  });
1137
1322
  }
1138
1323
  recordingRelays.delete(connectionId);
1139
- const connection = extensionConnections.get(connectionId);
1140
- if (connection) {
1141
- for (const pending of connection.pendingRequests.values()) {
1324
+ // Reject all pending I/O requests (state cleanup happens in removeExtension below)
1325
+ const closingExt = store.getState().extensions.get(connectionId);
1326
+ if (closingExt) {
1327
+ stopExtensionPing(connectionId);
1328
+ for (const pending of closingExt.pendingRequests.values()) {
1142
1329
  pending.reject(new Error('Extension connection closed'));
1143
1330
  }
1144
- connection.pendingRequests.clear();
1145
- connection.connectedTargets.clear();
1146
1331
  }
1147
- if (connection) {
1148
- const mappedId = extensionKeyIndex.get(connection.stableKey);
1149
- if (mappedId === connectionId) {
1150
- extensionKeyIndex.delete(connection.stableKey);
1151
- }
1332
+ const currentRelayState = store.getState();
1333
+ const closingExtension = currentRelayState.extensions.get(connectionId);
1334
+ const successorCandidates = closingExtension
1335
+ ? Array.from(currentRelayState.extensions.values())
1336
+ .reverse()
1337
+ .filter((ext) => {
1338
+ return ext.id !== connectionId && ext.stableKey === closingExtension.stableKey && Boolean(ext.ws);
1339
+ })
1340
+ : [];
1341
+ const successorExtension = closingExtension
1342
+ ? successorCandidates[0]
1343
+ : undefined;
1344
+ if (successorExtension) {
1345
+ logger?.log(pc.yellow(`Rebinding clients from ${connectionId} to ${successorExtension.id} (stableKey: ${successorExtension.stableKey})`));
1346
+ store.setState((s) => {
1347
+ return relayState.rebindClientsToExtension(s, {
1348
+ fromExtensionId: connectionId,
1349
+ toExtensionId: successorExtension.id,
1350
+ });
1351
+ });
1152
1352
  }
1153
- extensionConnections.delete(connectionId);
1154
- for (const [clientId, client] of playwrightClients.entries()) {
1155
- if (client.extensionId !== connectionId) {
1156
- continue;
1353
+ // Close playwright clients bound to this extension when no successor exists.
1354
+ if (!successorExtension) {
1355
+ const { playwrightClients } = store.getState();
1356
+ for (const client of playwrightClients.values()) {
1357
+ if (client.extensionId === connectionId) {
1358
+ client.ws.close(1000, 'Extension disconnected');
1359
+ }
1157
1360
  }
1158
- client.ws.close(1000, 'Extension disconnected');
1159
- playwrightClients.delete(clientId);
1160
1361
  }
1362
+ // State transition: remove extension + its bound clients atomically
1363
+ store.setState((s) => relayState.removeExtension(s, { extensionId: connectionId }));
1161
1364
  },
1162
1365
  onError(event) {
1163
1366
  logger?.error('Extension WebSocket error:', event);
1164
- }
1367
+ },
1165
1368
  };
1166
1369
  }));
1167
1370
  // ============================================================================
@@ -1233,7 +1436,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1233
1436
  app.use('/recording/*', privilegedRouteMiddleware);
1234
1437
  app.post('/cli/execute', async (c) => {
1235
1438
  try {
1236
- const body = await c.req.json();
1439
+ const body = (await c.req.json());
1237
1440
  const sessionId = normalizeSessionId(body.sessionId);
1238
1441
  const { code, timeout = 10000 } = body;
1239
1442
  if (!sessionId || !code) {
@@ -1254,7 +1457,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1254
1457
  });
1255
1458
  app.post('/cli/reset', async (c) => {
1256
1459
  try {
1257
- const body = await c.req.json();
1460
+ const body = (await c.req.json());
1258
1461
  const sessionId = normalizeSessionId(body.sessionId);
1259
1462
  if (!sessionId) {
1260
1463
  return c.json({ error: 'sessionId is required' }, 400);
@@ -1284,13 +1487,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1284
1487
  return c.json({ next: nextSessionNumber });
1285
1488
  });
1286
1489
  app.post('/cli/session/new', async (c) => {
1287
- const body = await c.req.json().catch(() => ({}));
1490
+ const body = (await c.req.json().catch(() => ({})));
1288
1491
  const sessionId = String(nextSessionNumber++);
1289
1492
  const extensionId = body.extensionId || null;
1290
1493
  const cwd = body.cwd;
1291
- const allowDefault = !extensionId && extensionConnections.size === 1;
1292
- const extension = getExtensionConnection(extensionId, { allowFallback: allowDefault });
1293
- if (!extension) {
1494
+ const allowDefault = !extensionId && store.getState().extensions.size === 1;
1495
+ const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault });
1496
+ if (!conn) {
1294
1497
  const error = extensionId
1295
1498
  ? `Extension not connected: ${extensionId}`
1296
1499
  : 'Multiple extensions connected. Specify extensionId.';
@@ -1301,9 +1504,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1301
1504
  sessionId,
1302
1505
  cwd,
1303
1506
  sessionMetadata: {
1304
- extensionId: extension.stableKey,
1305
- browser: extension.info.browser || null,
1306
- profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
1507
+ extensionId: conn.stableKey,
1508
+ browser: conn.info.browser || null,
1509
+ profile: conn.info ? { email: conn.info.email || '', id: conn.info.id || '' } : null,
1307
1510
  },
1308
1511
  });
1309
1512
  const metadata = executor.getSessionMetadata();
@@ -1331,7 +1534,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1331
1534
  });
1332
1535
  app.post('/cli/session/delete', async (c) => {
1333
1536
  try {
1334
- const body = await c.req.json();
1537
+ const body = (await c.req.json());
1335
1538
  const sessionId = normalizeSessionId(body.sessionId);
1336
1539
  if (!sessionId) {
1337
1540
  return c.json({ error: 'sessionId is required' }, 400);
@@ -1352,70 +1555,54 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1352
1555
  // Recording Endpoints - For screen recording via chrome.tabCapture
1353
1556
  // ============================================================================
1354
1557
  app.post('/recording/start', async (c) => {
1355
- const body = await c.req.json();
1558
+ const body = (await c.req.json());
1356
1559
  const sessionId = normalizeSessionId(body.sessionId);
1357
1560
  const { sessionId: _sessionId, ...recordingOptions } = body;
1358
- const manager = await getExecutorManager();
1359
- const executor = sessionId ? manager.getSession(sessionId) : null;
1360
- if (sessionId && !executor) {
1361
- return c.json({ success: false, error: `Session ${sessionId} not found` }, 404);
1362
- }
1363
- const extensionId = executor?.getSessionMetadata().extensionId || null;
1561
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
1364
1562
  const relay = getRecordingRelay(extensionId);
1365
1563
  if (!relay) {
1366
1564
  return c.json({ success: false, error: 'Extension not connected' }, 500);
1367
1565
  }
1368
- const recordingParams = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions);
1566
+ const recordingParams = (resolvedSessionId
1567
+ ? { ...recordingOptions, sessionId: resolvedSessionId }
1568
+ : recordingOptions);
1369
1569
  const result = await relay.startRecording(recordingParams);
1370
- const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500);
1570
+ const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500;
1371
1571
  return c.json(result, status);
1372
1572
  });
1373
1573
  app.post('/recording/stop', async (c) => {
1374
- const body = await c.req.json();
1574
+ const body = (await c.req.json());
1375
1575
  const sessionId = normalizeSessionId(body.sessionId);
1376
- const manager = await getExecutorManager();
1377
- const executor = sessionId ? manager.getSession(sessionId) : null;
1378
- if (sessionId && !executor) {
1379
- return c.json({ success: false, error: `Session ${sessionId} not found` }, 404);
1380
- }
1381
- const extensionId = executor?.getSessionMetadata().extensionId || null;
1576
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
1382
1577
  const relay = getRecordingRelay(extensionId);
1383
1578
  if (!relay) {
1384
1579
  return c.json({ success: false, error: 'Extension not connected' }, 500);
1385
1580
  }
1386
- const stopParams = sessionId ? { sessionId } : {};
1581
+ const stopParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
1387
1582
  const result = await relay.stopRecording(stopParams);
1388
- const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500);
1583
+ const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500;
1389
1584
  return c.json(result, status);
1390
1585
  });
1391
1586
  app.get('/recording/status', async (c) => {
1392
1587
  const sessionId = normalizeSessionId(c.req.query('sessionId'));
1393
- const normalizedSessionId = sessionId || undefined;
1394
- const manager = await getExecutorManager();
1395
- const executor = normalizedSessionId ? manager.getSession(normalizedSessionId) : null;
1396
- const extensionId = executor?.getSessionMetadata().extensionId || null;
1588
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
1397
1589
  const relay = getRecordingRelay(extensionId);
1398
1590
  if (!relay) {
1399
1591
  return c.json({ isRecording: false });
1400
1592
  }
1401
- const isRecordingParams = normalizedSessionId ? { sessionId: normalizedSessionId } : {};
1593
+ const isRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
1402
1594
  const result = await relay.isRecording(isRecordingParams);
1403
1595
  return c.json(result);
1404
1596
  });
1405
1597
  app.post('/recording/cancel', async (c) => {
1406
- const body = await c.req.json();
1598
+ const body = (await c.req.json());
1407
1599
  const sessionId = normalizeSessionId(body.sessionId);
1408
- const manager = await getExecutorManager();
1409
- const executor = sessionId ? manager.getSession(sessionId) : null;
1410
- if (sessionId && !executor) {
1411
- return c.json({ success: false, error: `Session ${sessionId} not found` }, 404);
1412
- }
1413
- const extensionId = executor?.getSessionMetadata().extensionId || null;
1600
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
1414
1601
  const relay = getRecordingRelay(extensionId);
1415
1602
  if (!relay) {
1416
1603
  return c.json({ success: false, error: 'Extension not connected' }, 500);
1417
1604
  }
1418
- const cancelParams = sessionId ? { sessionId } : {};
1605
+ const cancelParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
1419
1606
  const result = await relay.cancelRecording(cancelParams);
1420
1607
  return c.json(result);
1421
1608
  });
@@ -1431,14 +1618,21 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1431
1618
  logger?.log('CDP endpoint:', cdpEndpoint);
1432
1619
  return {
1433
1620
  close() {
1621
+ const { extensions, playwrightClients } = store.getState();
1434
1622
  for (const client of playwrightClients.values()) {
1435
1623
  client.ws.close(1000, 'Server stopped');
1436
1624
  }
1437
- playwrightClients.clear();
1438
- for (const extension of extensionConnections.values()) {
1439
- extension.ws.close(1000, 'Server stopped');
1625
+ for (const ext of extensions.values()) {
1626
+ if (ext.pingInterval) {
1627
+ clearInterval(ext.pingInterval);
1628
+ }
1629
+ ext.ws?.close(1000, 'Server stopped');
1440
1630
  }
1441
- extensionConnections.clear();
1631
+ // Reset store state
1632
+ store.setState({
1633
+ extensions: new Map(),
1634
+ playwrightClients: new Map(),
1635
+ });
1442
1636
  server.close();
1443
1637
  emitter.removeAllListeners();
1444
1638
  },
@@ -1447,7 +1641,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1447
1641
  },
1448
1642
  off(event, listener) {
1449
1643
  emitter.off(event, listener);
1450
- }
1644
+ },
1451
1645
  };
1452
1646
  }
1453
1647
  //# sourceMappingURL=cdp-relay.js.map