e2e-pilot 0.0.69

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 (152) hide show
  1. package/bin.js +3 -0
  2. package/dist/aria-snapshot.d.ts +95 -0
  3. package/dist/aria-snapshot.d.ts.map +1 -0
  4. package/dist/aria-snapshot.js +490 -0
  5. package/dist/aria-snapshot.js.map +1 -0
  6. package/dist/bippy.js +971 -0
  7. package/dist/cdp-relay.d.ts +16 -0
  8. package/dist/cdp-relay.d.ts.map +1 -0
  9. package/dist/cdp-relay.js +715 -0
  10. package/dist/cdp-relay.js.map +1 -0
  11. package/dist/cdp-session.d.ts +42 -0
  12. package/dist/cdp-session.d.ts.map +1 -0
  13. package/dist/cdp-session.js +154 -0
  14. package/dist/cdp-session.js.map +1 -0
  15. package/dist/cdp-types.d.ts +63 -0
  16. package/dist/cdp-types.d.ts.map +1 -0
  17. package/dist/cdp-types.js +91 -0
  18. package/dist/cdp-types.js.map +1 -0
  19. package/dist/cli.d.ts +3 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +213 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/create-logger.d.ts +9 -0
  24. package/dist/create-logger.d.ts.map +1 -0
  25. package/dist/create-logger.js +25 -0
  26. package/dist/create-logger.js.map +1 -0
  27. package/dist/debugger-api.md +458 -0
  28. package/dist/debugger-examples-types.d.ts +24 -0
  29. package/dist/debugger-examples-types.d.ts.map +1 -0
  30. package/dist/debugger-examples-types.js +2 -0
  31. package/dist/debugger-examples-types.js.map +1 -0
  32. package/dist/debugger-examples.d.ts +6 -0
  33. package/dist/debugger-examples.d.ts.map +1 -0
  34. package/dist/debugger-examples.js +53 -0
  35. package/dist/debugger-examples.js.map +1 -0
  36. package/dist/debugger.d.ts +381 -0
  37. package/dist/debugger.d.ts.map +1 -0
  38. package/dist/debugger.js +633 -0
  39. package/dist/debugger.js.map +1 -0
  40. package/dist/editor-api.md +364 -0
  41. package/dist/editor-examples.d.ts +11 -0
  42. package/dist/editor-examples.d.ts.map +1 -0
  43. package/dist/editor-examples.js +124 -0
  44. package/dist/editor-examples.js.map +1 -0
  45. package/dist/editor.d.ts +203 -0
  46. package/dist/editor.d.ts.map +1 -0
  47. package/dist/editor.js +336 -0
  48. package/dist/editor.js.map +1 -0
  49. package/dist/execute.d.ts +50 -0
  50. package/dist/execute.d.ts.map +1 -0
  51. package/dist/execute.js +576 -0
  52. package/dist/execute.js.map +1 -0
  53. package/dist/index.d.ts +11 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +7 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/mcp-client.d.ts +20 -0
  58. package/dist/mcp-client.d.ts.map +1 -0
  59. package/dist/mcp-client.js +56 -0
  60. package/dist/mcp-client.js.map +1 -0
  61. package/dist/mcp.d.ts +5 -0
  62. package/dist/mcp.d.ts.map +1 -0
  63. package/dist/mcp.js +720 -0
  64. package/dist/mcp.js.map +1 -0
  65. package/dist/mcp.test.d.ts +10 -0
  66. package/dist/mcp.test.d.ts.map +1 -0
  67. package/dist/mcp.test.js +2999 -0
  68. package/dist/mcp.test.js.map +1 -0
  69. package/dist/network-capture.d.ts +23 -0
  70. package/dist/network-capture.d.ts.map +1 -0
  71. package/dist/network-capture.js +98 -0
  72. package/dist/network-capture.js.map +1 -0
  73. package/dist/protocol.d.ts +54 -0
  74. package/dist/protocol.d.ts.map +1 -0
  75. package/dist/protocol.js +2 -0
  76. package/dist/protocol.js.map +1 -0
  77. package/dist/react-source.d.ts +13 -0
  78. package/dist/react-source.d.ts.map +1 -0
  79. package/dist/react-source.js +68 -0
  80. package/dist/react-source.js.map +1 -0
  81. package/dist/scoped-fs.d.ts +94 -0
  82. package/dist/scoped-fs.d.ts.map +1 -0
  83. package/dist/scoped-fs.js +356 -0
  84. package/dist/scoped-fs.js.map +1 -0
  85. package/dist/selector-generator.js +8126 -0
  86. package/dist/start-relay-server.d.ts +6 -0
  87. package/dist/start-relay-server.d.ts.map +1 -0
  88. package/dist/start-relay-server.js +33 -0
  89. package/dist/start-relay-server.js.map +1 -0
  90. package/dist/styles-api.md +117 -0
  91. package/dist/styles-examples.d.ts +8 -0
  92. package/dist/styles-examples.d.ts.map +1 -0
  93. package/dist/styles-examples.js +64 -0
  94. package/dist/styles-examples.js.map +1 -0
  95. package/dist/styles.d.ts +27 -0
  96. package/dist/styles.d.ts.map +1 -0
  97. package/dist/styles.js +234 -0
  98. package/dist/styles.js.map +1 -0
  99. package/dist/trace-utils.d.ts +14 -0
  100. package/dist/trace-utils.d.ts.map +1 -0
  101. package/dist/trace-utils.js +21 -0
  102. package/dist/trace-utils.js.map +1 -0
  103. package/dist/utils.d.ts +20 -0
  104. package/dist/utils.d.ts.map +1 -0
  105. package/dist/utils.js +75 -0
  106. package/dist/utils.js.map +1 -0
  107. package/dist/wait-for-page-load.d.ts +16 -0
  108. package/dist/wait-for-page-load.d.ts.map +1 -0
  109. package/dist/wait-for-page-load.js +127 -0
  110. package/dist/wait-for-page-load.js.map +1 -0
  111. package/package.json +67 -0
  112. package/src/aria-snapshot.ts +610 -0
  113. package/src/assets/aria-labels-github-snapshot.txt +605 -0
  114. package/src/assets/aria-labels-github.png +0 -0
  115. package/src/assets/aria-labels-google-snapshot.txt +49 -0
  116. package/src/assets/aria-labels-google.png +0 -0
  117. package/src/assets/aria-labels-hacker-news-snapshot.txt +1023 -0
  118. package/src/assets/aria-labels-hacker-news.png +0 -0
  119. package/src/cdp-relay.ts +925 -0
  120. package/src/cdp-session.ts +203 -0
  121. package/src/cdp-timing.md +128 -0
  122. package/src/cdp-types.ts +155 -0
  123. package/src/cli.ts +250 -0
  124. package/src/create-logger.ts +36 -0
  125. package/src/debugger-examples-types.ts +13 -0
  126. package/src/debugger-examples.ts +66 -0
  127. package/src/debugger.md +453 -0
  128. package/src/debugger.ts +713 -0
  129. package/src/editor-examples.ts +148 -0
  130. package/src/editor.ts +390 -0
  131. package/src/execute.ts +763 -0
  132. package/src/index.ts +10 -0
  133. package/src/mcp-client.ts +78 -0
  134. package/src/mcp.test.ts +3596 -0
  135. package/src/mcp.ts +876 -0
  136. package/src/network-capture.ts +140 -0
  137. package/src/prompt.bak.md +323 -0
  138. package/src/prompt.md +7 -0
  139. package/src/protocol.ts +63 -0
  140. package/src/react-source.ts +94 -0
  141. package/src/resource.md +436 -0
  142. package/src/scoped-fs.ts +411 -0
  143. package/src/snapshots/hacker-news-focused-accessibility.md +202 -0
  144. package/src/snapshots/hacker-news-initial-accessibility.md +11 -0
  145. package/src/snapshots/hacker-news-tabbed-accessibility.md +202 -0
  146. package/src/snapshots/shadcn-ui-accessibility.md +11 -0
  147. package/src/start-relay-server.ts +43 -0
  148. package/src/styles-examples.ts +77 -0
  149. package/src/styles.ts +345 -0
  150. package/src/trace-utils.ts +43 -0
  151. package/src/utils.ts +91 -0
  152. package/src/wait-for-page-load.ts +174 -0
