tabwright 1.0.0

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 (599) hide show
  1. package/LICENSE +21 -0
  2. package/bin.js +4 -0
  3. package/dist/a11y-client.js +204 -0
  4. package/dist/agent-skills/tabwright/SKILL.md +119 -0
  5. package/dist/aria-snapshot.d.ts +262 -0
  6. package/dist/aria-snapshot.d.ts.map +1 -0
  7. package/dist/aria-snapshot.js +1256 -0
  8. package/dist/aria-snapshot.js.map +1 -0
  9. package/dist/aria-snapshot.test.d.ts +2 -0
  10. package/dist/aria-snapshot.test.d.ts.map +1 -0
  11. package/dist/aria-snapshot.test.js +270 -0
  12. package/dist/aria-snapshot.test.js.map +1 -0
  13. package/dist/aria-snapshot.unit.test.d.ts +2 -0
  14. package/dist/aria-snapshot.unit.test.d.ts.map +1 -0
  15. package/dist/aria-snapshot.unit.test.js +542 -0
  16. package/dist/aria-snapshot.unit.test.js.map +1 -0
  17. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  18. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  19. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  20. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  21. package/dist/bippy.js +894 -0
  22. package/dist/browser-config.d.ts +13 -0
  23. package/dist/browser-config.d.ts.map +1 -0
  24. package/dist/browser-config.js +126 -0
  25. package/dist/browser-config.js.map +1 -0
  26. package/dist/browser-install.d.ts +16 -0
  27. package/dist/browser-install.d.ts.map +1 -0
  28. package/dist/browser-install.js +238 -0
  29. package/dist/browser-install.js.map +1 -0
  30. package/dist/browser-launch.d.ts +17 -0
  31. package/dist/browser-launch.d.ts.map +1 -0
  32. package/dist/browser-launch.js +44 -0
  33. package/dist/browser-launch.js.map +1 -0
  34. package/dist/builtin-capabilities.d.ts +28 -0
  35. package/dist/builtin-capabilities.d.ts.map +1 -0
  36. package/dist/builtin-capabilities.js +2673 -0
  37. package/dist/builtin-capabilities.js.map +1 -0
  38. package/dist/capability-agent-skill.d.ts +60 -0
  39. package/dist/capability-agent-skill.d.ts.map +1 -0
  40. package/dist/capability-agent-skill.js +225 -0
  41. package/dist/capability-agent-skill.js.map +1 -0
  42. package/dist/capability-auth-state.d.ts +34 -0
  43. package/dist/capability-auth-state.d.ts.map +1 -0
  44. package/dist/capability-auth-state.js +157 -0
  45. package/dist/capability-auth-state.js.map +1 -0
  46. package/dist/capability-auth.d.ts +36 -0
  47. package/dist/capability-auth.d.ts.map +1 -0
  48. package/dist/capability-auth.js +198 -0
  49. package/dist/capability-auth.js.map +1 -0
  50. package/dist/capability-cli-confirmation.test.d.ts +2 -0
  51. package/dist/capability-cli-confirmation.test.d.ts.map +1 -0
  52. package/dist/capability-cli-confirmation.test.js +79 -0
  53. package/dist/capability-cli-confirmation.test.js.map +1 -0
  54. package/dist/capability-options.d.ts +20 -0
  55. package/dist/capability-options.d.ts.map +1 -0
  56. package/dist/capability-options.js +31 -0
  57. package/dist/capability-options.js.map +1 -0
  58. package/dist/capability-options.test.d.ts +2 -0
  59. package/dist/capability-options.test.d.ts.map +1 -0
  60. package/dist/capability-options.test.js +81 -0
  61. package/dist/capability-options.test.js.map +1 -0
  62. package/dist/capability-package.d.ts +27 -0
  63. package/dist/capability-package.d.ts.map +1 -0
  64. package/dist/capability-package.js +444 -0
  65. package/dist/capability-package.js.map +1 -0
  66. package/dist/capability-package.test.d.ts +2 -0
  67. package/dist/capability-package.test.d.ts.map +1 -0
  68. package/dist/capability-package.test.js +179 -0
  69. package/dist/capability-package.test.js.map +1 -0
  70. package/dist/capability-registry.d.ts +262 -0
  71. package/dist/capability-registry.d.ts.map +1 -0
  72. package/dist/capability-registry.js +931 -0
  73. package/dist/capability-registry.js.map +1 -0
  74. package/dist/capability-registry.test.d.ts +2 -0
  75. package/dist/capability-registry.test.d.ts.map +1 -0
  76. package/dist/capability-registry.test.js +1683 -0
  77. package/dist/capability-registry.test.js.map +1 -0
  78. package/dist/capability-runner.d.ts +99 -0
  79. package/dist/capability-runner.d.ts.map +1 -0
  80. package/dist/capability-runner.js +608 -0
  81. package/dist/capability-runner.js.map +1 -0
  82. package/dist/capability-search-relevance.test.d.ts +2 -0
  83. package/dist/capability-search-relevance.test.d.ts.map +1 -0
  84. package/dist/capability-search-relevance.test.js +67 -0
  85. package/dist/capability-search-relevance.test.js.map +1 -0
  86. package/dist/capability-studio.d.ts +11 -0
  87. package/dist/capability-studio.d.ts.map +1 -0
  88. package/dist/capability-studio.js +593 -0
  89. package/dist/capability-studio.js.map +1 -0
  90. package/dist/capability-studio.test.d.ts +2 -0
  91. package/dist/capability-studio.test.d.ts.map +1 -0
  92. package/dist/capability-studio.test.js +41 -0
  93. package/dist/capability-studio.test.js.map +1 -0
  94. package/dist/cdp-log.d.ts +19 -0
  95. package/dist/cdp-log.d.ts.map +1 -0
  96. package/dist/cdp-log.js +83 -0
  97. package/dist/cdp-log.js.map +1 -0
  98. package/dist/cdp-log.test.d.ts +2 -0
  99. package/dist/cdp-log.test.d.ts.map +1 -0
  100. package/dist/cdp-log.test.js +109 -0
  101. package/dist/cdp-log.test.js.map +1 -0
  102. package/dist/cdp-relay.d.ts +19 -0
  103. package/dist/cdp-relay.d.ts.map +1 -0
  104. package/dist/cdp-relay.js +2375 -0
  105. package/dist/cdp-relay.js.map +1 -0
  106. package/dist/cdp-session.d.ts +37 -0
  107. package/dist/cdp-session.d.ts.map +1 -0
  108. package/dist/cdp-session.js +36 -0
  109. package/dist/cdp-session.js.map +1 -0
  110. package/dist/cdp-types.d.ts +65 -0
  111. package/dist/cdp-types.d.ts.map +1 -0
  112. package/dist/cdp-types.js +91 -0
  113. package/dist/cdp-types.js.map +1 -0
  114. package/dist/channel-owner-inspect.test.d.ts +2 -0
  115. package/dist/channel-owner-inspect.test.d.ts.map +1 -0
  116. package/dist/channel-owner-inspect.test.js +75 -0
  117. package/dist/channel-owner-inspect.test.js.map +1 -0
  118. package/dist/chrome-discovery.d.ts +65 -0
  119. package/dist/chrome-discovery.d.ts.map +1 -0
  120. package/dist/chrome-discovery.js +173 -0
  121. package/dist/chrome-discovery.js.map +1 -0
  122. package/dist/chrome-discovery.test.d.ts +2 -0
  123. package/dist/chrome-discovery.test.d.ts.map +1 -0
  124. package/dist/chrome-discovery.test.js +41 -0
  125. package/dist/chrome-discovery.test.js.map +1 -0
  126. package/dist/clean-html.d.ts +11 -0
  127. package/dist/clean-html.d.ts.map +1 -0
  128. package/dist/clean-html.js +106 -0
  129. package/dist/clean-html.js.map +1 -0
  130. package/dist/cli-help.test.d.ts +2 -0
  131. package/dist/cli-help.test.d.ts.map +1 -0
  132. package/dist/cli-help.test.js +98 -0
  133. package/dist/cli-help.test.js.map +1 -0
  134. package/dist/cli.d.ts +3 -0
  135. package/dist/cli.d.ts.map +1 -0
  136. package/dist/cli.js +2709 -0
  137. package/dist/cli.js.map +1 -0
  138. package/dist/cloud-client.d.ts +56 -0
  139. package/dist/cloud-client.d.ts.map +1 -0
  140. package/dist/cloud-client.js +127 -0
  141. package/dist/cloud-client.js.map +1 -0
  142. package/dist/create-logger.d.ts +9 -0
  143. package/dist/create-logger.d.ts.map +1 -0
  144. package/dist/create-logger.js +27 -0
  145. package/dist/create-logger.js.map +1 -0
  146. package/dist/debugger-api.md +458 -0
  147. package/dist/debugger-examples-types.d.ts +24 -0
  148. package/dist/debugger-examples-types.d.ts.map +1 -0
  149. package/dist/debugger-examples-types.js +2 -0
  150. package/dist/debugger-examples-types.js.map +1 -0
  151. package/dist/debugger-examples.d.ts +6 -0
  152. package/dist/debugger-examples.d.ts.map +1 -0
  153. package/dist/debugger-examples.js +53 -0
  154. package/dist/debugger-examples.js.map +1 -0
  155. package/dist/debugger.d.ts +381 -0
  156. package/dist/debugger.d.ts.map +1 -0
  157. package/dist/debugger.js +630 -0
  158. package/dist/debugger.js.map +1 -0
  159. package/dist/diff-utils.d.ts +20 -0
  160. package/dist/diff-utils.d.ts.map +1 -0
  161. package/dist/diff-utils.js +50 -0
  162. package/dist/diff-utils.js.map +1 -0
  163. package/dist/diff-utils.test.d.ts +2 -0
  164. package/dist/diff-utils.test.d.ts.map +1 -0
  165. package/dist/diff-utils.test.js +137 -0
  166. package/dist/diff-utils.test.js.map +1 -0
  167. package/dist/doctor.d.ts +44 -0
  168. package/dist/doctor.d.ts.map +1 -0
  169. package/dist/doctor.js +246 -0
  170. package/dist/doctor.js.map +1 -0
  171. package/dist/doctor.test.d.ts +2 -0
  172. package/dist/doctor.test.d.ts.map +1 -0
  173. package/dist/doctor.test.js +208 -0
  174. package/dist/doctor.test.js.map +1 -0
  175. package/dist/editor-api.md +374 -0
  176. package/dist/editor-examples.d.ts +11 -0
  177. package/dist/editor-examples.d.ts.map +1 -0
  178. package/dist/editor-examples.js +124 -0
  179. package/dist/editor-examples.js.map +1 -0
  180. package/dist/editor.d.ts +203 -0
  181. package/dist/editor.d.ts.map +1 -0
  182. package/dist/editor.js +363 -0
  183. package/dist/editor.js.map +1 -0
  184. package/dist/env-compat.d.ts +2 -0
  185. package/dist/env-compat.d.ts.map +1 -0
  186. package/dist/env-compat.js +27 -0
  187. package/dist/env-compat.js.map +1 -0
  188. package/dist/executor.d.ts +229 -0
  189. package/dist/executor.d.ts.map +1 -0
  190. package/dist/executor.js +1465 -0
  191. package/dist/executor.js.map +1 -0
  192. package/dist/executor.unit.test.d.ts +2 -0
  193. package/dist/executor.unit.test.d.ts.map +1 -0
  194. package/dist/executor.unit.test.js +143 -0
  195. package/dist/executor.unit.test.js.map +1 -0
  196. package/dist/extension/_locales/en/messages.json +278 -0
  197. package/dist/extension/_locales/zh_CN/messages.json +256 -0
  198. package/dist/extension/assets/options-CYH96Lcx.css +1349 -0
  199. package/dist/extension/background.js +3994 -0
  200. package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
  201. package/dist/extension/icons/icon-black-128.png +0 -0
  202. package/dist/extension/icons/icon-black-16.png +0 -0
  203. package/dist/extension/icons/icon-black-32.png +0 -0
  204. package/dist/extension/icons/icon-black-48.png +0 -0
  205. package/dist/extension/icons/icon-gray-128.png +0 -0
  206. package/dist/extension/icons/icon-gray-16.png +0 -0
  207. package/dist/extension/icons/icon-gray-32.png +0 -0
  208. package/dist/extension/icons/icon-gray-48.png +0 -0
  209. package/dist/extension/icons/icon-green-128.png +0 -0
  210. package/dist/extension/icons/icon-green-16.png +0 -0
  211. package/dist/extension/icons/icon-green-32.png +0 -0
  212. package/dist/extension/icons/icon-green-48.png +0 -0
  213. package/dist/extension/icons/tabwright-icon-black.png +0 -0
  214. package/dist/extension/icons/tabwright-icon-gray-disabled.png +0 -0
  215. package/dist/extension/icons/tabwright-icon-gray-disabled.svg +1 -0
  216. package/dist/extension/manifest.json +54 -0
  217. package/dist/extension/options.js +16145 -0
  218. package/dist/extension/rrweb-recorder.js +12952 -0
  219. package/dist/extension/src/options.html +165 -0
  220. package/dist/extension/src/prism-bash.min.js +1 -0
  221. package/dist/extension/src/prism.min.js +1 -0
  222. package/dist/extension/src/welcome.html +396 -0
  223. package/dist/extension-connection.test.d.ts +2 -0
  224. package/dist/extension-connection.test.d.ts.map +1 -0
  225. package/dist/extension-connection.test.js +708 -0
  226. package/dist/extension-connection.test.js.map +1 -0
  227. package/dist/extension-icon-warning.test.d.ts +2 -0
  228. package/dist/extension-icon-warning.test.d.ts.map +1 -0
  229. package/dist/extension-icon-warning.test.js +33 -0
  230. package/dist/extension-icon-warning.test.js.map +1 -0
  231. package/dist/ghost-browser.d.ts +62 -0
  232. package/dist/ghost-browser.d.ts.map +1 -0
  233. package/dist/ghost-browser.js +107 -0
  234. package/dist/ghost-browser.js.map +1 -0
  235. package/dist/ghost-cursor-client.js +374 -0
  236. package/dist/ghost-cursor-controller.d.ts +46 -0
  237. package/dist/ghost-cursor-controller.d.ts.map +1 -0
  238. package/dist/ghost-cursor-controller.js +98 -0
  239. package/dist/ghost-cursor-controller.js.map +1 -0
  240. package/dist/ghost-cursor.d.ts +27 -0
  241. package/dist/ghost-cursor.d.ts.map +1 -0
  242. package/dist/ghost-cursor.js +79 -0
  243. package/dist/ghost-cursor.js.map +1 -0
  244. package/dist/htmlrewrite.d.ts +8 -0
  245. package/dist/htmlrewrite.d.ts.map +1 -0
  246. package/dist/htmlrewrite.js +339 -0
  247. package/dist/htmlrewrite.js.map +1 -0
  248. package/dist/htmlrewrite.test.d.ts +2 -0
  249. package/dist/htmlrewrite.test.d.ts.map +1 -0
  250. package/dist/htmlrewrite.test.js +7975 -0
  251. package/dist/htmlrewrite.test.js.map +1 -0
  252. package/dist/index.d.ts +20 -0
  253. package/dist/index.d.ts.map +1 -0
  254. package/dist/index.js +11 -0
  255. package/dist/index.js.map +1 -0
  256. package/dist/kill-port.d.ts +29 -0
  257. package/dist/kill-port.d.ts.map +1 -0
  258. package/dist/kill-port.js +190 -0
  259. package/dist/kill-port.js.map +1 -0
  260. package/dist/kitty-graphics.d.ts +22 -0
  261. package/dist/kitty-graphics.d.ts.map +1 -0
  262. package/dist/kitty-graphics.js +71 -0
  263. package/dist/kitty-graphics.js.map +1 -0
  264. package/dist/kitty-graphics.test.d.ts +2 -0
  265. package/dist/kitty-graphics.test.d.ts.map +1 -0
  266. package/dist/kitty-graphics.test.js +125 -0
  267. package/dist/kitty-graphics.test.js.map +1 -0
  268. package/dist/locator-selector.test.d.ts +2 -0
  269. package/dist/locator-selector.test.d.ts.map +1 -0
  270. package/dist/locator-selector.test.js +98 -0
  271. package/dist/locator-selector.test.js.map +1 -0
  272. package/dist/mcp-client.d.ts +20 -0
  273. package/dist/mcp-client.d.ts.map +1 -0
  274. package/dist/mcp-client.js +56 -0
  275. package/dist/mcp-client.js.map +1 -0
  276. package/dist/mcp.d.ts +6 -0
  277. package/dist/mcp.d.ts.map +1 -0
  278. package/dist/mcp.js +511 -0
  279. package/dist/mcp.js.map +1 -0
  280. package/dist/on-mouse-action.test.d.ts +2 -0
  281. package/dist/on-mouse-action.test.d.ts.map +1 -0
  282. package/dist/on-mouse-action.test.js +180 -0
  283. package/dist/on-mouse-action.test.js.map +1 -0
  284. package/dist/options-page.test.d.ts +2 -0
  285. package/dist/options-page.test.d.ts.map +1 -0
  286. package/dist/options-page.test.js +773 -0
  287. package/dist/options-page.test.js.map +1 -0
  288. package/dist/package-paths.d.ts +4 -0
  289. package/dist/package-paths.d.ts.map +1 -0
  290. package/dist/package-paths.js +30 -0
  291. package/dist/package-paths.js.map +1 -0
  292. package/dist/page-markdown.d.ts +40 -0
  293. package/dist/page-markdown.d.ts.map +1 -0
  294. package/dist/page-markdown.js +181 -0
  295. package/dist/page-markdown.js.map +1 -0
  296. package/dist/performance-examples.d.ts +5 -0
  297. package/dist/performance-examples.d.ts.map +1 -0
  298. package/dist/performance-examples.js +112 -0
  299. package/dist/performance-examples.js.map +1 -0
  300. package/dist/performance-profiling.md +417 -0
  301. package/dist/playwright-import.d.ts +19 -0
  302. package/dist/playwright-import.d.ts.map +1 -0
  303. package/dist/playwright-import.js +40 -0
  304. package/dist/playwright-import.js.map +1 -0
  305. package/dist/popup-relocation.test.d.ts +7 -0
  306. package/dist/popup-relocation.test.d.ts.map +1 -0
  307. package/dist/popup-relocation.test.js +139 -0
  308. package/dist/popup-relocation.test.js.map +1 -0
  309. package/dist/product-paths.d.ts +9 -0
  310. package/dist/product-paths.d.ts.map +1 -0
  311. package/dist/product-paths.js +24 -0
  312. package/dist/product-paths.js.map +1 -0
  313. package/dist/product-paths.test.d.ts +2 -0
  314. package/dist/product-paths.test.d.ts.map +1 -0
  315. package/dist/product-paths.test.js +39 -0
  316. package/dist/product-paths.test.js.map +1 -0
  317. package/dist/prompt.md +1004 -0
  318. package/dist/protocol.d.ts +248 -0
  319. package/dist/protocol.d.ts.map +1 -0
  320. package/dist/protocol.js +59 -0
  321. package/dist/protocol.js.map +1 -0
  322. package/dist/protocol.test.d.ts +2 -0
  323. package/dist/protocol.test.d.ts.map +1 -0
  324. package/dist/protocol.test.js +31 -0
  325. package/dist/protocol.test.js.map +1 -0
  326. package/dist/react-source.d.ts +57 -0
  327. package/dist/react-source.d.ts.map +1 -0
  328. package/dist/react-source.js +254 -0
  329. package/dist/react-source.js.map +1 -0
  330. package/dist/readability.js +1674 -0
  331. package/dist/relay-client.d.ts +104 -0
  332. package/dist/relay-client.d.ts.map +1 -0
  333. package/dist/relay-client.js +426 -0
  334. package/dist/relay-client.js.map +1 -0
  335. package/dist/relay-client.test.d.ts +2 -0
  336. package/dist/relay-client.test.d.ts.map +1 -0
  337. package/dist/relay-client.test.js +305 -0
  338. package/dist/relay-client.test.js.map +1 -0
  339. package/dist/relay-core.test.d.ts +2 -0
  340. package/dist/relay-core.test.d.ts.map +1 -0
  341. package/dist/relay-core.test.js +1427 -0
  342. package/dist/relay-core.test.js.map +1 -0
  343. package/dist/relay-navigation.test.d.ts +2 -0
  344. package/dist/relay-navigation.test.d.ts.map +1 -0
  345. package/dist/relay-navigation.test.js +661 -0
  346. package/dist/relay-navigation.test.js.map +1 -0
  347. package/dist/relay-session.test.d.ts +2 -0
  348. package/dist/relay-session.test.d.ts.map +1 -0
  349. package/dist/relay-session.test.js +1121 -0
  350. package/dist/relay-session.test.js.map +1 -0
  351. package/dist/relay-state.d.ts +178 -0
  352. package/dist/relay-state.d.ts.map +1 -0
  353. package/dist/relay-state.js +346 -0
  354. package/dist/relay-state.js.map +1 -0
  355. package/dist/relay-state.test.d.ts +2 -0
  356. package/dist/relay-state.test.d.ts.map +1 -0
  357. package/dist/relay-state.test.js +594 -0
  358. package/dist/relay-state.test.js.map +1 -0
  359. package/dist/replay-ai-index.d.ts +99 -0
  360. package/dist/replay-ai-index.d.ts.map +1 -0
  361. package/dist/replay-ai-index.js +677 -0
  362. package/dist/replay-ai-index.js.map +1 -0
  363. package/dist/replay-ai-index.test.d.ts +2 -0
  364. package/dist/replay-ai-index.test.d.ts.map +1 -0
  365. package/dist/replay-ai-index.test.js +255 -0
  366. package/dist/replay-ai-index.test.js.map +1 -0
  367. package/dist/replay-cli.test.d.ts +2 -0
  368. package/dist/replay-cli.test.d.ts.map +1 -0
  369. package/dist/replay-cli.test.js +275 -0
  370. package/dist/replay-cli.test.js.map +1 -0
  371. package/dist/replay-eval.d.ts +44 -0
  372. package/dist/replay-eval.d.ts.map +1 -0
  373. package/dist/replay-eval.js +902 -0
  374. package/dist/replay-eval.js.map +1 -0
  375. package/dist/replay-handoff.d.ts +29 -0
  376. package/dist/replay-handoff.d.ts.map +1 -0
  377. package/dist/replay-handoff.js +83 -0
  378. package/dist/replay-handoff.js.map +1 -0
  379. package/dist/replay-handoff.test.d.ts +2 -0
  380. package/dist/replay-handoff.test.d.ts.map +1 -0
  381. package/dist/replay-handoff.test.js +123 -0
  382. package/dist/replay-handoff.test.js.map +1 -0
  383. package/dist/replay-workflow-compiler.d.ts +44 -0
  384. package/dist/replay-workflow-compiler.d.ts.map +1 -0
  385. package/dist/replay-workflow-compiler.js +601 -0
  386. package/dist/replay-workflow-compiler.js.map +1 -0
  387. package/dist/replay-workflow-compiler.test.d.ts +2 -0
  388. package/dist/replay-workflow-compiler.test.d.ts.map +1 -0
  389. package/dist/replay-workflow-compiler.test.js +223 -0
  390. package/dist/replay-workflow-compiler.test.js.map +1 -0
  391. package/dist/rrweb-recording-relay.d.ts +56 -0
  392. package/dist/rrweb-recording-relay.d.ts.map +1 -0
  393. package/dist/rrweb-recording-relay.js +299 -0
  394. package/dist/rrweb-recording-relay.js.map +1 -0
  395. package/dist/rrweb-recording-relay.test.d.ts +2 -0
  396. package/dist/rrweb-recording-relay.test.d.ts.map +1 -0
  397. package/dist/rrweb-recording-relay.test.js +85 -0
  398. package/dist/rrweb-recording-relay.test.js.map +1 -0
  399. package/dist/rrweb-recording.d.ts +104 -0
  400. package/dist/rrweb-recording.d.ts.map +1 -0
  401. package/dist/rrweb-recording.js +180 -0
  402. package/dist/rrweb-recording.js.map +1 -0
  403. package/dist/scoped-fs.d.ts +95 -0
  404. package/dist/scoped-fs.d.ts.map +1 -0
  405. package/dist/scoped-fs.js +358 -0
  406. package/dist/scoped-fs.js.map +1 -0
  407. package/dist/scoped-fs.test.d.ts +5 -0
  408. package/dist/scoped-fs.test.d.ts.map +1 -0
  409. package/dist/scoped-fs.test.js +73 -0
  410. package/dist/scoped-fs.test.js.map +1 -0
  411. package/dist/selector-generator.js +7378 -0
  412. package/dist/skill-stub.test.d.ts +2 -0
  413. package/dist/skill-stub.test.d.ts.map +1 -0
  414. package/dist/skill-stub.test.js +22 -0
  415. package/dist/skill-stub.test.js.map +1 -0
  416. package/dist/snapshot-tools.test.d.ts +2 -0
  417. package/dist/snapshot-tools.test.d.ts.map +1 -0
  418. package/dist/snapshot-tools.test.js +856 -0
  419. package/dist/snapshot-tools.test.js.map +1 -0
  420. package/dist/start-relay-server.d.ts +6 -0
  421. package/dist/start-relay-server.d.ts.map +1 -0
  422. package/dist/start-relay-server.js +57 -0
  423. package/dist/start-relay-server.js.map +1 -0
  424. package/dist/styles-api.md +124 -0
  425. package/dist/styles-examples.d.ts +8 -0
  426. package/dist/styles-examples.d.ts.map +1 -0
  427. package/dist/styles-examples.js +64 -0
  428. package/dist/styles-examples.js.map +1 -0
  429. package/dist/styles.d.ts +27 -0
  430. package/dist/styles.d.ts.map +1 -0
  431. package/dist/styles.js +231 -0
  432. package/dist/styles.js.map +1 -0
  433. package/dist/tabwright-agent-skill.d.ts +30 -0
  434. package/dist/tabwright-agent-skill.d.ts.map +1 -0
  435. package/dist/tabwright-agent-skill.js +74 -0
  436. package/dist/tabwright-agent-skill.js.map +1 -0
  437. package/dist/tabwright-agent-skill.test.d.ts +2 -0
  438. package/dist/tabwright-agent-skill.test.d.ts.map +1 -0
  439. package/dist/tabwright-agent-skill.test.js +51 -0
  440. package/dist/tabwright-agent-skill.test.js.map +1 -0
  441. package/dist/test-declarations.d.ts +13 -0
  442. package/dist/test-declarations.d.ts.map +1 -0
  443. package/dist/test-declarations.js +2 -0
  444. package/dist/test-declarations.js.map +1 -0
  445. package/dist/test-utils.d.ts +69 -0
  446. package/dist/test-utils.d.ts.map +1 -0
  447. package/dist/test-utils.js +448 -0
  448. package/dist/test-utils.js.map +1 -0
  449. package/dist/test-utils.test.d.ts +2 -0
  450. package/dist/test-utils.test.d.ts.map +1 -0
  451. package/dist/test-utils.test.js +55 -0
  452. package/dist/test-utils.test.js.map +1 -0
  453. package/dist/utils.d.ts +26 -0
  454. package/dist/utils.d.ts.map +1 -0
  455. package/dist/utils.js +60 -0
  456. package/dist/utils.js.map +1 -0
  457. package/dist/wait-for-page-load.d.ts +16 -0
  458. package/dist/wait-for-page-load.d.ts.map +1 -0
  459. package/dist/wait-for-page-load.js +146 -0
  460. package/dist/wait-for-page-load.js.map +1 -0
  461. package/dist/workflow-capability.d.ts +72 -0
  462. package/dist/workflow-capability.d.ts.map +1 -0
  463. package/dist/workflow-capability.js +316 -0
  464. package/dist/workflow-capability.js.map +1 -0
  465. package/dist/workflow-script-flow.test.d.ts +2 -0
  466. package/dist/workflow-script-flow.test.d.ts.map +1 -0
  467. package/dist/workflow-script-flow.test.js +285 -0
  468. package/dist/workflow-script-flow.test.js.map +1 -0
  469. package/package.json +80 -0
  470. package/src/__snapshots__/x.com.processed.html +1003 -0
  471. package/src/__snapshots__/x.com.processed.withStyles.html +2720 -0
  472. package/src/a11y-client.ts +241 -0
  473. package/src/agent-skills/conan-config/SKILL.md +141 -0
  474. package/src/agent-skills/conan-config/agents/openai.yaml +4 -0
  475. package/src/aria-snapshot.test.ts +311 -0
  476. package/src/aria-snapshot.ts +1681 -0
  477. package/src/aria-snapshot.unit.test.ts +615 -0
  478. package/src/aria-snapshots/github-interactive.txt +193 -0
  479. package/src/aria-snapshots/github-raw.txt +309 -0
  480. package/src/aria-snapshots/hackernews-interactive.txt +477 -0
  481. package/src/aria-snapshots/hackernews-raw.txt +428 -0
  482. package/src/aria-snapshots/prosemirror-interactive.txt +66 -0
  483. package/src/aria-snapshots/prosemirror-raw.txt +145 -0
  484. package/src/assets/aria-labels-hacker-news.png +0 -0
  485. package/src/assets/aria-labels-old-reddit.png +0 -0
  486. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  487. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  488. package/src/assets/x.com.html +32946 -0
  489. package/src/browser-config.ts +179 -0
  490. package/src/browser-install.ts +284 -0
  491. package/src/browser-launch.ts +75 -0
  492. package/src/builtin-capabilities.ts +2765 -0
  493. package/src/capability-agent-skill.ts +305 -0
  494. package/src/capability-auth-state.ts +197 -0
  495. package/src/capability-auth.ts +249 -0
  496. package/src/capability-cli-confirmation.test.ts +90 -0
  497. package/src/capability-options.test.ts +89 -0
  498. package/src/capability-options.ts +57 -0
  499. package/src/capability-package.test.ts +204 -0
  500. package/src/capability-package.ts +555 -0
  501. package/src/capability-registry.test.ts +1905 -0
  502. package/src/capability-registry.ts +1234 -0
  503. package/src/capability-runner.ts +801 -0
  504. package/src/capability-search-relevance.test.ts +73 -0
  505. package/src/capability-studio.test.ts +45 -0
  506. package/src/capability-studio.ts +641 -0
  507. package/src/cdp-log.test.ts +131 -0
  508. package/src/cdp-log.ts +112 -0
  509. package/src/cdp-relay.ts +2944 -0
  510. package/src/cdp-session.ts +77 -0
  511. package/src/cdp-types.ts +158 -0
  512. package/src/channel-owner-inspect.test.ts +94 -0
  513. package/src/chrome-discovery.test.ts +50 -0
  514. package/src/chrome-discovery.ts +220 -0
  515. package/src/clean-html.ts +141 -0
  516. package/src/cli-help.test.ts +123 -0
  517. package/src/cli.ts +3181 -0
  518. package/src/cloud-client.ts +179 -0
  519. package/src/create-logger.ts +38 -0
  520. package/src/debugger-examples-types.ts +16 -0
  521. package/src/debugger-examples.ts +66 -0
  522. package/src/debugger.ts +708 -0
  523. package/src/diff-utils.test.ts +152 -0
  524. package/src/diff-utils.ts +70 -0
  525. package/src/doctor.test.ts +244 -0
  526. package/src/doctor.ts +321 -0
  527. package/src/editor-examples.ts +158 -0
  528. package/src/editor.ts +426 -0
  529. package/src/env-compat.ts +28 -0
  530. package/src/executor.ts +1824 -0
  531. package/src/executor.unit.test.ts +168 -0
  532. package/src/extension-connection.test.ts +855 -0
  533. package/src/extension-icon-warning.test.ts +45 -0
  534. package/src/ghost-browser.ts +149 -0
  535. package/src/ghost-cursor-client.ts +494 -0
  536. package/src/ghost-cursor-controller.ts +129 -0
  537. package/src/ghost-cursor.ts +123 -0
  538. package/src/htmlrewrite.test.ts +8008 -0
  539. package/src/htmlrewrite.ts +390 -0
  540. package/src/index.ts +53 -0
  541. package/src/kill-port.ts +219 -0
  542. package/src/kitty-graphics.test.ts +139 -0
  543. package/src/kitty-graphics.ts +79 -0
  544. package/src/locator-selector.test.ts +119 -0
  545. package/src/mcp-client.ts +78 -0
  546. package/src/mcp.ts +629 -0
  547. package/src/on-mouse-action.test.ts +226 -0
  548. package/src/options-page.test.ts +839 -0
  549. package/src/package-paths.ts +38 -0
  550. package/src/page-markdown.ts +240 -0
  551. package/src/performance-examples.ts +186 -0
  552. package/src/playwright-import.ts +59 -0
  553. package/src/popup-relocation.test.ts +163 -0
  554. package/src/product-paths.test.ts +47 -0
  555. package/src/product-paths.ts +28 -0
  556. package/src/protocol.test.ts +52 -0
  557. package/src/protocol.ts +351 -0
  558. package/src/react-source.ts +379 -0
  559. package/src/relay-client.test.ts +375 -0
  560. package/src/relay-client.ts +572 -0
  561. package/src/relay-core.test.ts +1603 -0
  562. package/src/relay-navigation.test.ts +790 -0
  563. package/src/relay-session.test.ts +1375 -0
  564. package/src/relay-state.test.ts +729 -0
  565. package/src/relay-state.ts +561 -0
  566. package/src/replay-ai-index.test.ts +278 -0
  567. package/src/replay-ai-index.ts +872 -0
  568. package/src/replay-cli.test.ts +325 -0
  569. package/src/replay-eval.ts +1039 -0
  570. package/src/replay-handoff.test.ts +163 -0
  571. package/src/replay-handoff.ts +109 -0
  572. package/src/replay-workflow-compiler.test.ts +250 -0
  573. package/src/replay-workflow-compiler.ts +700 -0
  574. package/src/resource.md +409 -0
  575. package/src/rrweb-recording-relay.test.ts +99 -0
  576. package/src/rrweb-recording-relay.ts +400 -0
  577. package/src/rrweb-recording.ts +322 -0
  578. package/src/scoped-fs.test.ts +80 -0
  579. package/src/scoped-fs.ts +420 -0
  580. package/src/skill-stub.test.ts +24 -0
  581. package/src/skill.md +1413 -0
  582. package/src/snapshot-tools.test.ts +1017 -0
  583. package/src/snapshots/hacker-news-accessibility-full.md +81 -0
  584. package/src/snapshots/hacker-news-accessibility-interactive.md +43 -0
  585. package/src/snapshots/page-markdown-output.txt +16 -0
  586. package/src/snapshots/shadcn-ui-accessibility-full.md +201 -0
  587. package/src/snapshots/shadcn-ui-accessibility-interactive.md +109 -0
  588. package/src/start-relay-server.ts +69 -0
  589. package/src/styles-examples.ts +84 -0
  590. package/src/styles.ts +343 -0
  591. package/src/tabwright-agent-skill.test.ts +61 -0
  592. package/src/tabwright-agent-skill.ts +105 -0
  593. package/src/test-declarations.ts +13 -0
  594. package/src/test-utils.test.ts +70 -0
  595. package/src/test-utils.ts +566 -0
  596. package/src/utils.ts +78 -0
  597. package/src/wait-for-page-load.ts +198 -0
  598. package/src/workflow-capability.ts +417 -0
  599. package/src/workflow-script-flow.test.ts +388 -0
