react-devtools-bridge 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/SKILL.md +96 -0
- package/TOOLS.md +96 -0
- package/dist/cli.js +2806 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +1099 -0
- package/dist/index.js +2799 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2806 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListToolsRequestSchema,
|
|
9
|
+
ListResourcesRequestSchema,
|
|
10
|
+
ReadResourceRequestSchema
|
|
11
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
|
|
13
|
+
// src/bridge.ts
|
|
14
|
+
import { WebSocket } from "ws";
|
|
15
|
+
import { EventEmitter } from "events";
|
|
16
|
+
|
|
17
|
+
// src/logger.ts
|
|
18
|
+
var LOG_LEVELS = {
|
|
19
|
+
debug: 0,
|
|
20
|
+
info: 1,
|
|
21
|
+
warn: 2,
|
|
22
|
+
error: 3,
|
|
23
|
+
silent: 4
|
|
24
|
+
};
|
|
25
|
+
function defaultOutput(entry) {
|
|
26
|
+
const timestamp = new Date(entry.timestamp).toISOString();
|
|
27
|
+
const prefix = entry.prefix ? `[${entry.prefix}]` : "";
|
|
28
|
+
const level = entry.level.toUpperCase().padEnd(5);
|
|
29
|
+
const meta = entry.meta ? ` ${JSON.stringify(entry.meta)}` : "";
|
|
30
|
+
console.error(`${timestamp} ${level} ${prefix} ${entry.message}${meta}`);
|
|
31
|
+
}
|
|
32
|
+
function createLogger(options = {}) {
|
|
33
|
+
const level = options.level ?? "warn";
|
|
34
|
+
const prefix = options.prefix;
|
|
35
|
+
const output = options.output ?? defaultOutput;
|
|
36
|
+
const minLevel = LOG_LEVELS[level];
|
|
37
|
+
const log = (logLevel, message, meta) => {
|
|
38
|
+
if (LOG_LEVELS[logLevel] < minLevel) return;
|
|
39
|
+
output({
|
|
40
|
+
level: logLevel,
|
|
41
|
+
message,
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
prefix,
|
|
44
|
+
meta
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
debug: (message, meta) => log("debug", message, meta),
|
|
49
|
+
info: (message, meta) => log("info", message, meta),
|
|
50
|
+
warn: (message, meta) => log("warn", message, meta),
|
|
51
|
+
error: (message, meta) => log("error", message, meta),
|
|
52
|
+
child: (childPrefix) => createLogger({
|
|
53
|
+
level,
|
|
54
|
+
prefix: prefix ? `${prefix}:${childPrefix}` : childPrefix,
|
|
55
|
+
output
|
|
56
|
+
})
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
var noopLogger = {
|
|
60
|
+
debug: () => {
|
|
61
|
+
},
|
|
62
|
+
info: () => {
|
|
63
|
+
},
|
|
64
|
+
warn: () => {
|
|
65
|
+
},
|
|
66
|
+
error: () => {
|
|
67
|
+
},
|
|
68
|
+
child: () => noopLogger
|
|
69
|
+
};
|
|
70
|
+
function getLogLevelFromEnv() {
|
|
71
|
+
const envLevel = process.env.DEVTOOLS_LOG_LEVEL?.toLowerCase();
|
|
72
|
+
if (envLevel && envLevel in LOG_LEVELS) {
|
|
73
|
+
return envLevel;
|
|
74
|
+
}
|
|
75
|
+
return process.env.DEVTOOLS_DEBUG === "true" ? "debug" : "warn";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/errors.ts
|
|
79
|
+
var DevToolsError = class extends Error {
|
|
80
|
+
constructor(message, code, details) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.code = code;
|
|
83
|
+
this.details = details;
|
|
84
|
+
this.name = "DevToolsError";
|
|
85
|
+
Error.captureStackTrace?.(this, this.constructor);
|
|
86
|
+
}
|
|
87
|
+
toJSON() {
|
|
88
|
+
return {
|
|
89
|
+
name: this.name,
|
|
90
|
+
code: this.code,
|
|
91
|
+
message: this.message,
|
|
92
|
+
details: this.details
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var ConnectionError = class extends DevToolsError {
|
|
97
|
+
constructor(message, details) {
|
|
98
|
+
super(message, "NOT_CONNECTED", details);
|
|
99
|
+
this.name = "ConnectionError";
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
var TimeoutError = class extends DevToolsError {
|
|
103
|
+
constructor(operation, timeout, details) {
|
|
104
|
+
super(`Request timeout after ${timeout}ms: ${operation}`, "TIMEOUT", {
|
|
105
|
+
operation,
|
|
106
|
+
timeout,
|
|
107
|
+
...details
|
|
108
|
+
});
|
|
109
|
+
this.name = "TimeoutError";
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// src/bridge.ts
|
|
114
|
+
var ELEMENT_TYPE_MAP = {
|
|
115
|
+
1: "class",
|
|
116
|
+
2: "context",
|
|
117
|
+
5: "function",
|
|
118
|
+
6: "forward_ref",
|
|
119
|
+
7: "fragment",
|
|
120
|
+
8: "host",
|
|
121
|
+
9: "memo",
|
|
122
|
+
10: "portal",
|
|
123
|
+
11: "root",
|
|
124
|
+
12: "profiler",
|
|
125
|
+
13: "suspense",
|
|
126
|
+
14: "lazy",
|
|
127
|
+
15: "cache",
|
|
128
|
+
16: "activity",
|
|
129
|
+
17: "virtual"
|
|
130
|
+
};
|
|
131
|
+
var TREE_OP = {
|
|
132
|
+
ADD: 1,
|
|
133
|
+
REMOVE: 2,
|
|
134
|
+
REORDER: 3,
|
|
135
|
+
UPDATE_TREE_BASE_DURATION: 4,
|
|
136
|
+
UPDATE_ERRORS_OR_WARNINGS: 5
|
|
137
|
+
};
|
|
138
|
+
var DEFAULT_CONFIG = {
|
|
139
|
+
host: "localhost",
|
|
140
|
+
port: 8097,
|
|
141
|
+
timeout: 5e3,
|
|
142
|
+
autoReconnect: true
|
|
143
|
+
};
|
|
144
|
+
var RECONNECT = {
|
|
145
|
+
MAX_ATTEMPTS: 5,
|
|
146
|
+
BASE_DELAY: 1e3,
|
|
147
|
+
MAX_DELAY: 3e4
|
|
148
|
+
};
|
|
149
|
+
var DEFAULT_CAPABILITIES = {
|
|
150
|
+
bridgeProtocolVersion: 2,
|
|
151
|
+
backendVersion: null,
|
|
152
|
+
supportsInspectElementPaths: false,
|
|
153
|
+
supportsProfilingChangeDescriptions: false,
|
|
154
|
+
supportsTimeline: false,
|
|
155
|
+
supportsNativeStyleEditor: false,
|
|
156
|
+
supportsErrorBoundaryTesting: false,
|
|
157
|
+
supportsTraceUpdates: false,
|
|
158
|
+
isBackendStorageAPISupported: false,
|
|
159
|
+
isSynchronousXHRSupported: false
|
|
160
|
+
};
|
|
161
|
+
var DevToolsBridge = class extends EventEmitter {
|
|
162
|
+
config;
|
|
163
|
+
logger;
|
|
164
|
+
ws = null;
|
|
165
|
+
state = "disconnected";
|
|
166
|
+
error = null;
|
|
167
|
+
// Connection management (Phase 1.2: Race condition fix)
|
|
168
|
+
connectPromise = null;
|
|
169
|
+
// Reconnection state (Phase 1.3: Auto-reconnection)
|
|
170
|
+
reconnectAttempts = 0;
|
|
171
|
+
reconnectTimer = null;
|
|
172
|
+
manualDisconnect = false;
|
|
173
|
+
// Component tree state
|
|
174
|
+
elements = /* @__PURE__ */ new Map();
|
|
175
|
+
rootIDs = /* @__PURE__ */ new Set();
|
|
176
|
+
renderers = /* @__PURE__ */ new Map();
|
|
177
|
+
elementToRenderer = /* @__PURE__ */ new Map();
|
|
178
|
+
// Phase 2.3: Element-to-renderer mapping
|
|
179
|
+
// Request tracking (Phase 1.5 & 1.6: Memory leak fix + ID correlation)
|
|
180
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
181
|
+
requestIdCounter = 0;
|
|
182
|
+
staleRequestCleanupTimer = null;
|
|
183
|
+
/**
|
|
184
|
+
* Unified fallback key mapping for request/response correlation.
|
|
185
|
+
* Maps element-based keys to requestID-based keys.
|
|
186
|
+
*
|
|
187
|
+
* Flow:
|
|
188
|
+
* 1. Request sent with requestID=123 for elementID=456
|
|
189
|
+
* 2. Store mapping: "inspect_456" -> "inspect_123"
|
|
190
|
+
* 3. Response arrives with responseID=123 OR just id=456
|
|
191
|
+
* 4. Try "inspect_123" first, fall back to mapping["inspect_456"]
|
|
192
|
+
* 5. Clean up mapping after resolving
|
|
193
|
+
*
|
|
194
|
+
* Needed because some React DevTools backends don't echo responseID reliably.
|
|
195
|
+
*/
|
|
196
|
+
responseFallbackKeys = /* @__PURE__ */ new Map();
|
|
197
|
+
// Errors/warnings state
|
|
198
|
+
elementErrors = /* @__PURE__ */ new Map();
|
|
199
|
+
elementWarnings = /* @__PURE__ */ new Map();
|
|
200
|
+
// Profiling state
|
|
201
|
+
isProfiling = false;
|
|
202
|
+
profilingData = null;
|
|
203
|
+
// Protocol info (Phase 2.2)
|
|
204
|
+
backendVersion = null;
|
|
205
|
+
capabilities = { ...DEFAULT_CAPABILITIES };
|
|
206
|
+
capabilitiesNegotiated = false;
|
|
207
|
+
lastMessageAt = 0;
|
|
208
|
+
// Native inspection state (Phase 2.1)
|
|
209
|
+
isInspectingNative = false;
|
|
210
|
+
// External communication (for headless server integration)
|
|
211
|
+
externalSendFn = null;
|
|
212
|
+
isExternallyAttached = false;
|
|
213
|
+
constructor(options = {}) {
|
|
214
|
+
super();
|
|
215
|
+
this.config = { ...DEFAULT_CONFIG, ...options };
|
|
216
|
+
this.logger = options.logger ?? noopLogger;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Attach to an external message source (e.g., HeadlessDevToolsServer).
|
|
220
|
+
* When attached, the bridge receives messages from the external source
|
|
221
|
+
* instead of connecting via WebSocket.
|
|
222
|
+
*/
|
|
223
|
+
attachToExternal(sendFn, onDetach) {
|
|
224
|
+
this.logger.info("Attaching to external message source");
|
|
225
|
+
this.externalSendFn = sendFn;
|
|
226
|
+
this.isExternallyAttached = true;
|
|
227
|
+
this.setState("connected");
|
|
228
|
+
this.error = null;
|
|
229
|
+
this.lastMessageAt = Date.now();
|
|
230
|
+
this.startStaleRequestCleanup();
|
|
231
|
+
this.send("bridge", { version: 2 });
|
|
232
|
+
this.negotiateCapabilities();
|
|
233
|
+
this.emit("connected");
|
|
234
|
+
return {
|
|
235
|
+
receiveMessage: (data) => {
|
|
236
|
+
this.handleMessage(data);
|
|
237
|
+
},
|
|
238
|
+
detach: () => {
|
|
239
|
+
this.logger.info("Detaching from external message source");
|
|
240
|
+
this.externalSendFn = null;
|
|
241
|
+
this.isExternallyAttached = false;
|
|
242
|
+
this.setState("disconnected");
|
|
243
|
+
this.reset();
|
|
244
|
+
onDetach?.();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Check if bridge is attached to an external source
|
|
250
|
+
*/
|
|
251
|
+
isAttachedExternally() {
|
|
252
|
+
return this.isExternallyAttached;
|
|
253
|
+
}
|
|
254
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
255
|
+
// CONNECTION MANAGEMENT
|
|
256
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
257
|
+
/**
|
|
258
|
+
* Connect to DevTools backend.
|
|
259
|
+
* Handles deduplication of concurrent connect calls (Phase 1.2).
|
|
260
|
+
*/
|
|
261
|
+
async connect() {
|
|
262
|
+
if (this.isExternallyAttached) {
|
|
263
|
+
this.logger.debug("Already attached externally, skipping WebSocket connect");
|
|
264
|
+
return this.getStatus();
|
|
265
|
+
}
|
|
266
|
+
if (this.connectPromise) {
|
|
267
|
+
this.logger.debug("Returning existing connection attempt");
|
|
268
|
+
return this.connectPromise;
|
|
269
|
+
}
|
|
270
|
+
if (this.state === "connected" && this.ws?.readyState === WebSocket.OPEN) {
|
|
271
|
+
this.logger.debug("Already connected");
|
|
272
|
+
return this.getStatus();
|
|
273
|
+
}
|
|
274
|
+
if (this.ws) {
|
|
275
|
+
this.logger.debug("Cleaning up stale WebSocket");
|
|
276
|
+
this.ws.removeAllListeners();
|
|
277
|
+
this.ws.close();
|
|
278
|
+
this.ws = null;
|
|
279
|
+
}
|
|
280
|
+
this.manualDisconnect = false;
|
|
281
|
+
this.connectPromise = this.doConnect();
|
|
282
|
+
try {
|
|
283
|
+
return await this.connectPromise;
|
|
284
|
+
} finally {
|
|
285
|
+
this.connectPromise = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Internal connection logic
|
|
290
|
+
*/
|
|
291
|
+
async doConnect() {
|
|
292
|
+
this.setState("connecting");
|
|
293
|
+
const url = `ws://${this.config.host}:${this.config.port}`;
|
|
294
|
+
this.logger.info("Connecting to DevTools", { url });
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const connectionTimeout = setTimeout(() => {
|
|
297
|
+
this.logger.error("Connection timeout", { url, timeout: this.config.timeout });
|
|
298
|
+
this.ws?.close();
|
|
299
|
+
this.setError("Connection timeout");
|
|
300
|
+
reject(new ConnectionError("Connection timeout", { url, timeout: this.config.timeout }));
|
|
301
|
+
}, this.config.timeout);
|
|
302
|
+
try {
|
|
303
|
+
this.ws = new WebSocket(url);
|
|
304
|
+
this.ws.on("open", () => {
|
|
305
|
+
clearTimeout(connectionTimeout);
|
|
306
|
+
this.logger.info("Connected to DevTools");
|
|
307
|
+
this.onConnected();
|
|
308
|
+
resolve(this.getStatus());
|
|
309
|
+
});
|
|
310
|
+
this.ws.on("message", (data) => {
|
|
311
|
+
this.handleMessage(data.toString());
|
|
312
|
+
});
|
|
313
|
+
this.ws.on("close", (code, reason) => {
|
|
314
|
+
this.handleClose(code, reason.toString());
|
|
315
|
+
});
|
|
316
|
+
this.ws.on("error", (err) => {
|
|
317
|
+
clearTimeout(connectionTimeout);
|
|
318
|
+
this.logger.error("WebSocket error", { error: err.message });
|
|
319
|
+
this.setError(err.message);
|
|
320
|
+
reject(new ConnectionError(err.message));
|
|
321
|
+
});
|
|
322
|
+
} catch (err) {
|
|
323
|
+
clearTimeout(connectionTimeout);
|
|
324
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
325
|
+
this.logger.error("Connection failed", { error: message });
|
|
326
|
+
this.setError(message);
|
|
327
|
+
reject(new ConnectionError(message));
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Called when connection is established
|
|
333
|
+
*/
|
|
334
|
+
onConnected() {
|
|
335
|
+
this.setState("connected");
|
|
336
|
+
this.error = null;
|
|
337
|
+
this.reconnectAttempts = 0;
|
|
338
|
+
this.lastMessageAt = Date.now();
|
|
339
|
+
if (this.reconnectTimer) {
|
|
340
|
+
clearTimeout(this.reconnectTimer);
|
|
341
|
+
this.reconnectTimer = null;
|
|
342
|
+
}
|
|
343
|
+
this.startStaleRequestCleanup();
|
|
344
|
+
this.send("bridge", { version: 2 });
|
|
345
|
+
this.negotiateCapabilities();
|
|
346
|
+
this.emit("connected");
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Negotiate protocol capabilities with backend (Phase 2.2)
|
|
350
|
+
*/
|
|
351
|
+
negotiateCapabilities() {
|
|
352
|
+
this.logger.debug("Negotiating protocol capabilities");
|
|
353
|
+
this.send("isBackendStorageAPISupported", {});
|
|
354
|
+
this.send("isSynchronousXHRSupported", {});
|
|
355
|
+
this.send("getSupportedRendererInterfaces", {});
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Handle WebSocket close event
|
|
359
|
+
*/
|
|
360
|
+
handleClose(code, reason) {
|
|
361
|
+
this.logger.info("Connection closed", { code, reason });
|
|
362
|
+
this.setState("disconnected");
|
|
363
|
+
this.emit("disconnected", { code, reason });
|
|
364
|
+
this.stopStaleRequestCleanup();
|
|
365
|
+
for (const [, req] of this.pendingRequests) {
|
|
366
|
+
clearTimeout(req.timeout);
|
|
367
|
+
req.reject(new ConnectionError("Connection closed"));
|
|
368
|
+
}
|
|
369
|
+
this.pendingRequests.clear();
|
|
370
|
+
if (!this.manualDisconnect && this.config.autoReconnect && code !== 1e3 && code !== 1001) {
|
|
371
|
+
this.scheduleReconnect();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Schedule a reconnection attempt with exponential backoff (Phase 1.3)
|
|
376
|
+
*/
|
|
377
|
+
scheduleReconnect() {
|
|
378
|
+
if (this.reconnectAttempts >= RECONNECT.MAX_ATTEMPTS) {
|
|
379
|
+
this.logger.error("Max reconnection attempts reached", { attempts: this.reconnectAttempts });
|
|
380
|
+
this.emit("reconnectFailed", { attempts: this.reconnectAttempts });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const delay = Math.min(
|
|
384
|
+
RECONNECT.BASE_DELAY * Math.pow(2, this.reconnectAttempts) + Math.random() * 1e3,
|
|
385
|
+
RECONNECT.MAX_DELAY
|
|
386
|
+
);
|
|
387
|
+
this.reconnectAttempts++;
|
|
388
|
+
this.logger.info("Scheduling reconnection", { attempt: this.reconnectAttempts, delay });
|
|
389
|
+
this.emit("reconnecting", { attempt: this.reconnectAttempts, delay });
|
|
390
|
+
this.reconnectTimer = setTimeout(() => {
|
|
391
|
+
this.connect().catch((err) => {
|
|
392
|
+
this.logger.warn("Reconnection failed", { error: err.message });
|
|
393
|
+
});
|
|
394
|
+
}, delay);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Disconnect from DevTools backend
|
|
398
|
+
*/
|
|
399
|
+
disconnect() {
|
|
400
|
+
this.logger.info("Disconnecting");
|
|
401
|
+
this.manualDisconnect = true;
|
|
402
|
+
if (this.reconnectTimer) {
|
|
403
|
+
clearTimeout(this.reconnectTimer);
|
|
404
|
+
this.reconnectTimer = null;
|
|
405
|
+
}
|
|
406
|
+
if (this.ws) {
|
|
407
|
+
this.ws.close(1e3, "Client disconnect");
|
|
408
|
+
this.ws = null;
|
|
409
|
+
}
|
|
410
|
+
this.setState("disconnected");
|
|
411
|
+
this.reset();
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Get current connection status
|
|
415
|
+
*/
|
|
416
|
+
getStatus() {
|
|
417
|
+
return {
|
|
418
|
+
state: this.state,
|
|
419
|
+
rendererCount: this.renderers.size,
|
|
420
|
+
reactVersion: this.backendVersion,
|
|
421
|
+
error: this.error
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Check if connected
|
|
426
|
+
*/
|
|
427
|
+
isConnected() {
|
|
428
|
+
if (this.isExternallyAttached) {
|
|
429
|
+
return this.state === "connected";
|
|
430
|
+
}
|
|
431
|
+
return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
|
|
432
|
+
}
|
|
433
|
+
setState(state) {
|
|
434
|
+
this.state = state;
|
|
435
|
+
this.emit("stateChange", state);
|
|
436
|
+
}
|
|
437
|
+
setError(message) {
|
|
438
|
+
this.error = message;
|
|
439
|
+
this.setState("error");
|
|
440
|
+
}
|
|
441
|
+
reset() {
|
|
442
|
+
this.elements.clear();
|
|
443
|
+
this.rootIDs.clear();
|
|
444
|
+
this.renderers.clear();
|
|
445
|
+
this.elementToRenderer.clear();
|
|
446
|
+
this.elementErrors.clear();
|
|
447
|
+
this.elementWarnings.clear();
|
|
448
|
+
this.isProfiling = false;
|
|
449
|
+
this.profilingData = null;
|
|
450
|
+
this.isInspectingNative = false;
|
|
451
|
+
this.capabilities = { ...DEFAULT_CAPABILITIES };
|
|
452
|
+
this.capabilitiesNegotiated = false;
|
|
453
|
+
this.stopStaleRequestCleanup();
|
|
454
|
+
}
|
|
455
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
456
|
+
// REQUEST MANAGEMENT (Phase 1.5 & 1.6)
|
|
457
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
458
|
+
/**
|
|
459
|
+
* Generate unique request ID (Phase 1.6)
|
|
460
|
+
*/
|
|
461
|
+
nextRequestId() {
|
|
462
|
+
return ++this.requestIdCounter;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Create a pending request with proper cleanup (Phase 1.5)
|
|
466
|
+
*/
|
|
467
|
+
createPending(key, operation, timeout) {
|
|
468
|
+
return new Promise((resolve, reject) => {
|
|
469
|
+
const cleanup = () => {
|
|
470
|
+
const req = this.pendingRequests.get(key);
|
|
471
|
+
if (req) {
|
|
472
|
+
clearTimeout(req.timeout);
|
|
473
|
+
this.pendingRequests.delete(key);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
const timeoutMs = timeout ?? this.config.timeout;
|
|
477
|
+
const timeoutId = setTimeout(() => {
|
|
478
|
+
this.logger.warn("Request timeout", { key, operation, timeout: timeoutMs });
|
|
479
|
+
cleanup();
|
|
480
|
+
reject(new TimeoutError(operation, timeoutMs, { key }));
|
|
481
|
+
}, timeoutMs);
|
|
482
|
+
this.pendingRequests.set(key, {
|
|
483
|
+
resolve: (value) => {
|
|
484
|
+
cleanup();
|
|
485
|
+
resolve(value);
|
|
486
|
+
},
|
|
487
|
+
reject: (error) => {
|
|
488
|
+
cleanup();
|
|
489
|
+
reject(error);
|
|
490
|
+
},
|
|
491
|
+
timeout: timeoutId,
|
|
492
|
+
createdAt: Date.now(),
|
|
493
|
+
operation
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Resolve a pending request
|
|
499
|
+
*/
|
|
500
|
+
resolvePending(key, value) {
|
|
501
|
+
const pending = this.pendingRequests.get(key);
|
|
502
|
+
if (pending) {
|
|
503
|
+
this.logger.debug("Resolving request", { key, operation: pending.operation });
|
|
504
|
+
pending.resolve(value);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Resolve a correlated request using responseID/requestID/fallback pattern.
|
|
509
|
+
* Handles the common pattern of: responseID -> requestID -> element ID fallback.
|
|
510
|
+
*
|
|
511
|
+
* @param prefix - Key prefix (e.g., 'inspect', 'owners', 'nativeStyle')
|
|
512
|
+
* @param payload - Response payload with optional responseID, requestID, and id
|
|
513
|
+
* @param result - Value to resolve the promise with
|
|
514
|
+
*/
|
|
515
|
+
resolveCorrelatedRequest(prefix, payload, result) {
|
|
516
|
+
let key;
|
|
517
|
+
if (payload.responseID !== void 0) {
|
|
518
|
+
key = `${prefix}_${payload.responseID}`;
|
|
519
|
+
} else if (payload.requestID !== void 0) {
|
|
520
|
+
key = `${prefix}_${payload.requestID}`;
|
|
521
|
+
} else {
|
|
522
|
+
key = `${prefix}_${payload.id ?? "unknown"}`;
|
|
523
|
+
}
|
|
524
|
+
if (!this.pendingRequests.has(key) && payload.id !== void 0) {
|
|
525
|
+
const fallbackKey = `${prefix}_${payload.id}`;
|
|
526
|
+
const primaryKey = this.responseFallbackKeys.get(fallbackKey);
|
|
527
|
+
if (primaryKey && this.pendingRequests.has(primaryKey)) {
|
|
528
|
+
key = primaryKey;
|
|
529
|
+
}
|
|
530
|
+
this.responseFallbackKeys.delete(fallbackKey);
|
|
531
|
+
} else if (payload.id !== void 0) {
|
|
532
|
+
this.responseFallbackKeys.delete(`${prefix}_${payload.id}`);
|
|
533
|
+
}
|
|
534
|
+
this.resolvePending(key, result);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Store a fallback key mapping for request correlation.
|
|
538
|
+
* Call this when sending a request that uses requestID.
|
|
539
|
+
*
|
|
540
|
+
* @param prefix - Key prefix (e.g., 'inspect', 'owners')
|
|
541
|
+
* @param requestID - The requestID being sent
|
|
542
|
+
* @param elementID - The element ID (used as fallback key)
|
|
543
|
+
*/
|
|
544
|
+
storeFallbackKey(prefix, requestID, elementID) {
|
|
545
|
+
const fallbackKey = `${prefix}_${elementID}`;
|
|
546
|
+
const primaryKey = `${prefix}_${requestID}`;
|
|
547
|
+
this.responseFallbackKeys.set(fallbackKey, primaryKey);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Start periodic cleanup of stale requests (Phase 1.5)
|
|
551
|
+
*/
|
|
552
|
+
startStaleRequestCleanup() {
|
|
553
|
+
this.staleRequestCleanupTimer = setInterval(() => {
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
const maxAge = this.config.timeout * 2;
|
|
556
|
+
for (const [key, req] of this.pendingRequests) {
|
|
557
|
+
const age = now - req.createdAt;
|
|
558
|
+
if (age > maxAge) {
|
|
559
|
+
this.logger.warn("Cleaning stale request", { key, operation: req.operation, age });
|
|
560
|
+
clearTimeout(req.timeout);
|
|
561
|
+
this.pendingRequests.delete(key);
|
|
562
|
+
req.reject(new TimeoutError(req.operation, age, { key, stale: true }));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}, 6e4);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Stop stale request cleanup
|
|
569
|
+
*/
|
|
570
|
+
stopStaleRequestCleanup() {
|
|
571
|
+
if (this.staleRequestCleanupTimer) {
|
|
572
|
+
clearInterval(this.staleRequestCleanupTimer);
|
|
573
|
+
this.staleRequestCleanupTimer = null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
577
|
+
// MESSAGE HANDLING
|
|
578
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
579
|
+
send(event, payload) {
|
|
580
|
+
if (this.isExternallyAttached && this.externalSendFn) {
|
|
581
|
+
this.logger.debug("Sending message via external", { event });
|
|
582
|
+
this.externalSendFn(event, payload);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
586
|
+
throw new ConnectionError("Not connected");
|
|
587
|
+
}
|
|
588
|
+
const message = JSON.stringify({ event, payload });
|
|
589
|
+
this.logger.debug("Sending message", { event, payloadSize: message.length });
|
|
590
|
+
this.ws.send(message);
|
|
591
|
+
}
|
|
592
|
+
handleMessage(data) {
|
|
593
|
+
this.lastMessageAt = Date.now();
|
|
594
|
+
let parsed;
|
|
595
|
+
try {
|
|
596
|
+
parsed = JSON.parse(data);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
const error = err instanceof Error ? err.message : "Unknown parse error";
|
|
599
|
+
this.logger.error("Failed to parse message", { error, dataPreview: data.substring(0, 100) });
|
|
600
|
+
this.emit("parseError", { data: data.substring(0, 100), error });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const { event, payload } = parsed;
|
|
604
|
+
if (!event) {
|
|
605
|
+
this.logger.warn("Message missing event field", { dataPreview: data.substring(0, 100) });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
this.logger.debug("Received message", { event });
|
|
609
|
+
switch (event) {
|
|
610
|
+
case "operations":
|
|
611
|
+
this.handleOperations(payload);
|
|
612
|
+
break;
|
|
613
|
+
case "inspectedElement":
|
|
614
|
+
this.handleInspectedElement(payload);
|
|
615
|
+
break;
|
|
616
|
+
case "ownersList":
|
|
617
|
+
this.handleOwnersList(payload);
|
|
618
|
+
break;
|
|
619
|
+
case "profilingData":
|
|
620
|
+
this.handleProfilingData(payload);
|
|
621
|
+
break;
|
|
622
|
+
case "profilingStatus":
|
|
623
|
+
this.handleProfilingStatus(payload);
|
|
624
|
+
break;
|
|
625
|
+
case "backendVersion":
|
|
626
|
+
this.backendVersion = payload;
|
|
627
|
+
this.logger.info("Backend version", { version: this.backendVersion });
|
|
628
|
+
break;
|
|
629
|
+
case "bridge":
|
|
630
|
+
case "bridgeProtocol":
|
|
631
|
+
this.logger.debug("Bridge protocol", { payload });
|
|
632
|
+
break;
|
|
633
|
+
case "renderer":
|
|
634
|
+
this.handleRenderer(payload);
|
|
635
|
+
break;
|
|
636
|
+
case "unsupportedRendererVersion":
|
|
637
|
+
this.logger.error("Unsupported React version", { version: payload });
|
|
638
|
+
this.setError(`Unsupported React version: ${payload}`);
|
|
639
|
+
break;
|
|
640
|
+
case "shutdown":
|
|
641
|
+
this.logger.info("Backend shutdown received");
|
|
642
|
+
this.disconnect();
|
|
643
|
+
break;
|
|
644
|
+
case "NativeStyleEditor_styleAndLayout":
|
|
645
|
+
this.handleNativeStyleResponse(payload);
|
|
646
|
+
break;
|
|
647
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
648
|
+
// Phase 2.1: Additional Message Handlers
|
|
649
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
650
|
+
case "isBackendStorageAPISupported":
|
|
651
|
+
this.handleStorageSupport(payload);
|
|
652
|
+
break;
|
|
653
|
+
case "isSynchronousXHRSupported":
|
|
654
|
+
this.handleXHRSupport(payload);
|
|
655
|
+
break;
|
|
656
|
+
case "getSupportedRendererInterfaces":
|
|
657
|
+
this.handleRendererInterfaces(payload);
|
|
658
|
+
break;
|
|
659
|
+
case "updateComponentFilters":
|
|
660
|
+
this.logger.debug("Component filters updated");
|
|
661
|
+
this.emit("filtersUpdated");
|
|
662
|
+
break;
|
|
663
|
+
case "savedToClipboard":
|
|
664
|
+
this.logger.debug("Content saved to clipboard");
|
|
665
|
+
this.handleClipboardResponse(payload);
|
|
666
|
+
break;
|
|
667
|
+
case "viewAttributeSourceResult":
|
|
668
|
+
this.handleAttributeSourceResult(payload);
|
|
669
|
+
break;
|
|
670
|
+
case "overrideContextResult":
|
|
671
|
+
this.handleOverrideContextResponse(payload);
|
|
672
|
+
break;
|
|
673
|
+
case "inspectingNativeStarted":
|
|
674
|
+
this.isInspectingNative = true;
|
|
675
|
+
this.logger.info("Native inspection started");
|
|
676
|
+
this.emit("inspectingNativeStarted");
|
|
677
|
+
break;
|
|
678
|
+
case "inspectingNativeStopped":
|
|
679
|
+
this.isInspectingNative = false;
|
|
680
|
+
this.handleInspectingNativeStopped(payload);
|
|
681
|
+
break;
|
|
682
|
+
case "captureScreenshotResult":
|
|
683
|
+
this.handleScreenshotResponse(payload);
|
|
684
|
+
break;
|
|
685
|
+
default:
|
|
686
|
+
this.logger.debug("Unknown message type", { event });
|
|
687
|
+
this.emit("unknown", { event, payload });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
handleRenderer(payload) {
|
|
691
|
+
const renderer = {
|
|
692
|
+
id: payload.id,
|
|
693
|
+
version: payload.rendererVersion,
|
|
694
|
+
packageName: payload.rendererPackageName,
|
|
695
|
+
rootIDs: /* @__PURE__ */ new Set(),
|
|
696
|
+
elementIDs: /* @__PURE__ */ new Set()
|
|
697
|
+
};
|
|
698
|
+
this.renderers.set(payload.id, renderer);
|
|
699
|
+
this.logger.info("Renderer connected", { id: payload.id, version: payload.rendererVersion });
|
|
700
|
+
this.emit("renderer", { id: payload.id, rendererVersion: payload.rendererVersion });
|
|
701
|
+
}
|
|
702
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
703
|
+
// Phase 2.1: Capability Detection Handlers
|
|
704
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
705
|
+
handleStorageSupport(payload) {
|
|
706
|
+
this.capabilities.isBackendStorageAPISupported = payload.isSupported;
|
|
707
|
+
this.logger.debug("Storage API support", { isSupported: payload.isSupported });
|
|
708
|
+
this.checkCapabilitiesComplete();
|
|
709
|
+
}
|
|
710
|
+
handleXHRSupport(payload) {
|
|
711
|
+
this.capabilities.isSynchronousXHRSupported = payload.isSupported;
|
|
712
|
+
this.logger.debug("Synchronous XHR support", { isSupported: payload.isSupported });
|
|
713
|
+
this.checkCapabilitiesComplete();
|
|
714
|
+
}
|
|
715
|
+
handleRendererInterfaces(payload) {
|
|
716
|
+
this.logger.debug("Renderer interfaces received", { count: payload.rendererInterfaces?.length ?? 0 });
|
|
717
|
+
if (payload.rendererInterfaces) {
|
|
718
|
+
for (const iface of payload.rendererInterfaces) {
|
|
719
|
+
const renderer = this.renderers.get(iface.id);
|
|
720
|
+
if (renderer) {
|
|
721
|
+
renderer.version = iface.version;
|
|
722
|
+
renderer.packageName = iface.renderer;
|
|
723
|
+
}
|
|
724
|
+
const versionNum = parseFloat(iface.version);
|
|
725
|
+
if (versionNum >= 18) {
|
|
726
|
+
this.capabilities.supportsProfilingChangeDescriptions = true;
|
|
727
|
+
this.capabilities.supportsTimeline = true;
|
|
728
|
+
this.capabilities.supportsErrorBoundaryTesting = true;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
this.checkCapabilitiesComplete();
|
|
733
|
+
}
|
|
734
|
+
checkCapabilitiesComplete() {
|
|
735
|
+
if (!this.capabilitiesNegotiated) {
|
|
736
|
+
this.capabilitiesNegotiated = true;
|
|
737
|
+
this.logger.info("Protocol capabilities negotiated", { capabilities: this.capabilities });
|
|
738
|
+
this.emit("capabilitiesNegotiated", this.capabilities);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
handleAttributeSourceResult(payload) {
|
|
742
|
+
this.resolveCorrelatedRequest("attributeSource", payload, payload.source);
|
|
743
|
+
if (payload.source) {
|
|
744
|
+
this.emit("attributeSource", payload.source);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
handleInspectingNativeStopped(payload) {
|
|
748
|
+
this.logger.info("Native inspection stopped", { elementID: payload.elementID });
|
|
749
|
+
this.resolvePending("inspectNative", payload.elementID);
|
|
750
|
+
this.emit("inspectingNativeStopped", payload.elementID);
|
|
751
|
+
}
|
|
752
|
+
handleNativeStyleResponse(payload) {
|
|
753
|
+
this.resolveCorrelatedRequest("nativeStyle", payload, { style: payload.style, layout: payload.layout });
|
|
754
|
+
}
|
|
755
|
+
handleClipboardResponse(payload) {
|
|
756
|
+
if (payload.responseID !== void 0) {
|
|
757
|
+
this.resolvePending(`clipboard_${payload.responseID}`, { success: true });
|
|
758
|
+
} else {
|
|
759
|
+
for (const pendingKey of this.pendingRequests.keys()) {
|
|
760
|
+
if (pendingKey.startsWith("clipboard_")) {
|
|
761
|
+
this.resolvePending(pendingKey, { success: true });
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
handleOverrideContextResponse(payload) {
|
|
768
|
+
this.resolveCorrelatedRequest("overrideContext", payload, payload);
|
|
769
|
+
}
|
|
770
|
+
handleScreenshotResponse(payload) {
|
|
771
|
+
this.resolveCorrelatedRequest("screenshot", payload, payload);
|
|
772
|
+
}
|
|
773
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
774
|
+
// OPERATIONS PARSING (Phase 1.4: Bounds Checking)
|
|
775
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
776
|
+
/**
|
|
777
|
+
* Decode UTF-8 string from operations array
|
|
778
|
+
* Based on react-devtools-shared/src/utils.js utfDecodeStringWithRanges
|
|
779
|
+
*/
|
|
780
|
+
utfDecodeString(operations, start, end) {
|
|
781
|
+
let result = "";
|
|
782
|
+
for (let i = start; i <= end; i++) {
|
|
783
|
+
const charCode = operations[i];
|
|
784
|
+
if (typeof charCode === "number" && charCode >= 0 && charCode <= 1114111) {
|
|
785
|
+
result += String.fromCodePoint(charCode);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return result;
|
|
789
|
+
}
|
|
790
|
+
handleOperations(operations) {
|
|
791
|
+
if (!Array.isArray(operations)) {
|
|
792
|
+
this.logger.warn("Invalid operations: not an array");
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (operations.length < 3) {
|
|
796
|
+
this.logger.debug("Empty operations array");
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const rendererID = operations[0];
|
|
800
|
+
const rootID = operations[1];
|
|
801
|
+
if (rootID !== 0) {
|
|
802
|
+
this.rootIDs.add(rootID);
|
|
803
|
+
}
|
|
804
|
+
let i = 2;
|
|
805
|
+
const stringTableSize = operations[i];
|
|
806
|
+
i++;
|
|
807
|
+
const stringTable = [null];
|
|
808
|
+
const stringTableEnd = i + stringTableSize;
|
|
809
|
+
while (i < stringTableEnd && i < operations.length) {
|
|
810
|
+
const strLength = operations[i];
|
|
811
|
+
i++;
|
|
812
|
+
if (strLength > 0 && i + strLength - 1 < operations.length) {
|
|
813
|
+
const str = this.utfDecodeString(operations, i, i + strLength - 1);
|
|
814
|
+
stringTable.push(str);
|
|
815
|
+
i += strLength;
|
|
816
|
+
} else {
|
|
817
|
+
stringTable.push("");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
this.logger.debug("Parsed string table", {
|
|
821
|
+
rendererID,
|
|
822
|
+
rootID,
|
|
823
|
+
stringCount: stringTable.length - 1,
|
|
824
|
+
strings: stringTable.slice(1),
|
|
825
|
+
operationsStart: i
|
|
826
|
+
});
|
|
827
|
+
while (i < operations.length) {
|
|
828
|
+
const op = operations[i];
|
|
829
|
+
if (typeof op !== "number") {
|
|
830
|
+
this.logger.warn("Invalid operation code", { index: i, value: op });
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
switch (op) {
|
|
834
|
+
case TREE_OP.ADD:
|
|
835
|
+
i = this.processAddOperation(operations, i + 1, rendererID, stringTable);
|
|
836
|
+
break;
|
|
837
|
+
case TREE_OP.REMOVE:
|
|
838
|
+
i = this.processRemoveOperation(operations, i + 1);
|
|
839
|
+
break;
|
|
840
|
+
case TREE_OP.REORDER:
|
|
841
|
+
i = this.processReorderOperation(operations, i + 1);
|
|
842
|
+
break;
|
|
843
|
+
case TREE_OP.UPDATE_TREE_BASE_DURATION:
|
|
844
|
+
i += 3;
|
|
845
|
+
break;
|
|
846
|
+
case TREE_OP.UPDATE_ERRORS_OR_WARNINGS:
|
|
847
|
+
i = this.processErrorsWarningsOperation(operations, i + 1);
|
|
848
|
+
break;
|
|
849
|
+
default:
|
|
850
|
+
this.logger.warn("Unknown operation code", { code: op, index: i });
|
|
851
|
+
i++;
|
|
852
|
+
}
|
|
853
|
+
if (i <= 0) {
|
|
854
|
+
this.logger.error("Operations parser stuck", { index: i });
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
this.emit("operationsComplete");
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Process ADD operation with string table lookup
|
|
862
|
+
* Based on react-devtools-shared/src/devtools/store.js onBridgeOperations
|
|
863
|
+
*
|
|
864
|
+
* Root format: [id, type=11, isStrictModeCompliant, profilerFlags, supportsStrictMode, hasOwnerMetadata]
|
|
865
|
+
* Non-root format: [id, type, parentID, ownerID, displayNameStringID, keyStringID, namePropStringID]
|
|
866
|
+
*/
|
|
867
|
+
processAddOperation(ops, i, rendererID, stringTable) {
|
|
868
|
+
if (i + 2 > ops.length) {
|
|
869
|
+
this.logger.warn("ADD operation: insufficient data for id/type", { index: i, available: ops.length - i });
|
|
870
|
+
return ops.length;
|
|
871
|
+
}
|
|
872
|
+
const id = ops[i++];
|
|
873
|
+
const type = ops[i++];
|
|
874
|
+
if (type === 11) {
|
|
875
|
+
if (i + 4 > ops.length) {
|
|
876
|
+
this.logger.warn("ADD root: insufficient data", { index: i, available: ops.length - i, needed: 4 });
|
|
877
|
+
return ops.length;
|
|
878
|
+
}
|
|
879
|
+
const isStrictModeCompliant = ops[i++] > 0;
|
|
880
|
+
const profilerFlags = ops[i++];
|
|
881
|
+
const supportsStrictMode = ops[i++] > 0;
|
|
882
|
+
const hasOwnerMetadata = ops[i++] > 0;
|
|
883
|
+
const element2 = {
|
|
884
|
+
id,
|
|
885
|
+
parentID: null,
|
|
886
|
+
displayName: "Root",
|
|
887
|
+
type: "root",
|
|
888
|
+
key: null,
|
|
889
|
+
depth: 0,
|
|
890
|
+
weight: 1,
|
|
891
|
+
ownerID: null,
|
|
892
|
+
hasChildren: false,
|
|
893
|
+
env: null,
|
|
894
|
+
hocDisplayNames: null
|
|
895
|
+
};
|
|
896
|
+
this.rootIDs.add(id);
|
|
897
|
+
this.elements.set(id, element2);
|
|
898
|
+
this.elementToRenderer.set(id, rendererID);
|
|
899
|
+
const renderer2 = this.renderers.get(rendererID);
|
|
900
|
+
if (renderer2) {
|
|
901
|
+
renderer2.rootIDs.add(id);
|
|
902
|
+
renderer2.elementIDs.add(id);
|
|
903
|
+
}
|
|
904
|
+
this.logger.debug("Added root element", {
|
|
905
|
+
id,
|
|
906
|
+
rendererID,
|
|
907
|
+
isStrictModeCompliant,
|
|
908
|
+
profilerFlags,
|
|
909
|
+
supportsStrictMode,
|
|
910
|
+
hasOwnerMetadata
|
|
911
|
+
});
|
|
912
|
+
this.emit("elementAdded", element2);
|
|
913
|
+
return i;
|
|
914
|
+
}
|
|
915
|
+
if (i + 5 > ops.length) {
|
|
916
|
+
this.logger.warn("ADD operation: insufficient data", { index: i, available: ops.length - i, needed: 5 });
|
|
917
|
+
return ops.length;
|
|
918
|
+
}
|
|
919
|
+
const parentID = ops[i++];
|
|
920
|
+
const ownerID = ops[i++];
|
|
921
|
+
const displayNameStringID = ops[i++];
|
|
922
|
+
const keyStringID = ops[i++];
|
|
923
|
+
i++;
|
|
924
|
+
const displayName = displayNameStringID > 0 && displayNameStringID < stringTable.length ? stringTable[displayNameStringID] ?? "Unknown" : "Unknown";
|
|
925
|
+
const key = keyStringID > 0 && keyStringID < stringTable.length ? stringTable[keyStringID] : null;
|
|
926
|
+
const element = {
|
|
927
|
+
id,
|
|
928
|
+
parentID: parentID === 0 ? null : parentID,
|
|
929
|
+
displayName,
|
|
930
|
+
type: ELEMENT_TYPE_MAP[type] ?? "function",
|
|
931
|
+
key,
|
|
932
|
+
depth: 0,
|
|
933
|
+
weight: 1,
|
|
934
|
+
ownerID: ownerID === 0 ? null : ownerID,
|
|
935
|
+
hasChildren: false,
|
|
936
|
+
env: null,
|
|
937
|
+
hocDisplayNames: null
|
|
938
|
+
};
|
|
939
|
+
if (element.parentID !== null) {
|
|
940
|
+
const parent = this.elements.get(element.parentID);
|
|
941
|
+
if (parent) {
|
|
942
|
+
element.depth = parent.depth + 1;
|
|
943
|
+
parent.hasChildren = true;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
this.elements.set(id, element);
|
|
947
|
+
this.elementToRenderer.set(id, rendererID);
|
|
948
|
+
const renderer = this.renderers.get(rendererID);
|
|
949
|
+
if (renderer) {
|
|
950
|
+
renderer.elementIDs.add(id);
|
|
951
|
+
}
|
|
952
|
+
this.logger.debug("Added element", { id, displayName, type: element.type, parentID });
|
|
953
|
+
this.emit("elementAdded", element);
|
|
954
|
+
return i;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Process REMOVE operation with bounds checking
|
|
958
|
+
*/
|
|
959
|
+
processRemoveOperation(ops, i) {
|
|
960
|
+
if (i >= ops.length) {
|
|
961
|
+
this.logger.warn("REMOVE operation: missing count");
|
|
962
|
+
return ops.length;
|
|
963
|
+
}
|
|
964
|
+
const count = ops[i++];
|
|
965
|
+
if (count < 0 || count > 1e5) {
|
|
966
|
+
this.logger.warn("REMOVE operation: invalid count", { count });
|
|
967
|
+
return ops.length;
|
|
968
|
+
}
|
|
969
|
+
if (i + count > ops.length) {
|
|
970
|
+
this.logger.warn("REMOVE operation: not enough IDs", { count, available: ops.length - i });
|
|
971
|
+
return ops.length;
|
|
972
|
+
}
|
|
973
|
+
for (let j = 0; j < count; j++) {
|
|
974
|
+
const id = ops[i++];
|
|
975
|
+
const element = this.elements.get(id);
|
|
976
|
+
if (element) {
|
|
977
|
+
const rendererID = this.elementToRenderer.get(id);
|
|
978
|
+
if (rendererID !== void 0) {
|
|
979
|
+
const renderer = this.renderers.get(rendererID);
|
|
980
|
+
if (renderer) {
|
|
981
|
+
renderer.elementIDs.delete(id);
|
|
982
|
+
renderer.rootIDs.delete(id);
|
|
983
|
+
}
|
|
984
|
+
this.elementToRenderer.delete(id);
|
|
985
|
+
}
|
|
986
|
+
this.elements.delete(id);
|
|
987
|
+
this.rootIDs.delete(id);
|
|
988
|
+
this.elementErrors.delete(id);
|
|
989
|
+
this.elementWarnings.delete(id);
|
|
990
|
+
this.emit("elementRemoved", element);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return i;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Process REORDER operation with bounds checking
|
|
997
|
+
*/
|
|
998
|
+
processReorderOperation(ops, i) {
|
|
999
|
+
if (i + 1 >= ops.length) {
|
|
1000
|
+
this.logger.warn("REORDER operation: insufficient data");
|
|
1001
|
+
return ops.length;
|
|
1002
|
+
}
|
|
1003
|
+
const id = ops[i++];
|
|
1004
|
+
const childCount = ops[i++];
|
|
1005
|
+
if (childCount < 0 || childCount > 1e5) {
|
|
1006
|
+
this.logger.warn("REORDER operation: invalid childCount", { childCount });
|
|
1007
|
+
return ops.length;
|
|
1008
|
+
}
|
|
1009
|
+
if (i + childCount > ops.length) {
|
|
1010
|
+
this.logger.warn("REORDER operation: not enough child IDs", { childCount, available: ops.length - i });
|
|
1011
|
+
return ops.length;
|
|
1012
|
+
}
|
|
1013
|
+
i += childCount;
|
|
1014
|
+
this.emit("elementReordered", { id, childCount });
|
|
1015
|
+
return i;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Process ERRORS/WARNINGS operation with bounds checking
|
|
1019
|
+
*/
|
|
1020
|
+
processErrorsWarningsOperation(ops, i) {
|
|
1021
|
+
if (i + 2 >= ops.length) {
|
|
1022
|
+
this.logger.warn("ERRORS_WARNINGS operation: insufficient data");
|
|
1023
|
+
return ops.length;
|
|
1024
|
+
}
|
|
1025
|
+
const id = ops[i++];
|
|
1026
|
+
const errorCount = ops[i++];
|
|
1027
|
+
const warningCount = ops[i++];
|
|
1028
|
+
if (errorCount > 0) {
|
|
1029
|
+
this.elementErrors.set(id, []);
|
|
1030
|
+
} else {
|
|
1031
|
+
this.elementErrors.delete(id);
|
|
1032
|
+
}
|
|
1033
|
+
if (warningCount > 0) {
|
|
1034
|
+
this.elementWarnings.set(id, []);
|
|
1035
|
+
} else {
|
|
1036
|
+
this.elementWarnings.delete(id);
|
|
1037
|
+
}
|
|
1038
|
+
return i;
|
|
1039
|
+
}
|
|
1040
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1041
|
+
// RESPONSE HANDLERS (Phase 1.6: ID Correlation)
|
|
1042
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1043
|
+
handleInspectedElement(payload) {
|
|
1044
|
+
this.resolveCorrelatedRequest("inspect", payload, payload);
|
|
1045
|
+
}
|
|
1046
|
+
handleOwnersList(payload) {
|
|
1047
|
+
this.resolveCorrelatedRequest("owners", payload, payload.owners);
|
|
1048
|
+
}
|
|
1049
|
+
handleProfilingData(payload) {
|
|
1050
|
+
this.profilingData = payload;
|
|
1051
|
+
this.resolvePending("profilingData", payload);
|
|
1052
|
+
}
|
|
1053
|
+
handleProfilingStatus(payload) {
|
|
1054
|
+
this.isProfiling = payload.isProfiling;
|
|
1055
|
+
this.resolvePending("profilingStatus", payload);
|
|
1056
|
+
}
|
|
1057
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1058
|
+
// PUBLIC API
|
|
1059
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1060
|
+
getComponentTree(rootID, maxDepth) {
|
|
1061
|
+
const result = [];
|
|
1062
|
+
const rootsToProcess = rootID ? [rootID] : Array.from(this.rootIDs);
|
|
1063
|
+
for (const rid of rootsToProcess) {
|
|
1064
|
+
const root = this.elements.get(rid);
|
|
1065
|
+
if (!root) continue;
|
|
1066
|
+
const elements = [];
|
|
1067
|
+
const collectElements = (id, depth) => {
|
|
1068
|
+
const el = this.elements.get(id);
|
|
1069
|
+
if (!el) return;
|
|
1070
|
+
if (maxDepth !== void 0 && depth > maxDepth) return;
|
|
1071
|
+
elements.push(el);
|
|
1072
|
+
for (const [, child] of this.elements) {
|
|
1073
|
+
if (child.parentID === id) {
|
|
1074
|
+
collectElements(child.id, depth + 1);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
collectElements(rid, 0);
|
|
1079
|
+
result.push({
|
|
1080
|
+
rootID: rid,
|
|
1081
|
+
displayName: root.displayName,
|
|
1082
|
+
elements
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
return result;
|
|
1086
|
+
}
|
|
1087
|
+
getElementById(id) {
|
|
1088
|
+
return this.elements.get(id) ?? null;
|
|
1089
|
+
}
|
|
1090
|
+
searchComponents(query, caseSensitive = false, isRegex = false) {
|
|
1091
|
+
const matches = [];
|
|
1092
|
+
let pattern = null;
|
|
1093
|
+
if (isRegex) {
|
|
1094
|
+
try {
|
|
1095
|
+
pattern = new RegExp(query, caseSensitive ? "" : "i");
|
|
1096
|
+
} catch {
|
|
1097
|
+
this.logger.warn("Invalid regex pattern", { query });
|
|
1098
|
+
return [];
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const searchLower = caseSensitive ? query : query.toLowerCase();
|
|
1102
|
+
for (const [, element] of this.elements) {
|
|
1103
|
+
const name = caseSensitive ? element.displayName : element.displayName.toLowerCase();
|
|
1104
|
+
if (pattern) {
|
|
1105
|
+
if (pattern.test(element.displayName)) {
|
|
1106
|
+
matches.push(element);
|
|
1107
|
+
}
|
|
1108
|
+
} else if (name.includes(searchLower)) {
|
|
1109
|
+
matches.push(element);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return matches;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Inspect element with request ID correlation (Phase 1.6)
|
|
1116
|
+
*/
|
|
1117
|
+
async inspectElement(id, paths) {
|
|
1118
|
+
this.ensureConnected();
|
|
1119
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1120
|
+
if (rendererID === null) {
|
|
1121
|
+
return { type: "not-found", id };
|
|
1122
|
+
}
|
|
1123
|
+
const requestID = this.nextRequestId();
|
|
1124
|
+
const primaryKey = `inspect_${requestID}`;
|
|
1125
|
+
const promise = this.createPending(primaryKey, `inspectElement(${id})`);
|
|
1126
|
+
this.storeFallbackKey("inspect", requestID, id);
|
|
1127
|
+
this.send("inspectElement", {
|
|
1128
|
+
id,
|
|
1129
|
+
rendererID,
|
|
1130
|
+
requestID,
|
|
1131
|
+
forceFullData: true,
|
|
1132
|
+
path: paths?.[0] ?? null
|
|
1133
|
+
});
|
|
1134
|
+
return promise;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Get owners list with request ID correlation (Phase 1.6)
|
|
1138
|
+
*/
|
|
1139
|
+
async getOwnersList(id) {
|
|
1140
|
+
this.ensureConnected();
|
|
1141
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1142
|
+
if (rendererID === null) {
|
|
1143
|
+
return [];
|
|
1144
|
+
}
|
|
1145
|
+
const requestID = this.nextRequestId();
|
|
1146
|
+
const primaryKey = `owners_${requestID}`;
|
|
1147
|
+
const promise = this.createPending(primaryKey, `getOwnersList(${id})`);
|
|
1148
|
+
this.storeFallbackKey("owners", requestID, id);
|
|
1149
|
+
this.send("getOwnersList", { id, rendererID, requestID });
|
|
1150
|
+
return promise;
|
|
1151
|
+
}
|
|
1152
|
+
highlightElement(id) {
|
|
1153
|
+
this.ensureConnected();
|
|
1154
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1155
|
+
if (rendererID === null) return;
|
|
1156
|
+
this.send("highlightNativeElement", { id, rendererID });
|
|
1157
|
+
}
|
|
1158
|
+
clearHighlight() {
|
|
1159
|
+
if (this.isConnected()) {
|
|
1160
|
+
this.send("clearNativeElementHighlight", {});
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
scrollToElement(id) {
|
|
1164
|
+
this.ensureConnected();
|
|
1165
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1166
|
+
if (rendererID === null) return;
|
|
1167
|
+
this.send("scrollToNativeElement", { id, rendererID });
|
|
1168
|
+
}
|
|
1169
|
+
logToConsole(id) {
|
|
1170
|
+
this.ensureConnected();
|
|
1171
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1172
|
+
if (rendererID === null) return;
|
|
1173
|
+
this.send("logElementToConsole", { id, rendererID });
|
|
1174
|
+
}
|
|
1175
|
+
storeAsGlobal(id, path, count) {
|
|
1176
|
+
this.ensureConnected();
|
|
1177
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1178
|
+
if (rendererID === null) return;
|
|
1179
|
+
this.send("storeAsGlobal", { id, rendererID, path, count });
|
|
1180
|
+
}
|
|
1181
|
+
viewElementSource(id) {
|
|
1182
|
+
this.ensureConnected();
|
|
1183
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1184
|
+
if (rendererID === null) return;
|
|
1185
|
+
this.send("viewElementSource", { id, rendererID });
|
|
1186
|
+
}
|
|
1187
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1188
|
+
// OVERRIDES
|
|
1189
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1190
|
+
overrideValueAtPath(target, id, path, value, hookIndex) {
|
|
1191
|
+
this.ensureConnected();
|
|
1192
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1193
|
+
if (rendererID === null) return;
|
|
1194
|
+
this.send("overrideValueAtPath", {
|
|
1195
|
+
type: target,
|
|
1196
|
+
id,
|
|
1197
|
+
rendererID,
|
|
1198
|
+
path,
|
|
1199
|
+
value,
|
|
1200
|
+
hookID: hookIndex
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
deletePath(target, id, path, hookIndex) {
|
|
1204
|
+
this.ensureConnected();
|
|
1205
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1206
|
+
if (rendererID === null) return;
|
|
1207
|
+
this.send("deletePath", {
|
|
1208
|
+
type: target,
|
|
1209
|
+
id,
|
|
1210
|
+
rendererID,
|
|
1211
|
+
path,
|
|
1212
|
+
hookID: hookIndex
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
renamePath(target, id, path, oldKey, newKey, hookIndex) {
|
|
1216
|
+
this.ensureConnected();
|
|
1217
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1218
|
+
if (rendererID === null) return;
|
|
1219
|
+
this.send("renamePath", {
|
|
1220
|
+
type: target,
|
|
1221
|
+
id,
|
|
1222
|
+
rendererID,
|
|
1223
|
+
path,
|
|
1224
|
+
oldKey,
|
|
1225
|
+
newKey,
|
|
1226
|
+
hookID: hookIndex
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1230
|
+
// ERROR / SUSPENSE
|
|
1231
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1232
|
+
overrideError(id, isErrored) {
|
|
1233
|
+
this.ensureConnected();
|
|
1234
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1235
|
+
if (rendererID === null) return;
|
|
1236
|
+
this.send("overrideError", { id, rendererID, forceError: isErrored });
|
|
1237
|
+
}
|
|
1238
|
+
overrideSuspense(id, isSuspended) {
|
|
1239
|
+
this.ensureConnected();
|
|
1240
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1241
|
+
if (rendererID === null) return;
|
|
1242
|
+
this.send("overrideSuspense", { id, rendererID, forceFallback: isSuspended });
|
|
1243
|
+
}
|
|
1244
|
+
clearErrorsAndWarnings(id) {
|
|
1245
|
+
this.ensureConnected();
|
|
1246
|
+
if (id !== void 0) {
|
|
1247
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1248
|
+
if (rendererID === null) return;
|
|
1249
|
+
this.send("clearErrorsForFiberID", { id, rendererID });
|
|
1250
|
+
} else {
|
|
1251
|
+
this.send("clearErrorsAndWarnings", {});
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
getErrorsAndWarnings() {
|
|
1255
|
+
return {
|
|
1256
|
+
errors: new Map(this.elementErrors),
|
|
1257
|
+
warnings: new Map(this.elementWarnings)
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1261
|
+
// PROFILING
|
|
1262
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1263
|
+
startProfiling(recordTimeline = false, recordChangeDescriptions = true) {
|
|
1264
|
+
this.ensureConnected();
|
|
1265
|
+
this.send("startProfiling", { recordTimeline, recordChangeDescriptions });
|
|
1266
|
+
this.isProfiling = true;
|
|
1267
|
+
this.logger.info("Profiling started", { recordTimeline, recordChangeDescriptions });
|
|
1268
|
+
}
|
|
1269
|
+
stopProfiling() {
|
|
1270
|
+
this.ensureConnected();
|
|
1271
|
+
this.send("stopProfiling", {});
|
|
1272
|
+
this.isProfiling = false;
|
|
1273
|
+
this.logger.info("Profiling stopped");
|
|
1274
|
+
}
|
|
1275
|
+
async getProfilingData() {
|
|
1276
|
+
if (!this.isProfiling && this.profilingData) {
|
|
1277
|
+
return this.profilingData;
|
|
1278
|
+
}
|
|
1279
|
+
this.ensureConnected();
|
|
1280
|
+
const promise = this.createPending("profilingData", "getProfilingData");
|
|
1281
|
+
this.send("getProfilingData", {});
|
|
1282
|
+
return promise;
|
|
1283
|
+
}
|
|
1284
|
+
getProfilingStatus() {
|
|
1285
|
+
return { isProfiling: this.isProfiling };
|
|
1286
|
+
}
|
|
1287
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1288
|
+
// FILTERS
|
|
1289
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1290
|
+
setComponentFilters(filters) {
|
|
1291
|
+
this.ensureConnected();
|
|
1292
|
+
this.send("updateComponentFilters", { componentFilters: filters });
|
|
1293
|
+
}
|
|
1294
|
+
setTraceUpdatesEnabled(enabled) {
|
|
1295
|
+
this.ensureConnected();
|
|
1296
|
+
this.send("setTraceUpdatesEnabled", { enabled });
|
|
1297
|
+
}
|
|
1298
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1299
|
+
// REACT NATIVE SPECIFIC
|
|
1300
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1301
|
+
async getNativeStyle(id) {
|
|
1302
|
+
this.ensureConnected();
|
|
1303
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1304
|
+
if (rendererID === null) {
|
|
1305
|
+
return { style: null, layout: null };
|
|
1306
|
+
}
|
|
1307
|
+
const requestID = this.nextRequestId();
|
|
1308
|
+
const primaryKey = `nativeStyle_${requestID}`;
|
|
1309
|
+
const promise = this.createPending(primaryKey, `getNativeStyle(${id})`);
|
|
1310
|
+
this.storeFallbackKey("nativeStyle", requestID, id);
|
|
1311
|
+
this.send("NativeStyleEditor_measure", { id, rendererID, requestID });
|
|
1312
|
+
return promise;
|
|
1313
|
+
}
|
|
1314
|
+
setNativeStyle(id, property, value) {
|
|
1315
|
+
this.ensureConnected();
|
|
1316
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1317
|
+
if (rendererID === null) return;
|
|
1318
|
+
this.send("NativeStyleEditor_setValue", { id, rendererID, name: property, value });
|
|
1319
|
+
}
|
|
1320
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1321
|
+
// PHASE 2.1: ADDITIONAL PUBLIC API
|
|
1322
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1323
|
+
/**
|
|
1324
|
+
* Save content to clipboard
|
|
1325
|
+
*/
|
|
1326
|
+
async saveToClipboard(value) {
|
|
1327
|
+
this.ensureConnected();
|
|
1328
|
+
const requestID = this.nextRequestId();
|
|
1329
|
+
const primaryKey = `clipboard_${requestID}`;
|
|
1330
|
+
const promise = this.createPending(primaryKey, "saveToClipboard");
|
|
1331
|
+
this.send("saveToClipboard", { value, requestID });
|
|
1332
|
+
return Promise.race([
|
|
1333
|
+
promise,
|
|
1334
|
+
new Promise(
|
|
1335
|
+
(resolve) => setTimeout(() => resolve({ success: true }), 500)
|
|
1336
|
+
)
|
|
1337
|
+
]);
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* View attribute source location
|
|
1341
|
+
*/
|
|
1342
|
+
async viewAttributeSource(id, path) {
|
|
1343
|
+
this.ensureConnected();
|
|
1344
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1345
|
+
if (rendererID === null) return null;
|
|
1346
|
+
const requestID = this.nextRequestId();
|
|
1347
|
+
const primaryKey = `attributeSource_${requestID}`;
|
|
1348
|
+
const promise = this.createPending(primaryKey, `viewAttributeSource(${id})`);
|
|
1349
|
+
this.storeFallbackKey("attributeSource", requestID, id);
|
|
1350
|
+
this.send("viewAttributeSource", { id, rendererID, path, requestID });
|
|
1351
|
+
return promise;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Override context value
|
|
1355
|
+
*/
|
|
1356
|
+
async overrideContext(id, path, value) {
|
|
1357
|
+
this.ensureConnected();
|
|
1358
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1359
|
+
if (rendererID === null) return false;
|
|
1360
|
+
const requestID = this.nextRequestId();
|
|
1361
|
+
const primaryKey = `overrideContext_${requestID}`;
|
|
1362
|
+
const promise = this.createPending(primaryKey, `overrideContext(${id})`);
|
|
1363
|
+
this.storeFallbackKey("overrideContext", requestID, id);
|
|
1364
|
+
this.send("overrideContext", { id, rendererID, path, value, requestID });
|
|
1365
|
+
try {
|
|
1366
|
+
const result = await promise;
|
|
1367
|
+
return result.success;
|
|
1368
|
+
} catch {
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Start native element inspection mode
|
|
1374
|
+
*/
|
|
1375
|
+
startInspectingNative() {
|
|
1376
|
+
this.ensureConnected();
|
|
1377
|
+
this.send("startInspectingNative", {});
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Stop native element inspection mode
|
|
1381
|
+
* @param selectNextElement - Whether to select the next element under pointer
|
|
1382
|
+
* @returns The ID of the selected element, or null
|
|
1383
|
+
*/
|
|
1384
|
+
async stopInspectingNative(selectNextElement = true) {
|
|
1385
|
+
this.ensureConnected();
|
|
1386
|
+
const promise = this.createPending("inspectNative", "stopInspectingNative");
|
|
1387
|
+
this.send("stopInspectingNative", { selectNextElement });
|
|
1388
|
+
return promise;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Check if currently in native inspection mode
|
|
1392
|
+
*/
|
|
1393
|
+
isInspectingNativeMode() {
|
|
1394
|
+
return this.isInspectingNative;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Capture screenshot of an element
|
|
1398
|
+
*/
|
|
1399
|
+
async captureScreenshot(id) {
|
|
1400
|
+
this.ensureConnected();
|
|
1401
|
+
const rendererID = this.getRendererIDForElement(id);
|
|
1402
|
+
if (rendererID === null) return null;
|
|
1403
|
+
const requestID = this.nextRequestId();
|
|
1404
|
+
const primaryKey = `screenshot_${requestID}`;
|
|
1405
|
+
const promise = this.createPending(primaryKey, `captureScreenshot(${id})`);
|
|
1406
|
+
this.storeFallbackKey("screenshot", requestID, id);
|
|
1407
|
+
this.send("captureScreenshot", { id, rendererID, requestID });
|
|
1408
|
+
try {
|
|
1409
|
+
const result = await promise;
|
|
1410
|
+
return result.screenshot;
|
|
1411
|
+
} catch {
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1416
|
+
// PHASE 2.2: CAPABILITIES API
|
|
1417
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1418
|
+
/**
|
|
1419
|
+
* Get negotiated protocol capabilities
|
|
1420
|
+
*/
|
|
1421
|
+
getCapabilities() {
|
|
1422
|
+
return { ...this.capabilities };
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Check if capabilities have been negotiated
|
|
1426
|
+
*/
|
|
1427
|
+
hasNegotiatedCapabilities() {
|
|
1428
|
+
return this.capabilitiesNegotiated;
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Wait for capabilities negotiation to complete
|
|
1432
|
+
*/
|
|
1433
|
+
async waitForCapabilities(timeout = 5e3) {
|
|
1434
|
+
if (this.capabilitiesNegotiated) {
|
|
1435
|
+
return this.getCapabilities();
|
|
1436
|
+
}
|
|
1437
|
+
return new Promise((resolve, reject) => {
|
|
1438
|
+
const timer = setTimeout(() => {
|
|
1439
|
+
this.removeListener("capabilitiesNegotiated", handler);
|
|
1440
|
+
reject(new TimeoutError("waitForCapabilities", timeout));
|
|
1441
|
+
}, timeout);
|
|
1442
|
+
const handler = (capabilities) => {
|
|
1443
|
+
clearTimeout(timer);
|
|
1444
|
+
resolve(capabilities);
|
|
1445
|
+
};
|
|
1446
|
+
this.once("capabilitiesNegotiated", handler);
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1450
|
+
// PHASE 2.3: RENDERER MANAGEMENT API
|
|
1451
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1452
|
+
/**
|
|
1453
|
+
* Get all connected renderers
|
|
1454
|
+
*/
|
|
1455
|
+
getRenderers() {
|
|
1456
|
+
return Array.from(this.renderers.values()).map((r) => ({
|
|
1457
|
+
...r,
|
|
1458
|
+
rootIDs: new Set(r.rootIDs),
|
|
1459
|
+
elementIDs: new Set(r.elementIDs)
|
|
1460
|
+
}));
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Get renderer by ID
|
|
1464
|
+
*/
|
|
1465
|
+
getRenderer(id) {
|
|
1466
|
+
const renderer = this.renderers.get(id);
|
|
1467
|
+
if (!renderer) return null;
|
|
1468
|
+
return {
|
|
1469
|
+
...renderer,
|
|
1470
|
+
rootIDs: new Set(renderer.rootIDs),
|
|
1471
|
+
elementIDs: new Set(renderer.elementIDs)
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Get renderer for a specific element
|
|
1476
|
+
*/
|
|
1477
|
+
getRendererForElement(elementID) {
|
|
1478
|
+
const rendererID = this.getRendererIDForElement(elementID);
|
|
1479
|
+
if (rendererID === null) return null;
|
|
1480
|
+
return this.getRenderer(rendererID);
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Get elements for a specific renderer
|
|
1484
|
+
*/
|
|
1485
|
+
getElementsByRenderer(rendererID) {
|
|
1486
|
+
const renderer = this.renderers.get(rendererID);
|
|
1487
|
+
if (!renderer) return [];
|
|
1488
|
+
return Array.from(renderer.elementIDs).map((id) => this.elements.get(id)).filter((el) => el !== void 0);
|
|
1489
|
+
}
|
|
1490
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1491
|
+
// HELPERS
|
|
1492
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1493
|
+
ensureConnected() {
|
|
1494
|
+
if (!this.isConnected()) {
|
|
1495
|
+
throw new ConnectionError("Not connected to DevTools");
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Get renderer ID for an element (Phase 2.3: Multi-renderer support)
|
|
1500
|
+
*/
|
|
1501
|
+
getRendererIDForElement(id) {
|
|
1502
|
+
if (!this.elements.has(id)) {
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
const rendererID = this.elementToRenderer.get(id);
|
|
1506
|
+
if (rendererID !== void 0) {
|
|
1507
|
+
return rendererID;
|
|
1508
|
+
}
|
|
1509
|
+
for (const renderer of this.renderers.values()) {
|
|
1510
|
+
if (renderer.elementIDs.has(id) || renderer.rootIDs.has(id)) {
|
|
1511
|
+
return renderer.id;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
if (this.renderers.size === 0) {
|
|
1515
|
+
return 1;
|
|
1516
|
+
}
|
|
1517
|
+
return this.renderers.keys().next().value ?? 1;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Get last message timestamp (for health monitoring)
|
|
1521
|
+
*/
|
|
1522
|
+
getLastMessageTime() {
|
|
1523
|
+
return this.lastMessageAt;
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Get pending request count (for monitoring)
|
|
1527
|
+
*/
|
|
1528
|
+
getPendingRequestCount() {
|
|
1529
|
+
return this.pendingRequests.size;
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
// src/headless-server.ts
|
|
1534
|
+
import WebSocket2, { WebSocketServer } from "ws";
|
|
1535
|
+
import { createServer as createHttpServer } from "http";
|
|
1536
|
+
import { createServer as createHttpsServer } from "https";
|
|
1537
|
+
import { readFileSync } from "fs";
|
|
1538
|
+
import { createRequire } from "module";
|
|
1539
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
1540
|
+
var require2 = createRequire(import.meta.url);
|
|
1541
|
+
var HeadlessDevToolsServer = class extends EventEmitter2 {
|
|
1542
|
+
_options;
|
|
1543
|
+
_httpServer = null;
|
|
1544
|
+
_wsServer = null;
|
|
1545
|
+
_socket = null;
|
|
1546
|
+
_state;
|
|
1547
|
+
_backendScript = null;
|
|
1548
|
+
constructor(options = {}) {
|
|
1549
|
+
super();
|
|
1550
|
+
this._options = {
|
|
1551
|
+
port: options.port ?? 8097,
|
|
1552
|
+
host: options.host ?? "localhost",
|
|
1553
|
+
httpsOptions: options.httpsOptions,
|
|
1554
|
+
logger: options.logger ?? noopLogger
|
|
1555
|
+
};
|
|
1556
|
+
this._state = {
|
|
1557
|
+
status: "stopped",
|
|
1558
|
+
port: this._options.port,
|
|
1559
|
+
host: this._options.host,
|
|
1560
|
+
connectedAt: null,
|
|
1561
|
+
error: null
|
|
1562
|
+
};
|
|
1563
|
+
this._loadBackendScript();
|
|
1564
|
+
}
|
|
1565
|
+
_loadBackendScript() {
|
|
1566
|
+
try {
|
|
1567
|
+
const backendPath = require2.resolve("react-devtools-core/dist/backend.js");
|
|
1568
|
+
this._backendScript = readFileSync(backendPath, "utf-8");
|
|
1569
|
+
this._options.logger.debug("Loaded backend.js from react-devtools-core");
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
this._options.logger.warn("Could not load backend.js - web apps will need to include it manually");
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
get state() {
|
|
1575
|
+
return { ...this._state };
|
|
1576
|
+
}
|
|
1577
|
+
get isConnected() {
|
|
1578
|
+
return this._socket !== null && this._socket.readyState === WebSocket2.OPEN;
|
|
1579
|
+
}
|
|
1580
|
+
// External message listeners (for MCP bridge integration)
|
|
1581
|
+
_externalMessageListeners = [];
|
|
1582
|
+
/**
|
|
1583
|
+
* Add an external message listener that receives all messages from React app.
|
|
1584
|
+
* Used to relay messages to the MCP's DevToolsBridge.
|
|
1585
|
+
*/
|
|
1586
|
+
addMessageListener(fn) {
|
|
1587
|
+
this._externalMessageListeners.push(fn);
|
|
1588
|
+
return () => {
|
|
1589
|
+
const idx = this._externalMessageListeners.indexOf(fn);
|
|
1590
|
+
if (idx >= 0) this._externalMessageListeners.splice(idx, 1);
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Send a message to the React app via WebSocket.
|
|
1595
|
+
* Used by the MCP's DevToolsBridge to send messages.
|
|
1596
|
+
*/
|
|
1597
|
+
sendMessage(event, payload) {
|
|
1598
|
+
if (this._socket && this._socket.readyState === WebSocket2.OPEN) {
|
|
1599
|
+
this._socket.send(JSON.stringify({ event, payload }));
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Start the headless DevTools server
|
|
1604
|
+
*/
|
|
1605
|
+
async start() {
|
|
1606
|
+
if (this._state.status === "listening" || this._state.status === "connected") {
|
|
1607
|
+
this._options.logger.debug("Server already running");
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
this._setState({ status: "starting", error: null });
|
|
1611
|
+
const { port, host, httpsOptions } = this._options;
|
|
1612
|
+
const logger = this._options.logger;
|
|
1613
|
+
return new Promise((resolve, reject) => {
|
|
1614
|
+
try {
|
|
1615
|
+
this._httpServer = httpsOptions ? createHttpsServer(httpsOptions) : createHttpServer();
|
|
1616
|
+
this._httpServer.on("request", (req, res) => {
|
|
1617
|
+
this._handleHttpRequest(req, res);
|
|
1618
|
+
});
|
|
1619
|
+
this._wsServer = new WebSocketServer({
|
|
1620
|
+
server: this._httpServer,
|
|
1621
|
+
maxPayload: 1e9
|
|
1622
|
+
// 1GB - same as standalone.js
|
|
1623
|
+
});
|
|
1624
|
+
this._wsServer.on("connection", (socket) => {
|
|
1625
|
+
this._handleConnection(socket);
|
|
1626
|
+
});
|
|
1627
|
+
this._wsServer.on("error", (err) => {
|
|
1628
|
+
logger.error("WebSocket server error", { error: err.message });
|
|
1629
|
+
this._setState({ status: "error", error: err.message });
|
|
1630
|
+
this.emit("error", err);
|
|
1631
|
+
});
|
|
1632
|
+
this._httpServer.on("error", (err) => {
|
|
1633
|
+
logger.error("HTTP server error", { error: err.message, code: err.code });
|
|
1634
|
+
if (err.code === "EADDRINUSE") {
|
|
1635
|
+
this._setState({
|
|
1636
|
+
status: "error",
|
|
1637
|
+
error: `Port ${port} is already in use. Another DevTools instance may be running.`
|
|
1638
|
+
});
|
|
1639
|
+
} else {
|
|
1640
|
+
this._setState({ status: "error", error: err.message });
|
|
1641
|
+
}
|
|
1642
|
+
this.emit("error", err);
|
|
1643
|
+
reject(err);
|
|
1644
|
+
});
|
|
1645
|
+
this._httpServer.listen(port, host, () => {
|
|
1646
|
+
logger.info("Headless DevTools server listening", { port, host });
|
|
1647
|
+
this._setState({ status: "listening" });
|
|
1648
|
+
this.emit("listening", { port, host });
|
|
1649
|
+
resolve();
|
|
1650
|
+
});
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
1653
|
+
logger.error("Failed to start server", { error: message });
|
|
1654
|
+
this._setState({ status: "error", error: message });
|
|
1655
|
+
reject(err);
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Stop the server
|
|
1661
|
+
*/
|
|
1662
|
+
async stop() {
|
|
1663
|
+
const logger = this._options.logger;
|
|
1664
|
+
logger.info("Stopping headless DevTools server");
|
|
1665
|
+
if (this._socket) {
|
|
1666
|
+
this._socket.close();
|
|
1667
|
+
this._socket = null;
|
|
1668
|
+
}
|
|
1669
|
+
if (this._wsServer) {
|
|
1670
|
+
this._wsServer.close();
|
|
1671
|
+
this._wsServer = null;
|
|
1672
|
+
}
|
|
1673
|
+
if (this._httpServer) {
|
|
1674
|
+
this._httpServer.close();
|
|
1675
|
+
this._httpServer = null;
|
|
1676
|
+
}
|
|
1677
|
+
this._setState({
|
|
1678
|
+
status: "stopped",
|
|
1679
|
+
connectedAt: null,
|
|
1680
|
+
error: null
|
|
1681
|
+
});
|
|
1682
|
+
this.emit("stopped");
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Handle HTTP requests - serve backend.js for web apps
|
|
1686
|
+
*/
|
|
1687
|
+
_handleHttpRequest(_req, res) {
|
|
1688
|
+
const { port, host, httpsOptions, logger } = this._options;
|
|
1689
|
+
const useHttps = !!httpsOptions;
|
|
1690
|
+
if (!this._backendScript) {
|
|
1691
|
+
logger.warn("Backend script not available");
|
|
1692
|
+
res.writeHead(503);
|
|
1693
|
+
res.end("Backend script not available. Web apps need to include react-devtools backend manually.");
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
logger.debug("Serving backend.js to web client");
|
|
1697
|
+
const responseScript = `${this._backendScript}
|
|
1698
|
+
;ReactDevToolsBackend.initialize();
|
|
1699
|
+
ReactDevToolsBackend.connectToDevTools({port: ${port}, host: '${host}', useHttps: ${useHttps}});
|
|
1700
|
+
`;
|
|
1701
|
+
res.end(responseScript);
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Handle new WebSocket connection from React app
|
|
1705
|
+
*/
|
|
1706
|
+
_handleConnection(socket) {
|
|
1707
|
+
const logger = this._options.logger;
|
|
1708
|
+
if (this._socket !== null) {
|
|
1709
|
+
logger.warn("Only one connection allowed at a time. Closing previous connection.");
|
|
1710
|
+
this._socket.close();
|
|
1711
|
+
}
|
|
1712
|
+
logger.info("React app connected");
|
|
1713
|
+
this._socket = socket;
|
|
1714
|
+
socket.on("message", (data) => {
|
|
1715
|
+
try {
|
|
1716
|
+
const message = JSON.parse(data.toString());
|
|
1717
|
+
logger.debug("Received message", { event: message.event });
|
|
1718
|
+
this._externalMessageListeners.forEach((fn) => {
|
|
1719
|
+
try {
|
|
1720
|
+
fn(message.event, message.payload);
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
logger.error("Error in external message listener", { error: err instanceof Error ? err.message : "Unknown" });
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
} catch (err) {
|
|
1726
|
+
logger.error("Failed to parse message", { data: data.toString().slice(0, 100) });
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1729
|
+
socket.on("close", () => {
|
|
1730
|
+
logger.info("React app disconnected");
|
|
1731
|
+
this._onDisconnected();
|
|
1732
|
+
});
|
|
1733
|
+
socket.on("error", (err) => {
|
|
1734
|
+
logger.error("WebSocket connection error", { error: err.message });
|
|
1735
|
+
this._onDisconnected();
|
|
1736
|
+
});
|
|
1737
|
+
this._setState({
|
|
1738
|
+
status: "connected",
|
|
1739
|
+
connectedAt: Date.now()
|
|
1740
|
+
});
|
|
1741
|
+
this.emit("connected");
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Handle disconnection
|
|
1745
|
+
*/
|
|
1746
|
+
_onDisconnected() {
|
|
1747
|
+
this._socket = null;
|
|
1748
|
+
this._setState({
|
|
1749
|
+
status: "listening",
|
|
1750
|
+
connectedAt: null
|
|
1751
|
+
});
|
|
1752
|
+
this.emit("disconnected");
|
|
1753
|
+
}
|
|
1754
|
+
_setState(updates) {
|
|
1755
|
+
this._state = { ...this._state, ...updates };
|
|
1756
|
+
this.emit("stateChange", this._state);
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
async function startHeadlessServer(options = {}) {
|
|
1760
|
+
const server = new HeadlessDevToolsServer(options);
|
|
1761
|
+
await server.start();
|
|
1762
|
+
return server;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// src/server.ts
|
|
1766
|
+
var TOOLS = [
|
|
1767
|
+
// Connection
|
|
1768
|
+
{
|
|
1769
|
+
name: "connect",
|
|
1770
|
+
description: "Connect to React DevTools backend via WebSocket",
|
|
1771
|
+
inputSchema: {
|
|
1772
|
+
type: "object",
|
|
1773
|
+
properties: {
|
|
1774
|
+
host: { type: "string", description: "Host (default: localhost)" },
|
|
1775
|
+
port: { type: "number", description: "Port (default: 8097)" },
|
|
1776
|
+
timeout: { type: "number", description: "Timeout in ms (default: 5000)" }
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
},
|
|
1780
|
+
{
|
|
1781
|
+
name: "disconnect",
|
|
1782
|
+
description: "Disconnect from React DevTools backend",
|
|
1783
|
+
inputSchema: { type: "object", properties: {} }
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
name: "get_connection_status",
|
|
1787
|
+
description: "Get current connection status",
|
|
1788
|
+
inputSchema: { type: "object", properties: {} }
|
|
1789
|
+
},
|
|
1790
|
+
// Component Tree
|
|
1791
|
+
{
|
|
1792
|
+
name: "get_component_tree",
|
|
1793
|
+
description: "Get the React component tree for all roots",
|
|
1794
|
+
inputSchema: {
|
|
1795
|
+
type: "object",
|
|
1796
|
+
properties: {
|
|
1797
|
+
rootID: { type: "number", description: "Filter by root ID (optional)" },
|
|
1798
|
+
maxDepth: { type: "number", description: "Maximum depth to return (optional)" }
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
},
|
|
1802
|
+
{
|
|
1803
|
+
name: "get_element_by_id",
|
|
1804
|
+
description: "Get basic element info by ID",
|
|
1805
|
+
inputSchema: {
|
|
1806
|
+
type: "object",
|
|
1807
|
+
properties: {
|
|
1808
|
+
id: { type: "number", description: "Element ID" }
|
|
1809
|
+
},
|
|
1810
|
+
required: ["id"]
|
|
1811
|
+
}
|
|
1812
|
+
},
|
|
1813
|
+
{
|
|
1814
|
+
name: "search_components",
|
|
1815
|
+
description: "Search for components by name",
|
|
1816
|
+
inputSchema: {
|
|
1817
|
+
type: "object",
|
|
1818
|
+
properties: {
|
|
1819
|
+
query: { type: "string", description: "Search query (component name)" },
|
|
1820
|
+
caseSensitive: { type: "boolean", description: "Case sensitive (default: false)" },
|
|
1821
|
+
isRegex: { type: "boolean", description: "Regex search (default: false)" }
|
|
1822
|
+
},
|
|
1823
|
+
required: ["query"]
|
|
1824
|
+
}
|
|
1825
|
+
},
|
|
1826
|
+
// Inspection
|
|
1827
|
+
{
|
|
1828
|
+
name: "inspect_element",
|
|
1829
|
+
description: "Get full inspection data for a component including props, state, hooks",
|
|
1830
|
+
inputSchema: {
|
|
1831
|
+
type: "object",
|
|
1832
|
+
properties: {
|
|
1833
|
+
id: { type: "number", description: "Element ID to inspect" },
|
|
1834
|
+
paths: {
|
|
1835
|
+
type: "array",
|
|
1836
|
+
items: {
|
|
1837
|
+
type: "array",
|
|
1838
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] }
|
|
1839
|
+
},
|
|
1840
|
+
description: "Paths to hydrate for lazy loading"
|
|
1841
|
+
}
|
|
1842
|
+
},
|
|
1843
|
+
required: ["id"]
|
|
1844
|
+
}
|
|
1845
|
+
},
|
|
1846
|
+
{
|
|
1847
|
+
name: "get_owners_list",
|
|
1848
|
+
description: "Get the chain of components that rendered this element",
|
|
1849
|
+
inputSchema: {
|
|
1850
|
+
type: "object",
|
|
1851
|
+
properties: {
|
|
1852
|
+
id: { type: "number", description: "Element ID" }
|
|
1853
|
+
},
|
|
1854
|
+
required: ["id"]
|
|
1855
|
+
}
|
|
1856
|
+
},
|
|
1857
|
+
{
|
|
1858
|
+
name: "get_element_source",
|
|
1859
|
+
description: "Get source location for an element",
|
|
1860
|
+
inputSchema: {
|
|
1861
|
+
type: "object",
|
|
1862
|
+
properties: {
|
|
1863
|
+
id: { type: "number", description: "Element ID" }
|
|
1864
|
+
},
|
|
1865
|
+
required: ["id"]
|
|
1866
|
+
}
|
|
1867
|
+
},
|
|
1868
|
+
// Overrides
|
|
1869
|
+
{
|
|
1870
|
+
name: "override_props",
|
|
1871
|
+
description: "Override a prop value on a component",
|
|
1872
|
+
inputSchema: {
|
|
1873
|
+
type: "object",
|
|
1874
|
+
properties: {
|
|
1875
|
+
id: { type: "number", description: "Element ID" },
|
|
1876
|
+
path: {
|
|
1877
|
+
type: "array",
|
|
1878
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
1879
|
+
description: "Path to the prop"
|
|
1880
|
+
},
|
|
1881
|
+
value: { description: "New value" }
|
|
1882
|
+
},
|
|
1883
|
+
required: ["id", "path", "value"]
|
|
1884
|
+
}
|
|
1885
|
+
},
|
|
1886
|
+
{
|
|
1887
|
+
name: "override_state",
|
|
1888
|
+
description: "Override a state value on a class component",
|
|
1889
|
+
inputSchema: {
|
|
1890
|
+
type: "object",
|
|
1891
|
+
properties: {
|
|
1892
|
+
id: { type: "number", description: "Element ID" },
|
|
1893
|
+
path: {
|
|
1894
|
+
type: "array",
|
|
1895
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
1896
|
+
description: "Path to state key"
|
|
1897
|
+
},
|
|
1898
|
+
value: { description: "New value" }
|
|
1899
|
+
},
|
|
1900
|
+
required: ["id", "path", "value"]
|
|
1901
|
+
}
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
name: "override_hooks",
|
|
1905
|
+
description: "Override a hook value on a function component",
|
|
1906
|
+
inputSchema: {
|
|
1907
|
+
type: "object",
|
|
1908
|
+
properties: {
|
|
1909
|
+
id: { type: "number", description: "Element ID" },
|
|
1910
|
+
hookIndex: { type: "number", description: "Hook index" },
|
|
1911
|
+
path: {
|
|
1912
|
+
type: "array",
|
|
1913
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
1914
|
+
description: "Path within hook value"
|
|
1915
|
+
},
|
|
1916
|
+
value: { description: "New value" }
|
|
1917
|
+
},
|
|
1918
|
+
required: ["id", "hookIndex", "path", "value"]
|
|
1919
|
+
}
|
|
1920
|
+
},
|
|
1921
|
+
{
|
|
1922
|
+
name: "override_context",
|
|
1923
|
+
description: "Override a context value",
|
|
1924
|
+
inputSchema: {
|
|
1925
|
+
type: "object",
|
|
1926
|
+
properties: {
|
|
1927
|
+
id: { type: "number", description: "Element ID" },
|
|
1928
|
+
path: {
|
|
1929
|
+
type: "array",
|
|
1930
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
1931
|
+
description: "Path within context"
|
|
1932
|
+
},
|
|
1933
|
+
value: { description: "New value" }
|
|
1934
|
+
},
|
|
1935
|
+
required: ["id", "path", "value"]
|
|
1936
|
+
}
|
|
1937
|
+
},
|
|
1938
|
+
{
|
|
1939
|
+
name: "delete_path",
|
|
1940
|
+
description: "Delete a path from props/state/hooks/context",
|
|
1941
|
+
inputSchema: {
|
|
1942
|
+
type: "object",
|
|
1943
|
+
properties: {
|
|
1944
|
+
id: { type: "number", description: "Element ID" },
|
|
1945
|
+
target: { type: "string", enum: ["props", "state", "hooks", "context"], description: "Target" },
|
|
1946
|
+
hookIndex: { type: "number", description: "Hook index (if target is hooks)" },
|
|
1947
|
+
path: {
|
|
1948
|
+
type: "array",
|
|
1949
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
1950
|
+
description: "Path to delete"
|
|
1951
|
+
}
|
|
1952
|
+
},
|
|
1953
|
+
required: ["id", "target", "path"]
|
|
1954
|
+
}
|
|
1955
|
+
},
|
|
1956
|
+
{
|
|
1957
|
+
name: "rename_path",
|
|
1958
|
+
description: "Rename a key in props/state/hooks/context",
|
|
1959
|
+
inputSchema: {
|
|
1960
|
+
type: "object",
|
|
1961
|
+
properties: {
|
|
1962
|
+
id: { type: "number", description: "Element ID" },
|
|
1963
|
+
target: { type: "string", enum: ["props", "state", "hooks", "context"], description: "Target" },
|
|
1964
|
+
hookIndex: { type: "number", description: "Hook index (if target is hooks)" },
|
|
1965
|
+
path: {
|
|
1966
|
+
type: "array",
|
|
1967
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
1968
|
+
description: "Path to the key"
|
|
1969
|
+
},
|
|
1970
|
+
oldKey: { type: "string", description: "Old key name" },
|
|
1971
|
+
newKey: { type: "string", description: "New key name" }
|
|
1972
|
+
},
|
|
1973
|
+
required: ["id", "target", "path", "oldKey", "newKey"]
|
|
1974
|
+
}
|
|
1975
|
+
},
|
|
1976
|
+
// Profiling
|
|
1977
|
+
{
|
|
1978
|
+
name: "start_profiling",
|
|
1979
|
+
description: "Start profiling React renders",
|
|
1980
|
+
inputSchema: {
|
|
1981
|
+
type: "object",
|
|
1982
|
+
properties: {
|
|
1983
|
+
recordTimeline: { type: "boolean", description: "Record timeline data" },
|
|
1984
|
+
recordChangeDescriptions: { type: "boolean", description: "Record why components rendered" }
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1988
|
+
{
|
|
1989
|
+
name: "stop_profiling",
|
|
1990
|
+
description: "Stop profiling and get data",
|
|
1991
|
+
inputSchema: { type: "object", properties: {} }
|
|
1992
|
+
},
|
|
1993
|
+
{
|
|
1994
|
+
name: "get_profiling_data",
|
|
1995
|
+
description: "Get profiling data without stopping",
|
|
1996
|
+
inputSchema: { type: "object", properties: {} }
|
|
1997
|
+
},
|
|
1998
|
+
{
|
|
1999
|
+
name: "get_profiling_status",
|
|
2000
|
+
description: "Check if profiling is active",
|
|
2001
|
+
inputSchema: { type: "object", properties: {} }
|
|
2002
|
+
},
|
|
2003
|
+
// Error & Suspense
|
|
2004
|
+
{
|
|
2005
|
+
name: "get_errors_and_warnings",
|
|
2006
|
+
description: "Get all errors and warnings from components",
|
|
2007
|
+
inputSchema: { type: "object", properties: {} }
|
|
2008
|
+
},
|
|
2009
|
+
{
|
|
2010
|
+
name: "clear_errors_and_warnings",
|
|
2011
|
+
description: "Clear all or specific element's errors/warnings",
|
|
2012
|
+
inputSchema: {
|
|
2013
|
+
type: "object",
|
|
2014
|
+
properties: {
|
|
2015
|
+
id: { type: "number", description: "Element ID (optional, clears all if omitted)" },
|
|
2016
|
+
clearErrors: { type: "boolean", description: "Clear errors" },
|
|
2017
|
+
clearWarnings: { type: "boolean", description: "Clear warnings" }
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
name: "toggle_error",
|
|
2023
|
+
description: "Toggle error boundary state for testing",
|
|
2024
|
+
inputSchema: {
|
|
2025
|
+
type: "object",
|
|
2026
|
+
properties: {
|
|
2027
|
+
id: { type: "number", description: "Element ID" },
|
|
2028
|
+
isErrored: { type: "boolean", description: "Force error state" }
|
|
2029
|
+
},
|
|
2030
|
+
required: ["id", "isErrored"]
|
|
2031
|
+
}
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
name: "toggle_suspense",
|
|
2035
|
+
description: "Toggle suspense state for testing",
|
|
2036
|
+
inputSchema: {
|
|
2037
|
+
type: "object",
|
|
2038
|
+
properties: {
|
|
2039
|
+
id: { type: "number", description: "Element ID" },
|
|
2040
|
+
isSuspended: { type: "boolean", description: "Force suspended state" }
|
|
2041
|
+
},
|
|
2042
|
+
required: ["id", "isSuspended"]
|
|
2043
|
+
}
|
|
2044
|
+
},
|
|
2045
|
+
// Debugging
|
|
2046
|
+
{
|
|
2047
|
+
name: "highlight_element",
|
|
2048
|
+
description: "Highlight an element in the app UI",
|
|
2049
|
+
inputSchema: {
|
|
2050
|
+
type: "object",
|
|
2051
|
+
properties: {
|
|
2052
|
+
id: { type: "number", description: "Element ID to highlight" },
|
|
2053
|
+
duration: { type: "number", description: "Highlight duration in ms (default: 2000)" }
|
|
2054
|
+
},
|
|
2055
|
+
required: ["id"]
|
|
2056
|
+
}
|
|
2057
|
+
},
|
|
2058
|
+
{
|
|
2059
|
+
name: "clear_highlight",
|
|
2060
|
+
description: "Clear any active element highlight",
|
|
2061
|
+
inputSchema: { type: "object", properties: {} }
|
|
2062
|
+
},
|
|
2063
|
+
{
|
|
2064
|
+
name: "scroll_to_element",
|
|
2065
|
+
description: "Scroll the app to show an element",
|
|
2066
|
+
inputSchema: {
|
|
2067
|
+
type: "object",
|
|
2068
|
+
properties: {
|
|
2069
|
+
id: { type: "number", description: "Element ID" }
|
|
2070
|
+
},
|
|
2071
|
+
required: ["id"]
|
|
2072
|
+
}
|
|
2073
|
+
},
|
|
2074
|
+
{
|
|
2075
|
+
name: "log_to_console",
|
|
2076
|
+
description: "Log an element to the browser/app console as $r",
|
|
2077
|
+
inputSchema: {
|
|
2078
|
+
type: "object",
|
|
2079
|
+
properties: {
|
|
2080
|
+
id: { type: "number", description: "Element ID" }
|
|
2081
|
+
},
|
|
2082
|
+
required: ["id"]
|
|
2083
|
+
}
|
|
2084
|
+
},
|
|
2085
|
+
{
|
|
2086
|
+
name: "store_as_global",
|
|
2087
|
+
description: "Store a value as a global variable for console access",
|
|
2088
|
+
inputSchema: {
|
|
2089
|
+
type: "object",
|
|
2090
|
+
properties: {
|
|
2091
|
+
id: { type: "number", description: "Element ID" },
|
|
2092
|
+
path: {
|
|
2093
|
+
type: "array",
|
|
2094
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
2095
|
+
description: "Path to the value"
|
|
2096
|
+
},
|
|
2097
|
+
globalName: { type: "string", description: "Global variable name" }
|
|
2098
|
+
},
|
|
2099
|
+
required: ["id", "path", "globalName"]
|
|
2100
|
+
}
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
name: "view_source",
|
|
2104
|
+
description: "Open element source in IDE (if supported)",
|
|
2105
|
+
inputSchema: {
|
|
2106
|
+
type: "object",
|
|
2107
|
+
properties: {
|
|
2108
|
+
id: { type: "number", description: "Element ID" }
|
|
2109
|
+
},
|
|
2110
|
+
required: ["id"]
|
|
2111
|
+
}
|
|
2112
|
+
},
|
|
2113
|
+
// Filters
|
|
2114
|
+
{
|
|
2115
|
+
name: "get_component_filters",
|
|
2116
|
+
description: "Get current component filters",
|
|
2117
|
+
inputSchema: { type: "object", properties: {} }
|
|
2118
|
+
},
|
|
2119
|
+
{
|
|
2120
|
+
name: "set_component_filters",
|
|
2121
|
+
description: "Set component filters (hide certain components)",
|
|
2122
|
+
inputSchema: {
|
|
2123
|
+
type: "object",
|
|
2124
|
+
properties: {
|
|
2125
|
+
filters: {
|
|
2126
|
+
type: "array",
|
|
2127
|
+
items: {
|
|
2128
|
+
type: "object",
|
|
2129
|
+
properties: {
|
|
2130
|
+
type: { type: "string", enum: ["name", "location", "type", "hoc"] },
|
|
2131
|
+
value: { type: "string" },
|
|
2132
|
+
isEnabled: { type: "boolean" },
|
|
2133
|
+
isRegex: { type: "boolean" }
|
|
2134
|
+
},
|
|
2135
|
+
required: ["type", "value", "isEnabled"]
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
},
|
|
2139
|
+
required: ["filters"]
|
|
2140
|
+
}
|
|
2141
|
+
},
|
|
2142
|
+
{
|
|
2143
|
+
name: "set_trace_updates_enabled",
|
|
2144
|
+
description: "Enable/disable visual update highlighting",
|
|
2145
|
+
inputSchema: {
|
|
2146
|
+
type: "object",
|
|
2147
|
+
properties: {
|
|
2148
|
+
enabled: { type: "boolean", description: "Enable trace updates" }
|
|
2149
|
+
},
|
|
2150
|
+
required: ["enabled"]
|
|
2151
|
+
}
|
|
2152
|
+
},
|
|
2153
|
+
// React Native
|
|
2154
|
+
{
|
|
2155
|
+
name: "get_native_style",
|
|
2156
|
+
description: "Get native style and layout info (React Native only)",
|
|
2157
|
+
inputSchema: {
|
|
2158
|
+
type: "object",
|
|
2159
|
+
properties: {
|
|
2160
|
+
id: { type: "number", description: "Element ID" }
|
|
2161
|
+
},
|
|
2162
|
+
required: ["id"]
|
|
2163
|
+
}
|
|
2164
|
+
},
|
|
2165
|
+
{
|
|
2166
|
+
name: "set_native_style",
|
|
2167
|
+
description: "Set a native style property (React Native only)",
|
|
2168
|
+
inputSchema: {
|
|
2169
|
+
type: "object",
|
|
2170
|
+
properties: {
|
|
2171
|
+
id: { type: "number", description: "Element ID" },
|
|
2172
|
+
property: { type: "string", description: "Style property name" },
|
|
2173
|
+
value: { description: "New value" }
|
|
2174
|
+
},
|
|
2175
|
+
required: ["id", "property", "value"]
|
|
2176
|
+
}
|
|
2177
|
+
},
|
|
2178
|
+
// Health & Monitoring
|
|
2179
|
+
{
|
|
2180
|
+
name: "health_check",
|
|
2181
|
+
description: "Get server and connection health status",
|
|
2182
|
+
inputSchema: { type: "object", properties: {} }
|
|
2183
|
+
},
|
|
2184
|
+
// Phase 2: Protocol & Renderer Management
|
|
2185
|
+
{
|
|
2186
|
+
name: "get_capabilities",
|
|
2187
|
+
description: "Get negotiated protocol capabilities (features supported by backend)",
|
|
2188
|
+
inputSchema: { type: "object", properties: {} }
|
|
2189
|
+
},
|
|
2190
|
+
{
|
|
2191
|
+
name: "get_renderers",
|
|
2192
|
+
description: "Get all connected React renderers (for multi-renderer apps)",
|
|
2193
|
+
inputSchema: { type: "object", properties: {} }
|
|
2194
|
+
},
|
|
2195
|
+
{
|
|
2196
|
+
name: "get_renderer",
|
|
2197
|
+
description: "Get a specific renderer by ID",
|
|
2198
|
+
inputSchema: {
|
|
2199
|
+
type: "object",
|
|
2200
|
+
properties: {
|
|
2201
|
+
id: { type: "number", description: "Renderer ID" }
|
|
2202
|
+
},
|
|
2203
|
+
required: ["id"]
|
|
2204
|
+
}
|
|
2205
|
+
},
|
|
2206
|
+
{
|
|
2207
|
+
name: "get_elements_by_renderer",
|
|
2208
|
+
description: "Get all elements for a specific renderer",
|
|
2209
|
+
inputSchema: {
|
|
2210
|
+
type: "object",
|
|
2211
|
+
properties: {
|
|
2212
|
+
rendererID: { type: "number", description: "Renderer ID" }
|
|
2213
|
+
},
|
|
2214
|
+
required: ["rendererID"]
|
|
2215
|
+
}
|
|
2216
|
+
},
|
|
2217
|
+
// Phase 2: Native Inspection
|
|
2218
|
+
{
|
|
2219
|
+
name: "start_inspecting_native",
|
|
2220
|
+
description: "Start native element inspection mode (tap-to-select)",
|
|
2221
|
+
inputSchema: { type: "object", properties: {} }
|
|
2222
|
+
},
|
|
2223
|
+
{
|
|
2224
|
+
name: "stop_inspecting_native",
|
|
2225
|
+
description: "Stop native element inspection mode",
|
|
2226
|
+
inputSchema: {
|
|
2227
|
+
type: "object",
|
|
2228
|
+
properties: {
|
|
2229
|
+
selectNextElement: { type: "boolean", description: "Select element under pointer (default: true)" }
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
},
|
|
2233
|
+
{
|
|
2234
|
+
name: "get_inspecting_native_status",
|
|
2235
|
+
description: "Check if native inspection mode is active",
|
|
2236
|
+
inputSchema: { type: "object", properties: {} }
|
|
2237
|
+
},
|
|
2238
|
+
// Phase 2: Additional Features
|
|
2239
|
+
{
|
|
2240
|
+
name: "capture_screenshot",
|
|
2241
|
+
description: "Capture screenshot of an element (if supported)",
|
|
2242
|
+
inputSchema: {
|
|
2243
|
+
type: "object",
|
|
2244
|
+
properties: {
|
|
2245
|
+
id: { type: "number", description: "Element ID" }
|
|
2246
|
+
},
|
|
2247
|
+
required: ["id"]
|
|
2248
|
+
}
|
|
2249
|
+
},
|
|
2250
|
+
{
|
|
2251
|
+
name: "save_to_clipboard",
|
|
2252
|
+
description: "Save content to system clipboard",
|
|
2253
|
+
inputSchema: {
|
|
2254
|
+
type: "object",
|
|
2255
|
+
properties: {
|
|
2256
|
+
value: { type: "string", description: "Content to save" }
|
|
2257
|
+
},
|
|
2258
|
+
required: ["value"]
|
|
2259
|
+
}
|
|
2260
|
+
},
|
|
2261
|
+
{
|
|
2262
|
+
name: "view_attribute_source",
|
|
2263
|
+
description: "Get source location for a specific attribute path",
|
|
2264
|
+
inputSchema: {
|
|
2265
|
+
type: "object",
|
|
2266
|
+
properties: {
|
|
2267
|
+
id: { type: "number", description: "Element ID" },
|
|
2268
|
+
path: {
|
|
2269
|
+
type: "array",
|
|
2270
|
+
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
|
2271
|
+
description: "Path to attribute"
|
|
2272
|
+
}
|
|
2273
|
+
},
|
|
2274
|
+
required: ["id", "path"]
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
];
|
|
2278
|
+
function createServer(options = {}) {
|
|
2279
|
+
const logger = options.logger ?? createLogger({
|
|
2280
|
+
level: getLogLevelFromEnv(),
|
|
2281
|
+
prefix: "devtools-mcp"
|
|
2282
|
+
});
|
|
2283
|
+
const host = options.host ?? process.env.DEVTOOLS_HOST ?? "localhost";
|
|
2284
|
+
const port = options.port ?? (Number(process.env.DEVTOOLS_PORT) || 8097);
|
|
2285
|
+
const standalone = options.standalone ?? process.env.DEVTOOLS_STANDALONE !== "false";
|
|
2286
|
+
let headlessServer = null;
|
|
2287
|
+
const bridge = new DevToolsBridge({
|
|
2288
|
+
host,
|
|
2289
|
+
port,
|
|
2290
|
+
timeout: Number(process.env.DEVTOOLS_TIMEOUT) || 5e3,
|
|
2291
|
+
logger: logger.child("bridge")
|
|
2292
|
+
});
|
|
2293
|
+
const server = new Server(
|
|
2294
|
+
{
|
|
2295
|
+
name: "react-devtools-mcp",
|
|
2296
|
+
version: "0.1.0"
|
|
2297
|
+
},
|
|
2298
|
+
{
|
|
2299
|
+
capabilities: {
|
|
2300
|
+
tools: {},
|
|
2301
|
+
resources: {}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
);
|
|
2305
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2306
|
+
tools: TOOLS
|
|
2307
|
+
}));
|
|
2308
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
2309
|
+
resources: [
|
|
2310
|
+
{
|
|
2311
|
+
uri: "devtools://components",
|
|
2312
|
+
name: "Component Tree",
|
|
2313
|
+
description: "Live component tree updates",
|
|
2314
|
+
mimeType: "application/json"
|
|
2315
|
+
},
|
|
2316
|
+
{
|
|
2317
|
+
uri: "devtools://selection",
|
|
2318
|
+
name: "Current Selection",
|
|
2319
|
+
description: "Currently selected element",
|
|
2320
|
+
mimeType: "application/json"
|
|
2321
|
+
}
|
|
2322
|
+
]
|
|
2323
|
+
}));
|
|
2324
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
2325
|
+
const uri = request.params.uri;
|
|
2326
|
+
if (uri === "devtools://components") {
|
|
2327
|
+
const tree = bridge.getComponentTree();
|
|
2328
|
+
return {
|
|
2329
|
+
contents: [
|
|
2330
|
+
{
|
|
2331
|
+
uri,
|
|
2332
|
+
mimeType: "application/json",
|
|
2333
|
+
text: JSON.stringify(tree, null, 2)
|
|
2334
|
+
}
|
|
2335
|
+
]
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
if (uri === "devtools://selection") {
|
|
2339
|
+
return {
|
|
2340
|
+
contents: [
|
|
2341
|
+
{
|
|
2342
|
+
uri,
|
|
2343
|
+
mimeType: "application/json",
|
|
2344
|
+
text: JSON.stringify({ selectedElementID: null })
|
|
2345
|
+
}
|
|
2346
|
+
]
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
throw new Error(`Unknown resource: ${uri}`);
|
|
2350
|
+
});
|
|
2351
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2352
|
+
const { name, arguments: args } = request.params;
|
|
2353
|
+
try {
|
|
2354
|
+
const result = await handleToolCall(bridge, name, args ?? {});
|
|
2355
|
+
return {
|
|
2356
|
+
content: [
|
|
2357
|
+
{
|
|
2358
|
+
type: "text",
|
|
2359
|
+
text: JSON.stringify(result, null, 2)
|
|
2360
|
+
}
|
|
2361
|
+
]
|
|
2362
|
+
};
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2365
|
+
return {
|
|
2366
|
+
content: [
|
|
2367
|
+
{
|
|
2368
|
+
type: "text",
|
|
2369
|
+
text: JSON.stringify({ error: message })
|
|
2370
|
+
}
|
|
2371
|
+
],
|
|
2372
|
+
isError: true
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
const autoConnect = options.autoConnect ?? process.env.DEVTOOLS_AUTO_CONNECT !== "false";
|
|
2377
|
+
return {
|
|
2378
|
+
server,
|
|
2379
|
+
bridge,
|
|
2380
|
+
headlessServer: () => headlessServer,
|
|
2381
|
+
async start() {
|
|
2382
|
+
if (standalone) {
|
|
2383
|
+
try {
|
|
2384
|
+
logger.info("Starting standalone mode with embedded DevTools server", { host, port });
|
|
2385
|
+
headlessServer = await startHeadlessServer({
|
|
2386
|
+
host,
|
|
2387
|
+
port,
|
|
2388
|
+
logger: logger.child("headless")
|
|
2389
|
+
});
|
|
2390
|
+
let bridgeHandle = null;
|
|
2391
|
+
headlessServer.addMessageListener((event, payload) => {
|
|
2392
|
+
if (bridgeHandle) {
|
|
2393
|
+
const data = JSON.stringify({ event, payload });
|
|
2394
|
+
bridgeHandle.receiveMessage(data);
|
|
2395
|
+
} else {
|
|
2396
|
+
logger.debug("Message received before bridge attached", { event });
|
|
2397
|
+
}
|
|
2398
|
+
});
|
|
2399
|
+
headlessServer.on("connected", () => {
|
|
2400
|
+
logger.info("React app connected to embedded DevTools server");
|
|
2401
|
+
bridgeHandle = bridge.attachToExternal(
|
|
2402
|
+
(event, payload) => {
|
|
2403
|
+
headlessServer.sendMessage(event, payload);
|
|
2404
|
+
},
|
|
2405
|
+
() => {
|
|
2406
|
+
logger.info("MCP bridge detached from headless server");
|
|
2407
|
+
}
|
|
2408
|
+
);
|
|
2409
|
+
});
|
|
2410
|
+
headlessServer.on("disconnected", () => {
|
|
2411
|
+
logger.info("React app disconnected from embedded DevTools server");
|
|
2412
|
+
bridgeHandle?.detach();
|
|
2413
|
+
bridgeHandle = null;
|
|
2414
|
+
});
|
|
2415
|
+
headlessServer.on("error", (err) => {
|
|
2416
|
+
logger.error("Embedded DevTools server error", { error: err.message });
|
|
2417
|
+
});
|
|
2418
|
+
} catch (err) {
|
|
2419
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
2420
|
+
logger.error("Failed to start embedded DevTools server", { error: message });
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
const transport = new StdioServerTransport();
|
|
2424
|
+
await server.connect(transport);
|
|
2425
|
+
if (autoConnect && !standalone) {
|
|
2426
|
+
try {
|
|
2427
|
+
await bridge.connect();
|
|
2428
|
+
} catch {
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
},
|
|
2432
|
+
async stop() {
|
|
2433
|
+
if (headlessServer) {
|
|
2434
|
+
await headlessServer.stop();
|
|
2435
|
+
headlessServer = null;
|
|
2436
|
+
}
|
|
2437
|
+
bridge.disconnect();
|
|
2438
|
+
}
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
async function handleToolCall(bridge, name, args) {
|
|
2442
|
+
switch (name) {
|
|
2443
|
+
// Connection
|
|
2444
|
+
case "connect": {
|
|
2445
|
+
const status = await bridge.connect();
|
|
2446
|
+
return { success: true, status };
|
|
2447
|
+
}
|
|
2448
|
+
case "disconnect": {
|
|
2449
|
+
bridge.disconnect();
|
|
2450
|
+
return { success: true };
|
|
2451
|
+
}
|
|
2452
|
+
case "get_connection_status": {
|
|
2453
|
+
return { status: bridge.getStatus() };
|
|
2454
|
+
}
|
|
2455
|
+
// Component Tree
|
|
2456
|
+
case "get_component_tree": {
|
|
2457
|
+
const roots = bridge.getComponentTree(
|
|
2458
|
+
args.rootID,
|
|
2459
|
+
args.maxDepth
|
|
2460
|
+
);
|
|
2461
|
+
return { roots };
|
|
2462
|
+
}
|
|
2463
|
+
case "get_element_by_id": {
|
|
2464
|
+
const element = bridge.getElementById(args.id);
|
|
2465
|
+
return { element };
|
|
2466
|
+
}
|
|
2467
|
+
case "search_components": {
|
|
2468
|
+
const matches = bridge.searchComponents(
|
|
2469
|
+
args.query,
|
|
2470
|
+
args.caseSensitive,
|
|
2471
|
+
args.isRegex
|
|
2472
|
+
);
|
|
2473
|
+
return { matches, totalCount: matches.length };
|
|
2474
|
+
}
|
|
2475
|
+
// Inspection
|
|
2476
|
+
case "inspect_element": {
|
|
2477
|
+
const result = await bridge.inspectElement(
|
|
2478
|
+
args.id,
|
|
2479
|
+
args.paths
|
|
2480
|
+
);
|
|
2481
|
+
if (result.type === "full-data") {
|
|
2482
|
+
return { success: true, element: result.element, error: null };
|
|
2483
|
+
} else if (result.type === "not-found") {
|
|
2484
|
+
return { success: false, element: null, error: { type: "not_found", message: "Element not found" } };
|
|
2485
|
+
} else if (result.type === "error") {
|
|
2486
|
+
return { success: false, element: null, error: { type: result.errorType, message: result.message, stack: result.stack } };
|
|
2487
|
+
} else {
|
|
2488
|
+
return { success: true, element: null, error: null };
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
case "get_owners_list": {
|
|
2492
|
+
const owners = await bridge.getOwnersList(args.id);
|
|
2493
|
+
return { owners };
|
|
2494
|
+
}
|
|
2495
|
+
case "get_element_source": {
|
|
2496
|
+
const result = await bridge.inspectElement(args.id);
|
|
2497
|
+
if (result.type === "full-data") {
|
|
2498
|
+
return { source: result.element.source, stack: result.element.stack };
|
|
2499
|
+
}
|
|
2500
|
+
return { source: null, stack: null };
|
|
2501
|
+
}
|
|
2502
|
+
// Overrides
|
|
2503
|
+
case "override_props": {
|
|
2504
|
+
bridge.overrideValueAtPath(
|
|
2505
|
+
"props",
|
|
2506
|
+
args.id,
|
|
2507
|
+
args.path,
|
|
2508
|
+
args.value
|
|
2509
|
+
);
|
|
2510
|
+
return { success: true };
|
|
2511
|
+
}
|
|
2512
|
+
case "override_state": {
|
|
2513
|
+
bridge.overrideValueAtPath(
|
|
2514
|
+
"state",
|
|
2515
|
+
args.id,
|
|
2516
|
+
args.path,
|
|
2517
|
+
args.value
|
|
2518
|
+
);
|
|
2519
|
+
return { success: true };
|
|
2520
|
+
}
|
|
2521
|
+
case "override_hooks": {
|
|
2522
|
+
bridge.overrideValueAtPath(
|
|
2523
|
+
"hooks",
|
|
2524
|
+
args.id,
|
|
2525
|
+
args.path,
|
|
2526
|
+
args.value,
|
|
2527
|
+
args.hookIndex
|
|
2528
|
+
);
|
|
2529
|
+
return { success: true };
|
|
2530
|
+
}
|
|
2531
|
+
case "override_context": {
|
|
2532
|
+
bridge.overrideValueAtPath(
|
|
2533
|
+
"context",
|
|
2534
|
+
args.id,
|
|
2535
|
+
args.path,
|
|
2536
|
+
args.value
|
|
2537
|
+
);
|
|
2538
|
+
return { success: true };
|
|
2539
|
+
}
|
|
2540
|
+
case "delete_path": {
|
|
2541
|
+
bridge.deletePath(
|
|
2542
|
+
args.target,
|
|
2543
|
+
args.id,
|
|
2544
|
+
args.path,
|
|
2545
|
+
args.hookIndex
|
|
2546
|
+
);
|
|
2547
|
+
return { success: true };
|
|
2548
|
+
}
|
|
2549
|
+
case "rename_path": {
|
|
2550
|
+
bridge.renamePath(
|
|
2551
|
+
args.target,
|
|
2552
|
+
args.id,
|
|
2553
|
+
args.path,
|
|
2554
|
+
args.oldKey,
|
|
2555
|
+
args.newKey,
|
|
2556
|
+
args.hookIndex
|
|
2557
|
+
);
|
|
2558
|
+
return { success: true };
|
|
2559
|
+
}
|
|
2560
|
+
// Profiling
|
|
2561
|
+
case "start_profiling": {
|
|
2562
|
+
bridge.startProfiling(
|
|
2563
|
+
args.recordTimeline,
|
|
2564
|
+
args.recordChangeDescriptions
|
|
2565
|
+
);
|
|
2566
|
+
return { success: true, requiresReload: false };
|
|
2567
|
+
}
|
|
2568
|
+
case "stop_profiling": {
|
|
2569
|
+
bridge.stopProfiling();
|
|
2570
|
+
const data = await bridge.getProfilingData();
|
|
2571
|
+
return { success: true, data };
|
|
2572
|
+
}
|
|
2573
|
+
case "get_profiling_data": {
|
|
2574
|
+
const status = bridge.getProfilingStatus();
|
|
2575
|
+
const data = await bridge.getProfilingData();
|
|
2576
|
+
return { isActive: status.isProfiling, data };
|
|
2577
|
+
}
|
|
2578
|
+
case "get_profiling_status": {
|
|
2579
|
+
const status = bridge.getProfilingStatus();
|
|
2580
|
+
return {
|
|
2581
|
+
isProfiling: status.isProfiling,
|
|
2582
|
+
recordTimeline: false,
|
|
2583
|
+
recordChangeDescriptions: true
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
// Error & Suspense
|
|
2587
|
+
case "get_errors_and_warnings": {
|
|
2588
|
+
const { errors, warnings } = bridge.getErrorsAndWarnings();
|
|
2589
|
+
return {
|
|
2590
|
+
errors: Object.fromEntries(errors),
|
|
2591
|
+
warnings: Object.fromEntries(warnings)
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
case "clear_errors_and_warnings": {
|
|
2595
|
+
bridge.clearErrorsAndWarnings(args.id);
|
|
2596
|
+
return { success: true };
|
|
2597
|
+
}
|
|
2598
|
+
case "toggle_error": {
|
|
2599
|
+
bridge.overrideError(args.id, args.isErrored);
|
|
2600
|
+
return { success: true };
|
|
2601
|
+
}
|
|
2602
|
+
case "toggle_suspense": {
|
|
2603
|
+
bridge.overrideSuspense(args.id, args.isSuspended);
|
|
2604
|
+
return { success: true };
|
|
2605
|
+
}
|
|
2606
|
+
// Debugging
|
|
2607
|
+
case "highlight_element": {
|
|
2608
|
+
bridge.highlightElement(args.id);
|
|
2609
|
+
const duration = args.duration ?? 2e3;
|
|
2610
|
+
setTimeout(() => bridge.clearHighlight(), duration);
|
|
2611
|
+
return { success: true };
|
|
2612
|
+
}
|
|
2613
|
+
case "clear_highlight": {
|
|
2614
|
+
bridge.clearHighlight();
|
|
2615
|
+
return { success: true };
|
|
2616
|
+
}
|
|
2617
|
+
case "scroll_to_element": {
|
|
2618
|
+
bridge.scrollToElement(args.id);
|
|
2619
|
+
return { success: true };
|
|
2620
|
+
}
|
|
2621
|
+
case "log_to_console": {
|
|
2622
|
+
bridge.logToConsole(args.id);
|
|
2623
|
+
return { success: true };
|
|
2624
|
+
}
|
|
2625
|
+
case "store_as_global": {
|
|
2626
|
+
bridge.storeAsGlobal(
|
|
2627
|
+
args.id,
|
|
2628
|
+
args.path,
|
|
2629
|
+
1
|
|
2630
|
+
// count
|
|
2631
|
+
);
|
|
2632
|
+
return { success: true };
|
|
2633
|
+
}
|
|
2634
|
+
case "view_source": {
|
|
2635
|
+
bridge.viewElementSource(args.id);
|
|
2636
|
+
const result = await bridge.inspectElement(args.id);
|
|
2637
|
+
if (result.type === "full-data") {
|
|
2638
|
+
return { success: true, source: result.element.source };
|
|
2639
|
+
}
|
|
2640
|
+
return { success: true, source: null };
|
|
2641
|
+
}
|
|
2642
|
+
// Filters
|
|
2643
|
+
case "get_component_filters": {
|
|
2644
|
+
return { filters: [] };
|
|
2645
|
+
}
|
|
2646
|
+
case "set_component_filters": {
|
|
2647
|
+
bridge.setComponentFilters(args.filters);
|
|
2648
|
+
return { success: true };
|
|
2649
|
+
}
|
|
2650
|
+
case "set_trace_updates_enabled": {
|
|
2651
|
+
bridge.setTraceUpdatesEnabled(args.enabled);
|
|
2652
|
+
return { success: true };
|
|
2653
|
+
}
|
|
2654
|
+
// React Native
|
|
2655
|
+
case "get_native_style": {
|
|
2656
|
+
const result = await bridge.getNativeStyle(args.id);
|
|
2657
|
+
return result;
|
|
2658
|
+
}
|
|
2659
|
+
case "set_native_style": {
|
|
2660
|
+
bridge.setNativeStyle(
|
|
2661
|
+
args.id,
|
|
2662
|
+
args.property,
|
|
2663
|
+
args.value
|
|
2664
|
+
);
|
|
2665
|
+
return { success: true };
|
|
2666
|
+
}
|
|
2667
|
+
// Health & Monitoring
|
|
2668
|
+
case "health_check": {
|
|
2669
|
+
const status = bridge.getStatus();
|
|
2670
|
+
const lastMessageTime = bridge.getLastMessageTime();
|
|
2671
|
+
const pendingRequests = bridge.getPendingRequestCount();
|
|
2672
|
+
const now = Date.now();
|
|
2673
|
+
return {
|
|
2674
|
+
connected: status.state === "connected",
|
|
2675
|
+
state: status.state,
|
|
2676
|
+
rendererCount: status.rendererCount,
|
|
2677
|
+
reactVersion: status.reactVersion,
|
|
2678
|
+
error: status.error,
|
|
2679
|
+
lastMessageAgo: lastMessageTime > 0 ? now - lastMessageTime : null,
|
|
2680
|
+
pendingRequests,
|
|
2681
|
+
uptime: process.uptime()
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
// Phase 2: Protocol & Renderer Management
|
|
2685
|
+
case "get_capabilities": {
|
|
2686
|
+
const capabilities = bridge.getCapabilities();
|
|
2687
|
+
const negotiated = bridge.hasNegotiatedCapabilities();
|
|
2688
|
+
return { capabilities, negotiated };
|
|
2689
|
+
}
|
|
2690
|
+
case "get_renderers": {
|
|
2691
|
+
const renderers = bridge.getRenderers();
|
|
2692
|
+
return {
|
|
2693
|
+
renderers: renderers.map((r) => ({
|
|
2694
|
+
id: r.id,
|
|
2695
|
+
version: r.version,
|
|
2696
|
+
packageName: r.packageName,
|
|
2697
|
+
rootCount: r.rootIDs.size,
|
|
2698
|
+
elementCount: r.elementIDs.size
|
|
2699
|
+
}))
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
case "get_renderer": {
|
|
2703
|
+
const renderer = bridge.getRenderer(args.id);
|
|
2704
|
+
if (!renderer) {
|
|
2705
|
+
return { renderer: null };
|
|
2706
|
+
}
|
|
2707
|
+
return {
|
|
2708
|
+
renderer: {
|
|
2709
|
+
id: renderer.id,
|
|
2710
|
+
version: renderer.version,
|
|
2711
|
+
packageName: renderer.packageName,
|
|
2712
|
+
rootIDs: Array.from(renderer.rootIDs),
|
|
2713
|
+
elementCount: renderer.elementIDs.size
|
|
2714
|
+
}
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
case "get_elements_by_renderer": {
|
|
2718
|
+
const elements = bridge.getElementsByRenderer(args.rendererID);
|
|
2719
|
+
return { elements, count: elements.length };
|
|
2720
|
+
}
|
|
2721
|
+
// Phase 2: Native Inspection
|
|
2722
|
+
case "start_inspecting_native": {
|
|
2723
|
+
bridge.startInspectingNative();
|
|
2724
|
+
return { success: true, isInspecting: true };
|
|
2725
|
+
}
|
|
2726
|
+
case "stop_inspecting_native": {
|
|
2727
|
+
const selectNextElement = args.selectNextElement !== false;
|
|
2728
|
+
const elementID = await bridge.stopInspectingNative(selectNextElement);
|
|
2729
|
+
return { success: true, selectedElementID: elementID };
|
|
2730
|
+
}
|
|
2731
|
+
case "get_inspecting_native_status": {
|
|
2732
|
+
return { isInspecting: bridge.isInspectingNativeMode() };
|
|
2733
|
+
}
|
|
2734
|
+
// Phase 2: Additional Features
|
|
2735
|
+
case "capture_screenshot": {
|
|
2736
|
+
const screenshot = await bridge.captureScreenshot(args.id);
|
|
2737
|
+
return { success: screenshot !== null, screenshot };
|
|
2738
|
+
}
|
|
2739
|
+
case "save_to_clipboard": {
|
|
2740
|
+
const clipResult = await bridge.saveToClipboard(args.value);
|
|
2741
|
+
return clipResult;
|
|
2742
|
+
}
|
|
2743
|
+
case "view_attribute_source": {
|
|
2744
|
+
const source = await bridge.viewAttributeSource(
|
|
2745
|
+
args.id,
|
|
2746
|
+
args.path
|
|
2747
|
+
);
|
|
2748
|
+
return { source };
|
|
2749
|
+
}
|
|
2750
|
+
default:
|
|
2751
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// src/cli.ts
|
|
2756
|
+
async function main() {
|
|
2757
|
+
const logger = createLogger({
|
|
2758
|
+
level: getLogLevelFromEnv(),
|
|
2759
|
+
prefix: "cli"
|
|
2760
|
+
});
|
|
2761
|
+
logger.info("Starting React DevTools MCP Server...");
|
|
2762
|
+
const { bridge, start } = createServer({
|
|
2763
|
+
host: process.env.DEVTOOLS_HOST,
|
|
2764
|
+
port: process.env.DEVTOOLS_PORT ? Number(process.env.DEVTOOLS_PORT) : void 0,
|
|
2765
|
+
autoConnect: process.env.DEVTOOLS_AUTO_CONNECT !== "false",
|
|
2766
|
+
logger
|
|
2767
|
+
});
|
|
2768
|
+
bridge.on("connected", () => {
|
|
2769
|
+
logger.info("Connected to DevTools backend");
|
|
2770
|
+
});
|
|
2771
|
+
bridge.on("disconnected", ({ code, reason }) => {
|
|
2772
|
+
logger.info("Disconnected from DevTools backend", { code, reason });
|
|
2773
|
+
});
|
|
2774
|
+
bridge.on("reconnecting", ({ attempt, delay }) => {
|
|
2775
|
+
logger.info("Reconnecting to DevTools backend", { attempt, delay });
|
|
2776
|
+
});
|
|
2777
|
+
bridge.on("reconnectFailed", ({ attempts }) => {
|
|
2778
|
+
logger.error("Failed to reconnect after max attempts", { attempts });
|
|
2779
|
+
});
|
|
2780
|
+
bridge.on("renderer", (info) => {
|
|
2781
|
+
logger.info("Renderer attached", { id: info.id, version: info.rendererVersion });
|
|
2782
|
+
});
|
|
2783
|
+
bridge.on("parseError", ({ error }) => {
|
|
2784
|
+
logger.error("Protocol parse error", { error });
|
|
2785
|
+
});
|
|
2786
|
+
const shutdown = () => {
|
|
2787
|
+
logger.info("Shutting down...");
|
|
2788
|
+
bridge.disconnect();
|
|
2789
|
+
process.exit(0);
|
|
2790
|
+
};
|
|
2791
|
+
process.on("SIGINT", shutdown);
|
|
2792
|
+
process.on("SIGTERM", shutdown);
|
|
2793
|
+
try {
|
|
2794
|
+
await start();
|
|
2795
|
+
logger.info("Server started successfully");
|
|
2796
|
+
} catch (error) {
|
|
2797
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2798
|
+
logger.error("Failed to start server", { error: message });
|
|
2799
|
+
process.exit(1);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
main().catch((error) => {
|
|
2803
|
+
console.error("Fatal error:", error);
|
|
2804
|
+
process.exit(1);
|
|
2805
|
+
});
|
|
2806
|
+
//# sourceMappingURL=cli.js.map
|