@@ -0,0 +1,925 @@
1
+ import { Hono } from 'hono'
2
+ import { serve } from '@hono/node-server'
3
+ import { getConnInfo } from '@hono/node-server/conninfo'
4
+ import { createNodeWebSocket } from '@hono/node-ws'
5
+ import type { WSContext } from 'hono/ws'
6
+ import type { Protocol } from './cdp-types.js'
7
+ import type { CDPCommand, CDPResponseBase, CDPEventBase, CDPEventFor, RelayServerEvents } from './cdp-types.js'
8
+ import type { ExtensionMessage, ExtensionEventMessage } from './protocol.js'
9
+ import chalk from 'chalk'
10
+ import { EventEmitter } from 'node:events'
11
+ import { VERSION } from './utils.js'
12
+
13
+ type ConnectedTarget = {
14
+ sessionId: string
15
+ targetId: string
16
+ targetInfo: Protocol.Target.TargetInfo
17
+ }
18
+
19
+ type PlaywrightClient = {
20
+ id: string
21
+ ws: WSContext
22
+ }
23
+
24
+ export type RelayServer = {
25
+ close(): void
26
+ on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
27
+ off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
28
+ }
29
+
30
+ export async function startE2EPilotCDPRelayServer({
31
+ port = 19988,
32
+ host = '127.0.0.1',
33
+ token,
34
+ logger,
35
+ }: {
36
+ port?: number
37
+ host?: string
38
+ token?: string
39
+ logger?: { log(...args: any[]): void; error(...args: any[]): void }
40
+ } = {}): Promise<RelayServer> {
41
+ const emitter = new EventEmitter()
42
+ const connectedTargets = new Map<string, ConnectedTarget>()
43
+
44
+ const playwrightClients = new Map<string, PlaywrightClient>()
45
+ let extensionWs: WSContext | null = null
46
+
47
+ const extensionPendingRequests = new Map<
48
+ number,
49
+ {
50
+ resolve: (result: any) => void
51
+ reject: (error: Error) => void
52
+ }
53
+ >()
54
+ let extensionMessageId = 0
55
+ let extensionPingInterval: ReturnType<typeof setInterval> | null = null
56
+
57
+ function startExtensionPing() {
58
+ if (extensionPingInterval) {
59
+ clearInterval(extensionPingInterval)
60
+ }
61
+ extensionPingInterval = setInterval(() => {
62
+ extensionWs?.send(JSON.stringify({ method: 'ping' }))
63
+ }, 5000)
64
+ }
65
+
66
+ function stopExtensionPing() {
67
+ if (extensionPingInterval) {
68
+ clearInterval(extensionPingInterval)
69
+ extensionPingInterval = null
70
+ }
71
+ }
72
+
73
+ function logCdpMessage({
74
+ direction,
75
+ clientId,
76
+ method,
77
+ sessionId,
78
+ params,
79
+ id,
80
+ source,
81
+ }: {
82
+ direction: 'to-playwright' | 'from-playwright' | 'from-extension'
83
+ clientId?: string
84
+ method: string
85
+ sessionId?: string
86
+ params?: any
87
+ id?: number
88
+ source?: 'extension' | 'server'
89
+ }) {
90
+ const noisyEvents = [
91
+ 'Network.requestWillBeSentExtraInfo',
92
+ 'Network.responseReceived',
93
+ 'Network.responseReceivedExtraInfo',
94
+ 'Network.dataReceived',
95
+ 'Network.requestWillBeSent',
96
+ 'Network.loadingFinished',
97
+ ]
98
+
99
+ if (noisyEvents.includes(method)) {
100
+ return
101
+ }
102
+
103
+ const details: string[] = []
104
+
105
+ if (id !== undefined) {
106
+ details.push(`id=${id}`)
107
+ }
108
+
109
+ if (sessionId) {
110
+ details.push(`sessionId=${sessionId}`)
111
+ }
112
+
113
+ if (params) {
114
+ if (params.targetId) {
115
+ details.push(`targetId=${params.targetId}`)
116
+ }
117
+ if (params.targetInfo?.targetId) {
118
+ details.push(`targetId=${params.targetInfo.targetId}`)
119
+ }
120
+ if (params.sessionId && params.sessionId !== sessionId) {
121
+ details.push(`sessionId=${params.sessionId}`)
122
+ }
123
+ }
124
+
125
+ const detailsStr = details.length > 0 ? ` ${chalk.gray(details.join(', '))}` : ''
126
+
127
+ if (direction === 'from-playwright') {
128
+ const clientLabel = clientId ? chalk.blue(`[${clientId}]`) : ''
129
+ logger?.log(chalk.cyan('← Playwright'), clientLabel + ':', method + detailsStr)
130
+ } else if (direction === 'from-extension') {
131
+ logger?.log(chalk.yellow('← Extension:'), method + detailsStr)
132
+ } else if (direction === 'to-playwright') {
133
+ const color = source === 'server' ? chalk.magenta : chalk.green
134
+ const sourceLabel = source === 'server' ? chalk.gray(' (server-generated)') : ''
135
+ const clientLabel = clientId ? chalk.blue(`[${clientId}]`) : chalk.blue('[ALL]')
136
+ logger?.log(color('→ Playwright'), clientLabel + ':', method + detailsStr + sourceLabel)
137
+ }
138
+ }
139
+
140
+ function sendToPlaywright({
141
+ message,
142
+ clientId,
143
+ source = 'extension',
144
+ }: {
145
+ message: CDPResponseBase | CDPEventBase
146
+ clientId?: string
147
+ source?: 'extension' | 'server'
148
+ }) {
149
+ const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message
150
+
151
+ if ('method' in message) {
152
+ logCdpMessage({
153
+ direction: 'to-playwright',
154
+ clientId,
155
+ method: message.method,
156
+ sessionId: 'sessionId' in message ? message.sessionId : undefined,
157
+ params: 'params' in message ? message.params : undefined,
158
+ source,
159
+ })
160
+ }
161
+
162
+ const messageStr = JSON.stringify(messageToSend)
163
+
164
+ if (clientId) {
165
+ const client = playwrightClients.get(clientId)
166
+ if (client) {
167
+ client.ws.send(messageStr)
168
+ }
169
+ } else {
170
+ for (const client of playwrightClients.values()) {
171
+ client.ws.send(messageStr)
172
+ }
173
+ }
174
+ }
175
+
176
+ async function sendToExtension({
177
+ method,
178
+ params,
179
+ timeout = 30000,
180
+ }: {
181
+ method: string
182
+ params?: any
183
+ timeout?: number
184
+ }) {
185
+ if (!extensionWs) {
186
+ throw new Error('Extension not connected')
187
+ }
188
+
189
+ const id = ++extensionMessageId
190
+ const message = { id, method, params }
191
+
192
+ extensionWs.send(JSON.stringify(message))
193
+
194
+ return new Promise((resolve, reject) => {
195
+ const timeoutId = setTimeout(() => {
196
+ extensionPendingRequests.delete(id)
197
+ reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`))
198
+ }, timeout)
199
+
200
+ extensionPendingRequests.set(id, {
201
+ resolve: (result) => {
202
+ clearTimeout(timeoutId)
203
+ resolve(result)
204
+ },
205
+ reject: (error) => {
206
+ clearTimeout(timeoutId)
207
+ reject(error)
208
+ },
209
+ })
210
+ })
211
+ }
212
+
213
+ // Auto-create initial tab when E2E_PILOT_AUTO_ENABLE is set and no targets exist.
214
+ // This allows Playwright to connect and immediately have a page to work with.
215
+ async function maybeAutoCreateInitialTab(): Promise<void> {
216
+ if (!process.env.E2E_PILOT_AUTO_ENABLE) {
217
+ return
218
+ }
219
+ if (!extensionWs) {
220
+ return
221
+ }
222
+ if (connectedTargets.size > 0) {
223
+ return
224
+ }
225
+
226
+ try {
227
+ logger?.log(chalk.blue('Auto-creating initial tab for Playwright client'))
228
+ const result = (await sendToExtension({ method: 'createInitialTab', timeout: 10000 })) as {
229
+ success: boolean
230
+ tabId: number
231
+ sessionId: string
232
+ targetInfo: Protocol.Target.TargetInfo
233
+ }
234
+ if (result.success && result.sessionId && result.targetInfo) {
235
+ connectedTargets.set(result.sessionId, {
236
+ sessionId: result.sessionId,
237
+ targetId: result.targetInfo.targetId,
238
+ targetInfo: result.targetInfo,
239
+ })
240
+ logger?.log(
241
+ chalk.blue(`Auto-created tab, now have ${connectedTargets.size} targets, url: ${result.targetInfo.url}`),
242
+ )
243
+ }
244
+ } catch (e) {
245
+ logger?.error('Failed to auto-create initial tab:', e)
246
+ }
247
+ }
248
+
249
+ async function routeCdpCommand({ method, params, sessionId }: { method: string; params: any; sessionId?: string }) {
250
+ switch (method) {
251
+ case 'Browser.getVersion': {
252
+ return {
253
+ protocolVersion: '1.3',
254
+ product: 'Chrome/Extension-Bridge',
255
+ revision: '1.0.0',
256
+ userAgent: 'CDP-Bridge-Server/1.0.0',
257
+ jsVersion: 'V8',
258
+ } satisfies Protocol.Browser.GetVersionResponse
259
+ }
260
+
261
+ case 'Browser.setDownloadBehavior': {
262
+ return {}
263
+ }
264
+
265
+ // Target.setAutoAttach is a CDP command Playwright sends on first connection.
266
+ // We use it as the hook to auto-create an initial tab. If Playwright changes
267
+ // its initialization sequence in the future, this could be moved to a different command.
268
+ case 'Target.setAutoAttach': {
269
+ if (sessionId) {
270
+ break
271
+ }
272
+ await maybeAutoCreateInitialTab()
273
+ return {}
274
+ }
275
+
276
+ case 'Target.setDiscoverTargets': {
277
+ return {}
278
+ }
279
+
280
+ case 'Target.attachToTarget': {
281
+ const targetId = params?.targetId
282
+ if (!targetId) {
283
+ throw new Error('targetId is required for Target.attachToTarget')
284
+ }
285
+
286
+ for (const target of connectedTargets.values()) {
287
+ if (target.targetId === targetId) {
288
+ return { sessionId: target.sessionId } satisfies Protocol.Target.AttachToTargetResponse
289
+ }
290
+ }
291
+
292
+ throw new Error(`Target ${targetId} not found in connected targets`)
293
+ }
294
+
295
+ case 'Target.getTargetInfo': {
296
+ const targetId = params?.targetId
297
+
298
+ if (targetId) {
299
+ for (const target of connectedTargets.values()) {
300
+ if (target.targetId === targetId) {
301
+ return { targetInfo: target.targetInfo }
302
+ }
303
+ }
304
+ }
305
+
306
+ if (sessionId) {
307
+ const target = connectedTargets.get(sessionId)
308
+ if (target) {
309
+ return { targetInfo: target.targetInfo }
310
+ }
311
+ }
312
+
313
+ const firstTarget = Array.from(connectedTargets.values())[0]
314
+ return { targetInfo: firstTarget?.targetInfo }
315
+ }
316
+
317
+ case 'Target.getTargets': {
318
+ return {
319
+ targetInfos: Array.from(connectedTargets.values()).map((t) => ({
320
+ ...t.targetInfo,
321
+ attached: true,
322
+ })),
323
+ }
324
+ }
325
+
326
+ case 'Target.createTarget': {
327
+ return await sendToExtension({
328
+ method: 'forwardCDPCommand',
329
+ params: { method, params },
330
+ })
331
+ }
332
+
333
+ case 'Target.closeTarget': {
334
+ return await sendToExtension({
335
+ method: 'forwardCDPCommand',
336
+ params: { method, params },
337
+ })
338
+ }
339
+
340
+ case 'Runtime.enable': {
341
+ if (!sessionId) {
342
+ break
343
+ }
344
+
345
+ const contextCreatedPromise = new Promise<void>((resolve) => {
346
+ const handler = ({ event }: { event: CDPEventBase }) => {
347
+ if (event.method === 'Runtime.executionContextCreated' && event.sessionId === sessionId) {
348
+ const params = event.params as Protocol.Runtime.ExecutionContextCreatedEvent | undefined
349
+ if (params?.context?.auxData?.isDefault === true) {
350
+ clearTimeout(timeout)
351
+ emitter.off('cdp:event', handler)
352
+ resolve()
353
+ }
354
+ }
355
+ }
356
+ const timeout = setTimeout(() => {
357
+ emitter.off('cdp:event', handler)
358
+ logger?.log(
359
+ chalk.yellow(
360
+ `IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`,
361
+ ),
362
+ )
363
+ resolve()
364
+ }, 3000)
365
+ emitter.on('cdp:event', handler)
366
+ })
367
+
368
+ const result = await sendToExtension({
369
+ method: 'forwardCDPCommand',
370
+ params: { sessionId, method, params },
371
+ })
372
+
373
+ await contextCreatedPromise
374
+
375
+ return result
376
+ }
377
+ }
378
+
379
+ return await sendToExtension({
380
+ method: 'forwardCDPCommand',
381
+ params: { sessionId, method, params },
382
+ })
383
+ }
384
+
385
+ const app = new Hono()
386
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
387
+
388
+ app.get('/', (c) => {
389
+ return c.text('OK')
390
+ })
391
+
392
+ app.get('/version', (c) => {
393
+ return c.json({ version: VERSION })
394
+ })
395
+
396
+ app.get('/extension/status', (c) => {
397
+ return c.json({ connected: extensionWs !== null })
398
+ })
399
+
400
+ app.post('/mcp-log', async (c) => {
401
+ try {
402
+ const { level, args } = await c.req.json()
403
+ const logFn = (logger as any)?.[level] || logger?.log
404
+ const prefix = chalk.red(`[MCP] [${level.toUpperCase()}]`)
405
+ logFn?.(prefix, ...args)
406
+ return c.json({ ok: true })
407
+ } catch {
408
+ return c.json({ ok: false }, 400)
409
+ }
410
+ })
411
+
412
+ // Validate Origin header for WebSocket connections to prevent cross-origin attacks.
413
+ // Browsers always send Origin header for WebSocket connections, but Node.js clients don't.
414
+ // We allow any chrome-extension:// origin since the localhost IP check already ensures
415
+ // only local connections are accepted - the main security concern.
416
+ // This avoids hardcoding extension IDs which vary per machine for unpacked extensions.
417
+
418
+ app.get(
419
+ '/cdp/:clientId?',
420
+ (c, next) => {
421
+ const clientId = c.req.param('clientId') || 'default'
422
+ const origin = c.req.header('origin')
423
+
424
+ logger?.log(chalk.blue(`CDP connection request: clientId=${clientId}, origin=${origin || 'none'}`))
425
+
426
+ // Validate Origin header if present (Node.js clients don't send it)
427
+ // Only allow chrome-extension:// origins - reject http/https origins from websites
428
+ if (origin && !origin.startsWith('chrome-extension://')) {
429
+ logger?.log(chalk.red(`Rejecting /cdp WebSocket from origin: ${origin}`))
430
+ return c.text('Forbidden', 403)
431
+ }
432
+
433
+ if (token) {
434
+ const url = new URL(c.req.url, 'http://localhost')
435
+ const providedToken = url.searchParams.get('token')
436
+ if (providedToken !== token) {
437
+ logger?.log(chalk.red(`Rejecting /cdp WebSocket: invalid token`))
438
+ return c.text('Unauthorized', 401)
439
+ }
440
+ }
441
+ return next()
442
+ },
443
+ upgradeWebSocket((c) => {
444
+ const clientId = c.req.param('clientId') || 'default'
445
+
446
+ return {
447
+ async onOpen(_event, ws) {
448
+ if (playwrightClients.has(clientId)) {
449
+ logger?.log(chalk.red(`Rejecting duplicate client ID: ${clientId}`))
450
+ ws.close(1000, 'Client ID already connected')
451
+ return
452
+ }
453
+
454
+ // Add client first so it can receive Target.attachedToTarget events
455
+ playwrightClients.set(clientId, { id: clientId, ws })
456
+ logger?.log(
457
+ chalk.green(
458
+ `Playwright client connected: ${clientId} (${playwrightClients.size} total) (extension? ${!!extensionWs}) (${connectedTargets.size} pages)`,
459
+ ),
460
+ )
461
+ },
462
+
463
+ async onMessage(event, ws) {
464
+ let message: CDPCommand
465
+
466
+ try {
467
+ message = JSON.parse(event.data.toString())
468
+ } catch {
469
+ return
470
+ }
471
+
472
+ const { id, sessionId, method, params } = message
473
+
474
+ logCdpMessage({
475
+ direction: 'from-playwright',
476
+ clientId,
477
+ method,
478
+ sessionId,
479
+ id,
480
+ })
481
+
482
+ emitter.emit('cdp:command', { clientId, command: message })
483
+
484
+ if (!extensionWs) {
485
+ sendToPlaywright({
486
+ message: {
487
+ id,
488
+ sessionId,
489
+ error: { message: 'Extension not connected' },
490
+ },
491
+ clientId,
492
+ })
493
+ return
494
+ }
495
+
496
+ try {
497
+ const result: any = await routeCdpCommand({ method, params, sessionId })
498
+
499
+ if (method === 'Target.setAutoAttach' && !sessionId) {
500
+ for (const target of connectedTargets.values()) {
501
+ const attachedPayload = {
502
+ method: 'Target.attachedToTarget',
503
+ params: {
504
+ sessionId: target.sessionId,
505
+ targetInfo: {
506
+ ...target.targetInfo,
507
+ attached: true,
508
+ },
509
+ waitingForDebugger: false,
510
+ },
511
+ } satisfies CDPEventFor<'Target.attachedToTarget'>
512
+ if (!target.targetInfo.url) {
513
+ logger?.error(
514
+ chalk.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'),
515
+ JSON.stringify(attachedPayload),
516
+ )
517
+ }
518
+ logger?.log(
519
+ chalk.magenta('[Server] Target.attachedToTarget full payload:'),
520
+ JSON.stringify(attachedPayload),
521
+ )
522
+ sendToPlaywright({
523
+ message: attachedPayload,
524
+ clientId,
525
+ source: 'server',
526
+ })
527
+ }
528
+ }
529
+
530
+ if (method === 'Target.setDiscoverTargets' && (params as any)?.discover) {
531
+ for (const target of connectedTargets.values()) {
532
+ const targetCreatedPayload = {
533
+ method: 'Target.targetCreated',
534
+ params: {
535
+ targetInfo: {
536
+ ...target.targetInfo,
537
+ attached: true,
538
+ },
539
+ },
540
+ } satisfies CDPEventFor<'Target.targetCreated'>
541
+ if (!target.targetInfo.url) {
542
+ logger?.error(
543
+ chalk.red('[Server] WARNING: Target.targetCreated sent with empty URL!'),
544
+ JSON.stringify(targetCreatedPayload),
545
+ )
546
+ }
547
+ logger?.log(
548
+ chalk.magenta('[Server] Target.targetCreated full payload:'),
549
+ JSON.stringify(targetCreatedPayload),
550
+ )
551
+ sendToPlaywright({
552
+ message: targetCreatedPayload,
553
+ clientId,
554
+ source: 'server',
555
+ })
556
+ }
557
+ }
558
+
559
+ if (method === 'Target.attachToTarget' && result?.sessionId) {
560
+ const targetId = params?.targetId
561
+ const target = Array.from(connectedTargets.values()).find((t) => t.targetId === targetId)
562
+ if (target) {
563
+ const attachedPayload = {
564
+ method: 'Target.attachedToTarget',
565
+ params: {
566
+ sessionId: result.sessionId,
567
+ targetInfo: {
568
+ ...target.targetInfo,
569
+ attached: true,
570
+ },
571
+ waitingForDebugger: false,
572
+ },
573
+ } satisfies CDPEventFor<'Target.attachedToTarget'>
574
+ if (!target.targetInfo.url) {
575
+ logger?.error(
576
+ chalk.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'),
577
+ JSON.stringify(attachedPayload),
578
+ )
579
+ }
580
+ logger?.log(
581
+ chalk.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'),
582
+ JSON.stringify(attachedPayload),
583
+ )
584
+ sendToPlaywright({
585
+ message: attachedPayload,
586
+ clientId,
587
+ source: 'server',
588
+ })
589
+ }
590
+ }
591
+
592
+ const response: CDPResponseBase = { id, sessionId, result }
593
+ sendToPlaywright({ message: response, clientId })
594
+ emitter.emit('cdp:response', { clientId, response, command: message })
595
+ } catch (e) {
596
+ logger?.error('Error handling CDP command:', method, params, e)
597
+ const errorResponse: CDPResponseBase = {
598
+ id,
599
+ sessionId,
600
+ error: { message: (e as Error).message },
601
+ }
602
+ sendToPlaywright({ message: errorResponse, clientId })
603
+ emitter.emit('cdp:response', { clientId, response: errorResponse, command: message })
604
+ }
605
+ },
606
+
607
+ onClose() {
608
+ playwrightClients.delete(clientId)
609
+ logger?.log(chalk.yellow(`Playwright client disconnected: ${clientId} (${playwrightClients.size} remaining)`))
610
+ },
611
+
612
+ onError(event) {
613
+ logger?.error(`Playwright WebSocket error [${clientId}]:`, event)
614
+ },
615
+ }
616
+ }),
617
+ )
618
+
619
+ app.get(
620
+ '/extension',
621
+ (c, next) => {
622
+ // 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
623
+ // This prevents attackers on the network from hijacking the browser session
624
+ // even if the server is exposed via 0.0.0.0.
625
+ const info = getConnInfo(c)
626
+ const remoteAddress = info.remote.address
627
+ const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1'
628
+
629
+ if (!isLocalhost) {
630
+ logger?.log(chalk.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`))
631
+ return c.text('Forbidden - Extension must be local', 403)
632
+ }
633
+
634
+ // 2. Origin Validation: Prevent browser-based attacks (CSRF).
635
+ // Browsers cannot spoof the Origin header, so this ensures the connection
636
+ // is coming from a Chrome Extension, not a malicious website.
637
+ // We accept any chrome-extension:// origin since localhost check already provides security.
638
+ const origin = c.req.header('origin')
639
+ if (!origin || !origin.startsWith('chrome-extension://')) {
640
+ logger?.log(
641
+ chalk.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`),
642
+ )
643
+ return c.text('Forbidden', 403)
644
+ }
645
+
646
+ return next()
647
+ },
648
+ upgradeWebSocket(() => {
649
+ return {
650
+ onOpen(_event, ws) {
651
+ if (extensionWs) {
652
+ logger?.log(chalk.yellow('Closing existing extension connection to replace with new one'))
653
+ extensionWs.close(4001, 'Extension Replaced')
654
+
655
+ // Clear state from the old connection to prevent leaks
656
+ connectedTargets.clear()
657
+ for (const pending of extensionPendingRequests.values()) {
658
+ pending.reject(new Error('Extension connection replaced'))
659
+ }
660
+ extensionPendingRequests.clear()
661
+
662
+ for (const client of playwrightClients.values()) {
663
+ client.ws.close(1000, 'Extension Replaced')
664
+ }
665
+ playwrightClients.clear()
666
+ }
667
+
668
+ extensionWs = ws
669
+ startExtensionPing()
670
+ logger?.log('Extension connected with clean state')
671
+ },
672
+
673
+ async onMessage(event, ws) {
674
+ let message: ExtensionMessage
675
+
676
+ try {
677
+ message = JSON.parse(event.data.toString())
678
+ } catch {
679
+ ws.close(1000, 'Invalid JSON')
680
+ return
681
+ }
682
+
683
+ if (message.id !== undefined) {
684
+ const pending = extensionPendingRequests.get(message.id)
685
+ if (!pending) {
686
+ logger?.log('Unexpected response with id:', message.id)
687
+ return
688
+ }
689
+
690
+ extensionPendingRequests.delete(message.id)
691
+
692
+ if (message.error) {
693
+ pending.reject(new Error(message.error))
694
+ } else {
695
+ pending.resolve(message.result)
696
+ }
697
+ } else if (message.method === 'pong') {
698
+ // Keep-alive response, nothing to do
699
+ } else if (message.method === 'log') {
700
+ const { level, args } = message.params
701
+ const logFn = (logger as any)?.[level] || logger?.log
702
+ const prefix = chalk.yellow(`[Extension] [${level.toUpperCase()}]`)
703
+ logFn?.(prefix, ...args)
704
+ } else {
705
+ const extensionEvent = message as ExtensionEventMessage
706
+
707
+ if (extensionEvent.method !== 'forwardCDPEvent') {
708
+ return
709
+ }
710
+
711
+ const { method, params, sessionId } = extensionEvent.params
712
+
713
+ logCdpMessage({
714
+ direction: 'from-extension',
715
+ method,
716
+ sessionId,
717
+ params,
718
+ })
719
+
720
+ const cdpEvent: CDPEventBase = { method, sessionId, params }
721
+ emitter.emit('cdp:event', { event: cdpEvent, sessionId })
722
+
723
+ if (method === 'Target.attachedToTarget') {
724
+ const targetParams = params as Protocol.Target.AttachedToTargetEvent
725
+
726
+ if (!targetParams.targetInfo.url) {
727
+ logger?.error(
728
+ chalk.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'),
729
+ JSON.stringify({ method, params: targetParams, sessionId }),
730
+ )
731
+ }
732
+ logger?.log(
733
+ chalk.yellow('[Extension] Target.attachedToTarget full payload:'),
734
+ JSON.stringify({ method, params: targetParams, sessionId }),
735
+ )
736
+
737
+ // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
738
+ const alreadyConnected = connectedTargets.has(targetParams.sessionId)
739
+
740
+ // Always update our local state with latest target info
741
+ connectedTargets.set(targetParams.sessionId, {
742
+ sessionId: targetParams.sessionId,
743
+ targetId: targetParams.targetInfo.targetId,
744
+ targetInfo: targetParams.targetInfo,
745
+ })
746
+
747
+ // Only forward to Playwright if this is a new target to avoid duplicates
748
+ if (!alreadyConnected) {
749
+ sendToPlaywright({
750
+ message: {
751
+ method: 'Target.attachedToTarget',
752
+ params: targetParams,
753
+ } as CDPEventBase,
754
+ source: 'extension',
755
+ })
756
+ }
757
+ } else if (method === 'Target.detachedFromTarget') {
758
+ const detachParams = params as Protocol.Target.DetachedFromTargetEvent
759
+ connectedTargets.delete(detachParams.sessionId)
760
+
761
+ sendToPlaywright({
762
+ message: {
763
+ method: 'Target.detachedFromTarget',
764
+ params: detachParams,
765
+ } as CDPEventBase,
766
+ source: 'extension',
767
+ })
768
+ } else if (method === 'Target.targetCrashed') {
769
+ const crashParams = params as Protocol.Target.TargetCrashedEvent
770
+ for (const [sid, target] of connectedTargets.entries()) {
771
+ if (target.targetId === crashParams.targetId) {
772
+ connectedTargets.delete(sid)
773
+ logger?.log(chalk.red('[Server] Target crashed, removing:'), crashParams.targetId)
774
+ break
775
+ }
776
+ }
777
+
778
+ sendToPlaywright({
779
+ message: {
780
+ method: 'Target.targetCrashed',
781
+ params: crashParams,
782
+ } as CDPEventBase,
783
+ source: 'extension',
784
+ })
785
+ } else if (method === 'Target.targetInfoChanged') {
786
+ const infoParams = params as Protocol.Target.TargetInfoChangedEvent
787
+ for (const target of connectedTargets.values()) {
788
+ if (target.targetId === infoParams.targetInfo.targetId) {
789
+ target.targetInfo = infoParams.targetInfo
790
+ break
791
+ }
792
+ }
793
+
794
+ sendToPlaywright({
795
+ message: {
796
+ method: 'Target.targetInfoChanged',
797
+ params: infoParams,
798
+ } as CDPEventBase,
799
+ source: 'extension',
800
+ })
801
+ } else if (method === 'Page.frameNavigated') {
802
+ const frameParams = params as Protocol.Page.FrameNavigatedEvent
803
+ if (!frameParams.frame.parentId && sessionId) {
804
+ const target = connectedTargets.get(sessionId)
805
+ if (target) {
806
+ target.targetInfo = {
807
+ ...target.targetInfo,
808
+ url: frameParams.frame.url,
809
+ title: frameParams.frame.name || target.targetInfo.title,
810
+ }
811
+ logger?.log(
812
+ chalk.magenta('[Server] Updated target URL from Page.frameNavigated:'),
813
+ frameParams.frame.url,
814
+ )
815
+ }
816
+ }
817
+
818
+ sendToPlaywright({
819
+ message: {
820
+ sessionId,
821
+ method,
822
+ params,
823
+ } as CDPEventBase,
824
+ source: 'extension',
825
+ })
826
+ } else if (method === 'Page.navigatedWithinDocument') {
827
+ const navParams = params as Protocol.Page.NavigatedWithinDocumentEvent
828
+ if (sessionId) {
829
+ const target = connectedTargets.get(sessionId)
830
+ if (target) {
831
+ target.targetInfo = {
832
+ ...target.targetInfo,
833
+ url: navParams.url,
834
+ }
835
+ logger?.log(
836
+ chalk.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'),
837
+ navParams.url,
838
+ )
839
+ }
840
+ }
841
+
842
+ sendToPlaywright({
843
+ message: {
844
+ sessionId,
845
+ method,
846
+ params,
847
+ } as CDPEventBase,
848
+ source: 'extension',
849
+ })
850
+ } else {
851
+ sendToPlaywright({
852
+ message: {
853
+ sessionId,
854
+ method,
855
+ params,
856
+ } as CDPEventBase,
857
+ source: 'extension',
858
+ })
859
+ }
860
+ }
861
+ },
862
+
863
+ onClose(event, ws) {
864
+ logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'}`)
865
+ stopExtensionPing()
866
+
867
+ // If this is an old connection closing after we've already established a new one,
868
+ // don't clear the global state
869
+ if (extensionWs && extensionWs !== ws) {
870
+ logger?.log('Old extension connection closed, keeping new one active')
871
+ return
872
+ }
873
+
874
+ for (const pending of extensionPendingRequests.values()) {
875
+ pending.reject(new Error('Extension connection closed'))
876
+ }
877
+ extensionPendingRequests.clear()
878
+
879
+ extensionWs = null
880
+ connectedTargets.clear()
881
+
882
+ for (const client of playwrightClients.values()) {
883
+ client.ws.close(1000, 'Extension disconnected')
884
+ }
885
+ playwrightClients.clear()
886
+ },
887
+
888
+ onError(event) {
889
+ logger?.error('Extension WebSocket error:', event)
890
+ },
891
+ }
892
+ }),
893
+ )
894
+
895
+ const server = serve({ fetch: app.fetch, port, hostname: host })
896
+ injectWebSocket(server)
897
+
898
+ const wsHost = `ws://${host}:${port}`
899
+ const cdpEndpoint = `${wsHost}/cdp`
900
+ const extensionEndpoint = `${wsHost}/extension`
901
+
902
+ logger?.log('CDP relay server started')
903
+ logger?.log('Host:', host)
904
+ logger?.log('Port:', port)
905
+ logger?.log('Extension endpoint:', extensionEndpoint)
906
+ logger?.log('CDP endpoint:', cdpEndpoint)
907
+
908
+ return {
909
+ close() {
910
+ for (const client of playwrightClients.values()) {
911
+ client.ws.close(1000, 'Server stopped')
912
+ }
913
+ playwrightClients.clear()
914
+ extensionWs?.close(1000, 'Server stopped')
915
+ server.close()
916
+ emitter.removeAllListeners()
917
+ },
918
+ on<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
919
+ emitter.on(event, listener as (...args: unknown[]) => void)
920
+ },
921
+ off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]) {
922
+ emitter.off(event, listener as (...args: unknown[]) => void)
923
+ },
924
+ }
925
+ }