@@ -0,0 +1,2375 @@
1
+ import { Hono } from 'hono';
2
+ import { cors } from 'hono/cors';
3
+ import { createAdaptorServer } from '@hono/node-server';
4
+ import { getConnInfo } from '@hono/node-server/conninfo';
5
+ import { createNodeWebSocket } from '@hono/node-ws';
6
+ import { EXTENSION_FEATURE, RELAY_FEATURES, VERSION as EXTENSION_PROTOCOL_VERSION, allowsExtensionFeature, parseExtensionFeatures, requiredExtensionFeatureForMethod, } from './protocol.js';
7
+ import pc from 'picocolors';
8
+ import util from 'node:util';
9
+ // Prevent Buffers from dumping hex bytes in util.inspect output.
10
+ Buffer.prototype[util.inspect.custom] = function () {
11
+ return `<Buffer ${this.length} bytes>`;
12
+ };
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { EventEmitter } from 'node:events';
16
+ import { VERSION, EXTENSION_IDS, shouldAutoEnableTabwright } from './utils.js';
17
+ import { createCdpLogger } from './cdp-log.js';
18
+ import { RrwebRecordingRelay, getSavedRrwebRecording, getSavedRrwebRecordingWithEvents, listSavedRrwebRecordings, } from './rrweb-recording-relay.js';
19
+ import { getCapabilityOptionsDetail, listCapabilityOptions } from './capability-options.js';
20
+ import { getCapabilityAuthState } from './capability-auth-state.js';
21
+ import { refreshCapabilityAuthFromCookies, } from './capability-auth.js';
22
+ import { requireCapability } from './capability-registry.js';
23
+ import { appendSessionToWsUrl } from './chrome-discovery.js';
24
+ import * as relayState from './relay-state.js';
25
+ import { getTabwrightUserDataDir } from './product-paths.js';
26
+ /**
27
+ * Checks if a target should be filtered out (not exposed to Playwright).
28
+ * Filters extension pages, service workers, and other restricted targets,
29
+ * but allows our own extension pages for debugging purposes.
30
+ */
31
+ function isRestrictedTarget(targetInfo) {
32
+ const { url, type } = targetInfo;
33
+ // Filter by type - allow pages and iframe targets (OOPIFs)
34
+ if (type !== 'page' && type !== 'iframe') {
35
+ return true;
36
+ }
37
+ // Filter by URL - block extension and chrome internal pages
38
+ if (!url) {
39
+ return false;
40
+ }
41
+ // Allow our own extension pages
42
+ if (url.startsWith('chrome-extension://')) {
43
+ const extensionId = url.replace('chrome-extension://', '').split('/')[0];
44
+ if (EXTENSION_IDS.includes(extensionId)) {
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ // Block other restricted URLs
50
+ const blockedPrefixes = ['chrome://', 'devtools://', 'edge://'];
51
+ return blockedPrefixes.some((prefix) => url.startsWith(prefix));
52
+ }
53
+ function parseCapabilityAuthCookies(value) {
54
+ if (!isRecord(value) || !Array.isArray(value.cookies)) {
55
+ throw new Error('Browser returned an invalid cookie response');
56
+ }
57
+ return value.cookies.flatMap((cookie) => {
58
+ if (!isRecord(cookie) || typeof cookie.name !== 'string' || typeof cookie.value !== 'string') {
59
+ return [];
60
+ }
61
+ return [
62
+ {
63
+ name: cookie.name,
64
+ value: cookie.value,
65
+ expires: typeof cookie.expires === 'number' ? cookie.expires : undefined,
66
+ },
67
+ ];
68
+ });
69
+ }
70
+ function isRecord(value) {
71
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
72
+ }
73
+ export async function startTabwrightCDPRelayServer({ port = 19988, host = '127.0.0.1', token, logger, cdpLogger, } = {}) {
74
+ const emitter = new EventEmitter();
75
+ const store = relayState.createRelayStore();
76
+ const extensionDownloadBehavior = new Map();
77
+ const resolvedCdpLogger = cdpLogger || createCdpLogger();
78
+ const logCdpJson = (entry) => {
79
+ resolvedCdpLogger.log(entry);
80
+ };
81
+ const getDefaultExtensionId = () => {
82
+ return store.getState().extensions.keys().next().value || null;
83
+ };
84
+ /**
85
+ * Resolve an extension by ID, stableKey, or fallback.
86
+ * Returns the unified ExtensionEntry which includes both state and I/O.
87
+ */
88
+ const getExtensionConnection = (extensionId, options = {}) => {
89
+ const currentRelayState = store.getState();
90
+ const { extensions } = currentRelayState;
91
+ if (extensionId) {
92
+ const direct = extensions.get(extensionId);
93
+ if (direct?.ws) {
94
+ return direct;
95
+ }
96
+ // Try stableKey lookup.
97
+ const byKey = relayState.findExtensionByStableKey(currentRelayState, extensionId);
98
+ if (byKey) {
99
+ const candidates = Array.from(extensions.values())
100
+ .filter((ext) => ext.stableKey === byKey.stableKey)
101
+ .reverse();
102
+ for (const candidate of candidates) {
103
+ if (candidate.ws) {
104
+ return candidate;
105
+ }
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+ if (!options.allowFallback) {
111
+ return null;
112
+ }
113
+ // Single extension — use it directly
114
+ if (extensions.size === 1) {
115
+ const fallbackId = getDefaultExtensionId();
116
+ if (fallbackId) {
117
+ const ext = extensions.get(fallbackId);
118
+ if (ext?.ws) {
119
+ return ext;
120
+ }
121
+ }
122
+ }
123
+ // Multiple extensions — auto-select if exactly one has active targets.
124
+ // This handles the common case of multiple Chrome profiles with the extension
125
+ // installed, where only one profile has Tabwright-enabled tabs. (#52)
126
+ if (extensions.size > 1) {
127
+ const activeExtensions = Array.from(extensions.values()).filter((ext) => {
128
+ return ext.connectedTargets.size > 0;
129
+ });
130
+ if (activeExtensions.length === 1 && activeExtensions[0].ws) {
131
+ return activeExtensions[0];
132
+ }
133
+ }
134
+ return null;
135
+ };
136
+ const extensionAllowsFeature = (options) => {
137
+ const extension = store.getState().extensions.get(options.extensionId);
138
+ return allowsExtensionFeature({ features: extension?.info.features, feature: options.feature });
139
+ };
140
+ const extensionSupportsFeature = (options) => {
141
+ const extension = store.getState().extensions.get(options.extensionId);
142
+ return extension?.info.features?.includes(options.feature) || false;
143
+ };
144
+ const normalizeSessionId = (value) => {
145
+ if (value === undefined || value === null) {
146
+ return null;
147
+ }
148
+ const normalized = String(value);
149
+ return normalized ? normalized : null;
150
+ };
151
+ const getPageTargetForFrameId = ({ extensionState, frameId, }) => {
152
+ return Array.from(extensionState.connectedTargets.values()).find((target) => {
153
+ return target.targetInfo.type === 'page' && target.frameIds.has(frameId);
154
+ });
155
+ };
156
+ const startExtensionPing = (extensionId) => {
157
+ const ext = store.getState().extensions.get(extensionId);
158
+ if (!ext) {
159
+ return;
160
+ }
161
+ if (!extensionAllowsFeature({ extensionId, feature: EXTENSION_FEATURE.heartbeat })) {
162
+ return;
163
+ }
164
+ if (ext.pingInterval) {
165
+ clearInterval(ext.pingInterval);
166
+ }
167
+ const pingInterval = setInterval(() => {
168
+ const latestExt = store.getState().extensions.get(extensionId);
169
+ if (!latestExt?.ws) {
170
+ return;
171
+ }
172
+ if (relayState.hasExtensionHeartbeatExpired({
173
+ lastPongAt: latestExt.lastPongAt,
174
+ now: Date.now(),
175
+ })) {
176
+ logger?.log(pc.yellow(`Terminating unresponsive extension heartbeat (${extensionId})`));
177
+ if (latestExt.ws.raw) {
178
+ latestExt.ws.raw.terminate();
179
+ return;
180
+ }
181
+ latestExt.ws.close(4000, 'Extension heartbeat timeout');
182
+ return;
183
+ }
184
+ latestExt.ws.send(JSON.stringify({ method: 'ping' }));
185
+ }, 5000);
186
+ store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval }));
187
+ };
188
+ const stopExtensionPing = (extensionId) => {
189
+ const ext = store.getState().extensions.get(extensionId);
190
+ if (!ext || !ext.pingInterval) {
191
+ return;
192
+ }
193
+ clearInterval(ext.pingInterval);
194
+ store.setState((s) => relayState.updateExtensionIO(s, { extensionId, pingInterval: null }));
195
+ };
196
+ function logCdpMessage({ direction, clientId, method, sessionId, params, id, source, }) {
197
+ const noisyEvents = [
198
+ 'Network.requestWillBeSentExtraInfo',
199
+ 'Network.responseReceived',
200
+ 'Network.responseReceivedExtraInfo',
201
+ 'Network.dataReceived',
202
+ 'Network.requestWillBeSent',
203
+ 'Network.loadingFinished',
204
+ ];
205
+ if (noisyEvents.includes(method)) {
206
+ return;
207
+ }
208
+ const details = [];
209
+ if (id !== undefined) {
210
+ details.push(`id=${id}`);
211
+ }
212
+ if (sessionId) {
213
+ details.push(`sessionId=${sessionId}`);
214
+ }
215
+ if (params) {
216
+ if (params.targetId) {
217
+ details.push(`targetId=${params.targetId}`);
218
+ }
219
+ if (params.targetInfo?.targetId) {
220
+ details.push(`targetId=${params.targetInfo.targetId}`);
221
+ }
222
+ if (params.sessionId && params.sessionId !== sessionId) {
223
+ details.push(`sessionId=${params.sessionId}`);
224
+ }
225
+ }
226
+ const detailsStr = details.length > 0 ? ` ${pc.gray(details.join(', '))}` : '';
227
+ if (direction === 'from-playwright') {
228
+ const clientLabel = clientId ? pc.blue(`[${clientId}]`) : '';
229
+ logger?.log(pc.cyan('← Playwright'), clientLabel + ':', method + detailsStr);
230
+ }
231
+ else if (direction === 'from-extension') {
232
+ logger?.log(pc.yellow('← Extension:'), method + detailsStr);
233
+ }
234
+ else if (direction === 'to-playwright') {
235
+ const color = source === 'server' ? pc.magenta : pc.green;
236
+ const sourceLabel = source === 'server' ? pc.gray(' (server-generated)') : '';
237
+ const clientLabel = clientId ? pc.blue(`[${clientId}]`) : pc.blue('[ALL]');
238
+ logger?.log(color('→ Playwright'), clientLabel + ':', method + detailsStr + sourceLabel);
239
+ }
240
+ }
241
+ function sendToPlaywright({ message, clientId, source = 'extension', extensionId, }) {
242
+ const messageToSend = source === 'server' && 'method' in message ? { ...message, __serverGenerated: true } : message;
243
+ logCdpJson({
244
+ timestamp: new Date().toISOString(),
245
+ direction: 'to-playwright',
246
+ clientId,
247
+ source,
248
+ message: messageToSend,
249
+ });
250
+ if ('method' in message) {
251
+ logCdpMessage({
252
+ direction: 'to-playwright',
253
+ clientId,
254
+ method: message.method,
255
+ sessionId: 'sessionId' in message ? message.sessionId : undefined,
256
+ params: 'params' in message ? message.params : undefined,
257
+ source,
258
+ });
259
+ }
260
+ const messageStr = JSON.stringify(messageToSend);
261
+ // Helper to safely send to a WebSocket, catching errors from closing connections.
262
+ // When a Playwright client closes its WebSocket, there's a race window where:
263
+ // 1. Playwright's _onClose runs (clears callbacks map)
264
+ // 2. We might still have messages in flight or try to send
265
+ // This can cause "Assertion error" in Playwright's crConnection.js if a response
266
+ // arrives after callbacks were cleared. We wrap in try-catch to handle this gracefully.
267
+ const safeSend = (client) => {
268
+ try {
269
+ client.ws.send(messageStr);
270
+ }
271
+ catch (e) {
272
+ // WebSocket might be closing/closed - this is expected during disconnect
273
+ logger?.log(pc.gray(`[Relay] Skipped sending to closing client ${client.id}: ${e.message}`));
274
+ }
275
+ };
276
+ if (clientId) {
277
+ const client = store.getState().playwrightClients.get(clientId);
278
+ if (client) {
279
+ safeSend(client);
280
+ }
281
+ }
282
+ else {
283
+ const { playwrightClients } = store.getState();
284
+ for (const client of playwrightClients.values()) {
285
+ if (extensionId && client.extensionId !== extensionId) {
286
+ continue;
287
+ }
288
+ safeSend(client);
289
+ }
290
+ }
291
+ }
292
+ function getForwardCdpParams(value) {
293
+ if (!value || typeof value !== 'object') {
294
+ return undefined;
295
+ }
296
+ const record = value;
297
+ if (typeof record.method !== 'string') {
298
+ return undefined;
299
+ }
300
+ const sessionId = typeof record.sessionId === 'string' ? record.sessionId : undefined;
301
+ return { method: record.method, sessionId, params: record.params };
302
+ }
303
+ async function sendToExtension({ extensionId, method, params, timeout = 30000, }) {
304
+ const conn = getExtensionConnection(extensionId);
305
+ if (!conn) {
306
+ throw new Error('Extension not connected');
307
+ }
308
+ const resolvedExtensionId = conn.id;
309
+ const requiredFeature = requiredExtensionFeatureForMethod(method);
310
+ if (requiredFeature && !extensionAllowsFeature({ extensionId: resolvedExtensionId, feature: requiredFeature })) {
311
+ throw new Error(`Extension ${conn.stableKey} does not advertise required feature ${requiredFeature}. Update the extension to use this operation.`);
312
+ }
313
+ let id = 0;
314
+ store.setState((s) => {
315
+ const ext = s.extensions.get(resolvedExtensionId);
316
+ if (!ext) {
317
+ return s;
318
+ }
319
+ id = ext.messageId + 1;
320
+ const newExtensions = new Map(s.extensions);
321
+ newExtensions.set(resolvedExtensionId, { ...ext, messageId: id });
322
+ return { ...s, extensions: newExtensions };
323
+ });
324
+ if (!id) {
325
+ throw new Error('Extension not connected');
326
+ }
327
+ const message = { id, method, params };
328
+ const forwardCdpParams = method === 'forwardCDPCommand' ? getForwardCdpParams(params) : undefined;
329
+ if (forwardCdpParams) {
330
+ logCdpJson({
331
+ timestamp: new Date().toISOString(),
332
+ direction: 'to-extension',
333
+ message: {
334
+ method: forwardCdpParams.method,
335
+ sessionId: forwardCdpParams.sessionId,
336
+ params: forwardCdpParams.params,
337
+ },
338
+ });
339
+ }
340
+ return new Promise((resolve, reject) => {
341
+ const timeoutId = setTimeout(() => {
342
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
343
+ extensionId: resolvedExtensionId,
344
+ requestId: id,
345
+ }));
346
+ reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`));
347
+ }, timeout);
348
+ const pendingRequest = {
349
+ resolve: (result) => {
350
+ clearTimeout(timeoutId);
351
+ resolve(result);
352
+ },
353
+ reject: (error) => {
354
+ clearTimeout(timeoutId);
355
+ reject(error);
356
+ },
357
+ };
358
+ store.setState((s) => relayState.addExtensionPendingRequest(s, {
359
+ extensionId: resolvedExtensionId,
360
+ requestId: id,
361
+ pendingRequest,
362
+ }));
363
+ const latestExt = store.getState().extensions.get(resolvedExtensionId);
364
+ if (!latestExt?.ws) {
365
+ clearTimeout(timeoutId);
366
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
367
+ extensionId: resolvedExtensionId,
368
+ requestId: id,
369
+ }));
370
+ reject(new Error('Extension not connected'));
371
+ return;
372
+ }
373
+ try {
374
+ latestExt.ws.send(JSON.stringify(message));
375
+ }
376
+ catch (error) {
377
+ clearTimeout(timeoutId);
378
+ store.setState((s) => relayState.removeExtensionPendingRequest(s, {
379
+ extensionId: resolvedExtensionId,
380
+ requestId: id,
381
+ }));
382
+ const sendError = error instanceof Error ? error : new Error(String(error));
383
+ reject(new Error(`Extension send failed: ${method}`, { cause: sendError }));
384
+ }
385
+ });
386
+ }
387
+ const rrwebRecordingRelays = new Map();
388
+ // Find which extension connection owns a CDP tab session ID (pw-tab-*).
389
+ // Used by recording routes where sessionId identifies the target tab.
390
+ // Delegates to the pure derivation function from relay-state.ts.
391
+ const findExtensionIdByCdpSession = (cdpSessionId) => {
392
+ return relayState.findExtensionIdByCdpSession(store.getState(), cdpSessionId);
393
+ };
394
+ // Resolve recording route session ID (CDP tab session) to extension connection.
395
+ const resolveRecordingRoute = async ({ sessionId, }) => {
396
+ if (!sessionId) {
397
+ return { extensionId: null, sessionId: null };
398
+ }
399
+ const extensionId = findExtensionIdByCdpSession(sessionId);
400
+ return { extensionId, sessionId };
401
+ };
402
+ const getRrwebRecordingRelay = (extensionId) => {
403
+ const allowDefault = !extensionId && store.getState().extensions.size === 1;
404
+ const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault });
405
+ if (!conn) {
406
+ return null;
407
+ }
408
+ const connId = conn.id;
409
+ if (!rrwebRecordingRelays.has(connId)) {
410
+ rrwebRecordingRelays.set(connId, new RrwebRecordingRelay((params) => sendToExtension({ extensionId: connId, ...params }), () => store.getState().extensions.has(connId), logger));
411
+ }
412
+ return rrwebRecordingRelays.get(connId) || null;
413
+ };
414
+ const handleToolbarRecordingRequest = async (extensionId, message) => {
415
+ logger?.log(pc.blue(`Toolbar recording request: ${message.params.action} requestId=${message.params.requestId} sessionId=${message.params.sessionId || 'none'}`));
416
+ const rrwebRelay = getRrwebRecordingRelay(extensionId);
417
+ if (!rrwebRelay) {
418
+ logger?.log(pc.yellow(`Toolbar recording request failed: extension not connected requestId=${message.params.requestId}`));
419
+ return { success: false, isRecording: false, error: 'Extension not connected' };
420
+ }
421
+ const params = message.params.sessionId ? { sessionId: message.params.sessionId } : {};
422
+ const rrwebStatus = await rrwebRelay.isRecording(params);
423
+ const isRecording = rrwebStatus.isRecording;
424
+ logger?.log(pc.blue(`Toolbar replay recording status: rrweb=${rrwebStatus.isRecording} requestId=${message.params.requestId} tabId=${rrwebStatus.tabId || 'none'}`));
425
+ if (message.params.action === 'status') {
426
+ return {
427
+ success: true,
428
+ isRecording,
429
+ startedAt: rrwebStatus.startedAt,
430
+ tabId: rrwebStatus.tabId,
431
+ };
432
+ }
433
+ if (isRecording) {
434
+ const rrwebStopResult = await rrwebRelay.stopRecording(params);
435
+ if (!rrwebStopResult.success) {
436
+ const error = rrwebStopResult.error;
437
+ logger?.log(pc.yellow(`Toolbar recording stop failed requestId=${message.params.requestId}: ${error}`));
438
+ return { success: false, isRecording: true, error };
439
+ }
440
+ return {
441
+ success: true,
442
+ isRecording: false,
443
+ id: rrwebStopResult.id,
444
+ tabId: rrwebStopResult.tabId,
445
+ path: rrwebStopResult.path,
446
+ duration: rrwebStopResult.duration,
447
+ size: rrwebStopResult.size,
448
+ replayId: rrwebStopResult.id,
449
+ replayPath: rrwebStopResult.path,
450
+ replayDuration: rrwebStopResult.duration,
451
+ replaySize: rrwebStopResult.size,
452
+ replayEventCount: rrwebStopResult.eventCount,
453
+ };
454
+ }
455
+ const rrwebStartResult = await rrwebRelay.startRecording({
456
+ ...params,
457
+ checkoutEveryNms: 0,
458
+ maskAllInputs: false,
459
+ });
460
+ if (!rrwebStartResult.success) {
461
+ const error = rrwebStartResult.error || 'Failed to start replay recording';
462
+ logger?.log(pc.yellow(`Toolbar recording start failed requestId=${message.params.requestId}: ${error}`));
463
+ return { success: false, isRecording: false, error };
464
+ }
465
+ return {
466
+ success: true,
467
+ isRecording: true,
468
+ startedAt: rrwebStartResult.startedAt,
469
+ tabId: rrwebStartResult.tabId,
470
+ };
471
+ };
472
+ async function createInitialTabTarget(options) {
473
+ const result = (await sendToExtension({
474
+ extensionId: options.extensionId,
475
+ method: 'createInitialTab',
476
+ timeout: 10000,
477
+ }));
478
+ if (!result.success || !result.tabId || !result.sessionId || !result.targetInfo) {
479
+ throw new Error('Tabwright could not create a temporary browser tab');
480
+ }
481
+ const target = {
482
+ tabId: result.tabId,
483
+ sessionId: result.sessionId,
484
+ targetInfo: result.targetInfo,
485
+ };
486
+ store.setState((state) => relayState.addTarget(state, {
487
+ extensionId: options.extensionId,
488
+ sessionId: target.sessionId,
489
+ targetId: target.targetInfo.targetId,
490
+ targetInfo: target.targetInfo,
491
+ }));
492
+ const updatedTargets = store.getState().extensions.get(options.extensionId)?.connectedTargets.size || 0;
493
+ logger?.log(pc.blue(`Auto-created tab, now have ${updatedTargets} targets, url: ${target.targetInfo.url}`));
494
+ return target;
495
+ }
496
+ // Auto-create an initial blank tab when no targets exist. Set
497
+ // PLAYWRITER_AUTO_ENABLE=false to require manually enabled tabs instead.
498
+ async function maybeAutoCreateInitialTab(extensionId) {
499
+ if (!shouldAutoEnableTabwright()) {
500
+ return;
501
+ }
502
+ const conn = getExtensionConnection(extensionId);
503
+ if (!conn) {
504
+ return;
505
+ }
506
+ if (!extensionAllowsFeature({ extensionId: conn.id, feature: EXTENSION_FEATURE.createInitialTab })) {
507
+ return;
508
+ }
509
+ if (conn.connectedTargets.size > 0) {
510
+ return;
511
+ }
512
+ try {
513
+ logger?.log(pc.blue('Auto-creating initial tab for Playwright client'));
514
+ await createInitialTabTarget({ extensionId });
515
+ }
516
+ catch (error) {
517
+ logger?.error('Failed to auto-create initial tab:', error);
518
+ }
519
+ }
520
+ function getPageTargetSessionIds({ extensionId }) {
521
+ const extensionState = store.getState().extensions.get(extensionId);
522
+ if (!extensionState) {
523
+ return [];
524
+ }
525
+ return Array.from(extensionState.connectedTargets.values())
526
+ .filter((target) => {
527
+ return target.targetInfo.type === 'page';
528
+ })
529
+ .map((target) => {
530
+ return target.sessionId;
531
+ });
532
+ }
533
+ async function getCapabilityAuthSession(options) {
534
+ const existingSessionId = getPageTargetSessionIds({ extensionId: options.extensionId })[0];
535
+ if (existingSessionId) {
536
+ return { ok: true, sessionId: existingSessionId };
537
+ }
538
+ if (!extensionSupportsFeature({
539
+ extensionId: options.extensionId,
540
+ feature: EXTENSION_FEATURE.createInitialTab,
541
+ })) {
542
+ return {
543
+ ok: false,
544
+ code: 'no_enabled_tab',
545
+ error: 'enable Tabwright on a browser tab before authenticating this capability',
546
+ };
547
+ }
548
+ try {
549
+ // Authentication is already user-confirmed in Options. A temporary inactive tab lets
550
+ // chrome.debugger read profile cookies without requiring another extension-icon click.
551
+ const target = await createInitialTabTarget({ extensionId: options.extensionId });
552
+ return {
553
+ ok: true,
554
+ sessionId: target.sessionId,
555
+ temporaryTargetId: target.targetInfo.targetId,
556
+ };
557
+ }
558
+ catch (error) {
559
+ return {
560
+ ok: false,
561
+ code: 'temporary_tab_failed',
562
+ error: error instanceof Error ? error.message : String(error),
563
+ };
564
+ }
565
+ }
566
+ function maybeEmitBrowserDownloadCompatEvent({ method, params, extensionId, }) {
567
+ const browserEventMethod = method === 'Page.downloadWillBegin'
568
+ ? 'Browser.downloadWillBegin'
569
+ : method === 'Page.downloadProgress'
570
+ ? 'Browser.downloadProgress'
571
+ : null;
572
+ if (!browserEventMethod) {
573
+ return;
574
+ }
575
+ sendToPlaywright({
576
+ message: {
577
+ method: browserEventMethod,
578
+ params,
579
+ },
580
+ source: 'server',
581
+ extensionId,
582
+ });
583
+ }
584
+ async function applyDownloadBehaviorToTargets({ extensionId, behavior, source, targetSessionIds, }) {
585
+ const pageBehavior = behavior.behavior === 'allowAndName' ? 'allow' : behavior.behavior;
586
+ const pageParams = (() => {
587
+ if (pageBehavior === 'allow' && behavior.downloadPath) {
588
+ return { behavior: pageBehavior, downloadPath: behavior.downloadPath };
589
+ }
590
+ return { behavior: pageBehavior };
591
+ })();
592
+ const sessions = targetSessionIds || getPageTargetSessionIds({ extensionId });
593
+ if (sessions.length === 0) {
594
+ return;
595
+ }
596
+ await Promise.all(sessions.map(async (targetSessionId) => {
597
+ try {
598
+ await sendToExtension({
599
+ extensionId,
600
+ method: 'forwardCDPCommand',
601
+ params: {
602
+ sessionId: targetSessionId,
603
+ method: 'Page.setDownloadBehavior',
604
+ params: pageParams,
605
+ source,
606
+ },
607
+ });
608
+ }
609
+ catch (error) {
610
+ const message = error instanceof Error ? error.message : String(error);
611
+ logger?.log(pc.yellow(`[Server] Failed to apply Page.setDownloadBehavior to ${targetSessionId}: ${message}`));
612
+ }
613
+ }));
614
+ }
615
+ async function routeCdpCommand({ extensionId, method, params, sessionId, source, }) {
616
+ const conn = getExtensionConnection(extensionId);
617
+ const connectedTargets = conn?.connectedTargets || new Map();
618
+ const resolvedExtensionId = conn?.id || extensionId;
619
+ switch (method) {
620
+ case 'Browser.getVersion': {
621
+ return {
622
+ protocolVersion: '1.3',
623
+ product: 'Chrome/Extension-Bridge',
624
+ revision: '1.0.0',
625
+ userAgent: 'CDP-Bridge-Server/1.0.0',
626
+ jsVersion: 'V8',
627
+ };
628
+ }
629
+ case 'Browser.setDownloadBehavior': {
630
+ const downloadBehaviorParams = params;
631
+ if (!downloadBehaviorParams?.behavior) {
632
+ throw new Error('behavior is required for Browser.setDownloadBehavior');
633
+ }
634
+ if (resolvedExtensionId) {
635
+ extensionDownloadBehavior.set(resolvedExtensionId, downloadBehaviorParams);
636
+ await applyDownloadBehaviorToTargets({
637
+ extensionId: resolvedExtensionId,
638
+ behavior: downloadBehaviorParams,
639
+ source,
640
+ });
641
+ }
642
+ return {};
643
+ }
644
+ // Target.setAutoAttach is a CDP command Playwright sends on first connection.
645
+ // We use it as the hook to auto-create an initial tab. If Playwright changes
646
+ // its initialization sequence in the future, this could be moved to a different command.
647
+ case 'Target.setAutoAttach': {
648
+ if (sessionId) {
649
+ break;
650
+ }
651
+ if (conn) {
652
+ await maybeAutoCreateInitialTab(conn.id);
653
+ }
654
+ // Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
655
+ // Playwright relies on these (with parentFrameId) when reconnecting over CDP.
656
+ await sendToExtension({
657
+ extensionId: resolvedExtensionId,
658
+ method: 'forwardCDPCommand',
659
+ params: { method, params, source },
660
+ });
661
+ return {};
662
+ }
663
+ case 'Target.setDiscoverTargets': {
664
+ return {};
665
+ }
666
+ case 'Target.attachToTarget': {
667
+ const attachParams = params;
668
+ if (!attachParams?.targetId) {
669
+ throw new Error('targetId is required for Target.attachToTarget');
670
+ }
671
+ for (const target of connectedTargets.values()) {
672
+ if (target.targetId === attachParams.targetId) {
673
+ return { sessionId: target.sessionId };
674
+ }
675
+ }
676
+ throw new Error(`Target ${attachParams.targetId} not found in connected targets`);
677
+ }
678
+ case 'Target.getTargetInfo': {
679
+ const infoReqParams = params;
680
+ const targetId = infoReqParams?.targetId;
681
+ if (targetId) {
682
+ for (const target of connectedTargets.values()) {
683
+ if (target.targetId === targetId) {
684
+ return { targetInfo: target.targetInfo };
685
+ }
686
+ }
687
+ }
688
+ if (sessionId) {
689
+ const target = connectedTargets.get(sessionId);
690
+ if (target) {
691
+ return { targetInfo: target.targetInfo };
692
+ }
693
+ }
694
+ const firstTarget = Array.from(connectedTargets.values())[0];
695
+ return { targetInfo: firstTarget?.targetInfo };
696
+ }
697
+ case 'Target.getTargets': {
698
+ return {
699
+ targetInfos: Array.from(connectedTargets.values())
700
+ .filter((t) => !isRestrictedTarget(t.targetInfo))
701
+ .map((t) => ({
702
+ ...t.targetInfo,
703
+ attached: true,
704
+ })),
705
+ };
706
+ }
707
+ case 'Target.createTarget': {
708
+ return await sendToExtension({
709
+ extensionId: resolvedExtensionId,
710
+ method: 'forwardCDPCommand',
711
+ params: { method, params, source },
712
+ });
713
+ }
714
+ case 'Target.closeTarget': {
715
+ return await sendToExtension({
716
+ extensionId: resolvedExtensionId,
717
+ method: 'forwardCDPCommand',
718
+ params: { method, params, source },
719
+ });
720
+ }
721
+ // Ghost Browser API - forward to extension for chrome.ghostPublicAPI/ghostProxies/projects
722
+ case 'ghost-browser': {
723
+ return await sendToExtension({
724
+ extensionId: resolvedExtensionId,
725
+ method: 'ghost-browser',
726
+ params,
727
+ });
728
+ }
729
+ case 'Runtime.enable': {
730
+ if (!sessionId) {
731
+ break;
732
+ }
733
+ const contextCreatedPromise = new Promise((resolve) => {
734
+ const handler = ({ event }) => {
735
+ if (event.method === 'Runtime.executionContextCreated' && event.sessionId === sessionId) {
736
+ const params = event.params;
737
+ if (params?.context?.auxData?.isDefault === true) {
738
+ clearTimeout(timeout);
739
+ emitter.off('cdp:event', handler);
740
+ resolve();
741
+ }
742
+ }
743
+ };
744
+ const timeout = setTimeout(() => {
745
+ emitter.off('cdp:event', handler);
746
+ logger?.log(pc.yellow(`IMPORTANT: Runtime.enable timed out waiting for main frame executionContextCreated (sessionId: ${sessionId}). This may cause pages to not be visible immediately.`));
747
+ resolve();
748
+ }, 3000);
749
+ emitter.on('cdp:event', handler);
750
+ });
751
+ const result = await sendToExtension({
752
+ extensionId: resolvedExtensionId,
753
+ method: 'forwardCDPCommand',
754
+ params: { sessionId, method, params, source },
755
+ });
756
+ await contextCreatedPromise;
757
+ return result;
758
+ }
759
+ }
760
+ return await sendToExtension({
761
+ extensionId: resolvedExtensionId,
762
+ method: 'forwardCDPCommand',
763
+ params: { sessionId, method, params, source },
764
+ });
765
+ }
766
+ const app = new Hono();
767
+ // Global error handler — ensures server errors are logged, not silently swallowed
768
+ app.onError((err, c) => {
769
+ logger?.error('Unhandled route error:', err);
770
+ return c.json({ error: err.message }, 500);
771
+ });
772
+ // CORS middleware for HTTP endpoints - only allows our specific extension IDs.
773
+ // This prevents other extensions from reading responses via fetch/XHR.
774
+ // WebSocket connections have their own separate origin validation.
775
+ app.use('*', cors({
776
+ origin: (origin) => {
777
+ if (!origin.startsWith('chrome-extension://')) {
778
+ return null;
779
+ }
780
+ const extensionId = origin.replace('chrome-extension://', '');
781
+ if (!EXTENSION_IDS.includes(extensionId)) {
782
+ return null;
783
+ }
784
+ return origin;
785
+ },
786
+ allowMethods: ['GET', 'POST', 'HEAD', 'OPTIONS'],
787
+ }));
788
+ // Host header validation to prevent DNS rebinding attacks.
789
+ // DNS rebinding is worse than a simple cross-origin request: the attacker
790
+ // serves a page from http://evil.com:19988, then rebinds the DNS to
791
+ // 127.0.0.1. The browser now considers requests to our relay as same-origin,
792
+ // so Sec-Fetch-Site is "same-origin", CORS doesn't apply, and JSON POSTs
793
+ // don't need preflight. This bypasses all our other defenses.
794
+ // By rejecting any Host that isn't a known localhost value we kill DNS
795
+ // rebinding at the root. When a valid token is provided (remote access), we
796
+ // allow through regardless of Host since remote clients use real hostnames.
797
+ const ALLOWED_HOSTS = new Set([
798
+ 'localhost',
799
+ '127.0.0.1',
800
+ '[::1]',
801
+ '::1',
802
+ ]);
803
+ // Parse the Host header into just the hostname, handling IPv6 brackets and
804
+ // port suffixes. Returns null for missing or malformed values.
805
+ function parseHostname(hostHeader) {
806
+ const value = hostHeader?.trim().toLowerCase();
807
+ if (!value) {
808
+ return null;
809
+ }
810
+ // IPv6 in brackets: [::1] or [::1]:19988
811
+ if (value.startsWith('[')) {
812
+ const closingBracket = value.indexOf(']');
813
+ if (closingBracket === -1) {
814
+ return null;
815
+ }
816
+ const host = value.slice(0, closingBracket + 1);
817
+ const rest = value.slice(closingBracket + 1);
818
+ if (rest && !/^:\d+$/.test(rest)) {
819
+ return null;
820
+ }
821
+ return host;
822
+ }
823
+ // Bare ::1 without brackets (uncommon but possible)
824
+ if (value === '::1') {
825
+ return '::1';
826
+ }
827
+ // hostname or hostname:port
828
+ const colonIndex = value.indexOf(':');
829
+ if (colonIndex === -1) {
830
+ return value;
831
+ }
832
+ const host = value.slice(0, colonIndex);
833
+ const portPart = value.slice(colonIndex + 1);
834
+ if (!/^\d+$/.test(portPart)) {
835
+ return null;
836
+ }
837
+ return host || null;
838
+ }
839
+ function hasValidToken(c) {
840
+ if (!token) {
841
+ return false;
842
+ }
843
+ const authHeader = c.req.header('authorization') || '';
844
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
845
+ const queryToken = new URL(c.req.url, 'http://localhost').searchParams.get('token');
846
+ return bearerToken === token || queryToken === token;
847
+ }
848
+ app.use('*', async (c, next) => {
849
+ const hostname = parseHostname(c.req.header('host'));
850
+ if (hostname && ALLOWED_HOSTS.has(hostname)) {
851
+ return next();
852
+ }
853
+ // Remote clients with a valid token are allowed regardless of Host
854
+ if (hasValidToken(c)) {
855
+ return next();
856
+ }
857
+ // Missing Host header from non-browser clients (curl without Host) is fine
858
+ // in local mode since they're not browser-based DNS rebinding attacks
859
+ if (!hostname && !token) {
860
+ return next();
861
+ }
862
+ logger?.log(pc.red(`Rejecting request with unexpected Host header: ${c.req.header('host')} (DNS rebinding protection)`));
863
+ return c.text('Forbidden - Invalid Host header', 403);
864
+ });
865
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
866
+ const getCdpWsUrl = (c) => {
867
+ const hostHeader = c.req.header('host') || `${host}:${port}`;
868
+ return `ws://${hostHeader}/cdp`;
869
+ };
870
+ app.get('/', (c) => {
871
+ return c.text('OK');
872
+ });
873
+ app.get('/version', (c) => {
874
+ return c.json({ version: VERSION });
875
+ });
876
+ app.get('/features', (c) => {
877
+ return c.json({ protocolVersion: EXTENSION_PROTOCOL_VERSION, features: RELAY_FEATURES });
878
+ });
879
+ app.get('/extension/status', (c) => {
880
+ const defaultExtension = getExtensionConnection(null, { allowFallback: true });
881
+ const connected = store.getState().extensions.size > 0;
882
+ const activeTargets = defaultExtension?.connectedTargets.size || 0;
883
+ const info = defaultExtension?.info;
884
+ const negotiation = relayState.getExtensionNegotiationStatus(info);
885
+ return c.json({
886
+ connected,
887
+ activeTargets,
888
+ browser: info?.browser || null,
889
+ profile: info ? { email: info.email || '', id: info.id || '' } : null,
890
+ playwriterVersion: info?.version || null,
891
+ protocolVersion: info?.protocolVersion,
892
+ features: info?.features,
893
+ ...negotiation,
894
+ });
895
+ });
896
+ app.get('/extensions/status', (c) => {
897
+ const extensions = Array.from(store.getState().extensions.values()).map((ext) => {
898
+ const negotiation = relayState.getExtensionNegotiationStatus(ext.info);
899
+ return {
900
+ extensionId: ext.id,
901
+ stableKey: ext.stableKey,
902
+ browser: ext.info.browser || null,
903
+ profile: ext.info ? { email: ext.info.email || '', id: ext.info.id || '' } : null,
904
+ activeTargets: ext.connectedTargets.size,
905
+ playwriterVersion: ext.info?.version || null,
906
+ protocolVersion: ext.info.protocolVersion,
907
+ features: ext.info.features,
908
+ ...negotiation,
909
+ };
910
+ });
911
+ return c.json({ extensions });
912
+ });
913
+ // CDP Discovery Endpoints - Standard Chrome DevTools Protocol HTTP API
914
+ // Allows tools like Playwright to discover the WebSocket URL via http://host:port
915
+ // Spec: https://chromium.googlesource.com/chromium/src/+/main/content/browser/devtools/devtools_http_handler.cc
916
+ app
917
+ .on(['GET', 'PUT'], '/json/version', (c) => {
918
+ return c.json({
919
+ Browser: `Tabwright/${VERSION}`,
920
+ 'Protocol-Version': '1.3',
921
+ webSocketDebuggerUrl: getCdpWsUrl(c),
922
+ });
923
+ })
924
+ .on(['GET', 'PUT'], '/json/version/', (c) => {
925
+ return c.json({
926
+ Browser: `Tabwright/${VERSION}`,
927
+ 'Protocol-Version': '1.3',
928
+ webSocketDebuggerUrl: getCdpWsUrl(c),
929
+ });
930
+ })
931
+ .on(['GET', 'PUT'], '/json/list', (c) => {
932
+ const wsUrl = getCdpWsUrl(c);
933
+ const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
934
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
935
+ id: t.targetId,
936
+ type: t.targetInfo.type,
937
+ title: t.targetInfo.title,
938
+ description: t.targetInfo.title,
939
+ url: t.targetInfo.url,
940
+ webSocketDebuggerUrl: wsUrl,
941
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
942
+ })));
943
+ })
944
+ .on(['GET', 'PUT'], '/json/list/', (c) => {
945
+ const wsUrl = getCdpWsUrl(c);
946
+ const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
947
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
948
+ id: t.targetId,
949
+ type: t.targetInfo.type,
950
+ title: t.targetInfo.title,
951
+ description: t.targetInfo.title,
952
+ url: t.targetInfo.url,
953
+ webSocketDebuggerUrl: wsUrl,
954
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
955
+ })));
956
+ })
957
+ .on(['GET', 'PUT'], '/json', (c) => {
958
+ const wsUrl = getCdpWsUrl(c);
959
+ const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
960
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
961
+ id: t.targetId,
962
+ type: t.targetInfo.type,
963
+ title: t.targetInfo.title,
964
+ description: t.targetInfo.title,
965
+ url: t.targetInfo.url,
966
+ webSocketDebuggerUrl: wsUrl,
967
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
968
+ })));
969
+ })
970
+ .on(['GET', 'PUT'], '/json/', (c) => {
971
+ const wsUrl = getCdpWsUrl(c);
972
+ const defaultTargets = getExtensionConnection(null, { allowFallback: true })?.connectedTargets || new Map();
973
+ return c.json(Array.from(defaultTargets.values()).map((t) => ({
974
+ id: t.targetId,
975
+ type: t.targetInfo.type,
976
+ title: t.targetInfo.title,
977
+ description: t.targetInfo.title,
978
+ url: t.targetInfo.url,
979
+ webSocketDebuggerUrl: wsUrl,
980
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${wsUrl.replace('ws://', '')}`,
981
+ })));
982
+ });
983
+ app.post('/mcp-log', async (c) => {
984
+ try {
985
+ const { level, args } = await c.req.json();
986
+ const logFn = logger?.[level] || logger?.log;
987
+ const prefix = pc.red(`[MCP] [${level.toUpperCase()}]`);
988
+ logFn?.(prefix, ...args);
989
+ return c.json({ ok: true });
990
+ }
991
+ catch {
992
+ return c.json({ ok: false }, 400);
993
+ }
994
+ });
995
+ // Validate Origin header for WebSocket connections to prevent cross-origin attacks.
996
+ // Browsers always send Origin header for WebSocket connections, but Node.js clients don't.
997
+ // We only allow our specific extension IDs to prevent malicious websites or extensions
998
+ // from connecting to the local WebSocket server.
999
+ app.get('/cdp/:clientId?', (c, next) => {
1000
+ const origin = c.req.header('origin');
1001
+ // Validate Origin header if present (Node.js clients don't send it)
1002
+ if (origin) {
1003
+ if (origin.startsWith('chrome-extension://')) {
1004
+ const extensionId = origin.replace('chrome-extension://', '');
1005
+ if (!EXTENSION_IDS.includes(extensionId)) {
1006
+ logger?.log(pc.red(`Rejecting /cdp WebSocket from unknown extension: ${extensionId}`));
1007
+ return c.text('Forbidden', 403);
1008
+ }
1009
+ }
1010
+ else {
1011
+ logger?.log(pc.red(`Rejecting /cdp WebSocket from origin: ${origin}`));
1012
+ return c.text('Forbidden', 403);
1013
+ }
1014
+ }
1015
+ if (token) {
1016
+ const url = new URL(c.req.url, 'http://localhost');
1017
+ const providedToken = url.searchParams.get('token');
1018
+ if (providedToken !== token) {
1019
+ return c.text('Unauthorized', 401);
1020
+ }
1021
+ }
1022
+ return next();
1023
+ }, upgradeWebSocket((c) => {
1024
+ const clientId = c.req.param('clientId') || 'default';
1025
+ const url = new URL(c.req.url, 'http://localhost');
1026
+ const requestedExtensionId = url.searchParams.get('extensionId');
1027
+ // When extensionId is explicit, resolve directly. Otherwise use fallback which
1028
+ // handles single-extension and uniquely-active-extension cases (#52).
1029
+ const resolvedExtension = requestedExtensionId
1030
+ ? getExtensionConnection(requestedExtensionId)
1031
+ : getExtensionConnection(null, { allowFallback: true });
1032
+ const clientExtensionId = resolvedExtension?.id || null;
1033
+ const getBoundExtensionIdForClient = () => {
1034
+ const client = store.getState().playwrightClients.get(clientId);
1035
+ return client?.extensionId || null;
1036
+ };
1037
+ return {
1038
+ async onOpen(_event, ws) {
1039
+ if (store.getState().playwrightClients.has(clientId)) {
1040
+ logger?.log(pc.yellow(`Rejecting duplicate Playwright clientId: ${clientId}`));
1041
+ ws.close(4004, 'Duplicate Playwright clientId');
1042
+ return;
1043
+ }
1044
+ if (!clientExtensionId) {
1045
+ const reason = requestedExtensionId
1046
+ ? `Unknown extensionId: ${requestedExtensionId}`
1047
+ : 'Multiple extensions connected. Specify extensionId.';
1048
+ logger?.log(pc.yellow(`Rejecting Playwright client ${clientId}: ${reason}`));
1049
+ ws.close(4003, reason);
1050
+ return;
1051
+ }
1052
+ // Add client first so it can receive Target.attachedToTarget events
1053
+ store.setState((s) => {
1054
+ return relayState.addPlaywrightClient(s, { id: clientId, extensionId: clientExtensionId, ws });
1055
+ });
1056
+ const extensionConnection = getExtensionConnection(clientExtensionId);
1057
+ const targetCount = extensionConnection?.connectedTargets.size || 0;
1058
+ logger?.log(pc.green(`Playwright client connected: ${clientId} (${store.getState().playwrightClients.size} total) (extension? ${!!extensionConnection}) (${targetCount} pages)`));
1059
+ },
1060
+ async onMessage(event, ws) {
1061
+ let message;
1062
+ try {
1063
+ message = JSON.parse(event.data.toString());
1064
+ }
1065
+ catch {
1066
+ return;
1067
+ }
1068
+ const { id, sessionId, method, params, source } = message;
1069
+ logCdpJson({
1070
+ timestamp: new Date().toISOString(),
1071
+ direction: 'from-playwright',
1072
+ clientId,
1073
+ message,
1074
+ });
1075
+ logCdpMessage({
1076
+ direction: 'from-playwright',
1077
+ clientId,
1078
+ method,
1079
+ sessionId,
1080
+ id,
1081
+ });
1082
+ emitter.emit('cdp:command', { clientId, command: message });
1083
+ const boundExtensionId = getBoundExtensionIdForClient();
1084
+ const extensionConn = getExtensionConnection(boundExtensionId);
1085
+ if (!extensionConn) {
1086
+ sendToPlaywright({
1087
+ message: {
1088
+ id,
1089
+ sessionId,
1090
+ error: { message: 'Extension not connected' },
1091
+ },
1092
+ clientId,
1093
+ });
1094
+ return;
1095
+ }
1096
+ try {
1097
+ const result = await routeCdpCommand({
1098
+ extensionId: extensionConn.id,
1099
+ method,
1100
+ params,
1101
+ sessionId,
1102
+ source,
1103
+ });
1104
+ if (method === 'Target.setAutoAttach' && !sessionId) {
1105
+ // Re-read state after async routeCdpCommand — targets may have changed
1106
+ const freshExt = store.getState().extensions.get(extensionConn.id);
1107
+ const freshTargets = freshExt?.connectedTargets || new Map();
1108
+ for (const target of freshTargets.values()) {
1109
+ // Skip restricted targets (extensions, chrome:// URLs, non-page types)
1110
+ if (isRestrictedTarget(target.targetInfo)) {
1111
+ continue;
1112
+ }
1113
+ const attachedPayload = {
1114
+ method: 'Target.attachedToTarget',
1115
+ params: {
1116
+ sessionId: target.sessionId,
1117
+ targetInfo: {
1118
+ ...target.targetInfo,
1119
+ attached: true,
1120
+ },
1121
+ waitingForDebugger: false,
1122
+ },
1123
+ };
1124
+ if (!target.targetInfo.url) {
1125
+ logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget sent with empty URL!'), JSON.stringify(attachedPayload));
1126
+ }
1127
+ logger?.log(pc.magenta('[Server] Target.attachedToTarget full payload:'), JSON.stringify(attachedPayload));
1128
+ sendToPlaywright({
1129
+ message: attachedPayload,
1130
+ clientId,
1131
+ source: 'server',
1132
+ });
1133
+ }
1134
+ }
1135
+ if (method === 'Target.setDiscoverTargets' && params?.discover) {
1136
+ const freshExt2 = store.getState().extensions.get(extensionConn.id);
1137
+ const freshTargets2 = freshExt2?.connectedTargets || new Map();
1138
+ for (const target of freshTargets2.values()) {
1139
+ // Skip restricted targets (extensions, chrome:// URLs, non-page types)
1140
+ if (isRestrictedTarget(target.targetInfo)) {
1141
+ continue;
1142
+ }
1143
+ const targetCreatedPayload = {
1144
+ method: 'Target.targetCreated',
1145
+ params: {
1146
+ targetInfo: {
1147
+ ...target.targetInfo,
1148
+ attached: true,
1149
+ },
1150
+ },
1151
+ };
1152
+ if (!target.targetInfo.url) {
1153
+ logger?.error(pc.red('[Server] WARNING: Target.targetCreated sent with empty URL!'), JSON.stringify(targetCreatedPayload));
1154
+ }
1155
+ logger?.log(pc.magenta('[Server] Target.targetCreated full payload:'), JSON.stringify(targetCreatedPayload));
1156
+ sendToPlaywright({
1157
+ message: targetCreatedPayload,
1158
+ clientId,
1159
+ source: 'server',
1160
+ });
1161
+ }
1162
+ }
1163
+ if (method === 'Target.attachToTarget') {
1164
+ const attachResponse = result;
1165
+ const attachRequestParams = params;
1166
+ if (attachResponse?.sessionId) {
1167
+ const freshExt3 = store.getState().extensions.get(extensionConn.id);
1168
+ const freshTargets3 = freshExt3?.connectedTargets || new Map();
1169
+ const target = Array.from(freshTargets3.values()).find((t) => {
1170
+ return t.targetId === attachRequestParams?.targetId;
1171
+ });
1172
+ if (target) {
1173
+ const attachedPayload = {
1174
+ method: 'Target.attachedToTarget',
1175
+ params: {
1176
+ sessionId: attachResponse.sessionId,
1177
+ targetInfo: {
1178
+ ...target.targetInfo,
1179
+ attached: true,
1180
+ },
1181
+ waitingForDebugger: false,
1182
+ },
1183
+ };
1184
+ if (!target.targetInfo.url) {
1185
+ logger?.error(pc.red('[Server] WARNING: Target.attachedToTarget (from attachToTarget) sent with empty URL!'), JSON.stringify(attachedPayload));
1186
+ }
1187
+ logger?.log(pc.magenta('[Server] Target.attachedToTarget (from attachToTarget) payload:'), JSON.stringify(attachedPayload));
1188
+ sendToPlaywright({
1189
+ message: attachedPayload,
1190
+ clientId,
1191
+ source: 'server',
1192
+ });
1193
+ }
1194
+ }
1195
+ }
1196
+ const response = { id, sessionId, result };
1197
+ sendToPlaywright({ message: response, clientId });
1198
+ emitter.emit('cdp:response', { clientId, response, command: message });
1199
+ }
1200
+ catch (e) {
1201
+ logger?.error('Error handling CDP command:', method, params, e);
1202
+ const errorResponse = {
1203
+ id,
1204
+ sessionId,
1205
+ error: { message: e.message },
1206
+ };
1207
+ sendToPlaywright({ message: errorResponse, clientId });
1208
+ emitter.emit('cdp:response', { clientId, response: errorResponse, command: message });
1209
+ }
1210
+ },
1211
+ onClose() {
1212
+ store.setState((s) => relayState.removePlaywrightClient(s, { clientId }));
1213
+ logger?.log(pc.yellow(`Playwright client disconnected: ${clientId} (${store.getState().playwrightClients.size} remaining)`));
1214
+ },
1215
+ onError(event) {
1216
+ logger?.error(`Playwright WebSocket error [${clientId}]:`, event);
1217
+ },
1218
+ };
1219
+ }));
1220
+ const getExtensionInfoFromRequest = (c) => {
1221
+ const browser = c.req.query('browser');
1222
+ const email = c.req.query('email');
1223
+ const id = c.req.query('id');
1224
+ const installId = c.req.query('installId');
1225
+ const version = c.req.query('v');
1226
+ const protocolVersionValue = c.req.query('protocolVersion');
1227
+ const protocolVersion = protocolVersionValue && /^\d+$/.test(protocolVersionValue)
1228
+ ? Number(protocolVersionValue)
1229
+ : undefined;
1230
+ const featureValue = c.req.query('features');
1231
+ const features = parseExtensionFeatures(featureValue);
1232
+ return {
1233
+ browser: browser || undefined,
1234
+ email: email || undefined,
1235
+ id: id || undefined,
1236
+ installId: installId || undefined,
1237
+ version: version || undefined,
1238
+ protocolVersion,
1239
+ features,
1240
+ };
1241
+ };
1242
+ app.get('/extension', (c, next) => {
1243
+ // 1. Host Validation: The extension endpoint must ONLY be accessed from localhost.
1244
+ // This prevents attackers on the network from hijacking the browser session
1245
+ // even if the server is exposed via 0.0.0.0.
1246
+ const info = getConnInfo(c);
1247
+ const remoteAddress = info.remote.address;
1248
+ const isLocalhost = remoteAddress === '127.0.0.1' || remoteAddress === '::1';
1249
+ if (!isLocalhost) {
1250
+ logger?.log(pc.red(`Rejecting /extension WebSocket from remote IP: ${remoteAddress}`));
1251
+ return c.text('Forbidden - Extension must be local', 403);
1252
+ }
1253
+ // 2. Origin Validation: Prevent browser-based attacks (CSRF).
1254
+ // Browsers cannot spoof the Origin header, so this ensures the connection
1255
+ // is coming from our specific Chrome Extension, not a malicious website.
1256
+ const origin = c.req.header('origin');
1257
+ if (!origin || !origin.startsWith('chrome-extension://')) {
1258
+ logger?.log(pc.red(`Rejecting /extension WebSocket: origin must be chrome-extension://, got: ${origin || 'none'}`));
1259
+ return c.text('Forbidden', 403);
1260
+ }
1261
+ const extensionId = origin.replace('chrome-extension://', '');
1262
+ if (!EXTENSION_IDS.includes(extensionId)) {
1263
+ logger?.log(pc.red(`Rejecting /extension WebSocket from unknown extension: ${extensionId}`));
1264
+ return c.text('Forbidden', 403);
1265
+ }
1266
+ return next();
1267
+ }, upgradeWebSocket((c) => {
1268
+ const incomingExtensionInfo = getExtensionInfoFromRequest(c);
1269
+ const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1270
+ return {
1271
+ onOpen(_event, ws) {
1272
+ const stableKey = relayState.buildStableExtensionKey(incomingExtensionInfo, connectionId);
1273
+ // Check for existing connection with same stableKey and close it
1274
+ const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey);
1275
+ if (existingExt && existingExt.id !== connectionId) {
1276
+ logger?.log(pc.yellow(`Replacing extension connection for ${stableKey} (${existingExt.id} -> ${connectionId})`));
1277
+ if (existingExt.ws) {
1278
+ existingExt.ws.close(4001, 'Extension Replaced');
1279
+ }
1280
+ }
1281
+ // State transition: add extension with ws handle included.
1282
+ // Existing same-stableKey entry stays until old socket onClose.
1283
+ store.setState((s) => {
1284
+ return relayState.addExtension(s, { id: connectionId, info: incomingExtensionInfo, stableKey, ws });
1285
+ });
1286
+ startExtensionPing(connectionId);
1287
+ logger?.log(`Extension connected (${connectionId})`);
1288
+ },
1289
+ async onMessage(event, ws) {
1290
+ const ext = store.getState().extensions.get(connectionId);
1291
+ if (!ext) {
1292
+ ws.close(1000, 'Extension not registered');
1293
+ return;
1294
+ }
1295
+ // Older extension builds may still send legacy recording chunks. Ignore binary frames
1296
+ // instead of breaking the WS connection.
1297
+ if (event.data instanceof ArrayBuffer || Buffer.isBuffer(event.data)) {
1298
+ return;
1299
+ }
1300
+ let message;
1301
+ try {
1302
+ message = JSON.parse(event.data.toString());
1303
+ }
1304
+ catch {
1305
+ ws.close(1000, 'Invalid JSON');
1306
+ return;
1307
+ }
1308
+ if (message.id !== undefined) {
1309
+ const pending = (() => {
1310
+ let pendingRequest = null;
1311
+ store.setState((s) => {
1312
+ const extensionEntry = s.extensions.get(connectionId);
1313
+ if (!extensionEntry) {
1314
+ return s;
1315
+ }
1316
+ const nextPendingRequest = extensionEntry.pendingRequests.get(message.id);
1317
+ if (!nextPendingRequest) {
1318
+ return s;
1319
+ }
1320
+ pendingRequest = nextPendingRequest;
1321
+ return relayState.removeExtensionPendingRequest(s, {
1322
+ extensionId: connectionId,
1323
+ requestId: message.id,
1324
+ });
1325
+ });
1326
+ return pendingRequest;
1327
+ })();
1328
+ if (!pending) {
1329
+ logger?.log('Unexpected response with id:', message.id);
1330
+ return;
1331
+ }
1332
+ if (message.error) {
1333
+ pending.reject(new Error(message.error));
1334
+ }
1335
+ else {
1336
+ pending.resolve(message.result);
1337
+ }
1338
+ }
1339
+ else if (message.method === 'pong') {
1340
+ store.setState((s) => {
1341
+ return relayState.updateExtensionIO(s, {
1342
+ extensionId: connectionId,
1343
+ lastPongAt: Date.now(),
1344
+ });
1345
+ });
1346
+ }
1347
+ else if (message.method === 'log') {
1348
+ const { level, args } = message.params;
1349
+ const logFn = logger?.[level];
1350
+ const logFunc = logFn || logger?.log;
1351
+ const prefix = pc.yellow(`[Extension] [${level.toUpperCase()}]`);
1352
+ logFunc?.(prefix, ...args);
1353
+ }
1354
+ else if (message.method === 'rrwebRecordingData') {
1355
+ const relay = getRrwebRecordingRelay(connectionId);
1356
+ if (relay) {
1357
+ relay.handleRrwebRecordingData(message);
1358
+ }
1359
+ }
1360
+ else if (message.method === 'rrwebRecordingCancelled') {
1361
+ const relay = getRrwebRecordingRelay(connectionId);
1362
+ if (relay) {
1363
+ relay.handleRrwebRecordingCancelled(message);
1364
+ }
1365
+ }
1366
+ else if (message.method === 'toolbarRecordingRequest') {
1367
+ const toolbarMessage = message;
1368
+ const result = await handleToolbarRecordingRequest(connectionId, toolbarMessage);
1369
+ logger?.log(pc.blue(`Toolbar recording response: requestId=${toolbarMessage.params.requestId} success=${result.success} isRecording=${result.isRecording ?? 'unknown'}`));
1370
+ ws.send(JSON.stringify({
1371
+ method: 'toolbarRecordingResponse',
1372
+ params: {
1373
+ requestId: toolbarMessage.params.requestId,
1374
+ result,
1375
+ },
1376
+ }));
1377
+ }
1378
+ else {
1379
+ const extensionEvent = message;
1380
+ if (extensionEvent.method !== 'forwardCDPEvent') {
1381
+ return;
1382
+ }
1383
+ const { method, params, sessionId } = extensionEvent.params;
1384
+ logCdpJson({
1385
+ timestamp: new Date().toISOString(),
1386
+ direction: 'from-extension',
1387
+ message: { method, params, sessionId },
1388
+ });
1389
+ logCdpMessage({
1390
+ direction: 'from-extension',
1391
+ method,
1392
+ sessionId,
1393
+ params,
1394
+ });
1395
+ const cdpEvent = { method, sessionId, params };
1396
+ emitter.emit('cdp:event', { event: cdpEvent, sessionId });
1397
+ maybeEmitBrowserDownloadCompatEvent({ method, params, extensionId: connectionId });
1398
+ if (method === 'Target.attachedToTarget') {
1399
+ const targetParams = params;
1400
+ const incomingSessionId = sessionId;
1401
+ const iframeParentFrameId = targetParams.targetInfo.parentFrameId;
1402
+ // Read current extension state for iframe parent lookup
1403
+ const currentExtState = store.getState().extensions.get(connectionId);
1404
+ const iframeOwnerSessionId = targetParams.targetInfo.type === 'iframe' && iframeParentFrameId && currentExtState
1405
+ ? getPageTargetForFrameId({ extensionState: currentExtState, frameId: iframeParentFrameId })?.sessionId
1406
+ : undefined;
1407
+ // Filter out restricted targets (unsupported types, extension pages, chrome:// URLs, etc.)
1408
+ if (isRestrictedTarget(targetParams.targetInfo)) {
1409
+ if (targetParams.waitingForDebugger && targetParams.sessionId) {
1410
+ void sendToExtension({
1411
+ extensionId: connectionId,
1412
+ method: 'forwardCDPCommand',
1413
+ params: {
1414
+ sessionId: targetParams.sessionId,
1415
+ method: 'Runtime.runIfWaitingForDebugger',
1416
+ params: {},
1417
+ source: 'server',
1418
+ },
1419
+ }).catch((error) => {
1420
+ const msg = error instanceof Error ? error.message : String(error);
1421
+ logger?.log(pc.yellow('[Server] Failed to resume restricted target:'), msg);
1422
+ });
1423
+ }
1424
+ logger?.log(pc.gray(`[Server] Ignoring restricted target: ${targetParams.targetInfo.type} (${targetParams.targetInfo.url})`));
1425
+ return;
1426
+ }
1427
+ if (!targetParams.targetInfo.url) {
1428
+ logger?.error(pc.red('[Extension] WARNING: Target.attachedToTarget received with empty URL!'), JSON.stringify({ method, params: targetParams, sessionId }));
1429
+ }
1430
+ logger?.log(pc.yellow('[Extension] Target.attachedToTarget full payload:'), JSON.stringify({ method, params: targetParams, sessionId }));
1431
+ // Check if we already sent this target to clients (e.g., from Target.setAutoAttach response)
1432
+ const alreadyConnected = currentExtState?.connectedTargets.has(targetParams.sessionId) ?? false;
1433
+ // State transition: add/update target
1434
+ store.setState((s) => relayState.addTarget(s, {
1435
+ extensionId: connectionId,
1436
+ sessionId: targetParams.sessionId,
1437
+ targetId: targetParams.targetInfo.targetId,
1438
+ targetInfo: targetParams.targetInfo,
1439
+ }));
1440
+ const cachedDownloadBehavior = extensionDownloadBehavior.get(connectionId);
1441
+ if (cachedDownloadBehavior && targetParams.targetInfo.type === 'page') {
1442
+ void applyDownloadBehaviorToTargets({
1443
+ extensionId: connectionId,
1444
+ behavior: cachedDownloadBehavior,
1445
+ targetSessionIds: [targetParams.sessionId],
1446
+ });
1447
+ }
1448
+ // Only forward to Playwright if this is a new target to avoid duplicates
1449
+ if (!alreadyConnected) {
1450
+ sendToPlaywright({
1451
+ message: {
1452
+ // Iframe targets must be routed to the parent page sessionId so Playwright attaches them under the right page.
1453
+ // - iframeOwnerSessionId: derived parent session via parentFrameId -> page sessionId (frameId tracking).
1454
+ // - incomingSessionId: extension event sessionId for the parent tab.
1455
+ // The frameId mapping is racy: Target.attachedToTarget can arrive before Page.frameAttached/Page.frameNavigated populate frameIds.
1456
+ // When iframeOwnerSessionId is missing we must fall back to incomingSessionId, otherwise Playwright receives the attach on the root
1457
+ // session, detaches it, and the iframe stays paused (waitingForDebugger) which can hang navigations.
1458
+ sessionId: iframeOwnerSessionId ?? incomingSessionId,
1459
+ method: 'Target.attachedToTarget',
1460
+ params: targetParams,
1461
+ },
1462
+ source: 'extension',
1463
+ extensionId: connectionId,
1464
+ });
1465
+ }
1466
+ }
1467
+ else if (method === 'Target.detachedFromTarget') {
1468
+ const detachParams = params;
1469
+ store.setState((s) => relayState.removeTarget(s, { extensionId: connectionId, sessionId: detachParams.sessionId }));
1470
+ sendToPlaywright({
1471
+ message: {
1472
+ method: 'Target.detachedFromTarget',
1473
+ params: detachParams,
1474
+ },
1475
+ source: 'extension',
1476
+ extensionId: connectionId,
1477
+ });
1478
+ }
1479
+ else if (method === 'Target.targetCrashed') {
1480
+ const crashParams = params;
1481
+ store.setState((s) => relayState.removeTargetByCrash(s, { extensionId: connectionId, targetId: crashParams.targetId }));
1482
+ logger?.log(pc.red('[Server] Target crashed, removing:'), crashParams.targetId);
1483
+ sendToPlaywright({
1484
+ message: {
1485
+ method: 'Target.targetCrashed',
1486
+ params: crashParams,
1487
+ },
1488
+ source: 'extension',
1489
+ extensionId: connectionId,
1490
+ });
1491
+ }
1492
+ else if (method === 'Target.targetInfoChanged') {
1493
+ const infoParams = params;
1494
+ store.setState((s) => relayState.updateTargetInfo(s, { extensionId: connectionId, targetInfo: infoParams.targetInfo }));
1495
+ sendToPlaywright({
1496
+ message: {
1497
+ method: 'Target.targetInfoChanged',
1498
+ params: infoParams,
1499
+ },
1500
+ source: 'extension',
1501
+ extensionId: connectionId,
1502
+ });
1503
+ }
1504
+ else if (method === 'Page.frameAttached') {
1505
+ const frameParams = params;
1506
+ if (sessionId) {
1507
+ store.setState((s) => relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frameId }));
1508
+ }
1509
+ sendToPlaywright({
1510
+ message: {
1511
+ sessionId,
1512
+ method,
1513
+ params,
1514
+ },
1515
+ source: 'extension',
1516
+ extensionId: connectionId,
1517
+ });
1518
+ }
1519
+ else if (method === 'Page.frameDetached') {
1520
+ const frameParams = params;
1521
+ store.setState((s) => relayState.removeFrameId(s, { extensionId: connectionId, frameId: frameParams.frameId }));
1522
+ sendToPlaywright({
1523
+ message: {
1524
+ sessionId,
1525
+ method,
1526
+ params,
1527
+ },
1528
+ source: 'extension',
1529
+ extensionId: connectionId,
1530
+ });
1531
+ }
1532
+ else if (method === 'Page.frameNavigated') {
1533
+ const frameParams = params;
1534
+ if (sessionId) {
1535
+ store.setState((s) => relayState.addFrameId(s, { extensionId: connectionId, sessionId, frameId: frameParams.frame.id }));
1536
+ }
1537
+ if (!frameParams.frame.parentId && sessionId) {
1538
+ store.setState((s) => relayState.updateTargetUrl(s, {
1539
+ extensionId: connectionId,
1540
+ sessionId,
1541
+ url: frameParams.frame.url,
1542
+ title: frameParams.frame.name || undefined,
1543
+ }));
1544
+ logger?.log(pc.magenta('[Server] Updated target URL from Page.frameNavigated:'), frameParams.frame.url);
1545
+ }
1546
+ sendToPlaywright({
1547
+ message: {
1548
+ sessionId,
1549
+ method,
1550
+ params,
1551
+ },
1552
+ source: 'extension',
1553
+ extensionId: connectionId,
1554
+ });
1555
+ }
1556
+ else if (method === 'Page.navigatedWithinDocument') {
1557
+ const navParams = params;
1558
+ if (sessionId) {
1559
+ store.setState((s) => relayState.updateTargetUrl(s, { extensionId: connectionId, sessionId, url: navParams.url }));
1560
+ logger?.log(pc.magenta('[Server] Updated target URL from Page.navigatedWithinDocument:'), navParams.url);
1561
+ }
1562
+ sendToPlaywright({
1563
+ message: {
1564
+ sessionId,
1565
+ method,
1566
+ params,
1567
+ },
1568
+ source: 'extension',
1569
+ extensionId: connectionId,
1570
+ });
1571
+ }
1572
+ else {
1573
+ sendToPlaywright({
1574
+ message: {
1575
+ sessionId,
1576
+ method,
1577
+ params,
1578
+ },
1579
+ source: 'extension',
1580
+ extensionId: connectionId,
1581
+ });
1582
+ }
1583
+ }
1584
+ },
1585
+ onClose(event) {
1586
+ logger?.log(`Extension disconnected: code=${event.code} reason=${event.reason || 'none'} (${connectionId})`);
1587
+ // Reject all pending I/O requests (state cleanup happens in removeExtension below)
1588
+ const closingExt = store.getState().extensions.get(connectionId);
1589
+ if (closingExt) {
1590
+ stopExtensionPing(connectionId);
1591
+ for (const pending of closingExt.pendingRequests.values()) {
1592
+ pending.reject(new Error('Extension connection closed'));
1593
+ }
1594
+ }
1595
+ const currentRelayState = store.getState();
1596
+ const closingExtension = currentRelayState.extensions.get(connectionId);
1597
+ const successorCandidates = closingExtension
1598
+ ? Array.from(currentRelayState.extensions.values())
1599
+ .reverse()
1600
+ .filter((ext) => {
1601
+ return ext.id !== connectionId && ext.stableKey === closingExtension.stableKey && Boolean(ext.ws);
1602
+ })
1603
+ : [];
1604
+ const successorExtension = closingExtension
1605
+ ? successorCandidates[0]
1606
+ : undefined;
1607
+ if (successorExtension) {
1608
+ logger?.log(pc.yellow(`Rebinding clients from ${connectionId} to ${successorExtension.id} (stableKey: ${successorExtension.stableKey})`));
1609
+ store.setState((s) => {
1610
+ return relayState.rebindClientsToExtension(s, {
1611
+ fromExtensionId: connectionId,
1612
+ toExtensionId: successorExtension.id,
1613
+ });
1614
+ });
1615
+ }
1616
+ // Close playwright clients bound to this extension when no successor exists.
1617
+ if (!successorExtension) {
1618
+ const { playwrightClients } = store.getState();
1619
+ for (const client of playwrightClients.values()) {
1620
+ if (client.extensionId === connectionId) {
1621
+ client.ws.close(1000, 'Extension disconnected');
1622
+ }
1623
+ }
1624
+ }
1625
+ // State transition: remove extension + its bound clients atomically
1626
+ store.setState((s) => relayState.removeExtension(s, { extensionId: connectionId }));
1627
+ },
1628
+ onError(event) {
1629
+ logger?.error('Extension WebSocket error:', event);
1630
+ },
1631
+ };
1632
+ }));
1633
+ // ============================================================================
1634
+ // CLI Execute Endpoints - For stateful code execution via CLI
1635
+ // ============================================================================
1636
+ // Session counter for suggesting next session number
1637
+ let nextSessionNumber = 1;
1638
+ // Lazy-load ExecutorManager to avoid circular imports and only when needed
1639
+ let executorManager = null;
1640
+ const getExecutorManager = async () => {
1641
+ if (!executorManager) {
1642
+ const { ExecutorManager } = await import('./executor.js');
1643
+ // Pass config instead of URL so executor can generate unique client IDs for each connection
1644
+ executorManager = new ExecutorManager({
1645
+ cdpConfig: { host: '127.0.0.1', port, token },
1646
+ logger: logger || { log: console.error, error: console.error },
1647
+ });
1648
+ }
1649
+ return executorManager;
1650
+ };
1651
+ // ============================================================================
1652
+ // Security middleware for privileged HTTP routes (/cli/*, /recording/*, /mcp-log)
1653
+ //
1654
+ // CORS alone does NOT prevent cross-origin POST attacks. Browsers skip the
1655
+ // preflight for "simple" requests (POST + Content-Type: text/plain), so a
1656
+ // malicious website can fire-and-forget a POST to localhost:19988/cli/execute
1657
+ // and the code executes before CORS even enters the picture.
1658
+ //
1659
+ // Three layers of defense:
1660
+ // 1. Sec-Fetch-Site: browsers set this forbidden header on every request.
1661
+ // If present and not "same-origin"/"none", it's a cross-origin browser
1662
+ // request → reject. Node.js clients don't send it → unaffected.
1663
+ // 2. Content-Type must be application/json on POST. This forces a CORS
1664
+ // preflight as a fallback, which our CORS policy already blocks.
1665
+ // 3. When token mode is enabled (remote access), require the token on EVERY
1666
+ // request, including loopback. Tunnel agents (traforo, ngrok, cloudflared)
1667
+ // forward public traffic from 127.0.0.1, so a loopback bypass would be
1668
+ // a full auth bypass. In-process callers attach the token themselves
1669
+ // via PLAYWRITER_TOKEN env (set by the `serve` command at startup).
1670
+ // ============================================================================
1671
+ const privilegedRouteMiddleware = async (c, next) => {
1672
+ // Block cross-origin browser requests via Sec-Fetch-Site header.
1673
+ // Browsers always set this forbidden header; it cannot be spoofed.
1674
+ // Non-browser clients (Node.js, curl, MCP) don't send it.
1675
+ const secFetchSite = c.req.header('sec-fetch-site');
1676
+ if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
1677
+ logger?.log(pc.red(`Rejecting ${c.req.path}: cross-origin browser request (Sec-Fetch-Site: ${secFetchSite})`));
1678
+ return c.text('Forbidden - Cross-origin requests not allowed', 403);
1679
+ }
1680
+ // Require application/json on POST to force CORS preflight as backup defense.
1681
+ // A text/plain POST is a "simple request" that skips preflight entirely.
1682
+ if (c.req.method === 'POST') {
1683
+ const contentType = c.req.header('content-type') || '';
1684
+ if (!contentType.includes('application/json')) {
1685
+ logger?.log(pc.red(`Rejecting ${c.req.path}: Content-Type must be application/json, got: ${contentType}`));
1686
+ return c.text('Content-Type must be application/json', 415);
1687
+ }
1688
+ }
1689
+ // When token mode is enabled (remote/serve mode), require authentication
1690
+ // on EVERY request, including loopback. Earlier versions bypassed the
1691
+ // check for 127.0.0.1/::1 to spare in-process callers, but that's unsafe:
1692
+ // when the relay is fronted by a tunnel agent (traforo, ngrok, cloudflared,
1693
+ // etc.) running as a local process, every public request reaches the relay
1694
+ // from 127.0.0.1 and would skip auth. In-process callers must instead
1695
+ // attach the token themselves — they read PLAYWRITER_TOKEN from env, which
1696
+ // the `serve` command sets at startup.
1697
+ if (token) {
1698
+ const authHeader = c.req.header('authorization') || '';
1699
+ const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
1700
+ const url = new URL(c.req.url, 'http://localhost');
1701
+ const queryToken = url.searchParams.get('token');
1702
+ if (bearerToken !== token && queryToken !== token) {
1703
+ logger?.log(pc.red(`Rejecting ${c.req.path}: invalid or missing token`));
1704
+ return c.text('Unauthorized', 401);
1705
+ }
1706
+ }
1707
+ return next();
1708
+ };
1709
+ app.use('/cli/*', privilegedRouteMiddleware);
1710
+ app.use('/recording/*', privilegedRouteMiddleware);
1711
+ app.use('/rrweb-recording/*', privilegedRouteMiddleware);
1712
+ app.use('/mcp-log', privilegedRouteMiddleware);
1713
+ const reviewRouteMiddleware = async (c, next) => {
1714
+ if (c.req.method === 'POST') {
1715
+ const contentType = c.req.header('content-type') || '';
1716
+ if (!contentType.includes('application/json')) {
1717
+ logger?.log(pc.red(`Rejecting ${c.req.path}: Content-Type must be application/json, got: ${contentType}`));
1718
+ return c.text('Content-Type must be application/json', 415);
1719
+ }
1720
+ }
1721
+ const origin = c.req.header('origin');
1722
+ if (!origin) {
1723
+ const secFetchSite = c.req.header('sec-fetch-site');
1724
+ if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
1725
+ logger?.log(pc.red(`Rejecting ${c.req.path}: cross-origin browser request (Sec-Fetch-Site: ${secFetchSite})`));
1726
+ return c.text('Forbidden - Cross-origin requests not allowed', 403);
1727
+ }
1728
+ return next();
1729
+ }
1730
+ if (!origin.startsWith('chrome-extension://')) {
1731
+ logger?.log(pc.red(`Rejecting ${c.req.path}: origin must be Tabwright extension, got: ${origin}`));
1732
+ return c.text('Forbidden', 403);
1733
+ }
1734
+ const extensionId = origin.replace('chrome-extension://', '');
1735
+ if (!EXTENSION_IDS.includes(extensionId)) {
1736
+ logger?.log(pc.red(`Rejecting ${c.req.path}: unknown extension origin ${extensionId}`));
1737
+ return c.text('Forbidden', 403);
1738
+ }
1739
+ return next();
1740
+ };
1741
+ app.use('/rrweb-recordings', reviewRouteMiddleware);
1742
+ app.use('/rrweb-recordings/*', reviewRouteMiddleware);
1743
+ app.use('/capabilities', reviewRouteMiddleware);
1744
+ app.use('/capabilities/*', reviewRouteMiddleware);
1745
+ app.post('/cli/execute', async (c) => {
1746
+ try {
1747
+ const body = (await c.req.json());
1748
+ const sessionId = normalizeSessionId(body.sessionId);
1749
+ const { code, timeout = 10000 } = body;
1750
+ if (!sessionId || !code) {
1751
+ return c.json({ error: 'sessionId and code are required' }, 400);
1752
+ }
1753
+ const manager = await getExecutorManager();
1754
+ const existingExecutor = manager.getSession(sessionId);
1755
+ if (!existingExecutor) {
1756
+ return c.json({ text: `Session ${sessionId} not found. Run 'tabwright session new' first.`, images: [], screenshots: [], isError: true }, 404);
1757
+ }
1758
+ // Touch cloud session activity tracking if this session is cloud-backed
1759
+ const cloudTracking = cloudSessionTracking.get(sessionId);
1760
+ if (cloudTracking) {
1761
+ cloudTracking.lastActivityAt = Date.now();
1762
+ cloudTracking.activeExecutions++;
1763
+ }
1764
+ let result;
1765
+ try {
1766
+ result = await existingExecutor.execute(code, timeout, {
1767
+ includeStructuredResult: body.includeStructuredResult === true,
1768
+ });
1769
+ }
1770
+ finally {
1771
+ if (cloudTracking) {
1772
+ cloudTracking.activeExecutions--;
1773
+ cloudTracking.lastActivityAt = Date.now();
1774
+ }
1775
+ }
1776
+ // Use the cloudTracking snapshot captured before execute (not a fresh
1777
+ // map lookup) so long-running executes that outlive idle cleanup still
1778
+ // report isCloud correctly.
1779
+ return c.json({ ...result, isCloud: Boolean(cloudTracking) });
1780
+ }
1781
+ catch (error) {
1782
+ logger?.error('Execute endpoint error:', error);
1783
+ return c.json({ text: `Server error: ${error.message}`, images: [], screenshots: [], isError: true }, 500);
1784
+ }
1785
+ });
1786
+ app.post('/cli/reset', async (c) => {
1787
+ try {
1788
+ const body = (await c.req.json());
1789
+ const sessionId = normalizeSessionId(body.sessionId);
1790
+ if (!sessionId) {
1791
+ return c.json({ error: 'sessionId is required' }, 400);
1792
+ }
1793
+ const manager = await getExecutorManager();
1794
+ const existingExecutor = manager.getSession(sessionId);
1795
+ if (!existingExecutor) {
1796
+ return c.json({ error: `Session ${sessionId} not found. Run 'tabwright session new' first.` }, 404);
1797
+ }
1798
+ const { page, context } = await existingExecutor.reset();
1799
+ return c.json({
1800
+ success: true,
1801
+ pageUrl: page.url(),
1802
+ pagesCount: context.pages().length,
1803
+ });
1804
+ }
1805
+ catch (error) {
1806
+ logger?.error('Reset endpoint error:', error);
1807
+ return c.json({ error: error.message }, 500);
1808
+ }
1809
+ });
1810
+ app.get('/cli/sessions', async (c) => {
1811
+ const manager = await getExecutorManager();
1812
+ return c.json({ sessions: manager.listSessions() });
1813
+ });
1814
+ app.get('/cli/session/suggest', (c) => {
1815
+ return c.json({ next: nextSessionNumber });
1816
+ });
1817
+ app.get('/rrweb-recordings', (c) => {
1818
+ const rawLimit = Number(c.req.query('limit') || 50);
1819
+ const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 50;
1820
+ return c.json({ recordings: listSavedRrwebRecordings({ limit }) });
1821
+ });
1822
+ app.get('/rrweb-recordings/:id', (c) => {
1823
+ const recording = getSavedRrwebRecording(c.req.param('id'));
1824
+ if (!recording) {
1825
+ return c.json({ error: 'rrweb recording not found' }, 404);
1826
+ }
1827
+ return c.json({ recording });
1828
+ });
1829
+ app.get('/rrweb-recordings/:id/events', (c) => {
1830
+ const result = getSavedRrwebRecordingWithEvents(c.req.param('id'));
1831
+ if (!result) {
1832
+ return c.json({ error: 'rrweb recording not found' }, 404);
1833
+ }
1834
+ return c.json(result);
1835
+ });
1836
+ app.get('/capabilities', (c) => {
1837
+ return c.json(listCapabilityOptions({ cwd: process.cwd() }));
1838
+ });
1839
+ app.get('/capabilities/:id', (c) => {
1840
+ const result = getCapabilityOptionsDetail({ cwd: process.cwd(), id: c.req.param('id') });
1841
+ if (!result) {
1842
+ return c.json({ error: 'capability not found' }, 404);
1843
+ }
1844
+ return c.json(result);
1845
+ });
1846
+ app.post('/capabilities/:id/auth/refresh', async (c) => {
1847
+ const id = c.req.param('id');
1848
+ const body = (await c.req.json().catch(() => ({})));
1849
+ const installId = typeof body.installId === 'string' ? body.installId : undefined;
1850
+ const capability = (() => {
1851
+ try {
1852
+ return requireCapability({ id, cwd: process.cwd() });
1853
+ }
1854
+ catch {
1855
+ return null;
1856
+ }
1857
+ })();
1858
+ if (!capability) {
1859
+ return c.json({ error: 'capability not found' }, 404);
1860
+ }
1861
+ if (capability.manifest.auth.type !== 'cookie' || capability.manifest.auth.refresh !== 'from-browser') {
1862
+ return c.json({ error: 'capability does not support browser cookie authentication' }, 400);
1863
+ }
1864
+ const connectedExtensions = Array.from(store.getState().extensions.values()).filter((extension) => {
1865
+ return Boolean(extension.ws);
1866
+ });
1867
+ const matchingExtensions = installId
1868
+ ? connectedExtensions.filter((extension) => {
1869
+ return extension.info.installId === installId;
1870
+ })
1871
+ : connectedExtensions;
1872
+ if (matchingExtensions.length === 0) {
1873
+ return c.json({ code: 'browser_not_connected', error: 'current browser profile is not connected to Tabwright' }, 409);
1874
+ }
1875
+ if (matchingExtensions.length > 1) {
1876
+ return c.json({
1877
+ code: 'multiple_browser_profiles',
1878
+ error: 'multiple browser profiles are connected; refresh from the profile that opened this page',
1879
+ }, 409);
1880
+ }
1881
+ const extension = matchingExtensions[0];
1882
+ if (!extension) {
1883
+ return c.json({ code: 'browser_not_connected', error: 'current browser profile is not connected to Tabwright' }, 409);
1884
+ }
1885
+ const authSession = await getCapabilityAuthSession({ extensionId: extension.id });
1886
+ if (!authSession.ok) {
1887
+ return c.json({ code: authSession.code, error: authSession.error }, 409);
1888
+ }
1889
+ try {
1890
+ // Cookie values travel only over the existing extension/Relay CDP channel and are never returned to Options.
1891
+ const rawCookies = await routeCdpCommand({
1892
+ extensionId: extension.id,
1893
+ method: 'Network.getCookies',
1894
+ params: { urls: capability.manifest.auth.browserUrls },
1895
+ sessionId: authSession.sessionId,
1896
+ source: 'playwriter',
1897
+ });
1898
+ const cookies = parseCapabilityAuthCookies(rawCookies);
1899
+ const result = refreshCapabilityAuthFromCookies({
1900
+ id,
1901
+ cookies,
1902
+ cwd: process.cwd(),
1903
+ browserKey: extension.stableKey,
1904
+ });
1905
+ return c.json({
1906
+ capability: id,
1907
+ saved: result.saved,
1908
+ cookieCount: result.cookieCount,
1909
+ cookieNames: result.cookieNames,
1910
+ urls: result.urls,
1911
+ expiresAt: result.expiresAt,
1912
+ authState: getCapabilityAuthState({ capability: result.capability }),
1913
+ });
1914
+ }
1915
+ catch (error) {
1916
+ const message = error instanceof Error ? error.message : String(error);
1917
+ return c.json({ error: message }, 400);
1918
+ }
1919
+ finally {
1920
+ if (authSession.temporaryTargetId) {
1921
+ try {
1922
+ await routeCdpCommand({
1923
+ extensionId: extension.id,
1924
+ method: 'Target.closeTarget',
1925
+ params: { targetId: authSession.temporaryTargetId },
1926
+ source: 'playwriter',
1927
+ });
1928
+ }
1929
+ catch (error) {
1930
+ logger?.error('Failed to close temporary capability auth tab:', error);
1931
+ }
1932
+ }
1933
+ }
1934
+ });
1935
+ app.post('/cli/session/new', async (c) => {
1936
+ const body = (await c.req.json().catch(() => ({})));
1937
+ const sessionId = String(nextSessionNumber++);
1938
+ const cwd = body.cwd;
1939
+ // Headless mode: launch Chrome via chromium.launch(), no extension needed.
1940
+ // Force connection immediately so missing Chrome errors surface at creation time,
1941
+ // not on first execute call.
1942
+ if (body.headless) {
1943
+ const manager = await getExecutorManager();
1944
+ const executor = manager.getExecutor({
1945
+ sessionId,
1946
+ cwd,
1947
+ cdpConfig: { headless: true },
1948
+ sessionMetadata: {
1949
+ extensionId: null,
1950
+ browser: 'Chrome (Headless)',
1951
+ profile: null,
1952
+ },
1953
+ });
1954
+ try {
1955
+ await executor.reset();
1956
+ }
1957
+ catch (error) {
1958
+ manager.deleteExecutor(sessionId);
1959
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
1960
+ }
1961
+ const metadata = executor.getSessionMetadata();
1962
+ return c.json({
1963
+ id: sessionId,
1964
+ mode: 'headless',
1965
+ extensionId: metadata.extensionId,
1966
+ browser: metadata.browser,
1967
+ profile: metadata.profile,
1968
+ });
1969
+ }
1970
+ // Direct CDP mode: skip extension lookup, pass direct WebSocket URL to executor
1971
+ if (body.cdpEndpoint) {
1972
+ if (!body.cdpEndpoint.startsWith('ws://') && !body.cdpEndpoint.startsWith('wss://')) {
1973
+ return c.json({ error: `Invalid cdpEndpoint: must start with ws:// or wss:// (got: ${body.cdpEndpoint})` }, 400);
1974
+ }
1975
+ // Use first profile from discovery for session metadata (if available)
1976
+ const firstProfile = body.profiles?.[0];
1977
+ const cloudTimeoutAt = body.cloud?.timeoutAt
1978
+ ? (typeof body.cloud.timeoutAt === 'string' ? new Date(body.cloud.timeoutAt).getTime() : body.cloud.timeoutAt)
1979
+ : undefined;
1980
+ const manager = await getExecutorManager();
1981
+ const executor = manager.getExecutor({
1982
+ sessionId,
1983
+ cwd,
1984
+ cdpConfig: { directCdpUrl: appendSessionToWsUrl(body.cdpEndpoint, sessionId) },
1985
+ sessionMetadata: {
1986
+ extensionId: null,
1987
+ browser: body.browser || null,
1988
+ profile: firstProfile ? { email: firstProfile.email, id: firstProfile.name } : null,
1989
+ },
1990
+ cloudSession: body.cloud ? { timeoutAt: cloudTimeoutAt, blockProxyResources: body.cloud.blockProxyResources } : undefined,
1991
+ });
1992
+ const metadata = executor.getSessionMetadata();
1993
+ // Register cloud session tracking if cloud metadata was provided
1994
+ if (body.cloud) {
1995
+ cloudSessionTracking.set(sessionId, {
1996
+ cloudSessionId: body.cloud.cloudSessionId,
1997
+ cloudBaseUrl: body.cloud.cloudBaseUrl,
1998
+ cloudToken: body.cloud.cloudToken,
1999
+ lastActivityAt: Date.now(),
2000
+ activeExecutions: 0,
2001
+ timeoutAt: cloudTimeoutAt,
2002
+ });
2003
+ persistCloudSessions();
2004
+ }
2005
+ return c.json({
2006
+ id: sessionId,
2007
+ mode: 'direct',
2008
+ extensionId: metadata.extensionId,
2009
+ browser: metadata.browser,
2010
+ profile: metadata.profile,
2011
+ });
2012
+ }
2013
+ // Extension mode (existing behavior)
2014
+ const extensionId = body.extensionId || null;
2015
+ const allowDefault = !extensionId && store.getState().extensions.size === 1;
2016
+ const conn = getExtensionConnection(extensionId, { allowFallback: allowDefault });
2017
+ if (!conn) {
2018
+ const error = extensionId
2019
+ ? `Extension not connected: ${extensionId}`
2020
+ : 'Multiple extensions connected. Specify extensionId.';
2021
+ return c.json({ error }, 404);
2022
+ }
2023
+ const manager = await getExecutorManager();
2024
+ const executor = manager.getExecutor({
2025
+ sessionId,
2026
+ cwd,
2027
+ sessionMetadata: {
2028
+ extensionId: conn.stableKey,
2029
+ browser: conn.info.browser || null,
2030
+ profile: conn.info ? { email: conn.info.email || '', id: conn.info.id || '' } : null,
2031
+ },
2032
+ });
2033
+ const metadata = executor.getSessionMetadata();
2034
+ return c.json({
2035
+ id: sessionId,
2036
+ mode: 'extension',
2037
+ extensionId: metadata.extensionId,
2038
+ browser: metadata.browser,
2039
+ profile: metadata.profile,
2040
+ });
2041
+ });
2042
+ app.get('/cli/session/:id', async (c) => {
2043
+ const sessionId = c.req.param('id');
2044
+ const manager = await getExecutorManager();
2045
+ const executor = manager.getSession(sessionId);
2046
+ if (!executor) {
2047
+ return c.json({ error: 'not found' }, 404);
2048
+ }
2049
+ return c.json(executor.getSessionInfo({ id: sessionId }));
2050
+ });
2051
+ app.post('/cli/session/delete', async (c) => {
2052
+ try {
2053
+ const body = (await c.req.json());
2054
+ const sessionId = normalizeSessionId(body.sessionId);
2055
+ if (!sessionId) {
2056
+ return c.json({ error: 'sessionId is required' }, 400);
2057
+ }
2058
+ const manager = await getExecutorManager();
2059
+ const executor = manager.getSession(sessionId);
2060
+ // Close headless context before deleting to prevent context/page leaks
2061
+ // on the shared headless browser. Only affects headless sessions.
2062
+ if (executor) {
2063
+ await executor.closeHeadlessContext();
2064
+ }
2065
+ const deleted = manager.deleteExecutor(sessionId);
2066
+ if (!deleted) {
2067
+ return c.json({ error: `Session ${sessionId} not found` }, 404);
2068
+ }
2069
+ // If this was a cloud-backed session, stop the VM only if no other
2070
+ // relay session is still using the same cloud VM (reference counting).
2071
+ const cloudTracking = cloudSessionTracking.get(sessionId);
2072
+ if (cloudTracking) {
2073
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, cloudTracking.cloudSessionId);
2074
+ cloudSessionTracking.delete(sessionId);
2075
+ persistCloudSessions();
2076
+ if (shouldStopVm) {
2077
+ disconnectCloudVm(cloudTracking);
2078
+ }
2079
+ }
2080
+ return c.json({ success: true });
2081
+ }
2082
+ catch (error) {
2083
+ logger?.error('Delete session endpoint error:', error);
2084
+ return c.json({ error: error.message }, 500);
2085
+ }
2086
+ });
2087
+ const legacyRecordingRemovedResponse = {
2088
+ success: false,
2089
+ error: 'Legacy video recording has been removed. Use /rrweb-recording/* and /rrweb-recordings instead.',
2090
+ };
2091
+ app.post('/recording/start', (c) => {
2092
+ return c.json(legacyRecordingRemovedResponse, 410);
2093
+ });
2094
+ app.post('/recording/stop', (c) => {
2095
+ return c.json(legacyRecordingRemovedResponse, 410);
2096
+ });
2097
+ app.get('/recording/status', (c) => {
2098
+ return c.json({ isRecording: false });
2099
+ });
2100
+ app.post('/recording/cancel', (c) => {
2101
+ return c.json(legacyRecordingRemovedResponse, 410);
2102
+ });
2103
+ // ============================================================================
2104
+ // rrweb Recording Endpoints - For DOM replay recordings.
2105
+ // ============================================================================
2106
+ app.post('/rrweb-recording/start', async (c) => {
2107
+ const body = (await c.req.json());
2108
+ const sessionId = normalizeSessionId(body.sessionId);
2109
+ const { sessionId: _sessionId, ...recordingOptions } = body;
2110
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
2111
+ const relay = getRrwebRecordingRelay(extensionId);
2112
+ if (!relay) {
2113
+ return c.json({ success: false, error: 'Extension not connected' }, 500);
2114
+ }
2115
+ const recordingParams = (resolvedSessionId
2116
+ ? { ...recordingOptions, sessionId: resolvedSessionId }
2117
+ : recordingOptions);
2118
+ const result = await relay.startRecording(recordingParams);
2119
+ const status = result.success ? 200 : result.error?.includes('required') ? 400 : 500;
2120
+ return c.json(result, status);
2121
+ });
2122
+ app.post('/rrweb-recording/stop', async (c) => {
2123
+ const body = (await c.req.json());
2124
+ const sessionId = normalizeSessionId(body.sessionId);
2125
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
2126
+ const relay = getRrwebRecordingRelay(extensionId);
2127
+ if (!relay) {
2128
+ return c.json({ success: false, error: 'Extension not connected' }, 500);
2129
+ }
2130
+ const stopParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
2131
+ const result = await relay.stopRecording(stopParams);
2132
+ const status = result.success ? 200 : result.error?.includes('not found') ? 404 : 500;
2133
+ return c.json(result, status);
2134
+ });
2135
+ app.get('/rrweb-recording/status', async (c) => {
2136
+ const sessionId = normalizeSessionId(c.req.query('sessionId'));
2137
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
2138
+ const relay = getRrwebRecordingRelay(extensionId);
2139
+ if (!relay) {
2140
+ return c.json({ isRecording: false });
2141
+ }
2142
+ const isRecordingParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
2143
+ const result = await relay.isRecording(isRecordingParams);
2144
+ return c.json(result);
2145
+ });
2146
+ app.post('/rrweb-recording/cancel', async (c) => {
2147
+ const body = (await c.req.json());
2148
+ const sessionId = normalizeSessionId(body.sessionId);
2149
+ const { extensionId, sessionId: resolvedSessionId } = await resolveRecordingRoute({ sessionId });
2150
+ const relay = getRrwebRecordingRelay(extensionId);
2151
+ if (!relay) {
2152
+ return c.json({ success: false, error: 'Extension not connected' }, 500);
2153
+ }
2154
+ const cancelParams = resolvedSessionId ? { sessionId: resolvedSessionId } : {};
2155
+ const result = await relay.cancelRecording(cancelParams);
2156
+ return c.json(result);
2157
+ });
2158
+ const cloudSessionTracking = new Map();
2159
+ const CLOUD_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
2160
+ /** Check if any OTHER relay session references the same cloud VM.
2161
+ * Used to prevent stopping a VM that's still used by another relay session
2162
+ * (e.g. user attached twice via `session new --browser cloud-1`). */
2163
+ function hasOtherCloudReferences(relaySessionId, cloudSessionId) {
2164
+ for (const [otherId, tracking] of cloudSessionTracking) {
2165
+ if (otherId !== relaySessionId && tracking.cloudSessionId === cloudSessionId) {
2166
+ return true;
2167
+ }
2168
+ }
2169
+ return false;
2170
+ }
2171
+ /** Disconnect a cloud VM via the website API (best-effort, non-blocking). */
2172
+ function disconnectCloudVm(tracking) {
2173
+ fetch(new URL('/api/cloud/disconnect', tracking.cloudBaseUrl).toString(), {
2174
+ method: 'POST',
2175
+ headers: {
2176
+ Authorization: `Bearer ${tracking.cloudToken}`,
2177
+ 'Content-Type': 'application/json',
2178
+ },
2179
+ body: JSON.stringify({ cloudSessionId: tracking.cloudSessionId }),
2180
+ }).catch((err) => {
2181
+ logger?.error('[Cloud] Failed to disconnect cloud session:', err);
2182
+ });
2183
+ }
2184
+ // ── Cloud session crash recovery ──────────────────────────────────
2185
+ // Persist cloud session IDs to disk so orphaned VMs can be cleaned up
2186
+ // if the relay process crashes. On startup, read the file and disconnect
2187
+ // any leftover VMs (best-effort).
2188
+ const CLOUD_SESSIONS_FILE = path.join(getTabwrightUserDataDir(), 'cloud-sessions.json');
2189
+ function persistCloudSessions() {
2190
+ // Dedupe by cloudSessionId — multiple relay sessions can reference the same VM
2191
+ const seen = new Set();
2192
+ const entries = [];
2193
+ for (const t of cloudSessionTracking.values()) {
2194
+ if (seen.has(t.cloudSessionId))
2195
+ continue;
2196
+ seen.add(t.cloudSessionId);
2197
+ entries.push({
2198
+ cloudSessionId: t.cloudSessionId,
2199
+ cloudBaseUrl: t.cloudBaseUrl,
2200
+ cloudToken: t.cloudToken,
2201
+ });
2202
+ }
2203
+ try {
2204
+ const dir = path.dirname(CLOUD_SESSIONS_FILE);
2205
+ fs.mkdirSync(dir, { recursive: true });
2206
+ if (entries.length > 0) {
2207
+ // Atomic write: write to temp file then rename, so a crash mid-write
2208
+ // doesn't leave corrupt JSON that blocks future cleanup.
2209
+ const tmpFile = CLOUD_SESSIONS_FILE + '.tmp';
2210
+ fs.writeFileSync(tmpFile, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 });
2211
+ fs.renameSync(tmpFile, CLOUD_SESSIONS_FILE);
2212
+ }
2213
+ else {
2214
+ // No active sessions — remove file to avoid stale data
2215
+ try {
2216
+ fs.unlinkSync(CLOUD_SESSIONS_FILE);
2217
+ }
2218
+ catch { /* already gone */ }
2219
+ }
2220
+ }
2221
+ catch {
2222
+ // Best-effort: don't crash relay if disk write fails
2223
+ }
2224
+ }
2225
+ function cleanupOrphanedCloudSessions() {
2226
+ let raw;
2227
+ try {
2228
+ raw = fs.readFileSync(CLOUD_SESSIONS_FILE, 'utf-8');
2229
+ }
2230
+ catch {
2231
+ return; // No file — nothing to clean up
2232
+ }
2233
+ let entries;
2234
+ try {
2235
+ const parsed = JSON.parse(raw);
2236
+ if (!Array.isArray(parsed))
2237
+ return;
2238
+ // Validate shape: each entry must have cloudSessionId and cloudBaseUrl
2239
+ entries = parsed.filter((e) => {
2240
+ return e && typeof e.cloudSessionId === 'string' && typeof e.cloudBaseUrl === 'string' && typeof e.cloudToken === 'string';
2241
+ });
2242
+ }
2243
+ catch {
2244
+ // Corrupt JSON (e.g. crash during non-atomic write) — just remove it
2245
+ try {
2246
+ fs.unlinkSync(CLOUD_SESSIONS_FILE);
2247
+ }
2248
+ catch { /* ignore */ }
2249
+ return;
2250
+ }
2251
+ if (!entries.length) {
2252
+ try {
2253
+ fs.unlinkSync(CLOUD_SESSIONS_FILE);
2254
+ }
2255
+ catch { /* ignore */ }
2256
+ return;
2257
+ }
2258
+ logger?.log(pc.yellow(`[Cloud] Found ${entries.length} orphaned cloud session(s) from previous relay. Cleaning up...`));
2259
+ // Remove file after we've read it — disconnect calls are best-effort async.
2260
+ // If they fail, the BU VM will eventually hit its own timeout anyway.
2261
+ try {
2262
+ fs.unlinkSync(CLOUD_SESSIONS_FILE);
2263
+ }
2264
+ catch { /* ignore */ }
2265
+ for (const entry of entries) {
2266
+ disconnectCloudVm({
2267
+ cloudSessionId: entry.cloudSessionId,
2268
+ cloudBaseUrl: entry.cloudBaseUrl,
2269
+ cloudToken: entry.cloudToken,
2270
+ lastActivityAt: 0,
2271
+ activeExecutions: 0,
2272
+ });
2273
+ }
2274
+ }
2275
+ const cloudIdleInterval = setInterval(async () => {
2276
+ const now = Date.now();
2277
+ // Collect idle sessions first, then process — avoid mutating map during iteration
2278
+ const idleSessions = [];
2279
+ for (const [sessionId, tracking] of cloudSessionTracking) {
2280
+ // VM already past BU hard timeout — schedule for cleanup regardless of activity
2281
+ if (tracking.timeoutAt && tracking.timeoutAt <= now) {
2282
+ idleSessions.push([sessionId, tracking]);
2283
+ continue;
2284
+ }
2285
+ // Timeout warnings are handled by the executor on each execute() call
2286
+ // (deduped by minute bucket) — no need to enqueue from the relay interval.
2287
+ if (tracking.activeExecutions > 0)
2288
+ continue;
2289
+ if (now - tracking.lastActivityAt > CLOUD_IDLE_TIMEOUT_MS) {
2290
+ idleSessions.push([sessionId, tracking]);
2291
+ }
2292
+ }
2293
+ if (idleSessions.length > 0) {
2294
+ for (const [sessionId, tracking] of idleSessions) {
2295
+ logger?.log(pc.yellow(`[Cloud] Stopping idle relay session ${sessionId} (idle > 10 min)`));
2296
+ // Check if other relay sessions reference the same cloud VM.
2297
+ // Only stop the VM when this is the last relay session for it.
2298
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, tracking.cloudSessionId);
2299
+ cloudSessionTracking.delete(sessionId);
2300
+ executorManager?.deleteExecutor(sessionId);
2301
+ if (shouldStopVm) {
2302
+ disconnectCloudVm(tracking);
2303
+ }
2304
+ }
2305
+ persistCloudSessions();
2306
+ }
2307
+ }, 60_000);
2308
+ // Use createAdaptorServer instead of serve() so we control the listen()
2309
+ // timing. This lets us inject WebSocket upgrade handlers before binding and
2310
+ // await the bind to surface EADDRINUSE as a catchable error (issue #75).
2311
+ const server = createAdaptorServer({ fetch: app.fetch, hostname: host });
2312
+ injectWebSocket(server);
2313
+ await new Promise((resolve, reject) => {
2314
+ const onListening = () => {
2315
+ server.off('error', onError);
2316
+ resolve();
2317
+ };
2318
+ const onError = (error) => {
2319
+ server.off('listening', onListening);
2320
+ reject(error);
2321
+ };
2322
+ server.once('listening', onListening);
2323
+ server.once('error', onError);
2324
+ server.listen(port, host);
2325
+ });
2326
+ // Clean up orphaned cloud sessions from a previous relay crash.
2327
+ // Must run AFTER successful listen — if another relay is already running,
2328
+ // we'd fail with EADDRINUSE but only after killing its live VMs.
2329
+ cleanupOrphanedCloudSessions();
2330
+ const wsHost = `ws://${host}:${port}`;
2331
+ const cdpEndpoint = `${wsHost}/cdp`;
2332
+ const extensionEndpoint = `${wsHost}/extension`;
2333
+ logger?.log('CDP relay server started');
2334
+ logger?.log('Host:', host);
2335
+ logger?.log('Port:', port);
2336
+ logger?.log('Extension endpoint:', extensionEndpoint);
2337
+ logger?.log('CDP endpoint:', cdpEndpoint);
2338
+ return {
2339
+ close() {
2340
+ const { extensions, playwrightClients } = store.getState();
2341
+ for (const client of playwrightClients.values()) {
2342
+ client.ws.close(1000, 'Server stopped');
2343
+ }
2344
+ for (const ext of extensions.values()) {
2345
+ if (ext.pingInterval) {
2346
+ clearInterval(ext.pingInterval);
2347
+ }
2348
+ ext.ws?.close(1000, 'Server stopped');
2349
+ }
2350
+ // Close shared headless browser if any headless sessions were created (fire-and-forget)
2351
+ void import('./executor.js').then(({ PlaywrightExecutor }) => {
2352
+ return PlaywrightExecutor.closeSharedHeadlessBrowser();
2353
+ });
2354
+ // Reset store state
2355
+ store.setState({
2356
+ extensions: new Map(),
2357
+ playwrightClients: new Map(),
2358
+ });
2359
+ clearInterval(cloudIdleInterval);
2360
+ cloudSessionTracking.clear();
2361
+ persistCloudSessions(); // Remove the file on graceful shutdown
2362
+ server.close();
2363
+ emitter.removeAllListeners();
2364
+ },
2365
+ on(event, listener) {
2366
+ emitter.on(event, listener);
2367
+ },
2368
+ off(event, listener) {
2369
+ emitter.off(event, listener);
2370
+ },
2371
+ };
2372
+ }
2373
+ // Kept so extensions and library consumers can upgrade independently during the rename.
2374
+ export const startPlayWriterCDPRelayServer = startTabwrightCDPRelayServer;
2375
+ //# sourceMappingURL=cdp-relay.js.map