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/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,63 @@ 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
47
  const resolvedCdpLogger = cdpLogger || createCdpLogger();
48
48
  const logCdpJson = (entry) => {
49
49
  resolvedCdpLogger.log(entry);
50
50
  };
51
- const playwrightClients = new Map();
52
51
  const getDefaultExtensionId = () => {
53
- return extensionConnections.keys().next().value || null;
52
+ return store.getState().extensions.keys().next().value || null;
54
53
  };
54
+ /**
55
+ * Resolve an extension by ID, stableKey, or fallback.
56
+ * Returns the unified ExtensionEntry which includes both state and I/O.
57
+ */
55
58
  const getExtensionConnection = (extensionId, options = {}) => {
59
+ const currentRelayState = store.getState();
60
+ const { extensions } = currentRelayState;
56
61
  if (extensionId) {
57
- const direct = extensionConnections.get(extensionId);
58
- if (direct) {
62
+ const direct = extensions.get(extensionId);
63
+ if (direct?.ws) {
59
64
  return direct;
60
65
  }
61
- const mappedId = extensionKeyIndex.get(extensionId);
62
- if (mappedId) {
63
- return extensionConnections.get(mappedId) || null;
66
+ // Try stableKey lookup.
67
+ const byKey = relayState.findExtensionByStableKey(currentRelayState, extensionId);
68
+ if (byKey) {
69
+ const candidates = Array.from(extensions.values())
70
+ .filter((ext) => ext.stableKey === byKey.stableKey)
71
+ .reverse();
72
+ for (const candidate of candidates) {
73
+ if (candidate.ws) {
74
+ return candidate;
75
+ }
76
+ }
64
77
  }
65
78
  return null;
66
79
  }
67
80
  if (!options.allowFallback) {
68
81
  return null;
69
82
  }
70
- const fallbackId = getDefaultExtensionId();
71
- if (fallbackId) {
72
- return extensionConnections.get(fallbackId) || null;
83
+ // Single extension — use it directly
84
+ if (extensions.size === 1) {
85
+ const fallbackId = getDefaultExtensionId();
86
+ if (fallbackId) {
87
+ const ext = extensions.get(fallbackId);
88
+ if (ext?.ws) {
89
+ return ext;
90
+ }
91
+ }
92
+ }
93
+ // Multiple extensions — auto-select if exactly one has active targets.
94
+ // This handles the common case of multiple Chrome profiles with the extension
95
+ // installed, where only one profile has playwriter-enabled tabs. (#52)
96
+ if (extensions.size > 1) {
97
+ const activeExtensions = Array.from(extensions.values()).filter((ext) => {
98
+ return ext.connectedTargets.size > 0;
99
+ });
100
+ if (activeExtensions.length === 1 && activeExtensions[0].ws) {
101
+ return activeExtensions[0];
102
+ }
73
103
  }
74
104
  return null;
75
105
  };
@@ -92,39 +122,41 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
92
122
  const normalized = String(value);
93
123
  return normalized ? normalized : null;
94
124
  };
95
- const getPageTargetForFrameId = ({ connection, frameId }) => {
96
- return Array.from(connection.connectedTargets.values()).find((target) => {
125
+ const getPageTargetForFrameId = ({ extensionState, frameId, }) => {
126
+ return Array.from(extensionState.connectedTargets.values()).find((target) => {
97
127
  return target.targetInfo.type === 'page' && target.frameIds.has(frameId);
98
128
  });
99
129
  };
100
130
  const startExtensionPing = (extensionId) => {
101
- const connection = extensionConnections.get(extensionId);
102
- if (!connection) {
131
+ const ext = store.getState().extensions.get(extensionId);
132
+ if (!ext) {
103
133
  return;
104
134
  }
105
- if (connection.pingInterval) {
106
- clearInterval(connection.pingInterval);
135
+ if (ext.pingInterval) {
136
+ clearInterval(ext.pingInterval);
107
137
  }
108
- connection.pingInterval = setInterval(() => {
109
- connection.ws.send(JSON.stringify({ method: 'ping' }));
138
+ const pingInterval = setInterval(() => {
139
+ const latestExt = store.getState().extensions.get(extensionId);
140
+ latestExt?.ws?.send(JSON.stringify({ method: 'ping' }));
110
141
  }, 5000);
142
+ store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval }));
111
143
  };
112
144
  const stopExtensionPing = (extensionId) => {
113
- const connection = extensionConnections.get(extensionId);
114
- if (!connection || !connection.pingInterval) {
145
+ const ext = store.getState().extensions.get(extensionId);
146
+ if (!ext || !ext.pingInterval) {
115
147
  return;
116
148
  }
117
- clearInterval(connection.pingInterval);
118
- connection.pingInterval = null;
149
+ clearInterval(ext.pingInterval);
150
+ store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval: null }));
119
151
  };
120
- function logCdpMessage({ direction, clientId, method, sessionId, params, id, source }) {
152
+ function logCdpMessage({ direction, clientId, method, sessionId, params, id, source, }) {
121
153
  const noisyEvents = [
122
154
  'Network.requestWillBeSentExtraInfo',
123
155
  'Network.responseReceived',
124
156
  'Network.responseReceivedExtraInfo',
125
157
  'Network.dataReceived',
126
158
  'Network.requestWillBeSent',
127
- 'Network.loadingFinished'
159
+ 'Network.loadingFinished',
128
160
  ];
129
161
  if (noisyEvents.includes(method)) {
130
162
  return;
@@ -163,9 +195,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
163
195
  }
164
196
  }
165
197
  function sendToPlaywright({ message, clientId, source = 'extension', extensionId, }) {
166
- const messageToSend = source === 'server' && 'method' in message
167
- ? { ...message, __serverGenerated: true }
168
- : message;
198
+ const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message;
169
199
  logCdpJson({
170
200
  timestamp: new Date().toISOString(),
171
201
  direction: 'to-playwright',
@@ -180,7 +210,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
180
210
  method: message.method,
181
211
  sessionId: 'sessionId' in message ? message.sessionId : undefined,
182
212
  params: 'params' in message ? message.params : undefined,
183
- source
213
+ source,
184
214
  });
185
215
  }
186
216
  const messageStr = JSON.stringify(messageToSend);
@@ -200,14 +230,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
200
230
  }
201
231
  };
202
232
  if (clientId) {
203
- const client = playwrightClients.get(clientId);
233
+ const client = store.getState().playwrightClients.get(clientId);
204
234
  if (client) {
205
235
  safeSend(client);
206
236
  }
207
237
  }
208
238
  else {
209
- const clients = Array.from(playwrightClients.values());
210
- for (const client of clients) {
239
+ const { playwrightClients } = store.getState();
240
+ for (const client of playwrightClients.values()) {
211
241
  if (extensionId && client.extensionId !== extensionId) {
212
242
  continue;
213
243
  }
@@ -227,11 +257,25 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
227
257
  return { method: record.method, sessionId, params: record.params };
228
258
  }
229
259
  async function sendToExtension({ extensionId, method, params, timeout = 30000, }) {
230
- const connection = getExtensionConnection(extensionId);
231
- if (!connection) {
260
+ const conn = getExtensionConnection(extensionId);
261
+ if (!conn) {
262
+ throw new Error('Extension not connected');
263
+ }
264
+ const resolvedExtensionId = conn.id;
265
+ let id = 0;
266
+ store.setState((s) => {
267
+ const ext = s.extensions.get(resolvedExtensionId);
268
+ if (!ext) {
269
+ return s;
270
+ }
271
+ id = ext.messageId + 1;
272
+ const newExtensions = new Map(s.extensions);
273
+ newExtensions.set(resolvedExtensionId, { ...ext, messageId: id });
274
+ return { ...s, extensions: newExtensions };
275
+ });
276
+ if (!id) {
232
277
  throw new Error('Extension not connected');
233
278
  }
234
- const id = ++connection.messageId;
235
279
  const message = { id, method, params };
236
280
  const forwardCdpParams = method === 'forwardCDPCommand' ? getForwardCdpParams(params) : undefined;
237
281
  if (forwardCdpParams) {
@@ -245,13 +289,15 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
245
289
  },
246
290
  });
247
291
  }
248
- connection.ws.send(JSON.stringify(message));
249
292
  return new Promise((resolve, reject) => {
250
293
  const timeoutId = setTimeout(() => {
251
- connection.pendingRequests.delete(id);
294
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
295
+ extensionId: resolvedExtensionId,
296
+ requestId: id,
297
+ }));
252
298
  reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
253
299
  }, timeout);
254
- connection.pendingRequests.set(id, {
300
+ const pendingRequest = {
255
301
  resolve: (result) => {
256
302
  clearTimeout(timeoutId);
257
303
  resolve(result);
@@ -259,21 +305,63 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
259
305
  reject: (error) => {
260
306
  clearTimeout(timeoutId);
261
307
  reject(error);
262
- }
263
- });
308
+ },
309
+ };
310
+ store.setState((s) => relayState.addExtensionPendingRequest(s, {
311
+ extensionId: resolvedExtensionId,
312
+ requestId: id,
313
+ pendingRequest,
314
+ }));
315
+ const latestExt = store.getState().extensions.get(resolvedExtensionId);
316
+ if (!latestExt?.ws) {
317
+ clearTimeout(timeoutId);
318
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
319
+ extensionId: resolvedExtensionId,
320
+ requestId: id,
321
+ }));
322
+ reject(new Error('Extension not connected'));
323
+ return;
324
+ }
325
+ try {
326
+ latestExt.ws.send(JSON.stringify(message));
327
+ }
328
+ catch (error) {
329
+ clearTimeout(timeoutId);
330
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
331
+ extensionId: resolvedExtensionId,
332
+ requestId: id,
333
+ }));
334
+ const sendError = error instanceof Error ? error : new Error(String(error));
335
+ reject(new Error(`Extension send failed: ${method}`, { cause: sendError }));
336
+ }
264
337
  });
