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