what-devtools-mcp 0.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.
package/src/client.js ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Browser-side WebSocket client for what-devtools-mcp.
3
+ * Connects to the Node.js bridge server, streams devtools state and events,
4
+ * and handles commands from the MCP server (set-signal, get-snapshot, etc.).
5
+ */
6
+
7
+ // Branded console logger
8
+ const BADGE = 'background:#6366f1;color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold';
9
+ const BADGE_CMD = 'background:#22c55e;color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold';
10
+ const BADGE_EVENT = 'background:#f97316;color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold';
11
+ const BADGE_WARN = 'background:#eab308;color:#000;padding:2px 6px;border-radius:3px;font-weight:bold';
12
+ const DIM = 'color:#888';
13
+
14
+ function log(badge, badgeStyle, ...args) {
15
+ console.log(`%c${badge}%c`, badgeStyle, '', ...args);
16
+ }
17
+
18
+ function logGrouped(badge, badgeStyle, title, data) {
19
+ console.groupCollapsed(`%c${badge}%c ${title}`, badgeStyle, 'color:inherit');
20
+ if (data !== undefined) console.log(data);
21
+ console.groupEnd();
22
+ }
23
+
24
+ export function connectDevToolsMCP({ port = 9229 } = {}) {
25
+ // Never connect in production
26
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'production') {
27
+ return { disconnect() {}, isConnected: false, eventCount: 0 };
28
+ }
29
+ try {
30
+ if (import.meta?.env?.PROD) {
31
+ return { disconnect() {}, isConnected: false, eventCount: 0 };
32
+ }
33
+ } catch {}
34
+
35
+ let ws = null;
36
+ let connected = false;
37
+ let reconnectTimer = null;
38
+ let reconnectDelay = 1000;
39
+ const MAX_RECONNECT_DELAY = 30000;
40
+ const pendingResponses = new Map();
41
+ let eventCount = 0;
42
+ let hasLoggedDisconnect = false;
43
+ let reconnectAttempts = 0;
44
+ let unsubscribeFn = null;
45
+
46
+ // Startup banner
47
+ console.log(
48
+ '%c⚔ What DevTools MCP %c Client v0.1.0',
49
+ 'background:linear-gradient(135deg,#6366f1,#a855f7);color:#fff;padding:4px 10px;border-radius:4px;font-weight:bold;font-size:13px',
50
+ 'color:#a855f7;font-weight:bold'
51
+ );
52
+ log('MCP', BADGE, `Connecting to bridge on ws://localhost:${port}`);
53
+
54
+ function connect() {
55
+ reconnectAttempts++;
56
+ try {
57
+ ws = new WebSocket(`ws://localhost:${port}`);
58
+ } catch {
59
+ if (reconnectAttempts <= 1) {
60
+ log('MCP', BADGE_WARN, 'Bridge not available — retrying silently in background');
61
+ }
62
+ scheduleReconnect();
63
+ return;
64
+ }
65
+
66
+ ws.onopen = () => {
67
+ connected = true;
68
+ reconnectDelay = 1000;
69
+ hasLoggedDisconnect = false;
70
+ reconnectAttempts = 0;
71
+ log('MCP', BADGE, '🟢 Connected to bridge — AI agent can now inspect this app');
72
+
73
+ // Send initial snapshot
74
+ const devtools = window.__WHAT_DEVTOOLS__;
75
+ if (devtools) {
76
+ const snapshot = devtools.getSnapshot();
77
+ send({ type: 'snapshot', data: devtools.safeSerialize(snapshot) });
78
+ const s = snapshot.signals?.length || 0;
79
+ const e = snapshot.effects?.length || 0;
80
+ const c = snapshot.components?.length || 0;
81
+ log('MCP', BADGE, `Sent initial snapshot — ${s} signals, ${e} effects, ${c} components`);
82
+ }
83
+
84
+ // Subscribe to devtools events and stream them
85
+ // Clean up previous subscription to prevent leak on reconnect
86
+ if (unsubscribeFn) {
87
+ unsubscribeFn();
88
+ unsubscribeFn = null;
89
+ }
90
+ if (devtools) {
91
+ let eventBatch = [];
92
+ let batchTimer = null;
93
+
94
+ function flushEventBatch() {
95
+ if (eventBatch.length === 0) return;
96
+ if (eventBatch.length === 1) {
97
+ // Single event — send normally for compatibility
98
+ const item = eventBatch[0];
99
+ send({ type: 'event', event: item.event, data: item.data });
100
+ } else {
101
+ send({ type: 'events', batch: eventBatch });
102
+ }
103
+ eventBatch = [];
104
+ batchTimer = null;
105
+ }
106
+
107
+ unsubscribeFn = devtools.subscribe((event, data) => {
108
+ eventCount++;
109
+ eventBatch.push({ event, data: devtools.safeSerialize(data) });
110
+ if (!batchTimer) {
111
+ batchTimer = setTimeout(flushEventBatch, 16);
112
+ }
113
+ });
114
+ log('MCP', BADGE, 'Subscribed to reactive events — streaming to bridge (batched)');
115
+ }
116
+ };
117
+
118
+ ws.onmessage = (event) => {
119
+ let msg;
120
+ try { msg = JSON.parse(event.data); } catch { return; }
121
+ handleCommand(msg);
122
+ };
123
+
124
+ ws.onclose = () => {
125
+ const wasConnected = connected;
126
+ connected = false;
127
+ if (wasConnected && !hasLoggedDisconnect) {
128
+ hasLoggedDisconnect = true;
129
+ log('MCP', BADGE_WARN, 'šŸ”“ Disconnected from bridge — will reconnect silently');
130
+ }
131
+ scheduleReconnect();
132
+ };
133
+
134
+ ws.onerror = () => {
135
+ // Silence — onclose handles reconnection.
136
+ // Without this, every failed reconnect attempt logs a loud red error.
137
+ };
138
+ }
139
+
140
+ function send(msg) {
141
+ if (ws && ws.readyState === WebSocket.OPEN) {
142
+ ws.send(JSON.stringify(msg));
143
+ }
144
+ }
145
+
146
+ async function handleCommand(msg) {
147
+ const { command, correlationId, args } = msg;
148
+ const devtools = window.__WHAT_DEVTOOLS__;
149
+ let result;
150
+
151
+ const cmdNames = {
152
+ 'get-snapshot': 'šŸ“ø Snapshot requested',
153
+ 'get-cache': 'šŸ’¾ Cache query',
154
+ 'set-signal': 'āœļø Signal write',
155
+ 'invalidate-cache': 'šŸ—‘ļø Cache invalidation',
156
+ };
157
+
158
+ const label = cmdNames[command] || `ā“ ${command}`;
159
+
160
+ switch (command) {
161
+ case 'get-snapshot': {
162
+ const snapshot = devtools?.getSnapshot() || { signals: [], effects: [], components: [], errors: [] };
163
+ result = devtools?.safeSerialize(snapshot) || snapshot;
164
+ const s = snapshot.signals?.length || 0;
165
+ const e = snapshot.effects?.length || 0;
166
+ const c = snapshot.components?.length || 0;
167
+ logGrouped('AI →', BADGE_CMD, `${label} — returning ${s} signals, ${e} effects, ${c} components`, result);
168
+ break;
169
+ }
170
+ case 'get-cache': {
171
+ let cacheData = [];
172
+ try {
173
+ const core = window.__WHAT_CORE__ || {};
174
+ if (core.__getCacheSnapshot) {
175
+ cacheData = core.__getCacheSnapshot();
176
+ }
177
+ } catch {}
178
+ result = devtools?.safeSerialize(cacheData) || cacheData;
179
+ logGrouped('AI →', BADGE_CMD, `${label} — ${cacheData.length} entries`, result);
180
+ break;
181
+ }
182
+ case 'set-signal': {
183
+ const { signalId, value } = args || {};
184
+ const registries = devtools?._registries;
185
+ if (registries?.signals) {
186
+ const entry = registries.signals.get(signalId);
187
+ if (entry) {
188
+ const prev = entry.ref.peek();
189
+ entry.ref(value);
190
+ result = { previous: devtools.safeSerialize(prev), current: devtools.safeSerialize(value) };
191
+ log('AI →', BADGE_CMD, `${label} — signal #${signalId} "${entry.name}": ${JSON.stringify(prev)} → ${JSON.stringify(value)}`);
192
+ } else {
193
+ result = { error: `Signal ${signalId} not found` };
194
+ log('AI →', BADGE_WARN, `${label} — signal #${signalId} not found`);
195
+ }
196
+ } else {
197
+ result = { error: 'DevTools not available' };
198
+ log('AI →', BADGE_WARN, `${label} — devtools not available`);
199
+ }
200
+ break;
201
+ }
202
+ case 'invalidate-cache': {
203
+ const { key } = args || {};
204
+ try {
205
+ const core = window.__WHAT_CORE__ || {};
206
+ if (core.invalidateQueries) {
207
+ core.invalidateQueries(key);
208
+ result = { success: true, key };
209
+ log('AI →', BADGE_CMD, `${label} — key "${key}" invalidated`);
210
+ } else {
211
+ result = { error: 'invalidateQueries not available' };
212
+ log('AI →', BADGE_WARN, `${label} — invalidateQueries not available`);
213
+ }
214
+ } catch (e) {
215
+ result = { error: e.message };
216
+ log('AI →', BADGE_WARN, `${label} — error: ${e.message}`);
217
+ }
218
+ break;
219
+ }
220
+ default: {
221
+ // Try extended command handlers
222
+ let extResult = null;
223
+ try {
224
+ const { handleExtendedCommand } = await import('./client-commands.js');
225
+ extResult = handleExtendedCommand(command, args, devtools);
226
+ } catch {}
227
+
228
+ if (extResult !== null) {
229
+ result = extResult;
230
+ logGrouped('AI →', BADGE_CMD, `šŸ”§ ${command}`, result);
231
+ } else {
232
+ result = { error: `Unknown command: ${command}` };
233
+ log('AI →', BADGE_WARN, `Unknown command: ${command}`);
234
+ }
235
+ }
236
+ }
237
+
238
+ if (correlationId) {
239
+ send({ type: 'response', correlationId, data: result });
240
+ }
241
+ }
242
+
243
+ function scheduleReconnect() {
244
+ if (reconnectTimer) return;
245
+ // After 5 failures, go very slow (30s) to avoid console spam
246
+ if (reconnectAttempts === 5 && !hasLoggedDisconnect) {
247
+ log('MCP', BADGE_WARN, `Bridge not available — will retry every 30s. Start the MCP server to connect.`);
248
+ hasLoggedDisconnect = true;
249
+ }
250
+ const delay = reconnectAttempts >= 5 ? MAX_RECONNECT_DELAY : reconnectDelay;
251
+ reconnectTimer = setTimeout(() => {
252
+ reconnectTimer = null;
253
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
254
+ connect();
255
+ }, delay);
256
+ }
257
+
258
+ function disconnect() {
259
+ if (unsubscribeFn) {
260
+ unsubscribeFn();
261
+ unsubscribeFn = null;
262
+ }
263
+ if (reconnectTimer) {
264
+ clearTimeout(reconnectTimer);
265
+ reconnectTimer = null;
266
+ }
267
+ if (ws) {
268
+ ws.onclose = null; // prevent reconnect
269
+ ws.close();
270
+ ws = null;
271
+ }
272
+ connected = false;
273
+ log('MCP', BADGE, 'Disconnected');
274
+ }
275
+
276
+ connect();
277
+
278
+ return {
279
+ disconnect,
280
+ get isConnected() { return connected; },
281
+ get eventCount() { return eventCount; },
282
+ };
283
+ }