265
338
  }
266
339
  const recordingRelays = new Map();
340
+ // Find which extension connection owns a CDP tab session ID (pw-tab-*).
341
+ // Used by recording routes where sessionId identifies the target tab.
342
+ // Delegates to the pure derivation function from relay-state.ts.
343
+ const findExtensionIdByCdpSession = (cdpSessionId) => {
344
+ return relayState.findExtensionIdByCdpSession(store.getState(), cdpSessionId);
345
+ };
346
+ // Resolve recording route session ID (CDP tab session) to extension connection.
347
+ const resolveRecordingRoute = async ({ sessionId, }) => {
348
+ if (!sessionId) {
349
+ return { extensionId: null, sessionId: null };
350
+ }
351
+ const extensionId = findExtensionIdByCdpSession(sessionId);
352
+ return { extensionId, sessionId };
353
+ };
267
354
  const getRecordingRelay = (extensionId) => {
268
- const allowDefault = !extensionId && extensionConnections.size === 1;
269
- const connection = getExtensionConnection(extensionId, { allowFallback: allowDefault });
270
- if (!connection) {
355
+ const allowDefault = !extensionId && store.getState().extensions.size === 1;
356
+ const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault });
357
+ if (!conn) {
271
358
  return null;
272
359
  }
273
- if (!recordingRelays.has(connection.id)) {
274
- recordingRelays.set(connection.id, new RecordingRelay((params) => sendToExtension({ extensionId: connection.id, ...params }), () => extensionConnections.has(connection.id), logger));
360
+ const connId = conn.id;
361
+ if (!recordingRelays.has(connId)) {
362
+ recordingRelays.set(connId, new RecordingRelay((params) => sendToExtension({ extensionId: connId, ...params }), () => store.getState().extensions.has(connId), logger));
275
363
  }
276
- return recordingRelays.get(connection.id) || null;
364
+ return recordingRelays.get(connId) || null;
277
365
  };
278
366
  // Auto-create initial tab when PLAYWRITER_AUTO_ENABLE is set and no targets exist.
279
367
  // This allows Playwright to connect and immediately have a page to work with.
@@ -281,24 +369,25 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
281
369
  if (!process.env.PLAYWRITER_AUTO_ENABLE) {
282
370
  return;
283
371
  }
