@browserless.io/mcp 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +557 -0
  2. package/README.md +280 -0
  3. package/bin/cli.js +2 -0
  4. package/build/src/@types/types.d.ts +538 -0
  5. package/build/src/config.d.ts +3 -0
  6. package/build/src/config.js +42 -0
  7. package/build/src/index.d.ts +4 -0
  8. package/build/src/index.js +153 -0
  9. package/build/src/lib/account-resolver.d.ts +17 -0
  10. package/build/src/lib/account-resolver.js +78 -0
  11. package/build/src/lib/agent-client.d.ts +58 -0
  12. package/build/src/lib/agent-client.js +530 -0
  13. package/build/src/lib/agent-format.d.ts +35 -0
  14. package/build/src/lib/agent-format.js +155 -0
  15. package/build/src/lib/amplitude.d.ts +11 -0
  16. package/build/src/lib/amplitude.js +65 -0
  17. package/build/src/lib/analytics.d.ts +18 -0
  18. package/build/src/lib/analytics.js +79 -0
  19. package/build/src/lib/api-client.d.ts +17 -0
  20. package/build/src/lib/api-client.js +357 -0
  21. package/build/src/lib/bounded-event-store.d.ts +22 -0
  22. package/build/src/lib/bounded-event-store.js +69 -0
  23. package/build/src/lib/cache.d.ts +12 -0
  24. package/build/src/lib/cache.js +49 -0
  25. package/build/src/lib/define-tool.d.ts +71 -0
  26. package/build/src/lib/define-tool.js +71 -0
  27. package/build/src/lib/error-classifier.d.ts +4 -0
  28. package/build/src/lib/error-classifier.js +125 -0
  29. package/build/src/lib/redis-oauth-proxy.d.ts +13 -0
  30. package/build/src/lib/redis-oauth-proxy.js +214 -0
  31. package/build/src/lib/retry.d.ts +2 -0
  32. package/build/src/lib/retry.js +19 -0
  33. package/build/src/lib/schema-fields.d.ts +10 -0
  34. package/build/src/lib/schema-fields.js +27 -0
  35. package/build/src/lib/supabase-token-patch.d.ts +6 -0
  36. package/build/src/lib/supabase-token-patch.js +33 -0
  37. package/build/src/lib/utils.d.ts +27 -0
  38. package/build/src/lib/utils.js +67 -0
  39. package/build/src/prompts/extract-content.d.ts +2 -0
  40. package/build/src/prompts/extract-content.js +33 -0
  41. package/build/src/prompts/scrape-url.d.ts +2 -0
  42. package/build/src/prompts/scrape-url.js +36 -0
  43. package/build/src/resources/api-docs.d.ts +3 -0
  44. package/build/src/resources/api-docs.js +54 -0
  45. package/build/src/resources/status.d.ts +3 -0
  46. package/build/src/resources/status.js +30 -0
  47. package/build/src/skills/autonomous-login.md +95 -0
  48. package/build/src/skills/captchas.md +48 -0
  49. package/build/src/skills/cookie-consent.md +50 -0
  50. package/build/src/skills/dynamic-content.md +72 -0
  51. package/build/src/skills/index.d.ts +9 -0
  52. package/build/src/skills/index.js +221 -0
  53. package/build/src/skills/modals.md +56 -0
  54. package/build/src/skills/screenshots.md +53 -0
  55. package/build/src/skills/shadow-dom.md +64 -0
  56. package/build/src/skills/snapshot-misses.md +67 -0
  57. package/build/src/skills/system-prompt.d.ts +2 -0
  58. package/build/src/skills/system-prompt.js +128 -0
  59. package/build/src/skills/tabs.md +77 -0
  60. package/build/src/tools/agent.d.ts +15 -0
  61. package/build/src/tools/agent.js +299 -0
  62. package/build/src/tools/crawl.d.ts +75 -0
  63. package/build/src/tools/crawl.js +426 -0
  64. package/build/src/tools/download.d.ts +11 -0
  65. package/build/src/tools/download.js +92 -0
  66. package/build/src/tools/export.d.ts +28 -0
  67. package/build/src/tools/export.js +129 -0
  68. package/build/src/tools/function.d.ts +24 -0
  69. package/build/src/tools/function.js +144 -0
  70. package/build/src/tools/map.d.ts +23 -0
  71. package/build/src/tools/map.js +129 -0
  72. package/build/src/tools/performance.d.ts +25 -0
  73. package/build/src/tools/performance.js +103 -0
  74. package/build/src/tools/schemas.d.ts +466 -0
  75. package/build/src/tools/schemas.js +487 -0
  76. package/build/src/tools/search.d.ts +67 -0
  77. package/build/src/tools/search.js +184 -0
  78. package/build/src/tools/smartscraper.d.ts +42 -0
  79. package/build/src/tools/smartscraper.js +136 -0
  80. package/package.json +111 -0
  81. package/patches/mcp-proxy+6.4.0.patch +31 -0
