@wordbricks/playwright-mcp 0.1.19 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +54 -44
  2. package/cli-wrapper.js +15 -14
  3. package/cli.js +1 -1
  4. package/config.d.ts +11 -6
  5. package/index.d.ts +7 -5
  6. package/index.js +1 -1
  7. package/lib/browserContextFactory.js +131 -58
  8. package/lib/browserServerBackend.js +14 -12
  9. package/lib/config.js +60 -46
  10. package/lib/context.js +41 -39
  11. package/lib/extension/cdpRelay.js +67 -61
  12. package/lib/extension/extensionContextFactory.js +10 -10
  13. package/lib/frameworkPatterns.js +21 -21
  14. package/lib/hooks/antiBotDetectionHook.js +178 -0
  15. package/lib/hooks/core.js +11 -10
  16. package/lib/hooks/eventConsumer.js +29 -16
  17. package/lib/hooks/events.js +3 -3
  18. package/lib/hooks/formatToolCallEvent.js +3 -7
  19. package/lib/hooks/frameworkStateHook.js +40 -40
  20. package/lib/hooks/grouping.js +3 -3
  21. package/lib/hooks/jsonLdDetectionHook.js +44 -37
  22. package/lib/hooks/networkFilters.js +24 -15
  23. package/lib/hooks/networkSetup.js +11 -6
  24. package/lib/hooks/networkTrackingHook.js +31 -19
  25. package/lib/hooks/pageHeightHook.js +9 -9
  26. package/lib/hooks/registry.js +18 -16
  27. package/lib/hooks/requireTabHook.js +3 -3
  28. package/lib/hooks/schema.js +44 -32
  29. package/lib/hooks/waitHook.js +7 -7
  30. package/lib/index.js +12 -10
  31. package/lib/mcp/inProcessTransport.js +3 -4
  32. package/lib/mcp/proxyBackend.js +43 -28
  33. package/lib/mcp/server.js +24 -19
  34. package/lib/mcp/tool.js +14 -8
  35. package/lib/mcp/transport.js +60 -53
  36. package/lib/playwrightTransformer.js +129 -106
  37. package/lib/program.js +54 -52
  38. package/lib/response.js +36 -30
  39. package/lib/sessionLog.js +19 -17
  40. package/lib/tab.js +41 -39
  41. package/lib/tools/common.js +19 -19
  42. package/lib/tools/console.js +11 -11
  43. package/lib/tools/dialogs.js +18 -15
  44. package/lib/tools/evaluate.js +26 -17
  45. package/lib/tools/extractFrameworkState.js +48 -37
  46. package/lib/tools/files.js +17 -14
  47. package/lib/tools/form.js +32 -23
  48. package/lib/tools/getSnapshot.js +14 -15
  49. package/lib/tools/getVisibleHtml.js +33 -17
  50. package/lib/tools/install.js +20 -20
  51. package/lib/tools/keyboard.js +29 -24
  52. package/lib/tools/mouse.js +29 -31
  53. package/lib/tools/navigate.js +19 -23
  54. package/lib/tools/network.js +12 -14
  55. package/lib/tools/networkDetail.js +68 -61
  56. package/lib/tools/networkSearch/bodySearch.js +46 -32
  57. package/lib/tools/networkSearch/grouping.js +15 -6
  58. package/lib/tools/networkSearch/helpers.js +4 -4
  59. package/lib/tools/networkSearch/searchHtml.js +25 -16
  60. package/lib/tools/networkSearch/urlSearch.js +56 -14
  61. package/lib/tools/networkSearch.js +65 -35
  62. package/lib/tools/pdf.js +13 -12
  63. package/lib/tools/repl.js +66 -54
  64. package/lib/tools/screenshot.js +57 -33
  65. package/lib/tools/scroll.js +29 -24
  66. package/lib/tools/snapshot.js +66 -49
  67. package/lib/tools/tabs.js +22 -19
  68. package/lib/tools/tool.js +5 -3
  69. package/lib/tools/utils.js +17 -13
  70. package/lib/tools/wait.js +24 -19
  71. package/lib/tools.js +21 -20
  72. package/lib/utils/adBlockFilter.js +29 -26
  73. package/lib/utils/codegen.js +20 -16
  74. package/lib/utils/extensionPath.js +4 -4
  75. package/lib/utils/fileUtils.js +17 -13
  76. package/lib/utils/graphql.js +69 -58
  77. package/lib/utils/guid.js +3 -3
  78. package/lib/utils/httpServer.js +9 -9
  79. package/lib/utils/log.js +3 -3
  80. package/lib/utils/manualPromise.js +7 -7
  81. package/lib/utils/networkFormat.js +7 -5
  82. package/lib/utils/package.js +4 -4
  83. package/lib/utils/sanitizeHtml.js +66 -34
  84. package/lib/utils/truncate.js +25 -25
  85. package/lib/utils/withTimeout.js +1 -1
  86. package/package.json +34 -57
  87. package/src/index.ts +27 -17
  88. package/LICENSE +0 -202