284
- const connection = getExtensionConnection(extensionId);
285
- if (!connection) {
372
+ const conn = getExtensionConnection(extensionId);
373
+ if (!conn) {
286
374
  return;
287
375
  }
288
- if (connection.connectedTargets.size > 0) {
376
+ if (conn.connectedTargets.size > 0) {
289
377
  return;
290
378
  }
291
379
  try {
292
380
  logger?.log(pc.blue('Auto-creating initial tab for Playwright client'));
293
- const result = await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 });
381
+ const result = (await sendToExtension({ extensionId, method: 'createInitialTab', timeout: 10000 }));
294
382
  if (result.success && result.sessionId && result.targetInfo) {
295
- connection.connectedTargets.set(result.sessionId, {
383
+ store.setState((s) => relayState.addTarget(s, {
384
+ extensionId,
296
385
  sessionId: result.sessionId,
297
386
  targetId: result.targetInfo.targetId,
298
387
  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}`));
388
+ }));
389
+ const updatedTargets = store.getState().extensions.get(extensionId)?.connectedTargets.size || 0;
390
+ logger?.log(pc.blue(`Auto-created tab, now have ${updatedTargets} targets, url: ${result.targetInfo.url}`));
302
391
  }
303
392
  }
304
393
  catch (e) {
@@ -306,8 +395,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
306
395
  }
307
396
  }
308
397
  async function routeCdpCommand({ extensionId, method, params, sessionId, source, }) {
309
- const extension = getExtensionConnection(extensionId);
310
- const connectedTargets = extension?.connectedTargets || new Map();
398
+ const conn = getExtensionConnection(extensionId);
399
+ const connectedTargets = conn?.connectedTargets || new Map();
400
+ const resolvedExtensionId = conn?.id || extensionId;
311
401
  switch (method) {
312
402
  case 'Browser.getVersion': {
313
403
  return {
@@ -315,7 +405,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
315
405
  product: 'Chrome/Extension-Bridge',
316
406
  revision: '1.0.0',
317
407
  userAgent: 'CDP-Bridge-Server/1.0.0',
318
- jsVersion: 'V8'
408
+ jsVersion: 'V8',
319
409
  };
320
410
  }
321
411
  case 'Browser.setDownloadBehavior': {
@@ -328,15 +418,15 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
328
418
  if (sessionId) {
329
419
  break;
330
420
  }
331
- if (extension) {
332
- await maybeAutoCreateInitialTab(extension.id);
421
+ if (conn) {
422
+ await maybeAutoCreateInitialTab(conn.id);
333
423
  }
334
424
  // Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
335
425
  // Playwright relies on these (with parentFrameId) when reconnecting over CDP.
336
426
  await sendToExtension({
337
- extensionId: extension?.id || extensionId,
427
+ extensionId: resolvedExtensionId,
338
428
  method: 'forwardCDPCommand',
339
- params: { method, params, source }
429
+ params: { method, params, source },
340
430
  });
341
431
  return {};
342
432
  }
@@ -344,19 +434,20 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
344
434
  return {};
345
435
  }
346
436
  case 'Target.attachToTarget': {
347
- const targetId = params?.targetId;
348
- if (!targetId) {
437
+ const attachParams = params;
438
+ if (!attachParams?.targetId) {
349
439
  throw new Error('targetId is required for Target.attachToTarget');
350
440
  }
351
441
  for (const target of connectedTargets.values()) {
352
- if (target.targetId === targetId) {
442
+ if (target.targetId === attachParams.targetId) {
353
443
  return { sessionId: target.sessionId };
354
444
  }
355
445
  }
356
- throw new Error(`Target ${targetId} not found in connected targets`);
446
+ throw new Error(`Target ${attachParams.targetId} not found in connected targets`);
357
447
  }
358
448
  case 'Target.getTargetInfo': {
359
- const targetId = params?.targetId;
449
+ const infoReqParams = params;
450
+ const targetId = infoReqParams?.targetId;
360
451
  if (targetId) {
361
452
  for (const target of connectedTargets.values()) {
362
453
  if (target.targetId === targetId) {
@@ -379,30 +470,30 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
379
470
  .filter((t) => !isRestrictedTarget(t.targetInfo))
380
471
  .map((t) => ({
381
472
  ...t.targetInfo,
382
- attached: true
383
- }))
473
+ attached: true,
474
+ })),
384
475
  };
385
476
  }
386
477
  case 'Target.createTarget': {
387
478
  return await sendToExtension({
388
- extensionId: extension?.id || extensionId,
479
+ extensionId: resolvedExtensionId,
389
480
  method: 'forwardCDPCommand',
390
- params: { method, params, source }
481
+ params: { method, params, source },
391
482
  });
392
483
  }
393
484
  case 'Target.closeTarget': {
394
485
  return await sendToExtension({
395
- extensionId: extension?.id || extensionId,
486
+ extensionId: resolvedExtensionId,
396
487
  method: 'forwardCDPCommand',
397
- params: { method, params, source }
488
+ params: { method, params, source },
398
489
  });
399
490
  }
400
491
  // Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects
401
492
  case 'ghost-browser': {
402
493
  return await sendToExtension({
403
- extensionId: extension?.id || extensionId,
494
+ extensionId: resolvedExtensionId,
404
495
  method: 'ghost-browser',
405
- params
496
+ params,
406
497
  });
407
498
  }
408
499
  case 'Runtime.enable': {
@@ -428,18 +519,18 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
428
519
  emitter.on('cdp:event', handler);
429
520
  });
430
521
  const result = await sendToExtension({
431
- extensionId: extension?.id || extensionId,
522
+ extensionId: resolvedExtensionId,
432
523
  method: 'forwardCDPCommand',
433
- params: { sessionId, method, params, source }
524
+ params: { sessionId, method, params, source },
434
525
  });
435
526
  await contextCreatedPromise;
436
527
  return result;
437
528
  }
438
529
  }
439
530
  return await sendToExtension({
440
- extensionId: extension?.id || extensionId,
531
+ extensionId: resolvedExtensionId,
441
532
  method: 'forwardCDPCommand',
442
- params: { sessionId, method, params, source }
533
+ params: { sessionId, method, params, source },
443
534
  });
444
535
  }
445
536
  const app = new Hono();
@@ -472,7 +563,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
472
563
  });
473
564
  app.get('/extension/status', (c) => {
474
565
  const defaultExtension = getExtensionConnection(null, { allowFallback: true });
475
- const connected = extensionConnections.size > 0;
566
+ const connected = store.getState().extensions.size > 0;
476
567
  const activeTargets = defaultExtension?.connectedTargets.size || 0;
477
568
  const info = defaultExtension?.info;
478
569
  return c.json({
@@ -484,14 +575,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
484
575
  });
485
576
  });
486
577
  app.get('/extensions/status', (c) => {
487
- const extensions = Array.from(extensionConnections.values()).map((extension) => {
578
+ const extensions = Array.from(store.getState().extensions.values()).map((ext) => {
488
579
  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,
580
+ extensionId: ext.id,
581
+ stableKey: ext.stableKey,
582
+ browser: ext.info.browser || null,
583
+ profile: ext.info ? { email: ext.info.email || '', id: ext.info.id || '' } : null,
584
+ activeTargets: ext.connectedTargets.size,
585
+ playwriterVersion: ext.info?.version || null,
495
586
  };
496
587
  });
497
588
  return c.json({ extensions });
@@ -502,68 +593,68 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
502
593
  app
503
594
  .on(['GET', 'PUT'], '/json/version', (c) => {
504
595
  return c.json({
505
- 'Browser': `Playwriter/${VERSION}`,
596
+ Browser: `Playwriter/${VERSION}`,
506
597
  'Protocol-Version': '1.3',
507
- 'webSocketDebuggerUrl': getCdpWsUrl(c)
598
+ webSocketDebuggerUrl: getCdpWsUrl(c),
508
599
  });
509
600
  })
510
601
  .on(['GET', 'PUT'], '/json/version/', (c) => {
511
602
  return c.json({
512
- 'Browser': `Playwriter/${VERSION}`,
603
+ Browser: `Playwriter/${VERSION}`,
513
604
  'Protocol-Version': '1.3',
514
- 'webSocketDebuggerUrl': getCdpWsUrl(c)
605
+ webSocketDebuggerUrl: getCdpWsUrl(c),
515
606
  });
516
607
  })
517
608
  .on(['GET', 'PUT'], '/json/list', (c) => {
518
609
  const wsUrl = getCdpWsUrl(c);
519
610
  const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
520
- return c.json(Array.from(defaultTargets.values()).map(t => ({
611
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
521
612
  id: t.targetId,
522
613
  type: t.targetInfo.type,
523
614
  title: t.targetInfo.title,
524
615
  description: t.targetInfo.title,
525
616
  url: t.targetInfo.url,
526
617
  webSocketDebuggerUrl: wsUrl,
527
- devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
618
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
528
619
  })));
529
620
  })
530
621
  .on(['GET', 'PUT'], '/json/list/', (c) => {
531
622
  const wsUrl = getCdpWsUrl(c);
532
623
  const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
533
- return c.json(Array.from(defaultTargets.values()).map(t => ({
624
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
534
625
  id: t.targetId,
535
626
  type: t.targetInfo.type,
536
627
  title: t.targetInfo.title,
537
628
  description: t.targetInfo.title,
538
629
  url: t.targetInfo.url,
539
630
  webSocketDebuggerUrl: wsUrl,
540
- devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
631
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
541
632
  })));
542
633
  })
543
634
  .on(['GET', 'PUT'], '/json', (c) => {
544
635
  const wsUrl = getCdpWsUrl(c);
545
636
  const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
546
- return c.json(Array.from(defaultTargets.values()).map(t => ({
637
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
547
638
  id: t.targetId,
548
639
  type: t.targetInfo.type,
549
640
  title: t.targetInfo.title,
550
641
  description: t.targetInfo.title,
551
642
  url: t.targetInfo.url,
552
643
  webSocketDebuggerUrl: wsUrl,
553
- devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
644
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
554
645
  })));
555
646
  })
556
647
  .on(['GET', 'PUT'], '/json/', (c) => {
557
648
  const wsUrl = getCdpWsUrl(c);
558
649
  const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
559
- return c.json(Array.from(defaultTargets.values()).map(t => ({
650
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
560
651
  id: t.targetId,
561
652
  type: t.targetInfo.type,
562
653
  title: t.targetInfo.title,
563
654
  description: t.targetInfo.title,
564
655
  url: t.targetInfo.url,
565
656
  webSocketDebuggerUrl: wsUrl,
566
- devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`
657
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
567
658
  })));
568
659
  });