@@ -0,0 +1,530 @@
1
+ import WebSocket from 'ws';
2
+ import { z } from 'zod';
3
+ import { createSkillState } from '../skills/index.js';
4
+ import { hashToken, isMeaningfulBody } from './utils.js';
5
+ /* ------------------------------------------------------------------ */
6
+ /* Proxy schemas — used by agent.ts's AgentParamsSchema and by the */
7
+ /* session key fingerprinting below. Co-located here to avoid a */
8
+ /* circular dep with agent.ts. */
9
+ /* ------------------------------------------------------------------ */
10
+ const ProxyOptionsObjectSchema = z.object({
11
+ proxy: z
12
+ .enum(['residential'])
13
+ .optional()
14
+ .describe('Routing tier. Only "residential" is supported today.'),
15
+ proxyCountry: z
16
+ .string()
17
+ .regex(/^[A-Za-z]{2}$/, 'Must be a 2-letter ISO-2 country code')
18
+ .transform((v) => v.toLowerCase())
19
+ .optional()
20
+ .describe('ISO-2 country code (e.g. "us", "de"). Normalized to lowercase.'),
21
+ proxyState: z
22
+ .string()
23
+ .optional()
24
+ .describe('US state name (whitespace replaced with underscores, e.g. "new_york"). ' +
25
+ 'Plan-gated — non-eligible tokens get a 401.'),
26
+ proxyCity: z
27
+ .string()
28
+ .optional()
29
+ .describe('City-level targeting. Requires paid/enterprise plan — non-eligible tokens get a 401.'),
30
+ proxySticky: z
31
+ .boolean()
32
+ .optional()
33
+ .describe('Stable IP while the underlying WebSocket stays open. Reconnects ' +
34
+ '(idle drop, network blip, browser crash) allocate a new sticky id.'),
35
+ proxyLocaleMatch: z
36
+ .boolean()
37
+ .optional()
38
+ .describe('Match navigator locale to the proxy IP country.'),
39
+ proxyPreset: z
40
+ .string()
41
+ .optional()
42
+ .describe('Named proxy preset (e.g. "px_amazon01"). Supported presets are ' +
43
+ 'plan-dependent; ask Browserless support for the list available to your token.'),
44
+ externalProxyServer: z
45
+ .string()
46
+ .regex(/^https?:\/\//i, 'externalProxyServer must start with http:// or https://')
47
+ .optional()
48
+ .describe('Bring-your-own upstream, e.g. http://user:pass@host:port'),
49
+ });
50
+ const DEPENDENT_PROXY_FIELDS = [
51
+ 'proxyCountry',
52
+ 'proxyState',
53
+ 'proxyCity',
54
+ 'proxySticky',
55
+ 'proxyLocaleMatch',
56
+ 'proxyPreset',
57
+ ];
58
+ export const ProxyOptionsSchema = ProxyOptionsObjectSchema.refine((v) => {
59
+ const hasDependent = DEPENDENT_PROXY_FIELDS.some((k) => v[k] !== undefined);
60
+ return (!hasDependent || v.proxy === 'residential' || !!v.externalProxyServer);
61
+ }, {
62
+ message: 'proxyCountry/proxyState/proxyCity/proxySticky/proxyLocaleMatch/proxyPreset ' +
63
+ "require proxy: 'residential' or externalProxyServer to be set; otherwise the API silently ignores them.",
64
+ });
65
+ export const PROXY_FIELDS = Object.keys(ProxyOptionsObjectSchema.shape);
66
+ /**
67
+ * Thrown when the agent WebSocket upgrade is rejected with a non-101 HTTP
68
+ * response. Carries the status code and body so the tool layer can render a
69
+ * status-specific UserError.
70
+ */
71
+ export class UpgradeError extends Error {
72
+ statusCode;
73
+ statusMessage;
74
+ body;
75
+ constructor(statusCode, statusMessage, body) {
76
+ const detail = body.trim() || statusMessage || 'no body';
77
+ super(`Agent WebSocket upgrade rejected: HTTP ${statusCode} — ${detail}`);
78
+ this.statusCode = statusCode;
79
+ this.statusMessage = statusMessage;
80
+ this.body = body;
81
+ this.name = 'UpgradeError';
82
+ }
83
+ }
84
+ /**
85
+ * UpgradeError specialization for the profile-not-found case (404 on the WS
86
+ * upgrade when `?profile=` was supplied). Mirrors api-client.ts so all tools
87
+ * surface profile errors through the same UserError pattern.
88
+ */
89
+ export class ProfileNotFoundError extends UpgradeError {
90
+ profile;
91
+ constructor(profile, statusMessage, body) {
92
+ super(404, statusMessage, body);
93
+ this.profile = profile;
94
+ this.name = 'ProfileNotFoundError';
95
+ const trimmed = body.trim();
96
+ this.message = isMeaningfulBody(trimmed)
97
+ ? trimmed
98
+ : `Profile "${profile}" was not found for the configured token.`;
99
+ }
100
+ }
101
+ // Upgrade statuses where a one-shot retry cannot help: bad request (400),
102
+ // bad auth (401), forbidden by plan/policy (403), or missing resource (404).
103
+ // Retrying just wastes time and emits a misleading "second attempt failed".
104
+ const NON_RETRYABLE_UPGRADE_STATUSES = new Set([400, 401, 403, 404]);
105
+ export const isRetryableUpgradeError = (err) => {
106
+ if (err instanceof UpgradeError) {
107
+ return !NON_RETRYABLE_UPGRADE_STATUSES.has(err.statusCode);
108
+ }
109
+ return true;
110
+ };
111
+ const sessions = new Map();
112
+ // In-flight session creations keyed by session key. Concurrent
113
+ // getOrCreateSession callers await the same promise instead of each
114
+ // opening their own WebSocket.
115
+ const pending = new Map();
116
+ const DEFAULT_TIMEOUT = 60_000;
117
+ const IDLE_TTL_MS = 15 * 60 * 1000;
118
+ const MAX_SESSIONS = 500;
119
+ const closeAndDelete = (key, reason) => {
120
+ const session = sessions.get(key);
121
+ if (!session)
122
+ return;
123
+ try {
124
+ session.ws.close();
125
+ }
126
+ catch {
127
+ /* ignore */
128
+ }
129
+ sessions.delete(key);
130
+ console.error(`[agent-client] evicted session key=${key} reason=${reason}`);
131
+ };
132
+ // Sweep idle sessions and enforce a hard cap. Called on every
133
+ // getOrCreateSession; cheap because the map is bounded.
134
+ const sweepSessions = () => {
135
+ const now = Date.now();
136
+ for (const [key, session] of sessions) {
137
+ if (now - session.lastUsedAt > IDLE_TTL_MS) {
138
+ closeAndDelete(key, 'idle');
139
+ }
140
+ }
141
+ if (sessions.size <= MAX_SESSIONS)
142
+ return;
143
+ const overage = sessions.size - MAX_SESSIONS;
144
+ const oldest = [...sessions.entries()]
145
+ .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
146
+ .slice(0, overage);
147
+ for (const [key] of oldest) {
148
+ closeAndDelete(key, 'cap');
149
+ }
150
+ };
151
+ // Separator between the host segment (mcpSessionId or stdio:<hash>) and
152
+ // the proxy fingerprint in a session key. NUL is illegal in any
153
+ // user-supplied field, so the two segments cannot ambiguously concatenate.
154
+ const KEY_SEP = '\u0000';
155
+ // Hash externalProxyServer rather than serialize it raw: the session key is
156
+ // logged on eviction and the URL may carry user:pass credentials. Hashing
157
+ // keeps per-upstream distinctness without putting secrets in stderr.
158
+ const fingerprintValue = (field, value) => field === 'externalProxyServer'
159
+ ? `external#${hashToken(String(value))}`
160
+ : String(value);
161
+ /**
162
+ * Build a stable, credential-free key segment for a proxy config — identical
163
+ * configs fingerprint the same regardless of key order. `externalProxyServer`
164
+ * is SHA-256 hashed so credentials never land in the eviction log.
165
+ */
166
+ export const proxyFingerprint = (proxy) => {
167
+ if (!proxy)
168
+ return '';
169
+ const parts = PROXY_FIELDS.map((k) => proxy[k] === undefined ? null : `${k}=${fingerprintValue(k, proxy[k])}`).filter(Boolean);
170
+ return parts.length ? KEY_SEP + parts.join('&') : '';
171
+ };
172
+ // Hash the profile rather than serialize it raw: like externalProxyServer,
173
+ // the eviction-logged session key may otherwise leak a user-identifying
174
+ // profile name. Hashing keeps per-profile distinctness without that leak.
175
+ const getSessionKey = (mcpSessionId, token, proxy, profile) => (mcpSessionId ?? `stdio:${hashToken(token)}`) +
176
+ proxyFingerprint(proxy) +
177
+ (profile ? KEY_SEP + 'profile#' + hashToken(profile) : '');
178
+ /**
179
+ * Build the WebSocket URL for `/chromium/agent`: normalize trailing slashes,
180
+ * swap http(s)→ws(s), and append `token` plus proxy params. Boolean proxy
181
+ * flags follow the API's presence-only contract (set only when truthy).
182
+ */
183
+ export const buildAgentWsUrl = (apiUrl, token, proxy, profile) => {
184
+ const base = apiUrl.replace(/^http/i, 'ws').replace(/\/+$/, '');
185
+ const url = new URL(base + '/chromium/agent');
186
+ url.searchParams.set('token', token);
187
+ if (proxy?.proxy)
188
+ url.searchParams.set('proxy', proxy.proxy);
189
+ if (proxy?.proxyCountry)
190
+ url.searchParams.set('proxyCountry', proxy.proxyCountry);
191
+ if (proxy?.proxyState)
192
+ url.searchParams.set('proxyState', proxy.proxyState);
193
+ if (proxy?.proxyCity)
194
+ url.searchParams.set('proxyCity', proxy.proxyCity);
195
+ if (proxy?.proxySticky)
196
+ url.searchParams.set('proxySticky', 'true');
197
+ if (proxy?.proxyLocaleMatch)
198
+ url.searchParams.set('proxyLocaleMatch', 'true');
199
+ if (proxy?.proxyPreset)
200
+ url.searchParams.set('proxyPreset', proxy.proxyPreset);
201
+ if (proxy?.externalProxyServer)
202
+ url.searchParams.set('externalProxyServer', proxy.externalProxyServer);
203
+ if (profile)
204
+ url.searchParams.set('profile', profile);
205
+ return url.toString();
206
+ };
207
+ // HTTP-status failures arrive on `unexpected-response` (typed as
208
+ // UpgradeError), so a 1006 close here only means a transport failure or a
209
+ // server crash before any HTTP response.
210
+ const describeConnectCloseCode = (code, reason) => {
211
+ if (reason)
212
+ return `code=${code}, reason="${reason}"`;
213
+ if (code === 1006)
214
+ return 'code=1006 (abnormal close before HTTP response — likely a network failure or server crash)';
215
+ if (code === 1008)
216
+ return 'code=1008 (policy violation)';
217
+ if (code === 1011)
218
+ return 'code=1011 (server error during upgrade)';
219
+ return `code=${code}`;
220
+ };
221
+ // Decode the Node `Error.code` field that `ws` propagates from the underlying
222
+ // socket on transport failure (DNS, refused connection, TLS validation).
223
+ const describeConnectErrorCode = (err) => {
224
+ if (!err || typeof err !== 'object')
225
+ return;
226
+ const code = err.code;
227
+ if (typeof code !== 'string')
228
+ return;
229
+ switch (code) {
230
+ case 'ENOTFOUND':
231
+ return 'DNS resolution failed (ENOTFOUND) — verify the apiUrl host';
232
+ case 'ECONNREFUSED':
233
+ return 'Connection refused (ECONNREFUSED) — server may be down or the port is blocked';
234
+ case 'ETIMEDOUT':
235
+ return 'Connection timed out (ETIMEDOUT) — network or firewall issue';
236
+ case 'ECONNRESET':
237
+ return 'Connection reset by peer (ECONNRESET)';
238
+ case 'CERT_HAS_EXPIRED':
239
+ return 'TLS certificate expired (CERT_HAS_EXPIRED)';
240
+ case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
241
+ case 'DEPTH_ZERO_SELF_SIGNED_CERT':
242
+ case 'SELF_SIGNED_CERT_IN_CHAIN':
243
+ return `TLS verification failed (${code})`;
244
+ default:
245
+ return `network error (${code})`;
246
+ }
247
+ };
248
+ // Bound the body buffer so a misbehaving or malicious server can't OOM the
249
+ // MCP process by streaming gigabytes into an error response. 64 KiB is far
250
+ // more than any legitimate plain-text error or sanitized HTML page needs.
251
+ const MAX_UPGRADE_BODY_BYTES = 64 * 1024;
252
+ const TRUNCATION_MARKER = `\n…[response truncated at ${MAX_UPGRADE_BODY_BYTES} bytes]`;
253
+ const READ_TIMEOUT_MARKER = '\n…[response body read timed out]';
254
+ // Bound the body-read phase so a server that sends non-101 headers and then
255
+ // stalls the body stream can't hang connect() indefinitely. The connect-level
256
+ // 30s timeout has already been cleared by the time we get here.
257
+ const UPGRADE_BODY_READ_TIMEOUT_MS = 10_000;
258
+ const readUpgradeError = (res, profile) => new Promise((resolve) => {
259
+ const chunks = [];
260
+ let total = 0;
261
+ let truncated = false;
262
+ let timedOut = false;
263
+ let settled = false;
264
+ const readTimeout = setTimeout(() => {
265
+ if (settled)
266
+ return;
267
+ timedOut = true;
268
+ // res.destroy() fires 'close' → finish() → resolve with whatever
269
+ // bytes arrived before the deadline.
270
+ res.destroy();
271
+ }, UPGRADE_BODY_READ_TIMEOUT_MS);
272
+ const onData = (chunk) => {
273
+ if (settled)
274
+ return;
275
+ total += chunk.length;
276
+ if (total > MAX_UPGRADE_BODY_BYTES) {
277
+ const overflow = total - MAX_UPGRADE_BODY_BYTES;
278
+ chunks.push(chunk.subarray(0, chunk.length - overflow));
279
+ truncated = true;
280
+ // Resolve eagerly with the truncated payload — `res.destroy()` may
281
+ // suppress the 'end' event, so don't wait for it.
282
+ res.destroy();
283
+ finish();
284
+ return;
285
+ }
286
+ chunks.push(chunk);
287
+ };
288
+ const onError = (err) => {
289
+ // Stream errors mid-body (TLS abort, decompression failure) would
290
+ // otherwise vanish into an UpgradeError with a partial body. Log so
291
+ // operators see the root cause; still settle with whatever was buffered.
292
+ console.error(`[agent-client] upgrade-response stream error: ${err.message}`);
293
+ finish();
294
+ };
295
+ const finish = () => {
296
+ if (settled)
297
+ return;
298
+ settled = true;
299
+ clearTimeout(readTimeout);
300
+ res.off('data', onData);
301
+ res.off('end', finish);
302
+ res.off('error', onError);
303
+ res.off('close', finish);
304
+ // Some upstream stacks prepend an extra CRLF between the header block
305
+ // and the body — trim so renderers don't open with a blank line.
306
+ let body = Buffer.concat(chunks).toString('utf8').trim();
307
+ if (truncated)
308
+ body += TRUNCATION_MARKER;
309
+ else if (timedOut)
310
+ body += READ_TIMEOUT_MARKER;
311
+ const status = res.statusCode ?? 0;
312
+ const statusMessage = res.statusMessage ?? '';
313
+ if (status === 404 && profile) {
314
+ resolve(new ProfileNotFoundError(profile, statusMessage, body));
315
+ return;
316
+ }
317
+ resolve(new UpgradeError(status, statusMessage, body));
318
+ };
319
+ res.on('data', onData);
320
+ res.on('end', finish);
321
+ res.on('error', onError);
322
+ // `res.destroy()` can fire 'close' without 'end' or 'error'; settle here too.
323
+ res.on('close', finish);
324
+ });
325
+ const connect = (apiUrl, token, proxy, profile) => new Promise((resolve, reject) => {
326
+ const wsUrl = buildAgentWsUrl(apiUrl, token, proxy, profile);
327
+ const ws = new WebSocket(wsUrl);
328
+ let settled = false;
329
+ const settle = (err, value) => {
330
+ if (settled)
331
+ return;
332
+ settled = true;
333
+ clearTimeout(timeout);
334
+ if (err) {
335
+ try {
336
+ ws.terminate();
337
+ }
338
+ catch {
339
+ /* ignore */
340
+ }
341
+ reject(err);
342
+ }
343
+ else {
344
+ resolve(value);
345
+ }
346
+ };
347
+ const timeout = setTimeout(() => {
348
+ settle(new Error('Agent WebSocket connection timed out after 30s'));
349
+ }, 30_000);
350
+ ws.on('open', () => settle(null, ws));
351
+ // Claim `settled` synchronously so the close/error events that race the
352
+ // async body read can't overwrite the typed UpgradeError we're building.
353
+ ws.on('unexpected-response', (_req, res) => {
354
+ if (settled)
355
+ return;
356
+ settled = true;
357
+ clearTimeout(timeout);
358
+ readUpgradeError(res, profile).then((err) => {
359
+ try {
360
+ ws.terminate();
361
+ }
362
+ catch {
363
+ /* ignore */
364
+ }
365
+ reject(err);
366
+ });
367
+ });
368
+ ws.on('error', (err) => {
369
+ const decoded = describeConnectErrorCode(err);
370
+ const detail = decoded ?? err.message ?? '';
371
+ settle(new Error(`Agent WebSocket connection failed${detail ? `: ${detail}` : ''}`));
372
+ });
373
+ // Close before settle means the transport dropped without ever producing
374
+ // an HTTP response; auth/proxy/profile failures are handled by the
375
+ // `unexpected-response` branch above.
376
+ ws.on('close', (code, reason) => {
377
+ settle(new Error(`Agent WebSocket closed during connect: ${describeConnectCloseCode(code, reason?.toString('utf8') || '')}`));
378
+ });
379
+ });
380
+ const sendMessage = (ws, msg, timeoutMs = DEFAULT_TIMEOUT) => new Promise((resolve, reject) => {
381
+ const cleanup = () => {
382
+ clearTimeout(timeout);
383
+ ws.off('message', handler);
384
+ ws.off('close', closeHandler);
385
+ };
386
+ const timeout = setTimeout(() => {
387
+ cleanup();
388
+ reject(new Error(`Agent command "${msg.method}" timed out after ${timeoutMs}ms`));
389
+ }, timeoutMs);
390
+ const closeHandler = () => {
391
+ cleanup();
392
+ reject(new Error(`WebSocket closed while waiting for "${msg.method}" response`));
393
+ };
394
+ const handler = (data) => {
395
+ let response;
396
+ const raw = data.toString('utf8');
397
+ try {
398
+ response = JSON.parse(raw);
399
+ }
400
+ catch {
401
+ console.error('[agent-client] dropping unparseable WS frame:', raw.slice(0, 200));
402
+ return;
403
+ }
404
+ // Only accept the response whose id matches the request we sent.
405
+ if (response.id !== msg.id)
406
+ return;
407
+ cleanup();
408
+ resolve(response);
409
+ };
410
+ ws.on('message', handler);
411
+ ws.on('close', closeHandler);
412
+ ws.send(JSON.stringify(msg));
413
+ });
414
+ export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, profile) => {
415
+ sweepSessions();
416
+ const key = getSessionKey(mcpSessionId, token, proxy, profile);
417
+ const existing = sessions.get(key);
418
+ if (existing && existing.ws.readyState === WebSocket.OPEN) {
419
+ existing.lastUsedAt = Date.now();
420
+ return existing;
421
+ }
422
+ // Another caller is already creating a session for this key — share it.
423
+ const inFlight = pending.get(key);
424
+ if (inFlight)
425
+ return inFlight;
426
+ // Clean up stale session if any
427
+ if (existing) {
428
+ try {
429
+ existing.ws.close();
430
+ }
431
+ catch {
432
+ /* ignore */
433
+ }
434
+ sessions.delete(key);
435
+ }
436
+ const creation = (async () => {
437
+ const ws = await connect(apiUrl, token, proxy, profile);
438
+ const session = {
439
+ ws,
440
+ msgId: 0,
441
+ apiUrl,
442
+ token,
443
+ proxy,
444
+ profile,
445
+ skillState: createSkillState(),
446
+ lastUsedAt: Date.now(),
447
+ };
448
+ // Auto-cleanup on close
449
+ ws.on('close', (code, reason) => {
450
+ if (code !== 1000) {
451
+ console.error(`[agent-client] WebSocket closed unexpectedly: code=${code} reason=${reason?.toString('utf8') || 'none'}`);
452
+ }
453
+ const current = sessions.get(key);
454
+ if (current?.ws === ws) {
455
+ sessions.delete(key);
456
+ }
457
+ });
458
+ sessions.set(key, session);
459
+ return session;
460
+ })();
461
+ pending.set(key, creation);
462
+ try {
463
+ return await creation;
464
+ }
465
+ finally {
466
+ // Clear the placeholder whether connect succeeded or threw, so a failed
467
+ // attempt doesn't block future retries.
468
+ if (pending.get(key) === creation) {
469
+ pending.delete(key);
470
+ }
471
+ }
472
+ };
473
+ export const send = async (session, method, params = {}, timeoutMs) => {
474
+ if (session.ws.readyState !== WebSocket.OPEN) {
475
+ if (!session.reconnecting) {
476
+ session.reconnecting = connect(session.apiUrl, session.token, session.proxy, session.profile).finally(() => {
477
+ session.reconnecting = undefined;
478
+ });
479
+ }
480
+ const ws = await session.reconnecting;
481
+ if (session.ws !== ws) {
482
+ session.ws = ws;
483
+ session.msgId = 0;
484
+ const key = [...sessions.entries()].find(([, s]) => s === session)?.[0];
485
+ if (key) {
486
+ ws.on('close', () => {
487
+ const current = sessions.get(key);
488
+ if (current?.ws === ws) {
489
+ sessions.delete(key);
490
+ }
491
+ });
492
+ }
493
+ }
494
+ }
495
+ session.msgId++;
496
+ session.lastUsedAt = Date.now();
497
+ return sendMessage(session.ws, { id: session.msgId, method, params }, timeoutMs);
498
+ };
499
+ export const closeSession = (mcpSessionId, token, proxy, profile) => {
500
+ const key = getSessionKey(mcpSessionId, token, proxy, profile);
501
+ const session = sessions.get(key);
502
+ if (session) {
503
+ try {
504
+ session.ws.close();
505
+ }
506
+ catch {
507
+ /* ignore */
508
+ }
509
+ sessions.delete(key);
510
+ }
511
+ };
512
+ /**
513
+ * Force-destroy a session after a browser crash or unrecoverable state, so
514
+ * the next call reconnects fresh. Unlike `closeSession`, it also drops any
515
+ * in-flight connect for the key so a concurrent caller can't reuse a dead WS.
516
+ */
517
+ export const destroySession = (mcpSessionId, token, proxy, profile) => {
518
+ const key = getSessionKey(mcpSessionId, token, proxy, profile);
519
+ const session = sessions.get(key);
520
+ if (session) {
521
+ try {
522
+ session.ws.close();
523
+ }
524
+ catch {
525
+ /* ignore */
526
+ }
527
+ sessions.delete(key);
528
+ }
529
+ pending.delete(key);
530
+ };
@@ -0,0 +1,35 @@
1
+ import type { SnapshotResult } from '../@types/types.js';
2
+ export type { SnapshotResult, SnapshotElement, TabInfo, } from '../@types/types.js';
3
+ /**
4
+ * Build the cross-origin notice shown above a snapshot when the page changed
5
+ * origin (protocol + host + port) since the last snapshot. Returns '' when
6
+ * origins match or either URL is missing or unparseable.
7
+ */
8
+ export declare const buildCrossOriginNotice: (previousUrl: string | undefined, newUrl: string | undefined) => string;
9
+ /**
10
+ * Format the body of a classified error response (without skill blocks).
11
+ * Used by both the resp.error branch and the WS-send-catch branch so the
12
+ * agent always sees the same `Category:` / `[CODE]` / `Recovery:` shape.
13
+ */
14
+ export declare const formatErrorMessage: (opts: {
15
+ category: string;
16
+ code?: string;
17
+ prefix: string;
18
+ message: string;
19
+ suggestion?: string;
20
+ recovery: string;
21
+ snapshotText?: string;
22
+ }) => string;
23
+ /**
24
+ * Sanitize a server-returned error body for a UserError. Nginx default error
25
+ * pages (502/503/504) arrive as full HTML that bloats the message and
26
+ * confuses the LLM — strip tags and cap the length to keep it readable.
27
+ */
28
+ export declare const sanitizeUpgradeBody: (body: string) => string;
29
+ /**
30
+ * Translate a connect-time error into UserError-ready text. Typed
31
+ * UpgradeErrors carry the HTTP response for status-aware guidance; anything
32
+ * else (network, timeout, post-upgrade) falls through to the plain message.
33
+ */
34
+ export declare const formatConnectError: (err: unknown) => string;
35
+ export declare const formatSnapshot: (snapshot: SnapshotResult) => string;