@glittering0717/ai-browser 0.0.0-stage → 1.2.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.
@@ -0,0 +1,289 @@
1
+ // main/mcp_tools.js — the curated MCP tool list.
2
+ // Exported separately so tests can assert on it without triggering the
3
+ // mcp_server's main() side effects (spawning Electron / opening stdio).
4
+ // Tool count and descriptions are kept minimal to reduce per-message token
5
+ // cost in MCP clients that inject the full tool list on every turn.
6
+ //
7
+ // annotations 遵循 MCP Tool annotation 约定(title / readOnlyHint /
8
+ // destructiveHint / openWorldHint),Smithery / Cursor / Claude 等目录与客户端
9
+ // 会读取并渲染成徽标。只放布尔结论、不放长句,把额外 token 压到最低。
10
+ export const MCP_TOOLS = [
11
+ {
12
+ name: 'browse_navigate',
13
+ title: 'Navigate tab',
14
+ description: 'Navigate the active/focused tab to a URL.',
15
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {
19
+ url: { type: 'string', description: 'URL to navigate to.' },
20
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
21
+ },
22
+ required: ['url']
23
+ }
24
+ },
25
+ {
26
+ name: 'browse_get_tree',
27
+ title: 'Read semantic tree',
28
+ description: 'Return the page semantic UI tree (roles, labels, actions, bounds, urls).',
29
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
30
+ inputSchema: {
31
+ type: 'object',
32
+ properties: {
33
+ focused_only: { type: 'boolean', description: 'Return only the focused element subtree.', default: false },
34
+ ax: { type: 'boolean', description: 'Use the AX read layer (links carry url).', default: false },
35
+ subset: { type: 'string', enum: ['interactive', 'full'], description: 'Prune layout-only branches (token saver).' },
36
+ mode: { type: 'string', enum: ['diff', 'full'], description: 'Return only new/hidden interactive nodes since last read.' },
37
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
38
+ }
39
+ }
40
+ },
41
+ {
42
+ name: 'browse_act',
43
+ title: 'Act on element',
44
+ description: 'Act on an element by data-ai-id: click, type (append at caret), setContent (replace all), clear, focus, hover, scroll_to, upload, get_value (read-only), drag, press, wheel.',
45
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ action: { type: 'string', enum: ['click', 'type', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel'], description: 'Action.' },
50
+ target: { type: 'string', description: 'Element data-ai-id from the semantic tree. Omit for press (keys go to the current focus); for drag you may also omit it and pass from_x/from_y to start from a bare point.' },
51
+ text: { type: 'string', description: 'Text to type (type only).' },
52
+ value: { type: 'string', description: 'Value for select.' },
53
+ offset: { type: 'integer', description: 'get_value: start offset in Unicode code points (default 0).' },
54
+ limit: { type: 'integer', description: 'get_value: max code points to return (default 20000, hard cap 1000000).' },
55
+ url: { type: 'string', description: 'For click: click the link whose href matches this URL instead of guessing the target label.' },
56
+ keep_tab: { type: 'boolean', description: 'For click: keep the current tab active instead of auto-following a newly opened tab.' },
57
+ file: { type: 'string', description: 'Absolute path to upload (upload only, target must be a file input).' },
58
+ timeout_ms: { type: 'integer', description: 'Override the action deadline in ms (default 15000; type/setContent/drag/press scale with text length / duration / repeat; 1s floor, 300s cap). A timeout returns success=false, timed_out=true and warns the action may have partially applied.' },
59
+ to_target: { type: 'string', description: 'drag: data-ai-id to drop onto (e.g. drag from one node\'s output to another node to connect them).' },
60
+ dx: { type: 'number', description: 'drag: horizontal distance to move (use with dy instead of to_target).' },
61
+ dy: { type: 'number', description: 'drag: vertical distance to move.' },
62
+ to_x: { type: 'number', description: 'drag: absolute viewport x to end at (overrides dx).' },
63
+ to_y: { type: 'number', description: 'drag: absolute viewport y to end at.' },
64
+ from_x: { type: 'number', description: 'drag: explicit viewport x to start the drag at (no target needed — e.g. panning empty canvas).' },
65
+ from_y: { type: 'number', description: 'drag: explicit viewport y to start the drag at.' },
66
+ from_anchor: { type: 'string', enum: ['center', 'left', 'right', 'top', 'bottom'], description: 'drag: which point of target to grab; a side prefers that node\'s connection handle (default center).' },
67
+ to_anchor: { type: 'string', enum: ['center', 'left', 'right', 'top', 'bottom'], description: 'drag: which point of to_target to drop on.' },
68
+ hold: { type: 'array', items: { type: 'string' }, description: 'drag: modifier keys held during the drag (e.g. ["Shift"]).' },
69
+ button: { type: 'string', enum: ['left', 'middle', 'right'], description: 'drag: mouse button (default left). Canvas apps often pan on middle/right drag.' },
70
+ key: { type: 'string', description: 'press: a key name such as Delete, Escape, ArrowRight, Enter, or a combo like "Meta+a".' },
71
+ delta_x: { type: 'number', description: 'wheel: horizontal scroll amount (positive scrolls right).' },
72
+ delta_y: { type: 'number', description: 'wheel: vertical scroll amount. Negative scrolls up / zooms out; add hold:["Control"] for canvas zoom.' },
73
+ keys: { type: 'array', items: { type: 'string' }, description: 'press: sequence of keys to press in order.' },
74
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
75
+ },
76
+ required: ['action']
77
+ }
78
+ },
79
+ {
80
+ name: 'browse_evaluate',
81
+ title: 'Run page JavaScript',
82
+ description: 'Run a JS expression in the page context and return its value.',
83
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ js: { type: 'string', description: 'Page-context JS expression (not statements). Max 5000 chars.' },
88
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
89
+ },
90
+ required: ['js']
91
+ }
92
+ },
93
+ {
94
+ name: 'browse_scroll',
95
+ title: 'Scroll page',
96
+ description: 'Scroll the page or an element into view.',
97
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ direction: { type: 'string', enum: ['up', 'down'], default: 'down' },
102
+ amount: { type: 'integer', default: 500, description: 'Pixels (ignored if target set).' },
103
+ target: { type: 'string', description: 'data-ai-id to scroll into view.' },
104
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
105
+ }
106
+ }
107
+ },
108
+ {
109
+ name: 'browse_wait',
110
+ title: 'Wait for condition',
111
+ description: 'Poll the page until a condition holds or timeout. Conditions: button_enabled, modal_appeared, text_contains, url_contains.',
112
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ condition: { type: 'string', enum: ['button_enabled', 'modal_appeared', 'text_contains', 'url_contains'] },
117
+ target: { type: 'string', description: 'Button label (button_enabled).' },
118
+ text: { type: 'string', description: 'Text/URL fragment (text_contains / url_contains).' },
119
+ timeout_ms: { type: 'integer', default: 10000 },
120
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
121
+ },
122
+ required: ['condition']
123
+ }
124
+ },
125
+ {
126
+ name: 'browse_list_tabs',
127
+ title: 'List tabs',
128
+ description: 'List open tabs (id, url, title, active).',
129
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
130
+ inputSchema: { type: 'object', properties: {} }
131
+ },
132
+ {
133
+ name: 'browse_new_tab',
134
+ title: 'New tab',
135
+ description: 'Open a new tab, optionally at a URL.',
136
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ url: { type: 'string', description: 'Optional URL.' }
141
+ }
142
+ }
143
+ },
144
+ {
145
+ name: 'browse_close_tab',
146
+ title: 'Close tab',
147
+ description: 'Close a tab by id.',
148
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ tab: { type: 'integer', description: 'Tab id.' }
153
+ },
154
+ required: ['tab']
155
+ }
156
+ },
157
+ {
158
+ name: 'browse_set_active_tab',
159
+ title: 'Switch active tab',
160
+ description: 'Switch the active tab.',
161
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
162
+ inputSchema: {
163
+ type: 'object',
164
+ properties: {
165
+ tab: { type: 'integer', description: 'Tab id.' }
166
+ },
167
+ required: ['tab']
168
+ }
169
+ },
170
+ {
171
+ name: 'browse_network',
172
+ title: 'Inspect network',
173
+ description: 'Query captured HTTP requests (Chrome Network panel): list/get/clear/configure. Captures method, headers and POST bodies; sensitive headers redacted by default.',
174
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
175
+ inputSchema: {
176
+ type: 'object',
177
+ properties: {
178
+ operation: { type: 'string', enum: ['list', 'get', 'clear', 'configure'], description: 'Operation.' },
179
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' },
180
+ network_id: { type: 'string', description: 'get: id from list.' },
181
+ method: { type: 'array', items: { type: 'string' }, description: 'list: filter by HTTP method.' },
182
+ url_contains: { type: 'string', description: 'list: URL substring filter.' },
183
+ status_min: { type: 'integer', description: 'list: minimum status.' },
184
+ status_max: { type: 'integer', description: 'list: maximum status.' },
185
+ resource_type: { type: 'array', items: { type: 'string' }, description: 'list: XHR/Fetch/Document/...' },
186
+ state: { type: 'array', items: { type: 'string' }, description: 'list: pending/finished/failed.' },
187
+ started_after: { type: 'string', description: 'list: ISO timestamp.' },
188
+ started_before: { type: 'string', description: 'list: ISO timestamp.' },
189
+ limit: { type: 'integer', description: 'list: page size (default 50, max 200).' },
190
+ before_seq: { type: 'integer', description: 'list: cursor from pagination.next_before_seq.' },
191
+ include_request_headers: { type: 'boolean' },
192
+ include_request_body: { type: 'boolean' },
193
+ include_response_headers: { type: 'boolean' },
194
+ include_response_body: { type: 'boolean' },
195
+ include_sensitive_headers: { type: 'boolean', description: 'Requires AI_BROWSER_NETWORK_SENSITIVE=1.' },
196
+ request_body_offset: { type: 'integer' },
197
+ response_body_offset: { type: 'integer' },
198
+ body_limit: { type: 'integer', description: 'Body page size (default 65536, max 262144).' },
199
+ enabled: { type: 'boolean', description: 'configure: turn capture on/off.' },
200
+ capture_bodies: { type: 'string', enum: ['none', 'api', 'all'], description: 'configure.' }
201
+ },
202
+ required: ['operation']
203
+ }
204
+ },
205
+ {
206
+ name: 'browse_canvas',
207
+ title: 'Read canvas',
208
+ description: 'Read <canvas> through captured draw calls (not pixels): list canvases, read texts/regions, configure, or capture a PNG fallback. 2D canvas text comes from fillText, so no OCR needed; WebGL is opaque.',
209
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
210
+ inputSchema: {
211
+ type: 'object',
212
+ properties: {
213
+ operation: { type: 'string', enum: ['list', 'read', 'configure', 'capture'], description: 'Operation.' },
214
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' },
215
+ canvas_id: { type: 'string', description: 'read/capture: id from list.' },
216
+ view: { type: 'string', enum: ['summary', 'calls'], description: 'read: summary (default) or raw draw calls.' },
217
+ since_seq: { type: 'integer', description: 'read: cursor from pagination.next_seq.' },
218
+ limit: { type: 'integer', description: 'read: page size (default 200, max 1000).' },
219
+ mode: { type: 'string', enum: ['off', 'semantic', 'trace'], description: 'configure: recording mode.' },
220
+ clear: { type: 'boolean', description: 'configure: drop retained calls.' }
221
+ },
222
+ required: ['operation']
223
+ }
224
+ },
225
+ {
226
+ name: 'browse_diff',
227
+ title: 'Snapshot and diff',
228
+ description: 'Prove an action changed the page: snapshot a baseline, then diff to get added/removed/changed paths with before/after values. Success is not evidence.',
229
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
230
+ inputSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ operation: { type: 'string', enum: ['snapshot', 'diff'], description: 'Operation.' },
234
+ js: { type: 'string', description: 'JSON-serializable expression to capture. Omit for a site-agnostic DOM summary (role/label/value/disabled/size per element).' },
235
+ label: { type: 'string', description: 'snapshot: a note for your own bookkeeping.' },
236
+ snapshot: { type: 'string', description: 'diff: the id returned by snapshot.' },
237
+ rearm: { type: 'boolean', description: 'diff: make the current state the new baseline, so you can loop "assert no change" over many steps.' },
238
+ forget: { type: 'boolean', description: 'diff: drop the stored baseline when done.' },
239
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
240
+ },
241
+ required: ['operation']
242
+ }
243
+ },
244
+ {
245
+ name: 'browse_capabilities',
246
+ title: 'Probe capabilities',
247
+ description: 'Probe what a page affords and which route to take: DOM vs canvas, same-origin iframes, app objects on window, JSON endpoints, plus a recommended plan with evidence.',
248
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
249
+ inputSchema: {
250
+ type: 'object',
251
+ properties: {
252
+ include_network: { type: 'boolean', description: 'Include observed JSON-ish endpoints (default true).' },
253
+ network_limit: { type: 'integer', description: 'How many captured requests to scan.' },
254
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
255
+ }
256
+ }
257
+ },
258
+ {
259
+ name: 'browse_repaint',
260
+ title: 'Force repaint',
261
+ description: "Force the compositor to redraw a tab's BrowserView when the window shows a stale page; returns repaint_count so the repaint is provable.",
262
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
263
+ inputSchema: {
264
+ type: 'object',
265
+ properties: {
266
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
267
+ }
268
+ }
269
+ },
270
+ {
271
+ name: 'browse_downloads',
272
+ title: 'List downloads',
273
+ description: 'List downloads for a tab with their on-disk paths. Use after clicking an export/download button — downloads are asynchronous, so poll this rather than expecting the path in the click response.',
274
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
275
+ inputSchema: {
276
+ type: 'object',
277
+ properties: {
278
+ tab: { type: 'integer', description: 'Tab ID (defaults to the active tab).' },
279
+ },
280
+ },
281
+ },
282
+ {
283
+ name: 'browse_quit',
284
+ title: 'Quit browser',
285
+ description: 'Gracefully shut down the AI Browser (releases resources).',
286
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
287
+ inputSchema: { type: 'object', properties: {} }
288
+ }
289
+ ];
@@ -0,0 +1,178 @@
1
+ // main/mcp_ws.js — Persistent WebSocket JSON-RPC client used by the MCP server.
2
+ // One WebSocket is opened lazily and reused for every RPC; requests carry
3
+ // incrementing ids routed through an id -> resolver map, so concurrent calls
4
+ // on the same connection resolve to the correct response. Server-side event
5
+ // notifications (broadcast with no jsonrpc id) are dropped: the MCP surface is
6
+ // request/response only (server→client notifications are dropped here, which is
7
+ // why live-push tools are not exposed over MCP — see below)
8
+ // (enables CDP monitors + network-body cache; does not stream live events).
9
+ //
10
+ // #5: robust auto-reconnect — when the connection drops (Electron relaunch /
11
+ // NetworkService wedge), `getWs` retries with exponential backoff until the
12
+ // server is reachable again (reconnectTimeout budget) instead of failing the
13
+ // first call after a drop. Calls wait on the connection first, so the RPC
14
+ // per-call `timeout` only starts after the socket is OPEN (a relaunch window is
15
+ // absorbed by backoff, not counted as a hung call).
16
+ //
17
+ // MW-4 race: a stale socket's deferred close/error events must never clobber the
18
+ // refs of a freshly re-opened connection. Two guards: (a) a socket isn't
19
+ // adopted as `_ws` until its 'open' fires, and (b) close/error handlers mutate
20
+ // state only via detachIfCurrent(ws), which no-ops if `_ws` already points at a
21
+ // different (newer) socket.
22
+ import WebSocket from 'ws';
23
+
24
+ export function createWsClient({
25
+ url,
26
+ timeout = 15000,
27
+ reconnectTimeout = 120000, // total budget to keep retrying after a drop
28
+ retryBase = 500, // initial backoff; doubles per attempt, capped
29
+ maxRetryDelay = 10000,
30
+ ensureRunning = null, // Lazy launch hook: async; called on connection
31
+ // failure so the server can spawn Electron on demand.
32
+ } = {}) {
33
+ let _ws = null; // current OPEN socket (or null)
34
+ let _connecting = null; // in-flight getWs() promise (dedupes concurrent callers)
35
+ let _rpcId = 0;
36
+ const _pending = new Map();
37
+
38
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
39
+
40
+ function flushPending(err) {
41
+ for (const [, cb] of _pending) cb({ error: { message: err.message } });
42
+ _pending.clear();
43
+ }
44
+
45
+ // Clear out our ref to `ws` ONLY if it is still the current socket. Prevents a
46
+ // stale handler from nulling a newer connection (MW-4).
47
+ function detachIfCurrent(ws) {
48
+ if (_ws === ws) _ws = null;
49
+ }
50
+
51
+ // Persistent handlers for an OPEN socket: route messages, drop the socket on
52
+ // error/close (guarded) and reject any in-flight calls so they fail fast while
53
+ // the NEXT call reconnects.
54
+ function attachPersistent(ws) {
55
+ ws.on('message', (data) => {
56
+ let msg;
57
+ try { msg = JSON.parse(data.toString()); } catch { return; }
58
+ const cb = msg.id !== undefined ? _pending.get(msg.id) : undefined;
59
+ if (cb) { _pending.delete(msg.id); cb(msg); }
60
+ // Notifications (no id) are dropped — see the createWsClient comment.
61
+ });
62
+ ws.on('error', (e) => {
63
+ detachIfCurrent(ws);
64
+ flushPending(new Error('WebSocket error: ' + e.message));
65
+ });
66
+ ws.on('close', () => {
67
+ detachIfCurrent(ws);
68
+ flushPending(new Error('WebSocket closed'));
69
+ });
70
+ }
71
+
72
+ function getWs() {
73
+ if (_ws && _ws.readyState === WebSocket.OPEN) return Promise.resolve(_ws);
74
+ if (_connecting) return _connecting;
75
+
76
+ _connecting = (async () => {
77
+ const deadline = Date.now() + reconnectTimeout;
78
+ let attempt = 0;
79
+ let launchErr = null; // ensureRunning 的失败原因,超时那一刻报给调用方
80
+ while (true) {
81
+ // A concurrent open socket appearing (e.g. rude open racing the loop) wins.
82
+ if (_ws && _ws.readyState === WebSocket.OPEN) return _ws;
83
+
84
+ const ws = new WebSocket(url);
85
+ // Connect-phase: handlers only settle the local await; the socket is not
86
+ // yet adopted as _ws, so a failed attempt cannot corrupt live state.
87
+ let open = false;
88
+ let connectionErr = null;
89
+ await new Promise((res) => {
90
+ const settle = () => res();
91
+ const to = setTimeout(settle, 5000);
92
+ ws.once('open', () => { open = true; clearTimeout(to); settle(); });
93
+ ws.once('error', (e) => { connectionErr = e; clearTimeout(to); settle(); });
94
+ ws.once('close', () => { clearTimeout(to); settle(); });
95
+ });
96
+
97
+ if (open) {
98
+ _ws = ws;
99
+ attachPersistent(ws);
100
+ return ws;
101
+ }
102
+
103
+ // Attempt failed — release this socket's connect-phase listeners fully so
104
+ // its later error/close can't touch live state (MW-4).
105
+ try { ws.removeAllListeners(); ws.close(); } catch (_e) {}
106
+ const err = connectionErr || new Error('connection refused');
107
+ // eslint-disable-next-line no-console
108
+ if (attempt > 0 && attempt % 4 === 0) console.error('[mcp] ws reconnect attempt', attempt, '-', err.message);
109
+
110
+ // Lazy launch: the first failure triggers the injected runner (spawns
111
+ // Electron on demand). ensureElectronRunning is idempotent — no-op when
112
+ // the port is already listening — so calling it on every retry is safe and
113
+ // also re-launches after a manual kill of Electron.
114
+ //
115
+ // ⚠️ 它的失败**不能吞**。此前这里是 `catch (_e) {}`,于是"浏览器根本没起来"
116
+ // 和"浏览器起来但 WS 还没好"表现完全一样:agent 先重试 120 秒,最后只拿到
117
+ // "WS reconnect timeout: connection refused",没有任何线索。现在把原因累积
118
+ // 起来,超时那一刻一并报出。
119
+ if (typeof ensureRunning === 'function') {
120
+ try { await ensureRunning(); launchErr = null; }
121
+ catch (e) { launchErr = e; }
122
+ }
123
+
124
+ if (Date.now() >= deadline) {
125
+ const suffix = launchErr
126
+ ? `; browser launch failed: ${launchErr.message}`
127
+ : '';
128
+ throw new Error('WS reconnect timeout: ' + err.message + suffix);
129
+ }
130
+ await wait(Math.min(retryBase * 2 ** attempt++, maxRetryDelay));
131
+ }
132
+ })().finally(() => { _connecting = null; });
133
+
134
+ return _connecting;
135
+ }
136
+
137
+ // JSON-RPC call; resolves to the result, rejects on {error} / timeout /
138
+ // connection failure. Uses the shared connection via getWs().
139
+ function call(method, params = {}) {
140
+ return getWs().then((conn) => new Promise((resolve, reject) => {
141
+ const id = ++_rpcId;
142
+ const timer = setTimeout(() => {
143
+ if (_pending.has(id)) { _pending.delete(id); reject(new Error('WS timeout')); }
144
+ }, timeout);
145
+ _pending.set(id, (msg) => {
146
+ clearTimeout(timer);
147
+ if (msg.error) reject(new Error(msg.error.message || 'RPC error'));
148
+ else resolve(msg.result);
149
+ });
150
+ try {
151
+ conn.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
152
+ } catch (e) {
153
+ clearTimeout(timer);
154
+ _pending.delete(id);
155
+ reject(e);
156
+ }
157
+ }));
158
+ }
159
+
160
+ // Force-sever the underlying connection. The NEXT call reconnects (MW-4).
161
+ // Detaches the socket's own handlers first so their deferred close/error
162
+ // events can't clobber the refs of a freshly re-opened connection created
163
+ // right after this returns (rapid reconnect race). In-flight calls are
164
+ // rejected.
165
+ function close() {
166
+ const ws = _ws;
167
+ _ws = null;
168
+ _connecting = null;
169
+ if (ws) {
170
+ ws.removeAllListeners('close');
171
+ ws.removeAllListeners('error');
172
+ try { ws.close(); } catch (_e) {}
173
+ }
174
+ flushPending(new Error('client closed'));
175
+ }
176
+
177
+ return { call, close };
178
+ }