569
660
  app.post('/mcp-log', async (c) => {
@@ -610,13 +701,19 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
610
701
  const clientId = c.req.param('clientId') || 'default';
611
702
  const url = new URL(c.req.url, 'http://localhost');
612
703
  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;
704
+ // When extensionId is explicit, resolve directly. Otherwise use fallback which
705
+ // handles single-extension and uniquely-active-extension cases (#52).
706
+ const resolvedExtension = requestedExtensionId
707
+ ? getExtensionConnection(requestedExtensionId)
708
+ : getExtensionConnection(null, { allowFallback: true });
709
+ const clientExtensionId = resolvedExtension?.id || null;
710
+ const getBoundExtensionIdForClient = () => {
711
+ const client = store.getState().playwrightClients.get(clientId);
712
+ return client?.extensionId || null;
713
+ };
617
714
  return {
618
715
  async onOpen(_event, ws) {
619
- if (playwrightClients.has(clientId)) {
716
+ if (store.getState().playwrightClients.has(clientId)) {
620
717
  logger?.log(pc.yellow(`Rejecting duplicate Playwright clientId: ${clientId}`));
621
718
  ws.close(4004, 'Duplicate Playwright clientId');
622
719
  return;
@@ -630,10 +727,12 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
630
727
  return;
631
728
  }
632
729
  // Add client first so it can receive Target.attachedToTarget events
633
- playwrightClients.set(clientId, { id: clientId, ws, extensionId: clientExtensionId });
730
+ store.setState((s) => {
731
+ return relayState.addPlaywrightClient(s, { id: clientId, extensionId: clientExtensionId, ws });
732
+ });
634
733
  const extensionConnection = getExtensionConnection(clientExtensionId);
635
734
  const targetCount = extensionConnection?.connectedTargets.size || 0;
636
- logger?.log(pc.green(`Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`));
735
+ logger?.log(pc.green(`Playwright client connected: ${clientId} (${store.getState().playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`));
637
736
  },
638
737
  async onMessage(event, ws) {
639
738
  let message;
@@ -655,25 +754,35 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
655
754
  clientId,
656
755
  method,
657
756
  sessionId,
658
- id
757
+ id,
659
758
  });
660
759
  emitter.emit('cdp:command', { clientId, command: message });
661
- const extensionConnection = getExtensionConnection(clientExtensionId);
662
- if (!extensionConnection) {
760
+ const boundExtensionId = getBoundExtensionIdForClient();
761
+ const extensionConn = getExtensionConnection(boundExtensionId);
762
+ if (!extensionConn) {
663
763
  sendToPlaywright({
664
764
  message: {
665
765
  id,
666
766
  sessionId,
667
- error: { message: 'Extension not connected' }
767
+ error: { message: 'Extension not connected' },
668
768
  },
669
- clientId
769
+ clientId,
670
770
  });
671
771
  return;
672
772
  }
673
773
  try {
674
- const result = await routeCdpCommand({ extensionId: extensionConnection.id, method, params, sessionId, source });
774
+ const result = await routeCdpCommand({
775
+ extensionId: extensionConn.id,
776
+ method,
777
+ params,
778
+ sessionId,
779
+ source,
780
+ });
675
781
  if (method === 'Target.setAutoAttach' && !sessionId) {
676
- for (const target of extensionConnection.connectedTargets.values()) {
782
+ // Re-read state after async routeCdpCommand — targets may have changed
783
+ const freshExt = store.getState().extensions.get(extensionConn.id);
784
+ const freshTargets = freshExt?.connectedTargets || new Map();
785
+ for (const target of freshTargets.values()) {
677
786
  // Skip restricted targets (extensions, chrome:// URLs, non-page types)
678
787
  if (isRestrictedTarget(target.targetInfo)) {
679
788
  continue;
@@ -684,10 +793,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
684
793
  sessionId: target.sessionId,
685
794
  targetInfo: {
686
795
  ...target.targetInfo,
687
- attached: true
796
+ attached: true,
688
797
  },
689
- waitingForDebugger: false
690
- }
798
+ waitingForDebugger: false,
799
+ },
691
800
  };
692
801
  if (!target.targetInfo.url) {
693
802
  logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), JSON.stringify(attachedPayload));
@@ -696,12 +805,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
696
805
  sendToPlaywright({
697
806
  message: attachedPayload,
698
807
  clientId,
699
- source: 'server'
808
+ source: 'server',
700
809
  });
701
810
  }
702
811
  }
703
812
  if (method === 'Target.setDiscoverTargets' && params?.discover) {
704
- for (const target of extensionConnection.connectedTargets.values()) {
813
+ const freshExt2 = store.getState().extensions.get(extensionConn.id);
814
+ const freshTargets2 = freshExt2?.connectedTargets || new Map();
815
+ for (const target of freshTargets2.values()) {
705
816
  // Skip restricted targets (extensions, chrome:// URLs, non-page types)
706
817
  if (isRestrictedTarget(target.targetInfo)) {
707
818
  continue;
@@ -711,9 +822,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
711
822
  params: {
712
823
  targetInfo: {
713
824
  ...target.targetInfo,
714
- attached: true
715
- }
716
- }
825
+ attached: true,
826
+ },
827
+ },
717
828
  };
718
829
  if (!target.targetInfo.url) {
719
830
  logger?.error(pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), JSON.stringify(targetCreatedPayload));
@@ -722,34 +833,41 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
722
833
  sendToPlaywright({
723
834
  message: targetCreatedPayload,
724
835
  clientId,
725
- source: 'server'
836
+ source: 'server',
726
837
  });
727
838
  }
728
839
  }
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
840
+ if (method === 'Target.attachToTarget') {
841
+ const attachResponse = result;
842
+ const attachRequestParams = params;
843
+ if (attachResponse?.sessionId) {
844
+ const freshExt3 = store.getState().extensions.get(extensionConn.id);
845
+ const freshTargets3 = freshExt3?.connectedTargets || new Map();
846
+ const target = Array.from(freshTargets3.values()).find((t) => {
847
+ return t.targetId === attachRequestParams?.targetId;
848
+ });
849
+ if (target) {
850
+ const attachedPayload = {
851
+ method: 'Target.attachedToTarget',
852
+ params: {
853
+ sessionId: attachResponse.sessionId,
854
+ targetInfo: {
855
+ ...target.targetInfo,
856
+ attached: true,
857
+ },
858
+ waitingForDebugger: false,
740
859
  },
741
- waitingForDebugger: false
860
+ };
861
+ if (!target.targetInfo.url) {
862
+ logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload));
742
863
  }
743
- };
744
- if (!target.targetInfo.url) {
745
- logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload));
864
+ logger?.log(pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload));
865
+ sendToPlaywright({
866
+ message: attachedPayload,
867
+ clientId,
868
+ source: 'server',
869
+ });
746
870
  }
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
871
  }