@@ -20,15 +20,15 @@
20
20
  * - /cdp/guid - Full CDP interface for Playwright MCP
21
21
  * - /extension/guid - Extension connection for chrome.debugger forwarding
22
22
  */
23
- import { spawn } from 'child_process';
24
- import debug from 'debug';
25
- import { WebSocket, WebSocketServer } from 'ws';
26
- import { httpAddressToString } from '../utils/httpServer.js';
27
- import { logUnhandledError } from '../utils/log.js';
28
- import { ManualPromise } from '../utils/manualPromise.js';
29
- // @ts-ignore
30
- const { registry } = await import('playwright-core/lib/server/registry/index');
31
- const debugLogger = debug('pw:mcp:relay');
23
+ import { spawn } from "child_process";
24
+ import debug from "debug";
25
+ import { WebSocket, WebSocketServer } from "ws";
26
+ import { httpAddressToString } from "../utils/httpServer.js";
27
+ import { logUnhandledError } from "../utils/log.js";
28
+ import { ManualPromise } from "../utils/manualPromise.js";
29
+ // @ts-expect-error
30
+ const { registry } = await import("playwright-core/lib/server/registry/index");
31
+ const debugLogger = debug("pw:mcp:relay");
32
32
  export class CDPRelayServer {
33
33
  _wsHost;
34
34
  _browserChannel;
@@ -42,7 +42,7 @@ export class CDPRelayServer {
42
42
  _nextSessionId = 1;
43
43
  _extensionConnectionPromise;
44
44
  constructor(server, browserChannel, userDataDir) {
45
- this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
45
+ this._wsHost = httpAddressToString(server.address()).replace(/^http/, "ws");
46
46
  this._browserChannel = browserChannel;
47
47
  this._userDataDir = userDataDir;
48
48
  const uuid = crypto.randomUUID();
@@ -50,7 +50,7 @@ export class CDPRelayServer {
50
50
  this._extensionPath = `/extension/${uuid}`;
51
51
  this._resetExtensionConnection();
52
52
  this._wss = new WebSocketServer({ server });
53
- this._wss.on('connection', this._onConnection.bind(this));
53
+ this._wss.on("connection", this._onConnection.bind(this));
54
54
  }
55
55
  cdpEndpoint() {
56
56
  return `${this._wsHost}${this._cdpPath}`;
@@ -59,23 +59,23 @@ export class CDPRelayServer {
59
59
  return `${this._wsHost}${this._extensionPath}`;
60
60
  }
61
61
  async ensureExtensionConnectionForMCPContext(clientInfo, abortSignal) {
62
- debugLogger('Ensuring extension connection for MCP context');
62
+ debugLogger("Ensuring extension connection for MCP context");
63
63
  if (this._extensionConnection)
64
64
  return;
65
65
  this._connectBrowser(clientInfo);
66
- debugLogger('Waiting for incoming extension connection');
66
+ debugLogger("Waiting for incoming extension connection");
67
67
  await Promise.race([
68
68
  this._extensionConnectionPromise,
69
- new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
69
+ new Promise((_, reject) => abortSignal.addEventListener("abort", reject)),
70
70
  ]);
71
- debugLogger('Extension connection established');
71
+ debugLogger("Extension connection established");
72
72
  }
73
73
  _connectBrowser(clientInfo) {
74
74
  const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
75
75
  // Need to specify "key" in the manifest.json to make the id stable when loading from file.
76
- const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
77
- url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
78
- url.searchParams.set('client', JSON.stringify(clientInfo));
76
+ const url = new URL("chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html");
77
+ url.searchParams.set("mcpRelayUrl", mcpRelayEndpoint);
78
+ url.searchParams.set("client", JSON.stringify(clientInfo));
79
79
  const href = url.toString();
80
80
  const executableInfo = registry.findExecutable(this._browserChannel);
81
81
  if (!executableInfo)
@@ -91,11 +91,11 @@ export class CDPRelayServer {
91
91
  windowsHide: true,
92
92
  detached: true,
93
93
  shell: false,
94
- stdio: 'ignore',
94
+ stdio: "ignore",
95
95
  });
96
96
  }
97
97
  stop() {
98
- this.closeConnections('Server stopped');
98
+ this.closeConnections("Server stopped");
99
99
  this._wss.close();
100
100
  }
101
101
  closeConnections(reason) {
@@ -113,17 +113,17 @@ export class CDPRelayServer {
113
113
  }
114
114
  else {
115
115
  debugLogger(`Invalid path: ${url.pathname}`);
116
- ws.close(4004, 'Invalid path');
116
+ ws.close(4004, "Invalid path");
117
117
  }
118
118
  }
119
119
  _handlePlaywrightConnection(ws) {
120
120
  if (this._playwrightConnection) {
121
- debugLogger('Rejecting second Playwright connection');
122
- ws.close(1000, 'Another CDP client already connected');
121
+ debugLogger("Rejecting second Playwright connection");
122
+ ws.close(1000, "Another CDP client already connected");
123
123
  return;
124
124
  }
125
125
  this._playwrightConnection = ws;
126
- ws.on('message', async (data) => {
126
+ ws.on("message", async (data) => {
127
127
  try {
128
128
  const message = JSON.parse(data.toString());
129
129
  await this._handlePlaywrightMessage(message);
@@ -132,17 +132,17 @@ export class CDPRelayServer {
132
132
  debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
133
133
  }
134
134
  });
135
- ws.on('close', () => {
135
+ ws.on("close", () => {
136
136
  if (this._playwrightConnection !== ws)
137
137
  return;
138
138
  this._playwrightConnection = null;
139
- this._closeExtensionConnection('Playwright client disconnected');
140
- debugLogger('Playwright WebSocket closed');
139
+ this._closeExtensionConnection("Playwright client disconnected");
140
+ debugLogger("Playwright WebSocket closed");
141
141
  });
142
- ws.on('error', error => {
143
- debugLogger('Playwright WebSocket error:', error);
142
+ ws.on("error", (error) => {
143
+ debugLogger("Playwright WebSocket error:", error);
144
144
  });
145
- debugLogger('Playwright MCP connected');
145
+ debugLogger("Playwright MCP connected");
146
146
  }
147
147
  _closeExtensionConnection(reason) {
148
148
  this._extensionConnection?.close(reason);
@@ -162,89 +162,91 @@ export class CDPRelayServer {
162
162
  }
163
163
  _handleExtensionConnection(ws) {
164
164
  if (this._extensionConnection) {
165
- ws.close(1000, 'Another extension connection already established');
165
+ ws.close(1000, "Another extension connection already established");
166
166
  return;
167
167
  }
168
168
  this._extensionConnection = new ExtensionConnection(ws);
169
169
  this._extensionConnection.onclose = (c, reason) => {
170
- debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
170
+ debugLogger("Extension WebSocket closed:", reason, c === this._extensionConnection);
171
171
  if (this._extensionConnection !== c)
172
172
  return;
173
173
  this._resetExtensionConnection();
174
174
  this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
175
175
  };
176
- this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
176
+ this._extensionConnection.onmessage =
177
+ this._handleExtensionMessage.bind(this);
177
178
  this._extensionConnectionPromise.resolve();
178
179
  }
179
180
  _handleExtensionMessage(method, params) {
180
181
  switch (method) {
181
- case 'forwardCDPEvent':
182
+ case "forwardCDPEvent": {
182
183
  const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
183
184
  this._sendToPlaywright({
184
185
  sessionId,
185
186
  method: params.method,
186
- params: params.params
187
+ params: params.params,
187
188
  });
188
189
  break;
189
- case 'detachedFromTab':
190
- debugLogger('← Debugger detached from tab:', params);
190
+ }
191
+ case "detachedFromTab":
192
+ debugLogger("← Debugger detached from tab:", params);
191
193
  this._connectedTabInfo = undefined;
192
194
  break;
193
195
  }
194
196
  }
195
197
  async _handlePlaywrightMessage(message) {
196
- debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
198
+ debugLogger("← Playwright:", `${message.method} (id=${message.id})`);
197
199
  const { id, sessionId, method, params } = message;
198
200
  try {
199
201
  const result = await this._handleCDPCommand(method, params, sessionId);
200
202
  this._sendToPlaywright({ id, sessionId, result });
201
203
  }
202
204
  catch (e) {
203
- debugLogger('Error in the extension:', e);
205
+ debugLogger("Error in the extension:", e);
204
206
  this._sendToPlaywright({
205
207
  id,
206
208
  sessionId,
207
- error: { message: e.message }
209
+ error: { message: e.message },
208
210
  });
209
211
  }
210
212
  }
211
213
  async _handleCDPCommand(method, params, sessionId) {
212
214
  switch (method) {
213
- case 'Browser.getVersion': {
215
+ case "Browser.getVersion": {
214
216
  return {
215
- protocolVersion: '1.3',
216
- product: 'Chrome/Extension-Bridge',
217
- userAgent: 'CDP-Bridge-Server/1.0.0',
217
+ protocolVersion: "1.3",
218
+ product: "Chrome/Extension-Bridge",
219
+ userAgent: "CDP-Bridge-Server/1.0.0",
218
220
  };
219
221
  }
220
- case 'Browser.setDownloadBehavior': {
222
+ case "Browser.setDownloadBehavior": {
221
223
  return {};
222
224
  }
223
- case 'Target.setAutoAttach': {
225
+ case "Target.setAutoAttach": {
224
226
  // Forward child session handling.
225
227
  if (sessionId)
226
228
  break;
227
229
  // Simulate auto-attach behavior with real target info
228
- const { targetInfo } = await this._extensionConnection.send('attachToTab');
230
+ const { targetInfo } = await this._extensionConnection.send("attachToTab");
229
231
  this._connectedTabInfo = {
230
232
  targetInfo,
231
233
  sessionId: `pw-tab-${this._nextSessionId++}`,
232
234
  };
233
- debugLogger('Simulating auto-attach');
235
+ debugLogger("Simulating auto-attach");
234
236
  this._sendToPlaywright({
235
- method: 'Target.attachedToTarget',
237
+ method: "Target.attachedToTarget",
236
238
  params: {
237
239
  sessionId: this._connectedTabInfo.sessionId,
238
240
  targetInfo: {
239
241
  ...this._connectedTabInfo.targetInfo,
240
242
  attached: true,
241
243
  },
242
- waitingForDebugger: false
243
- }
244
+ waitingForDebugger: false,
245
+ },
244
246
  });
245
247
  return {};
246
248
  }
247
- case 'Target.getTargetInfo': {
249
+ case "Target.getTargetInfo": {
248
250
  return this._connectedTabInfo?.targetInfo;
249
251
  }
250
252
  }
@@ -252,14 +254,18 @@ export class CDPRelayServer {
252
254
  }
253
255
  async _forwardToExtension(method, params, sessionId) {
254
256
  if (!this._extensionConnection)
255
- throw new Error('Extension not connected');
257
+ throw new Error("Extension not connected");
256
258
  // Top level sessionId is only passed between the relay and the client.
257
259
  if (this._connectedTabInfo?.sessionId === sessionId)
258
260
  sessionId = undefined;
259
- return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
261
+ return await this._extensionConnection.send("forwardCDPCommand", {
262
+ sessionId,
263
+ method,
264
+ params,
265
+ });
260
266
  }
261
267
  _sendToPlaywright(message) {
262
- debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
268
+ debugLogger("→ Playwright:", `${message.method ?? `response(id=${message.id})`}`);
263
269
  this._playwrightConnection?.send(JSON.stringify(message));
264
270
  }
265
271
  }
@@ -271,9 +277,9 @@ class ExtensionConnection {
271
277
  onclose;
272
278
  constructor(ws) {
273
279
  this._ws = ws;
274
- this._ws.on('message', this._onMessage.bind(this));
275
- this._ws.on('close', this._onClose.bind(this));
276
- this._ws.on('error', this._onError.bind(this));
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));
277
283
  }
278
284
  async send(method, params, sessionId) {
279
285
  if (this._ws.readyState !== WebSocket.OPEN)
@@ -286,7 +292,7 @@ class ExtensionConnection {
286
292
  });
287
293
  }
288
294
  close(message) {
289
- debugLogger('closing extension connection:', message);
295
+ debugLogger("closing extension connection:", message);
290
296
  if (this._ws.readyState === WebSocket.OPEN)
291
297
  this._ws.close(1000, message);
292
298
  }
@@ -323,7 +329,7 @@ class ExtensionConnection {
323
329
  }
324
330
  }
325
331
  else if (object.id) {
326
- debugLogger('← Extension: unexpected response', object);
332
+ debugLogger("← Extension: unexpected response", object);
327
333
  }
328
334
  else {
329
335
  this.onmessage?.(object.method, object.params);
@@ -340,7 +346,7 @@ class ExtensionConnection {
340
346
  }
341
347
  _dispose() {
342
348
  for (const callback of this._callbacks.values())
343
- callback.reject(new Error('WebSocket closed'));
349
+ callback.reject(new Error("WebSocket closed"));
344
350
  this._callbacks.clear();
345
351
  }
346
352
  }
@@ -13,14 +13,14 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import debug from 'debug';
17
- import * as playwright from 'playwright-core';
18
- import { startHttpServer } from '../utils/httpServer.js';
19
- import { CDPRelayServer } from './cdpRelay.js';
20
- const debugLogger = debug('pw:mcp:relay');
16
+ import debug from "debug";
17
+ import * as playwright from "playwright-core";
18
+ import { startHttpServer } from "../utils/httpServer.js";
19
+ import { CDPRelayServer } from "./cdpRelay.js";
20
+ const debugLogger = debug("pw:mcp:relay");
21
21
  export class ExtensionContextFactory {
22
- name = 'extension';
23
- description = 'Connect to a browser using the Playwright MCP extension';
22
+ name = "extension";
23
+ description = "Connect to a browser using the Playwright MCP extension";
24
24
  _browserChannel;
25
25
  _userDataDir;
26
26
  constructor(browserChannel, userDataDir) {
@@ -32,9 +32,9 @@ export class ExtensionContextFactory {
32
32
  return {
33
33
  browserContext: browser.contexts()[0],
34
34
  close: async () => {
35
- debugLogger('close() called for browser context');
35
+ debugLogger("close() called for browser context");
36
36
  await browser.close();
37
- }
37
+ },
38
38
  };
39
39
  }
40
40
  async _obtainBrowser(clientInfo, abortSignal) {
@@ -49,7 +49,7 @@ export class ExtensionContextFactory {
49
49
  throw new Error(abortSignal.reason);
50
50
  }
51
51
  const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
52
- abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
52
+ abortSignal.addEventListener("abort", () => cdpRelayServer.stop());
53
53
  debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
54
54
  return cdpRelayServer;
55
55
  }
@@ -3,33 +3,33 @@
3
3
  */
4
4
  export const FRAMEWORK_STATE_PATTERNS = [
5
5
  // React/Next.js
6
- '__NEXT_DATA__',
7
- '__reactServerState',
6
+ "__NEXT_DATA__",
7
+ "__reactServerState",
8
8
  // Remix
9
- '__remixContext',
10
- '__remixManifest',
11
- '__remixRouteModules',
9
+ "__remixContext",
10
+ "__remixManifest",
11
+ "__remixRouteModules",
12
12
  // Apollo GraphQL
13
- '__APOLLO_STATE__',
14
- '__APOLLO_CLIENT__',
13
+ "__APOLLO_STATE__",
14
+ "__APOLLO_CLIENT__",
15
15
  // Redux/State Management
16
- '__PRELOADED_STATE__',
17
- '__INITIAL_STATE__',
18
- '__REDUX_STATE__',
16
+ "__PRELOADED_STATE__",
17
+ "__INITIAL_STATE__",
18
+ "__REDUX_STATE__",
19
19
  // Vue/Nuxt
20
- '__NUXT__',
20
+ "__NUXT__",
21
21
  // Gatsby
22
- '___gatsby',
23
- '___loader',
22
+ "___gatsby",
23
+ "___loader",
24
24
  // Generic SSR
25
- '__SSR_DATA__',
26
- '__APP_STATE__',
27
- '__SERVER_STATE__',
25
+ "__SSR_DATA__",
26
+ "__APP_STATE__",
27
+ "__SERVER_STATE__",
28
28
  // Others
29
- '__QWIK_STATE__',
30
- '__SVELTE__',
31
- '__ANGULAR__',
32
- '__SOLID__',
33
- '__ASTRO_DATA__',
29
+ "__QWIK_STATE__",
30
+ "__SVELTE__",
31
+ "__ANGULAR__",
32
+ "__SOLID__",
33
+ "__ASTRO_DATA__",
34
34
  ];
35
35
  export const MAX_DISPLAY_ITEMS = 5;
@@ -0,0 +1,178 @@
1
+ import ms from "ms";
2
+ import { Ok } from "../utils/result.js";
3
+ import { getEventsAfter, isEventType, trackEvent } from "./events.js";
4
+ import { hookNameSchema } from "./schema.js";
5
+ const RESOLUTION_WAIT_MS = ms("10s");
6
+ const lastProcessedEventIdByContext = new WeakMap();
7
+ const detectedProvidersByContext = new WeakMap();
8
+ const isLikelyResolved = async (ctx) => {
9
+ if (!ctx.tab?.page)
10
+ return false;
11
+ const host = ctx.tab.page.url();
12
+ if (host.includes("challenges.cloudflare.com"))
13
+ return false;
14
+ return ctx.tab.page.evaluate(() => {
15
+ const challengeSelectors = [
16
+ "#challenge-stage",
17
+ "#cf-challenge-running",
18
+ 'iframe[src*="turnstile"]',
19
+ 'form[action*="/cdn-cgi/challenge-platform"]',
20
+ "[data-cf-challenge]",
21
+ ];
22
+ const hasChallengeDom = challengeSelectors.some((selector) => document.querySelector(selector));
23
+ if (hasChallengeDom)
24
+ return false;
25
+ const title = document.title.toLowerCase();
26
+ const bodyText = (document.body?.innerText || "")
27
+ .slice(0, 2000)
28
+ .toLowerCase();
29
+ if (title.includes("just a moment") || bodyText.includes("just a moment"))
30
+ return false;
31
+ if (title.includes("checking your browser") ||
32
+ bodyText.includes("checking your browser"))
33
+ return false;
34
+ return true;
35
+ });
36
+ };
37
+ const getDetectedProviders = (context) => {
38
+ const detectedProviders = detectedProvidersByContext.get(context);
39
+ if (detectedProviders)
40
+ return detectedProviders;
41
+ const newSet = new Set();
42
+ detectedProvidersByContext.set(context, newSet);
43
+ return newSet;
44
+ };
45
+ const updateLastProcessedEventId = (context, events) => {
46
+ const lastEvent = events[events.length - 1];
47
+ if (!lastEvent)
48
+ return;
49
+ lastProcessedEventIdByContext.set(context, lastEvent.id);
50
+ };
51
+ const isStatusOk = (status) => status >= 200 && status < 400;
52
+ const providerConfigs = [
53
+ {
54
+ provider: "cloudflare-turnstile",
55
+ match: (event) => isStatusOk(event.data.status) &&
56
+ event.data.url.includes("challenges.cloudflare.com") &&
57
+ event.data.url.includes("/turnstile/"),
58
+ },
59
+ {
60
+ provider: "aws-waf",
61
+ match: (event) => isStatusOk(event.data.status) &&
62
+ event.data.method.toUpperCase() === "POST" &&
63
+ event.data.url.includes(".awswaf.com") &&
64
+ event.data.url.includes("/telemetry"),
65
+ },
66
+ ];
67
+ export const getAntiBotProviderConfigs = () => providerConfigs;
68
+ const waitForResolution = async (ctx) => {
69
+ const start = Date.now();
70
+ if (!ctx.tab?.page)
71
+ return { resolved: false, waitedMs: 0 };
72
+ await ctx.tab.page.waitForTimeout(RESOLUTION_WAIT_MS);
73
+ const resolved = await isLikelyResolved(ctx);
74
+ return { resolved, waitedMs: Date.now() - start };
75
+ };
76
+ const detectAntiBot = (ctx, events) => {
77
+ const detectedProviders = getDetectedProviders(ctx.context);
78
+ const networkEvents = events.filter(isEventType("network-request"));
79
+ return providerConfigs.reduce((acc, config) => {
80
+ if (detectedProviders.has(config.provider))
81
+ return acc;
82
+ const match = networkEvents.find(config.match);
83
+ if (!match)
84
+ return acc;
85
+ detectedProviders.add(config.provider);
86
+ return {
87
+ hits: [
88
+ ...acc.hits,
89
+ {
90
+ provider: config.provider,
91
+ url: match.data.url,
92
+ status: match.data.status,
93
+ },
94
+ ],
95
+ };
96
+ }, { hits: [] });
97
+ };
98
+ export const antiBotDetectionPreHook = {
99
+ name: hookNameSchema.enum["anti-bot-detection-pre"],
100
+ handler: async (ctx) => {
101
+ if (lastProcessedEventIdByContext.has(ctx.context))
102
+ return Ok(undefined);
103
+ if (typeof ctx.eventStore.lastSeenEventId === "number")
104
+ lastProcessedEventIdByContext.set(ctx.context, ctx.eventStore.lastSeenEventId);
105
+ return Ok(undefined);
106
+ },
107
+ };
108
+ export const antiBotDetectionPostHook = {
109
+ name: hookNameSchema.enum["anti-bot-detection-post"],
110
+ handler: async (ctx) => {
111
+ const newEvents = getEventsAfter(ctx.eventStore, lastProcessedEventIdByContext.get(ctx.context));
112
+ if (newEvents.length === 0)
113
+ return Ok(undefined);
114
+ const detection = detectAntiBot(ctx, newEvents);
115
+ if (detection.hits.length > 0) {
116
+ detection.hits.forEach((hit) => {
117
+ trackEvent(ctx.context, {
118
+ type: "anti-bot",
119
+ data: {
120
+ provider: hit.provider,
121
+ detectionMethod: "network-request",
122
+ url: hit.url,
123
+ status: hit.status,
124
+ action: "detected",
125
+ waitMs: RESOLUTION_WAIT_MS,
126
+ },
127
+ });
128
+ });
129
+ const waitResult = await waitForResolution(ctx);
130
+ detection.hits.forEach((hit) => {
131
+ trackEvent(ctx.context, {
132
+ type: "anti-bot",
133
+ data: {
134
+ provider: hit.provider,
135
+ detectionMethod: "network-request",
136
+ url: hit.url,
137
+ status: hit.status,
138
+ action: waitResult.resolved ? "resolved" : "still-blocked",
139
+ waitMs: waitResult.waitedMs,
140
+ },
141
+ });
142
+ });
143
+ }
144
+ updateLastProcessedEventId(ctx.context, newEvents);
145
+ return Ok(undefined);
146
+ },
147
+ };
148
+ export const antiBotDetectionHooks = {
149
+ pre: antiBotDetectionPreHook,
150
+ post: antiBotDetectionPostHook,
151
+ };
152
+ export const formatAntiBotEvent = (event) => {
153
+ if (event.data.action === "still-blocked") {
154
+ const waitSeconds = event.data.waitMs
155
+ ? Math.round(event.data.waitMs / 1000)
156
+ : 0;
157
+ if (event.data.provider === "cloudflare-turnstile")
158
+ return `Anti-bot still active: Cloudflare Turnstile after ${waitSeconds || "<1"}s wait`;
159
+ if (event.data.provider === "aws-waf")
160
+ return `Anti-bot still active: AWS WAF after ${waitSeconds || "<1"}s wait`;
161
+ return "Anti-bot mechanism still active";
162
+ }
163
+ if (event.data.action === "resolved") {
164
+ const waitSeconds = event.data.waitMs
165
+ ? Math.round(event.data.waitMs / 1000)
166
+ : 0;
167
+ if (event.data.provider === "cloudflare-turnstile")
168
+ return `Anti-bot resolved: Cloudflare Turnstile after ${waitSeconds || "<1"}s wait`;
169
+ if (event.data.provider === "aws-waf")
170
+ return `Anti-bot resolved: AWS WAF after ${waitSeconds || "<1"}s wait`;
171
+ return "Anti-bot mechanism resolved";
172
+ }
173
+ if (event.data.provider === "cloudflare-turnstile")
174
+ return `Anti-bot detected: Cloudflare Turnstile (${event.data.status})`;
175
+ if (event.data.provider === "aws-waf")
176
+ return `Anti-bot detected: AWS WAF telemetry request (${event.data.status})`;
177
+ return "Anti-bot mechanism detected";
178
+ };
package/lib/hooks/core.js CHANGED
@@ -1,8 +1,8 @@
1
- import { reduce } from '@fxts/core';
2
- import { getEventStore, trackEvent } from './events.js';
3
- import { consumeEvents } from './eventConsumer.js';
4
- import { toolNameSchema } from './schema.js';
5
- export { Ok, Err } from '../utils/result.js';
1
+ import { reduce } from "@fxts/core";
2
+ import { consumeEvents } from "./eventConsumer.js";
3
+ import { getEventStore, trackEvent } from "./events.js";
4
+ import { toolNameSchema } from "./schema.js";
5
+ export { Err, Ok } from "../utils/result.js";
6
6
  export const runHook = async (hook, ctx) => {
7
7
  const result = await hook.handler(ctx);
8
8
  if (!result.ok)
@@ -28,7 +28,8 @@ export const wrapToolWithHooks = (tool, registry) => {
28
28
  const toolName = parsedName.data;
29
29
  const toolHooks = getToolHooks(registry, toolName);
30
30
  // Even if no hooks configured, we still need to consume events and track tool calls
31
- if (!toolHooks || (toolHooks.preHooks.length === 0 && toolHooks.postHooks.length === 0)) {
31
+ if (!toolHooks ||
32
+ (toolHooks.preHooks.length === 0 && toolHooks.postHooks.length === 0)) {
32
33
  return {
33
34
  ...tool,
34
35
  handle: async (context, params, response) => {
@@ -49,7 +50,7 @@ export const wrapToolWithHooks = (tool, registry) => {
49
50
  // Record tool call completion
50
51
  const executionTime = Date.now() - startTime;
51
52
  trackEvent(context, {
52
- type: 'tool-call',
53
+ type: "tool-call",
53
54
  data: {
54
55
  toolName,
55
56
  params: params,
@@ -102,7 +103,7 @@ export const wrapToolWithHooks = (tool, registry) => {
102
103
  // Record tool call completion
103
104
  const executionTime = Date.now() - startTime;
104
105
  trackEvent(context, {
105
- type: 'tool-call',
106
+ type: "tool-call",
106
107
  data: {
107
108
  toolName,
108
109
  params: params,
@@ -125,7 +126,7 @@ export const wrapToolWithHooks = (tool, registry) => {
125
126
  await reduce(async (ctx, hook) => runHook(hook, await ctx), Promise.resolve(postHookContext), toolHooks.postHooks);
126
127
  }
127
128
  catch (error) {
128
- response.addError(error instanceof Error ? error.message : 'Post-hook failed');
129
+ response.addError(error instanceof Error ? error.message : "Post-hook failed");
129
130
  }
130
131
  // Consume post-tool events
131
132
  consumeEvents(context, eventStore, response);
@@ -140,5 +141,5 @@ export const applyHooksToTools = (tools, context) => {
140
141
  const registry = getHookRegistry(context);
141
142
  if (registry.tools.size === 0)
142
143
  return tools;
143
- return tools.map(tool => wrapToolWithHooks(tool, registry));
144
+ return tools.map((tool) => wrapToolWithHooks(tool, registry));
144
145
  };