cdpRelay.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. "use strict";
  2. var __create = Object.create;
  3. var __defProp = Object.defineProperty;
  4. var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
  5. var __getOwnPropNames = Object.getOwnPropertyNames;
  6. var __getProtoOf = Object.getPrototypeOf;
  7. var __hasOwnProp = Object.prototype.hasOwnProperty;
  8. var __export = (target, all) => {
  9. for (var name in all)
  10. __defProp(target, name, { get: all[name], enumerable: true });
  11. };
  12. var __copyProps = (to, from, except, desc) => {
  13. if (from && typeof from === "object" || typeof from === "function") {
  14. for (let key of __getOwnPropNames(from))
  15. if (!__hasOwnProp.call(to, key) && key !== except)
  16. __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  17. }
  18. return to;
  19. };
  20. var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
  21. // If the importer is in node compatibility mode or this is not an ESM
  22. // file that has been converted to a CommonJS file using a Babel-
  23. // compatible transform (i.e. "__esModule" has not been set), then set
  24. // "default" to the CommonJS "module.exports" for node compatibility.
  25. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
  26. mod
  27. ));
  28. var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
  29. var cdpRelay_exports = {};
  30. __export(cdpRelay_exports, {
  31. CDPRelayServer: () => CDPRelayServer
  32. });
  33. module.exports = __toCommonJS(cdpRelay_exports);
  34. var import_child_process = require("child_process");
  35. var import_utilsBundle = require("playwright-core/lib/utilsBundle");
  36. var import_registry = require("playwright-core/lib/server/registry/index");
  37. var import_utils = require("playwright-core/lib/utils");
  38. var import_http2 = require("../sdk/http");
  39. var import_log = require("../log");
  40. var protocol = __toESM(require("./protocol"));
  41. const debugLogger = (0, import_utilsBundle.debug)("pw:mcp:relay");
  42. class CDPRelayServer {
  43. constructor(server, browserChannel, userDataDir, executablePath) {
  44. this._playwrightConnection = null;
  45. this._extensionConnection = null;
  46. this._nextSessionId = 1;
  47. this._wsHost = (0, import_http2.httpAddressToString)(server.address()).replace(/^http/, "ws");
  48. this._browserChannel = browserChannel;
  49. this._userDataDir = userDataDir;
  50. this._executablePath = executablePath;
  51. const uuid = crypto.randomUUID();
  52. this._cdpPath = `/cdp/${uuid}`;
  53. this._extensionPath = `/extension/${uuid}`;
  54. this._resetExtensionConnection();
  55. this._wss = new import_utilsBundle.wsServer({ server });
  56. this._wss.on("connection", this._onConnection.bind(this));
  57. }
  58. cdpEndpoint() {
  59. return `${this._wsHost}${this._cdpPath}`;
  60. }
  61. extensionEndpoint() {
  62. return `${this._wsHost}${this._extensionPath}`;
  63. }
  64. async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal, toolName) {
  65. debugLogger("Ensuring extension connection for MCP context");
  66. if (this._extensionConnection)
  67. return;
  68. this._connectBrowser(clientInfo, toolName);
  69. debugLogger("Waiting for incoming extension connection");
  70. await Promise.race([
  71. this._extensionConnectionPromise,
  72. new Promise((_, reject) => setTimeout(() => {
  73. reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
  74. }, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5e3)),
  75. new Promise((_, reject) => abortSignal.addEventListener("abort", reject))
  76. ]);
  77. debugLogger("Extension connection established");
  78. }
  79. _connectBrowser(clientInfo, toolName) {
  80. const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
  81. const url = new URL("chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html");
  82. url.searchParams.set("mcpRelayUrl", mcpRelayEndpoint);
  83. const client = {
  84. name: clientInfo.name,
  85. version: clientInfo.version
  86. };
  87. url.searchParams.set("client", JSON.stringify(client));
  88. url.searchParams.set("protocolVersion", process.env.PWMCP_TEST_PROTOCOL_VERSION ?? protocol.VERSION.toString());
  89. if (toolName)
  90. url.searchParams.set("newTab", String(toolName === "browser_navigate"));
  91. const token = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
  92. if (token)
  93. url.searchParams.set("token", token);
  94. const href = url.toString();
  95. let executablePath = this._executablePath;
  96. if (!executablePath) {
  97. const executableInfo = import_registry.registry.findExecutable(this._browserChannel);
  98. if (!executableInfo)
  99. throw new Error(`Unsupported channel: "${this._browserChannel}"`);
  100. executablePath = executableInfo.executablePath("javascript");
  101. if (!executablePath)
  102. throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
  103. }
  104. const args = [];
  105. if (this._userDataDir)
  106. args.push(`--user-data-dir=${this._userDataDir}`);
  107. args.push(href);
  108. (0, import_child_process.spawn)(executablePath, args, {
  109. windowsHide: true,
  110. detached: true,
  111. shell: false,
  112. stdio: "ignore"
  113. });
  114. }
  115. stop() {
  116. this.closeConnections("Server stopped");
  117. this._wss.close();
  118. }
  119. closeConnections(reason) {
  120. this._closePlaywrightConnection(reason);
  121. this._closeExtensionConnection(reason);
  122. }
  123. _onConnection(ws2, request) {
  124. const url = new URL(`http://localhost${request.url}`);
  125. debugLogger(`New connection to ${url.pathname}`);
  126. if (url.pathname === this._cdpPath) {
  127. this._handlePlaywrightConnection(ws2);
  128. } else if (url.pathname === this._extensionPath) {
  129. this._handleExtensionConnection(ws2);
  130. } else {
  131. debugLogger(`Invalid path: ${url.pathname}`);
  132. ws2.close(4004, "Invalid path");
  133. }
  134. }
  135. _handlePlaywrightConnection(ws2) {
  136. if (this._playwrightConnection) {
  137. debugLogger("Rejecting second Playwright connection");
  138. ws2.close(1e3, "Another CDP client already connected");
  139. return;
  140. }
  141. this._playwrightConnection = ws2;
  142. ws2.on("message", async (data) => {
  143. try {
  144. const message = JSON.parse(data.toString());
  145. await this._handlePlaywrightMessage(message);
  146. } catch (error) {
  147. debugLogger(`Error while handling Playwright message
  148. ${data.toString()}
  149. `, error);
  150. }
  151. });
  152. ws2.on("close", () => {
  153. if (this._playwrightConnection !== ws2)
  154. return;
  155. this._playwrightConnection = null;
  156. this._closeExtensionConnection("Playwright client disconnected");
  157. debugLogger("Playwright WebSocket closed");
  158. });
  159. ws2.on("error", (error) => {
  160. debugLogger("Playwright WebSocket error:", error);
  161. });
  162. debugLogger("Playwright MCP connected");
  163. }
  164. _closeExtensionConnection(reason) {
  165. this._extensionConnection?.close(reason);
  166. this._extensionConnectionPromise.reject(new Error(reason));
  167. this._resetExtensionConnection();
  168. }
  169. _resetExtensionConnection() {
  170. this._connectedTabInfo = void 0;
  171. this._extensionConnection = null;
  172. this._extensionConnectionPromise = new import_utils.ManualPromise();
  173. void this._extensionConnectionPromise.catch(import_log.logUnhandledError);
  174. }
  175. _closePlaywrightConnection(reason) {
  176. if (this._playwrightConnection?.readyState === import_utilsBundle.ws.OPEN)
  177. this._playwrightConnection.close(1e3, reason);
  178. this._playwrightConnection = null;
  179. }
  180. _handleExtensionConnection(ws2) {
  181. if (this._extensionConnection) {
  182. ws2.close(1e3, "Another extension connection already established");
  183. return;
  184. }
  185. this._extensionConnection = new ExtensionConnection(ws2);
  186. this._extensionConnection.onclose = (c, reason) => {
  187. debugLogger("Extension WebSocket closed:", reason, c === this._extensionConnection);
  188. if (this._extensionConnection !== c)
  189. return;
  190. this._resetExtensionConnection();
  191. this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
  192. };
  193. this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
  194. this._extensionConnectionPromise.resolve();
  195. }
  196. _handleExtensionMessage(method, params) {
  197. switch (method) {
  198. case "forwardCDPEvent":
  199. const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
  200. this._sendToPlaywright({
  201. sessionId,
  202. method: params.method,
  203. params: params.params
  204. });
  205. break;
  206. }
  207. }
  208. async _handlePlaywrightMessage(message) {
  209. debugLogger("\u2190 Playwright:", `${message.method} (id=${message.id})`);
  210. const { id, sessionId, method, params } = message;
  211. try {
  212. const result = await this._handleCDPCommand(method, params, sessionId);
  213. this._sendToPlaywright({ id, sessionId, result });
  214. } catch (e) {
  215. debugLogger("Error in the extension:", e);
  216. this._sendToPlaywright({
  217. id,
  218. sessionId,
  219. error: { message: e.message }
  220. });
  221. }
  222. }
  223. async _handleCDPCommand(method, params, sessionId) {
  224. switch (method) {
  225. case "Browser.getVersion": {
  226. return {
  227. protocolVersion: "1.3",
  228. product: "Chrome/Extension-Bridge",
  229. userAgent: "CDP-Bridge-Server/1.0.0"
  230. };
  231. }
  232. case "Browser.setDownloadBehavior": {
  233. return {};
  234. }
  235. case "Target.setAutoAttach": {
  236. if (sessionId)
  237. break;
  238. const { targetInfo } = await this._extensionConnection.send("attachToTab", {});
  239. this._connectedTabInfo = {
  240. targetInfo,
  241. sessionId: `pw-tab-${this._nextSessionId++}`
  242. };
  243. debugLogger("Simulating auto-attach");
  244. this._sendToPlaywright({
  245. method: "Target.attachedToTarget",
  246. params: {
  247. sessionId: this._connectedTabInfo.sessionId,
  248. targetInfo: {
  249. ...this._connectedTabInfo.targetInfo,
  250. attached: true
  251. },
  252. waitingForDebugger: false
  253. }
  254. });
  255. return {};
  256. }
  257. case "Target.getTargetInfo": {
  258. return this._connectedTabInfo?.targetInfo;
  259. }
  260. }
  261. return await this._forwardToExtension(method, params, sessionId);
  262. }
  263. async _forwardToExtension(method, params, sessionId) {
  264. if (!this._extensionConnection)
  265. throw new Error("Extension not connected");
  266. if (this._connectedTabInfo?.sessionId === sessionId)
  267. sessionId = void 0;
  268. return await this._extensionConnection.send("forwardCDPCommand", { sessionId, method, params });
  269. }
  270. _sendToPlaywright(message) {
  271. debugLogger("\u2192 Playwright:", `${message.method ?? `response(id=${message.id})`}`);
  272. this._playwrightConnection?.send(JSON.stringify(message));
  273. }
  274. }
  275. class ExtensionConnection {
  276. constructor(ws2) {
  277. this._callbacks = /* @__PURE__ */ new Map();
  278. this._lastId = 0;
  279. this._ws = ws2;
  280. this._ws.on("message", this._onMessage.bind(this));
  281. this._ws.on("close", this._onClose.bind(this));
  282. this._ws.on("error", this._onError.bind(this));
  283. }
  284. async send(method, params) {
  285. if (this._ws.readyState !== import_utilsBundle.ws.OPEN)
  286. throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
  287. const id = ++this._lastId;
  288. this._ws.send(JSON.stringify({ id, method, params }));
  289. const error = new Error(`Protocol error: ${method}`);
  290. return new Promise((resolve, reject) => {
  291. this._callbacks.set(id, { resolve, reject, error });
  292. });
  293. }
  294. close(message) {
  295. debugLogger("closing extension connection:", message);
  296. if (this._ws.readyState === import_utilsBundle.ws.OPEN)
  297. this._ws.close(1e3, message);
  298. }
  299. _onMessage(event) {
  300. const eventData = event.toString();
  301. let parsedJson;
  302. try {
  303. parsedJson = JSON.parse(eventData);
  304. } catch (e) {
  305. debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
  306. this._ws.close();
  307. return;
  308. }
  309. try {
  310. this._handleParsedMessage(parsedJson);
  311. } catch (e) {
  312. debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
  313. this._ws.close();
  314. }
  315. }
  316. _handleParsedMessage(object) {
  317. if (object.id && this._callbacks.has(object.id)) {
  318. const callback = this._callbacks.get(object.id);
  319. this._callbacks.delete(object.id);
  320. if (object.error) {
  321. const error = callback.error;
  322. error.message = object.error;
  323. callback.reject(error);
  324. } else {
  325. callback.resolve(object.result);
  326. }
  327. } else if (object.id) {
  328. debugLogger("\u2190 Extension: unexpected response", object);
  329. } else {
  330. this.onmessage?.(object.method, object.params);
  331. }
  332. }
  333. _onClose(event) {
  334. debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
  335. this._dispose();
  336. this.onclose?.(this, event.reason);
  337. }
  338. _onError(event) {
  339. debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
  340. this._dispose();
  341. }
  342. _dispose() {
  343. for (const callback of this._callbacks.values())
  344. callback.reject(new Error("WebSocket closed"));
  345. this._callbacks.clear();
  346. }
  347. }
  348. // Annotate the CommonJS export names for ESM import in node:
  349. 0 && (module.exports = {
  350. CDPRelayServer
  351. });