754
872
  }
755
873
  const response = { id, sessionId, result };
@@ -761,19 +879,19 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
761
879
  const errorResponse = {
762
880
  id,
763
881
  sessionId,
764
- error: { message: e.message }
882
+ error: { message: e.message },
765
883
  };
766
884
  sendToPlaywright({ message: errorResponse, clientId });
767
885
  emitter.emit('cdp:response', { clientId, response: errorResponse, command: message });
768
886
  }
769
887
  },
770
888
  onClose() {
771
- playwrightClients.delete(clientId);
772
- logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${playwrightClients.size} remaining)`));
889
+ store.setState((s) => relayState.removePlaywrightClient(s, { clientId }));
890
+ logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${store.getState().playwrightClients.size} remaining)`));
773
891
  },
774
892
  onError(event) {
775
893
  logger?.error(`Playwright WebSocket error [${clientId}]:`, event);
776
- }
894
+ },
777
895
  };
778
896
  }));
779
897
  const getExtensionInfoFromRequest = (c) => {
@@ -819,32 +937,25 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
819
937
  return {
820
938
  onOpen(_event, ws) {
821
939
  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');
940
+ // Check for existing connection with same stableKey and close it
941
+ const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey);
942
+ if (existingExt && existingExt.id !== connectionId) {
943
+ logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingExt.id} -> ${connectionId})`));
944
+ if (existingExt.ws) {
945
+ existingExt.ws.close(4001, 'Extension Replaced');
828
946
  }
829
947
  }
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);
948
+ // State transition: add extension with ws handle included.
949
+ // Existing same-stableKey entry stays until old socket onClose.
950
+ store.setState((s) => {
951
+ return relayState.addExtension(s, { id: connectionId, info: incomingExtensionInfo, stableKey, ws });
952
+ });
842
953
  startExtensionPing(connectionId);
843
954
  logger?.log(`Extension connected (${connectionId})`);
844
955
  },
845
956
  async onMessage(event, ws) {
846
- const connection = extensionConnections.get(connectionId);
847
- if (!connection) {
957
+ const ext = store.getState().extensions.get(connectionId);
958
+ if (!ext) {
848
959
  ws.close(1000, 'Extension not registered');
849
960
  return;
850
961
  }
@@ -866,12 +977,29 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
866
977
  return;
867
978
  }
868
979
  if (message.id !== undefined) {
869
- const pending = connection.pendingRequests.get(message.id);
980
+ const pending = (() => {
981
+ let pendingRequest = null;
982
+ store.setState((s) => {
983
+ const extensionEntry = s.extensions.get(connectionId);
984
+ if (!extensionEntry) {
985
+ return s;
986
+ }
987
+ const nextPendingRequest = extensionEntry.pendingRequests.get(message.id);
988
+ if (!nextPendingRequest) {
989
+ return s;
990
+ }
991
+ pendingRequest = nextPendingRequest;
992
+ return relayState.removeExtensionPendingRequest(s, {
993
+ extensionId: connectionId,
994
+ requestId: message.id,
995
+ });
996
+ });
997
+ return pendingRequest;
998
+ })();
870
999
  if (!pending) {
871
1000
  logger?.log('Unexpected response with id:', message.id);
872
1001
  return;
873
1002
  }
874
- connection.pendingRequests.delete(message.id);
875
1003
  if (message.error) {
876
1004
  pending.reject(new Error(message.error));
877
1005
  }
@@ -916,7 +1044,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
916
1044
  direction: 'from-extension',
917
1045
  method,
918
1046
  sessionId,
919
- params
1047
+ params,
920
1048
  });
921
1049
  const cdpEvent = { method, sessionId, params };
922
1050
  emitter.emit('cdp:event', { event: cdpEvent, sessionId });
@@ -924,8 +1052,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
924
1052
  const targetParams = params;
925
1053
  const incomingSessionId = sessionId;
926
1054
  const iframeParentFrameId = targetParams.targetInfo.parentFrameId;
927
- const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId
928
- ? getPageTargetForFrameId({ connection, frameId: iframeParentFrameId })?.sessionId
1055
+ // Read current extension state for iframe parent lookup
1056
+ const currentExtState = store.getState().extensions.get(connectionId);
1057
+ const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId && currentExtState
1058
+ ? getPageTargetForFrameId({ extensionState: currentExtState, frameId: iframeParentFrameId })?.sessionId
929
1059
  : undefined;
930
1060
  // Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.)
931
1061
  if (isRestrictedTarget(targetParams.targetInfo)) {
@@ -940,8 +1070,8 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
940
1070
  source: 'server',
941
1071
  },
942
1072
  }).catch((error) => {
943
- const message = error instanceof Error ? error.message : String(error);
944
- logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), message);
1073
+ const msg = error instanceof Error ? error.message : String(error);
1074
+ logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), msg);
945
1075
  });
946
1076
  }
947
1077
  logger?.log(pc.gray(`[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`));
@@ -952,15 +1082,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
952
1082
  }
953
1083
  logger?.log(pc.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId }));
954
1084
  // 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, {
1085
+ const alreadyConnected = currentExtState?.connectedTargets.has(targetParams.sessionId) ?? false;
1086
+ // State transition: add/update target
1087
+ store.setState((s) => relayState.addTarget(s, {
1088
+ extensionId: connectionId,
959
1089
  sessionId: targetParams.sessionId,
960
1090
  targetId: targetParams.targetInfo.targetId,
961
1091
  targetInfo: targetParams.targetInfo,
962
- frameIds: existingTarget?.frameIds ?? new Set()
963
- });
1092
+ }));
964
1093
  // Only forward to Playwright if this is a new target to avoid duplicates
965
1094
  if (!alreadyConnected) {
966
1095
  sendToPlaywright({
@@ -973,7 +1102,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
973
1102
  // session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations.
974
1103
  sessionId: iframeOwnerSessionId ?? incomingSessionId,
975
1104
  method: 'Target.attachedToTarget',
976
- params: targetParams
1105
+ params: targetParams,
977
1106
  },
978
1107
  source: 'extension',
979
1108
  extensionId: connectionId,
@@ -982,11 +1111,11 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
982
1111
  }
983
1112
  else if (method === 'Target.detachedFromTarget') {
984
1113
  const detachParams = params;
985
- connection.connectedTargets.delete(detachParams.sessionId);
1114
+ store.setState((s) => relayState.removeTarget(s, { extensionId: connectionId, sessionId: detachParams.sessionId }));
986
1115
  sendToPlaywright({
987
1116
  message: {
988
1117
  method: 'Target.detachedFromTarget',
989
- params: detachParams
1118
+ params: detachParams,
990
1119
  },
991
1120
  source: 'extension',
992
1121
  extensionId: connectionId,
@@ -994,17 +1123,12 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
994
1123
  }
995
1124
  else if (method === 'Target.targetCrashed') {
996
1125
  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
- }
1126
+ store.setState((s) => relayState.removeTargetByCrash(s, { extensionId: connectionId, targetId: crashParams.targetId }));
1127
+ logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId);
1004
1128
  sendToPlaywright({
1005
1129
  message: {
1006
1130
  method: 'Target.targetCrashed',
1007
- params: crashParams
1131
+ params: crashParams,
1008
1132
  },
1009
1133
  source: 'extension',
1010
1134
  extensionId: connectionId,
@@ -1012,16 +1136,11 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1012
1136
  }
1013
1137
  else if (method === 'Target.targetInfoChanged') {
1014
1138
  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
- }
1139
+ store.setState((s) => relayState.updateTargetInfo(s, { extensionId: connectionId, targetInfo: infoParams.targetInfo }));
1021
1140
  sendToPlaywright({
1022
1141
  message: {
1023
1142
  method: 'Target.targetInfoChanged',
1024
- params: infoParams
1143
+ params: infoParams,
1025
1144
  },
1026
1145
  source: 'extension',
1027
1146
  extensionId: connectionId,
@@ -1030,16 +1149,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1030
1149
  else if (method === 'Page.frameAttached') {
1031
1150
  const frameParams = params;
1032
1151
  if (sessionId) {
1033
- const target = connection.connectedTargets.get(sessionId);
1034
- if (target) {
1035
- target.frameIds.add(frameParams.frameId);
1036
- }
1152
+ store.setState((s) => relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frameId }));
1037
1153
  }
1038
1154
  sendToPlaywright({
1039
1155
  message: {
1040
1156
  sessionId,
1041
1157
  method,
1042
- params
1158
+ params,
1043
1159
  },
1044
1160
  source: 'extension',
1045
1161
  extensionId: connectionId,
@@ -1047,15 +1163,12 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1047
1163
  }
1048
1164
  else if (method === 'Page.frameDetached') {
1049
1165
  const frameParams = params;
1050
- const ownerTarget = getPageTargetForFrameId({ connection, frameId: frameParams.frameId });
1051
- if (ownerTarget) {
1052
- ownerTarget.frameIds.delete(frameParams.frameId);
1053
- }
1166
+ store.setState((s) => relayState.removeFrameId(s, { extensionId: connectionId, frameId: frameParams.frameId }));
1054
1167
  sendToPlaywright({
1055
1168
  message: {
1056
1169
  sessionId,
1057
1170
  method,
1058
- params
1171
+ params,
1059
1172
  },
1060
1173
  source: 'extension',
1061
1174
  extensionId: connectionId,
@@ -1064,27 +1177,22 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1064
1177
  else if (method === 'Page.frameNavigated') {
1065
1178
  const frameParams = params;
1066
1179
  if (sessionId) {
1067
- const target = connection.connectedTargets.get(sessionId);
1068
- if (target) {
1069
- target.frameIds.add(frameParams.frame.id);
1070
- }
1180
+ store.setState((s) => relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frame.id }));
1071
1181
  }
1072
1182
  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
- }
1183
+ store.setState((s) => relayState.updateTargetUrl(s, {
1184
+ extensionId: connectionId,
1185
+ sessionId,
1186
+ url: frameParams.frame.url,
1187
+ title: frameParams.frame.name || undefined,
1188
+ }));
1189
+ logger?.log(pc.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url);
1082
1190
  }
1083
1191
  sendToPlaywright({
1084
1192
  message: {
1085
1193
  sessionId,
1086
1194
  method,
1087
- params
1195
+ params,
1088
1196
  },
1089
1197
  source: 'extension',
1090
1198
  extensionId: connectionId,
@@ -1093,20 +1201,14 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1093
1201
  else if (method === 'Page.navigatedWithinDocument') {
1094
1202
  const navParams = params;
1095
1203
  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
- }
1204
+ store.setState((s) => relayState.updateTargetUrl(s, { extensionId: connectionId, sessionId, url: navParams.url }));
1205
+ logger?.log(pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url);
1104
1206
  }
1105
1207
  sendToPlaywright({
1106
1208
  message: {
1107
1209
  sessionId,
1108
1210
  method,
1109
- params
1211
+ params,
1110
1212
  },
1111
1213
  source: 'extension',
1112
1214
  extensionId: connectionId,
@@ -1117,7 +1219,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1117
1219
  message: {
1118
1220
  sessionId,
1119
1221
  method,
1120
- params
1222
+ params,
1121
1223
  },
1122
1224
  source: 'extension',
1123
1225
  extensionId: connectionId,
@@ -1125,10 +1227,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1125
1227
  }
1126
1228
  }
1127
1229
  },
1128
- onClose(event, ws) {
1230
+ onClose(event) {
1129
1231
  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)
1232
+ // Cancel recordings BEFORE removing extension state (cancelRecording checks isExtensionConnected)
1132
1233
  const recordingRelay = recordingRelays.get(connectionId);
1133
1234
  if (recordingRelay) {
1134
1235
  recordingRelay.cancelRecording({}).catch(() => {
@@ -1136,32 +1237,50 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1136
1237
  });
1137
1238
  }
1138
1239
  recordingRelays.delete(connectionId);
1139
- const connection = extensionConnections.get(connectionId);
1140
- if (connection) {
1141
- for (const pending of connection.pendingRequests.values()) {
1240
+ // Reject all pending I/O requests (state cleanup happens in removeExtension below)
1241
+ const closingExt = store.getState().extensions.get(connectionId);
1242
+ if (closingExt) {
1243
+ stopExtensionPing(connectionId);
1244
+ for (const pending of closingExt.pendingRequests.values()) {
1142
1245
  pending.reject(new Error('Extension connection closed'));
1143
1246
  }
1144
- connection.pendingRequests.clear();
1145
- connection.connectedTargets.clear();
1146
1247
  }
1147
- if (connection) {
1148
- const mappedId = extensionKeyIndex.get(connection.stableKey);
1149
- if (mappedId === connectionId) {
1150
- extensionKeyIndex.delete(connection.stableKey);
1151
- }
1248
+ const currentRelayState = store.getState();
1249
+ const closingExtension = currentRelayState.extensions.get(connectionId);
1250
+ const successorCandidates = closingExtension
1251
+ ? Array.from(currentRelayState.extensions.values())
1252
+ .reverse()
1253
+ .filter((ext) => {
1254
+ return ext.id !== connectionId && ext.stableKey === closingExtension.stableKey && Boolean(ext.ws);
1255
+ })
1256
+ : [];
1257
+ const successorExtension = closingExtension
1258
+ ? successorCandidates[0]
1259
+ : undefined;
1260
+ if (successorExtension) {
1261
+ logger?.log(pc.yellow(`Rebinding clients from ${connectionId} to ${successorExtension.id} (stableKey: ${successorExtension.stableKey})`));
1262
+ store.setState((s) => {
1263
+ return relayState.rebindClientsToExtension(s, {
1264
+ fromExtensionId: connectionId,
1265
+ toExtensionId: successorExtension.id,
1266
+ });
1267
+ });
1152
1268
  }
1153
- extensionConnections.delete(connectionId);
1154
- for (const [clientId, client] of playwrightClients.entries()) {
1155
- if (client.extensionId !== connectionId) {
1156
- continue;
1269
+ // Close playwright clients bound to this extension when no successor exists.
1270
+ if (!successorExtension) {
1271
+ const { playwrightClients } = store.getState();
1272
+ for (const client of playwrightClients.values()) {
1273
+ if (client.extensionId === connectionId) {
1274
+ client.ws.close(1000, 'Extension disconnected');
1275
+ }
1157
1276
  }
1158
- client.ws.close(1000, 'Extension disconnected');
1159
- playwrightClients.delete(clientId);
1160
1277
  }
1278
+ // State transition: remove extension + its bound clients atomically
1279
+ store.setState((s) => relayState.removeExtension(s, { extensionId: connectionId }));
1161
1280
  },
1162
1281
  onError(event) {
1163
1282
  logger?.error('Extension WebSocket error:', event);
1164
- }
1283
+ },
1165
1284
  };
1166
1285
  }));
1167
1286
  // ============================================================================
@@ -1233,7 +1352,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1233
1352
  app.use('/recording/*', privilegedRouteMiddleware);
1234
1353
  app.post('/cli/execute', async (c) => {
1235
1354
  try {
1236
- const body = await c.req.json();
1355
+ const body = (await c.req.json());
1237
1356
  const sessionId = normalizeSessionId(body.sessionId);
1238
1357
  const { code, timeout = 10000 } = body;
1239
1358
  if (!sessionId || !code) {
@@ -1254,7 +1373,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1254
1373
  });
1255
1374
  app.post('/cli/reset', async (c) => {
1256
1375
  try {
1257
- const body = await c.req.json();
1376
+ const body = (await c.req.json());
1258
1377
  const sessionId = normalizeSessionId(body.sessionId);
1259
1378
  if (!sessionId) {
1260
1379
  return c.json({ error: 'sessionId is required' }, 400);
@@ -1284,13 +1403,13 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1284
1403
  return c.json({ next: nextSessionNumber });
1285
1404
  });
1286
1405
  app.post('/cli/session/new', async (c) => {
1287
- const body = await c.req.json().catch(() => ({}));
1406
+ const body = (await c.req.json().catch(() => ({})));
1288
1407
  const sessionId = String(nextSessionNumber++);
1289
1408
  const extensionId = body.extensionId || null;
1290
1409
  const cwd = body.cwd;
1291
- const allowDefault = !extensionId && extensionConnections.size === 1;
1292
- const extension = getExtensionConnection(extensionId, { allowFallback: allowDefault });
1293
- if (!extension) {
1410
+ const allowDefault = !extensionId && store.getState().extensions.size === 1;
1411
+ const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault });
1412
+ if (!conn) {
1294
1413
  const error = extensionId
1295
1414
  ? `Extension not connected: ${extensionId}`
1296
1415
  : 'Multiple extensions connected. Specify extensionId.';
@@ -1301,9 +1420,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1301
1420
  sessionId,
1302
1421
  cwd,
1303
1422
  sessionMetadata: {
1304
- extensionId: extension.stableKey,
1305
- browser: extension.info.browser || null,
1306
- profile: extension.info ? { email: extension.info.email || '', id: extension.info.id || '' } : null,
1423
+ extensionId: conn.stableKey,
1424
+ browser: conn.info.browser || null,
1425
+ profile: conn.info ? { email: conn.info.email || '', id: conn.info.id || '' } : null,
1307
1426
  },
1308
1427
  });
1309
1428
  const metadata = executor.getSessionMetadata();
@@ -1331,7 +1450,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1331
1450
  });
1332
1451
  app.post('/cli/session/delete', async (c) => {
1333
1452
  try {
1334
- const body = await c.req.json();
1453
+ const body = (await c.req.json());
1335
1454
  const sessionId = normalizeSessionId(body.sessionId);
1336
1455
  if (!sessionId) {
1337
1456
  return c.json({ error: 'sessionId is required' }, 400);
@@ -1352,70 +1471,54 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1352
1471
  // Recording Endpoints - For screen recording via chrome.tabCapture
1353
1472
  // ============================================================================
1354
1473
  app.post('/recording/start', async (c) => {
1355
- const body = await c.req.json();
1474
+ const body = (await c.req.json());
1356
1475
  const sessionId = normalizeSessionId(body.sessionId);
1357
1476
  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;
1477
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
1364
1478
  const relay = getRecordingRelay(extensionId);
1365
1479
  if (!relay) {
1366
1480
  return c.json({ success: false, error: 'Extension not connected' }, 500);
1367
1481
  }
1368
- const recordingParams = (sessionId ? { ...recordingOptions, sessionId } : recordingOptions);
1482
+ const recordingParams = (resolvedSessionId
1483
+ ? { ...recordingOptions, sessionId: resolvedSessionId }
1484
+ : recordingOptions);
1369
1485
  const result = await relay.startRecording(recordingParams);
1370
- const status = result.success ? 200 : (result.error?.includes('required') ? 400 : 500);
1486
+ const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500;
1371
1487
  return c.json(result, status);
1372
1488
  });
1373
1489
  app.post('/recording/stop', async (c) => {
1374
- const body = await c.req.json();
1490
+ const body = (await c.req.json());
1375
1491
  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;
1492
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
1382
1493
  const relay = getRecordingRelay(extensionId);
1383
1494
  if (!relay) {
1384
1495
  return c.json({ success: false, error: 'Extension not connected' }, 500);
1385
1496
  }
1386
- const stopParams = sessionId ? { sessionId } : {};
1497
+ const stopParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
1387
1498
  const result = await relay.stopRecording(stopParams);
1388
- const status = result.success ? 200 : (result.error?.includes('not found') ? 404 : 500);
1499
+ const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500;
1389
1500
  return c.json(result, status);
1390
1501
  });
1391
1502
  app.get('/recording/status', async (c) => {
1392
1503
  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;
1504
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
1397
1505
  const relay = getRecordingRelay(extensionId);
1398
1506
  if (!relay) {
1399
1507
  return c.json({ isRecording: false });
1400
1508
  }
1401
- const isRecordingParams = normalizedSessionId ? { sessionId: normalizedSessionId } : {};
1509
+ const isRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
1402
1510
  const result = await relay.isRecording(isRecordingParams);
1403
1511
  return c.json(result);
1404
1512
  });
1405
1513
  app.post('/recording/cancel', async (c) => {
1406
- const body = await c.req.json();
1514
+ const body = (await c.req.json());
1407
1515
  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;
1516
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
1414
1517
  const relay = getRecordingRelay(extensionId);
1415
1518
  if (!relay) {
1416
1519
  return c.json({ success: false, error: 'Extension not connected' }, 500);
1417
1520
  }
1418
- const cancelParams = sessionId ? { sessionId } : {};
1521
+ const cancelParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
1419
1522
  const result = await relay.cancelRecording(cancelParams);
1420
1523
  return c.json(result);
1421
1524
  });
@@ -1431,14 +1534,21 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1431
1534
  logger?.log('CDP endpoint:', cdpEndpoint);
1432
1535
  return {
1433
1536
  close() {
1537
+ const { extensions, playwrightClients } = store.getState();
1434
1538
  for (const client of playwrightClients.values()) {
1435
1539
  client.ws.close(1000, 'Server stopped');
1436
1540
  }
1437
- playwrightClients.clear();
1438
- for (const extension of extensionConnections.values()) {
1439
- extension.ws.close(1000, 'Server stopped');
1541
+ for (const ext of extensions.values()) {
1542
+ if (ext.pingInterval) {
1543
+ clearInterval(ext.pingInterval);
1544
+ }
1545
+ ext.ws?.close(1000, 'Server stopped');
1440
1546
  }
1441
- extensionConnections.clear();
1547
+ // Reset store state
1548
+ store.setState({
1549
+ extensions: new Map(),
1550
+ playwrightClients: new Map(),
1551
+ });
1442
1552
  server.close();
1443
1553
  emitter.removeAllListeners();
1444
1554
  },
@@ -1447,7 +1557,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
1447
1557
  },
1448
1558
  off(event, listener) {
1449
1559
  emitter.off(event, listener);
1450
- }
1560
+ },
1451
1561
  };
1452
1562
  }
1453
1563
  //# sourceMappingURL=cdp-relay.js.map