agent-react-devtools 0.0.0 → 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/CHANGELOG.md +34 -0
- package/dist/cli.js +584 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon.js +1091 -0
- package/dist/daemon.js.map +1 -0
- package/package.json +35 -1
- package/src/__tests__/cli-parser.test.ts +76 -0
- package/src/__tests__/component-tree.test.ts +229 -0
- package/src/__tests__/formatters.test.ts +189 -0
- package/src/__tests__/profiler.test.ts +264 -0
- package/src/cli.ts +315 -0
- package/src/component-tree.ts +495 -0
- package/src/daemon-client.ts +144 -0
- package/src/daemon.ts +275 -0
- package/src/devtools-bridge.ts +391 -0
- package/src/formatters.ts +270 -0
- package/src/profiler.ts +356 -0
- package/src/types.ts +126 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +7 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DevToolsBridge } from './devtools-bridge.js';
|
|
5
|
+
import { ComponentTree } from './component-tree.js';
|
|
6
|
+
import { Profiler } from './profiler.js';
|
|
7
|
+
import type { IpcCommand, IpcResponse, DaemonInfo, StatusInfo } from './types.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_STATE_DIR = path.join(
|
|
10
|
+
process.env.HOME || process.env.USERPROFILE || '/tmp',
|
|
11
|
+
'.agent-react-devtools',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
let STATE_DIR = DEFAULT_STATE_DIR;
|
|
15
|
+
|
|
16
|
+
function getSocketPath(): string {
|
|
17
|
+
return path.join(STATE_DIR, 'daemon.sock');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getDaemonInfoPath(): string {
|
|
21
|
+
return path.join(STATE_DIR, 'daemon.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class Daemon {
|
|
25
|
+
private ipcServer: net.Server | null = null;
|
|
26
|
+
private bridge: DevToolsBridge;
|
|
27
|
+
private tree: ComponentTree;
|
|
28
|
+
private profiler: Profiler;
|
|
29
|
+
private port: number;
|
|
30
|
+
private startedAt = Date.now();
|
|
31
|
+
|
|
32
|
+
constructor(port: number) {
|
|
33
|
+
this.port = port;
|
|
34
|
+
this.tree = new ComponentTree();
|
|
35
|
+
this.profiler = new Profiler();
|
|
36
|
+
this.bridge = new DevToolsBridge(port, this.tree, this.profiler);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async start(): Promise<void> {
|
|
40
|
+
// Ensure state directory exists
|
|
41
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
42
|
+
|
|
43
|
+
// Clean up stale socket
|
|
44
|
+
const socketPath = getSocketPath();
|
|
45
|
+
if (fs.existsSync(socketPath)) {
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(socketPath);
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Start WebSocket bridge
|
|
54
|
+
await this.bridge.start();
|
|
55
|
+
|
|
56
|
+
// Start IPC server
|
|
57
|
+
await this.startIpc(socketPath);
|
|
58
|
+
|
|
59
|
+
// Write daemon info
|
|
60
|
+
const info: DaemonInfo = {
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
port: this.port,
|
|
63
|
+
socketPath,
|
|
64
|
+
startedAt: this.startedAt,
|
|
65
|
+
};
|
|
66
|
+
fs.writeFileSync(getDaemonInfoPath(), JSON.stringify(info, null, 2));
|
|
67
|
+
|
|
68
|
+
console.log(`Daemon started (pid=${process.pid}, port=${this.port})`);
|
|
69
|
+
|
|
70
|
+
// Handle shutdown
|
|
71
|
+
const shutdown = () => {
|
|
72
|
+
this.stop();
|
|
73
|
+
process.exit(0);
|
|
74
|
+
};
|
|
75
|
+
process.on('SIGTERM', shutdown);
|
|
76
|
+
process.on('SIGINT', shutdown);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private startIpc(socketPath: string): Promise<void> {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
this.ipcServer = net.createServer((conn) => {
|
|
82
|
+
let buffer = '';
|
|
83
|
+
|
|
84
|
+
conn.on('data', (chunk) => {
|
|
85
|
+
buffer += chunk.toString();
|
|
86
|
+
|
|
87
|
+
// Process complete messages (newline-delimited JSON)
|
|
88
|
+
let newlineIdx: number;
|
|
89
|
+
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
90
|
+
const line = buffer.slice(0, newlineIdx);
|
|
91
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const cmd: IpcCommand = JSON.parse(line);
|
|
95
|
+
this.handleCommand(cmd).then((response) => {
|
|
96
|
+
conn.write(JSON.stringify(response) + '\n');
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
const response: IpcResponse = {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: 'Invalid JSON',
|
|
102
|
+
};
|
|
103
|
+
conn.write(JSON.stringify(response) + '\n');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
this.ipcServer.on('error', reject);
|
|
110
|
+
|
|
111
|
+
this.ipcServer.listen(socketPath, () => {
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async handleCommand(cmd: IpcCommand): Promise<IpcResponse> {
|
|
118
|
+
try {
|
|
119
|
+
switch (cmd.type) {
|
|
120
|
+
case 'ping':
|
|
121
|
+
return { ok: true, data: 'pong' };
|
|
122
|
+
|
|
123
|
+
case 'status':
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
data: {
|
|
127
|
+
daemonRunning: true,
|
|
128
|
+
port: this.port,
|
|
129
|
+
connectedApps: this.bridge.getConnectedAppCount(),
|
|
130
|
+
componentCount: this.tree.getComponentCount(),
|
|
131
|
+
profilingActive: this.profiler.isActive(),
|
|
132
|
+
uptime: Date.now() - this.startedAt,
|
|
133
|
+
} satisfies StatusInfo,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
case 'get-tree':
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
data: this.tree.getTree(cmd.depth),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
case 'get-component': {
|
|
143
|
+
const resolvedId = this.tree.resolveId(cmd.id);
|
|
144
|
+
if (resolvedId === undefined) {
|
|
145
|
+
return { ok: false, error: `Component ${cmd.id} not found` };
|
|
146
|
+
}
|
|
147
|
+
const element = await this.bridge.inspectElement(resolvedId);
|
|
148
|
+
if (!element) {
|
|
149
|
+
return { ok: false, error: `Component ${cmd.id} not found` };
|
|
150
|
+
}
|
|
151
|
+
// Include the label if the request used one
|
|
152
|
+
const label = typeof cmd.id === 'string' ? cmd.id : undefined;
|
|
153
|
+
return { ok: true, data: element, label };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'find':
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
data: this.tree.findByName(cmd.name, cmd.exact),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
case 'count':
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
data: this.tree.getCountByType(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
case 'profile-start':
|
|
169
|
+
this.profiler.start(cmd.name);
|
|
170
|
+
// Snapshot existing component names so they survive unmounts
|
|
171
|
+
for (const id of this.tree.getAllNodeIds()) {
|
|
172
|
+
const node = this.tree.getNode(id);
|
|
173
|
+
if (node) this.profiler.trackComponent(id, node.displayName);
|
|
174
|
+
}
|
|
175
|
+
this.bridge.startProfiling();
|
|
176
|
+
return { ok: true, data: 'Profiling started' };
|
|
177
|
+
|
|
178
|
+
case 'profile-stop': {
|
|
179
|
+
await this.bridge.stopProfilingAndCollect();
|
|
180
|
+
const session = this.profiler.stop(this.tree);
|
|
181
|
+
if (!session) {
|
|
182
|
+
return { ok: false, error: 'No active profiling session' };
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, data: session };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'profile-report': {
|
|
188
|
+
const resolvedCompId = this.tree.resolveId(cmd.componentId);
|
|
189
|
+
if (resolvedCompId === undefined) {
|
|
190
|
+
return { ok: false, error: `Component ${cmd.componentId} not found` };
|
|
191
|
+
}
|
|
192
|
+
const report = this.profiler.getReport(resolvedCompId, this.tree);
|
|
193
|
+
if (!report) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: `No profiling data for component ${cmd.componentId}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const compLabel = typeof cmd.componentId === 'string' ? cmd.componentId : undefined;
|
|
200
|
+
return { ok: true, data: report, label: compLabel };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'profile-slow':
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
data: this.profiler.getSlowest(this.tree, cmd.limit),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
case 'profile-rerenders':
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
data: this.profiler.getMostRerenders(this.tree, cmd.limit),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
case 'profile-timeline':
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
data: this.profiler.getTimeline(cmd.limit),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
case 'profile-commit': {
|
|
222
|
+
const detail = this.profiler.getCommitDetails(cmd.index, this.tree, cmd.limit);
|
|
223
|
+
if (!detail) {
|
|
224
|
+
return { ok: false, error: `Commit #${cmd.index} not found` };
|
|
225
|
+
}
|
|
226
|
+
return { ok: true, data: detail };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
default:
|
|
230
|
+
return { ok: false, error: `Unknown command: ${(cmd as any).type}` };
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
error: err instanceof Error ? err.message : String(err),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
stop(): void {
|
|
241
|
+
this.bridge.stop();
|
|
242
|
+
if (this.ipcServer) {
|
|
243
|
+
this.ipcServer.close();
|
|
244
|
+
this.ipcServer = null;
|
|
245
|
+
}
|
|
246
|
+
// Clean up files
|
|
247
|
+
try {
|
|
248
|
+
fs.unlinkSync(getSocketPath());
|
|
249
|
+
} catch {
|
|
250
|
+
// ignore
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
fs.unlinkSync(getDaemonInfoPath());
|
|
254
|
+
} catch {
|
|
255
|
+
// ignore
|
|
256
|
+
}
|
|
257
|
+
console.log('Daemon stopped');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Main ──
|
|
262
|
+
|
|
263
|
+
const portArg = process.argv.find((a) => a.startsWith('--port='));
|
|
264
|
+
const port = portArg ? parseInt(portArg.split('=')[1], 10) : 8097;
|
|
265
|
+
|
|
266
|
+
const stateDirArg = process.argv.find((a) => a.startsWith('--state-dir='));
|
|
267
|
+
if (stateDirArg) {
|
|
268
|
+
STATE_DIR = stateDirArg.split('=')[1];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const daemon = new Daemon(port);
|
|
272
|
+
daemon.start().catch((err) => {
|
|
273
|
+
console.error('Failed to start daemon:', err);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
});
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import type { ComponentTree } from './component-tree.js';
|
|
3
|
+
import type { Profiler } from './profiler.js';
|
|
4
|
+
import type { InspectedElement } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* React DevTools protocol bridge.
|
|
8
|
+
*
|
|
9
|
+
* Implements the "Wall" messaging pattern that React DevTools uses:
|
|
10
|
+
* - The backend (inside React app) sends operations, profiling data, etc.
|
|
11
|
+
* - The frontend (us) can request element inspection, start/stop profiling, etc.
|
|
12
|
+
*
|
|
13
|
+
* Message format over WebSocket:
|
|
14
|
+
* { event: string, payload: any }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface DevToolsMessage {
|
|
18
|
+
event: string;
|
|
19
|
+
payload: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PendingInspection {
|
|
23
|
+
resolve: (value: InspectedElement | null) => void;
|
|
24
|
+
timer: ReturnType<typeof setTimeout>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PendingProfilingCollect {
|
|
28
|
+
resolve: () => void;
|
|
29
|
+
timer: ReturnType<typeof setTimeout>;
|
|
30
|
+
remaining: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class DevToolsBridge {
|
|
34
|
+
private wss: WebSocketServer | null = null;
|
|
35
|
+
private connections = new Set<WebSocket>();
|
|
36
|
+
private port: number;
|
|
37
|
+
private tree: ComponentTree;
|
|
38
|
+
private profiler: Profiler;
|
|
39
|
+
private pendingInspections = new Map<number, PendingInspection>();
|
|
40
|
+
private pendingProfilingCollect: PendingProfilingCollect | null = null;
|
|
41
|
+
private rendererIds = new Set<number>();
|
|
42
|
+
/** Track which root fiber IDs belong to each WebSocket connection */
|
|
43
|
+
private connectionRoots = new Map<WebSocket, Set<number>>();
|
|
44
|
+
|
|
45
|
+
constructor(port: number, tree: ComponentTree, profiler: Profiler) {
|
|
46
|
+
this.port = port;
|
|
47
|
+
this.tree = tree;
|
|
48
|
+
this.profiler = profiler;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async start(): Promise<void> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
this.wss = new WebSocketServer({ port: this.port }, () => {
|
|
54
|
+
resolve();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this.wss.on('error', (err) => {
|
|
58
|
+
reject(err);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.wss.on('connection', (ws) => {
|
|
62
|
+
this.connections.add(ws);
|
|
63
|
+
|
|
64
|
+
ws.on('message', (data) => {
|
|
65
|
+
try {
|
|
66
|
+
const msg: DevToolsMessage = JSON.parse(data.toString());
|
|
67
|
+
this.handleMessage(ws, msg);
|
|
68
|
+
} catch {
|
|
69
|
+
// ignore parse errors
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
ws.on('close', () => {
|
|
74
|
+
this.cleanupConnection(ws);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
ws.on('error', () => {
|
|
78
|
+
this.cleanupConnection(ws);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
stop(): void {
|
|
85
|
+
for (const conn of this.connections) {
|
|
86
|
+
conn.close();
|
|
87
|
+
}
|
|
88
|
+
this.connections.clear();
|
|
89
|
+
if (this.wss) {
|
|
90
|
+
this.wss.close();
|
|
91
|
+
this.wss = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getConnectedAppCount(): number {
|
|
96
|
+
return this.connections.size;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Request detailed inspection of a specific element.
|
|
101
|
+
* Sends a request to the React app and waits for the response.
|
|
102
|
+
*/
|
|
103
|
+
inspectElement(id: number): Promise<InspectedElement | null> {
|
|
104
|
+
const node = this.tree.getNode(id);
|
|
105
|
+
if (!node) return Promise.resolve(null);
|
|
106
|
+
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
this.pendingInspections.delete(id);
|
|
110
|
+
resolve(null);
|
|
111
|
+
}, 5000);
|
|
112
|
+
|
|
113
|
+
this.pendingInspections.set(id, { resolve, timer });
|
|
114
|
+
|
|
115
|
+
this.sendToAll({
|
|
116
|
+
event: 'inspectElement',
|
|
117
|
+
payload: {
|
|
118
|
+
id,
|
|
119
|
+
rendererID: node.rendererId,
|
|
120
|
+
forceFullData: true,
|
|
121
|
+
requestID: id,
|
|
122
|
+
path: null,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
startProfiling(): void {
|
|
129
|
+
this.sendToAll({
|
|
130
|
+
event: 'startProfiling',
|
|
131
|
+
payload: { recordChangeDescriptions: true },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Stop profiling and request data from each renderer.
|
|
137
|
+
* Returns a promise that resolves when profilingData arrives (or 5s timeout).
|
|
138
|
+
*/
|
|
139
|
+
stopProfilingAndCollect(): Promise<void> {
|
|
140
|
+
this.sendToAll({
|
|
141
|
+
event: 'stopProfiling',
|
|
142
|
+
payload: undefined,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// If no renderers known, resolve immediately
|
|
146
|
+
if (this.rendererIds.size === 0) {
|
|
147
|
+
return Promise.resolve();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Request profiling data from each renderer
|
|
151
|
+
for (const rendererID of this.rendererIds) {
|
|
152
|
+
this.sendToAll({
|
|
153
|
+
event: 'getProfilingData',
|
|
154
|
+
payload: { rendererID },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const expected = this.rendererIds.size;
|
|
159
|
+
return new Promise<void>((resolve) => {
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
this.pendingProfilingCollect = null;
|
|
162
|
+
resolve();
|
|
163
|
+
}, 5000);
|
|
164
|
+
|
|
165
|
+
this.pendingProfilingCollect = { resolve, timer, remaining: expected };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private handleMessage(ws: WebSocket, msg: DevToolsMessage): void {
|
|
170
|
+
switch (msg.event) {
|
|
171
|
+
case 'backendInitialized':
|
|
172
|
+
// Send the full frontend handshake sequence
|
|
173
|
+
this.sendTo(ws, { event: 'getBridgeProtocol', payload: undefined });
|
|
174
|
+
this.sendTo(ws, { event: 'getBackendVersion', payload: undefined });
|
|
175
|
+
this.sendTo(ws, { event: 'getIfHasUnsupportedRendererVersion', payload: undefined });
|
|
176
|
+
this.sendTo(ws, { event: 'getHookSettings', payload: undefined });
|
|
177
|
+
this.sendTo(ws, { event: 'getProfilingStatus', payload: undefined });
|
|
178
|
+
break;
|
|
179
|
+
|
|
180
|
+
case 'bridgeProtocol':
|
|
181
|
+
case 'backendVersion':
|
|
182
|
+
case 'profilingStatus':
|
|
183
|
+
case 'overrideComponentFilters':
|
|
184
|
+
break;
|
|
185
|
+
|
|
186
|
+
case 'operations':
|
|
187
|
+
this.handleOperations(ws, msg.payload as number[]);
|
|
188
|
+
break;
|
|
189
|
+
|
|
190
|
+
case 'inspectedElement':
|
|
191
|
+
this.handleInspectedElement(msg.payload);
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
case 'profilingData':
|
|
195
|
+
this.handleProfilingData(msg.payload);
|
|
196
|
+
break;
|
|
197
|
+
|
|
198
|
+
case 'renderer': {
|
|
199
|
+
const payload = msg.payload as { id: number };
|
|
200
|
+
this.rendererIds.add(payload.id);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case 'rendererAttached': {
|
|
205
|
+
const payload = msg.payload as { id: number };
|
|
206
|
+
this.rendererIds.add(payload.id);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case 'shutdown':
|
|
211
|
+
ws.close();
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
// Silently ignore known but unhandled events
|
|
215
|
+
case 'hookSettings':
|
|
216
|
+
case 'isBackendStorageAPISupported':
|
|
217
|
+
case 'isReactNativeEnvironment':
|
|
218
|
+
case 'isReloadAndProfileSupportedByBackend':
|
|
219
|
+
case 'isSynchronousXHRSupported':
|
|
220
|
+
case 'syncSelectionFromNativeElementsPanel':
|
|
221
|
+
case 'unsupportedRendererVersion':
|
|
222
|
+
break;
|
|
223
|
+
|
|
224
|
+
default:
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private handleOperations(ws: WebSocket, operations: number[]): void {
|
|
230
|
+
if (operations.length >= 2) {
|
|
231
|
+
// Track renderer ID (first element of every operations array)
|
|
232
|
+
this.rendererIds.add(operations[0]);
|
|
233
|
+
|
|
234
|
+
// Track which root fiber IDs belong to this connection
|
|
235
|
+
const rootFiberId = operations[1];
|
|
236
|
+
let roots = this.connectionRoots.get(ws);
|
|
237
|
+
if (!roots) {
|
|
238
|
+
roots = new Set();
|
|
239
|
+
this.connectionRoots.set(ws, roots);
|
|
240
|
+
}
|
|
241
|
+
roots.add(rootFiberId);
|
|
242
|
+
}
|
|
243
|
+
const added = this.tree.applyOperations(operations);
|
|
244
|
+
|
|
245
|
+
// Cache display names during profiling so unmounted components are still identifiable
|
|
246
|
+
if (this.profiler.isActive()) {
|
|
247
|
+
for (const node of added) {
|
|
248
|
+
this.profiler.trackComponent(node.id, node.displayName);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private cleanupConnection(ws: WebSocket): void {
|
|
254
|
+
this.connections.delete(ws);
|
|
255
|
+
// Remove all root trees that belonged to this connection
|
|
256
|
+
const roots = this.connectionRoots.get(ws);
|
|
257
|
+
if (roots) {
|
|
258
|
+
for (const rootId of roots) {
|
|
259
|
+
this.tree.removeRoot(rootId);
|
|
260
|
+
}
|
|
261
|
+
this.connectionRoots.delete(ws);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private handleInspectedElement(payload: unknown): void {
|
|
266
|
+
const data = payload as {
|
|
267
|
+
type: string;
|
|
268
|
+
id: number;
|
|
269
|
+
value?: {
|
|
270
|
+
id: number;
|
|
271
|
+
displayName: string;
|
|
272
|
+
type: number;
|
|
273
|
+
key: string | null;
|
|
274
|
+
props: Record<string, unknown>;
|
|
275
|
+
state: Record<string, unknown> | null;
|
|
276
|
+
hooks: unknown[] | null;
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (data.type !== 'full-data' && data.type !== 'hydrated-path') {
|
|
281
|
+
// No data available
|
|
282
|
+
const pending = this.pendingInspections.get(data.id);
|
|
283
|
+
if (pending) {
|
|
284
|
+
clearTimeout(pending.timer);
|
|
285
|
+
this.pendingInspections.delete(data.id);
|
|
286
|
+
pending.resolve(null);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const pending = this.pendingInspections.get(data.id);
|
|
292
|
+
if (!pending || !data.value) return;
|
|
293
|
+
|
|
294
|
+
clearTimeout(pending.timer);
|
|
295
|
+
this.pendingInspections.delete(data.id);
|
|
296
|
+
|
|
297
|
+
const node = this.tree.getNode(data.id);
|
|
298
|
+
const inspected: InspectedElement = {
|
|
299
|
+
id: data.id,
|
|
300
|
+
displayName: data.value.displayName || node?.displayName || 'Unknown',
|
|
301
|
+
type: node?.type || 'other',
|
|
302
|
+
key: data.value.key,
|
|
303
|
+
props: cleanDehydrated(data.value.props) as Record<string, unknown>,
|
|
304
|
+
state: data.value.state
|
|
305
|
+
? (cleanDehydrated(data.value.state) as Record<string, unknown>)
|
|
306
|
+
: null,
|
|
307
|
+
hooks: data.value.hooks
|
|
308
|
+
? parseHooks(data.value.hooks)
|
|
309
|
+
: null,
|
|
310
|
+
renderedAt: null,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
pending.resolve(inspected);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private handleProfilingData(payload: unknown): void {
|
|
317
|
+
// React DevTools sends profiling data as a complex nested structure.
|
|
318
|
+
// We forward it to the profiler for processing.
|
|
319
|
+
this.profiler.processProfilingData(payload);
|
|
320
|
+
|
|
321
|
+
// Resolve once all expected renderer responses have arrived
|
|
322
|
+
if (this.pendingProfilingCollect) {
|
|
323
|
+
this.pendingProfilingCollect.remaining--;
|
|
324
|
+
if (this.pendingProfilingCollect.remaining <= 0) {
|
|
325
|
+
clearTimeout(this.pendingProfilingCollect.timer);
|
|
326
|
+
const pending = this.pendingProfilingCollect;
|
|
327
|
+
this.pendingProfilingCollect = null;
|
|
328
|
+
pending.resolve();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private sendTo(ws: WebSocket, msg: DevToolsMessage): void {
|
|
334
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
335
|
+
ws.send(JSON.stringify(msg));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private sendToAll(msg: DevToolsMessage): void {
|
|
340
|
+
const raw = JSON.stringify(msg);
|
|
341
|
+
for (const conn of this.connections) {
|
|
342
|
+
if (conn.readyState === WebSocket.OPEN) {
|
|
343
|
+
conn.send(raw);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* React DevTools uses "dehydrated" values for complex objects.
|
|
351
|
+
* These appear as objects with `type: 'string'` and other metadata.
|
|
352
|
+
* We simplify them for display.
|
|
353
|
+
*/
|
|
354
|
+
function cleanDehydrated(obj: unknown): unknown {
|
|
355
|
+
if (obj === null || obj === undefined) return obj;
|
|
356
|
+
if (typeof obj !== 'object') return obj;
|
|
357
|
+
if (Array.isArray(obj)) return obj.map(cleanDehydrated);
|
|
358
|
+
|
|
359
|
+
const record = obj as Record<string, unknown>;
|
|
360
|
+
|
|
361
|
+
// Dehydrated value markers from React DevTools
|
|
362
|
+
if ('type' in record && 'preview_short' in record) {
|
|
363
|
+
return record['preview_short'];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const cleaned: Record<string, unknown> = {};
|
|
367
|
+
for (const [key, value] of Object.entries(record)) {
|
|
368
|
+
cleaned[key] = cleanDehydrated(value);
|
|
369
|
+
}
|
|
370
|
+
return cleaned;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseHooks(hooks: unknown[]): { name: string; value: unknown; subHooks?: { name: string; value: unknown }[] }[] {
|
|
374
|
+
return hooks.map((hook) => {
|
|
375
|
+
const h = hook as {
|
|
376
|
+
id: number | null;
|
|
377
|
+
isStateEditable: boolean;
|
|
378
|
+
name: string;
|
|
379
|
+
value: unknown;
|
|
380
|
+
subHooks?: unknown[];
|
|
381
|
+
};
|
|
382
|
+
const result: { name: string; value: unknown; subHooks?: { name: string; value: unknown }[] } = {
|
|
383
|
+
name: h.name,
|
|
384
|
+
value: cleanDehydrated(h.value),
|
|
385
|
+
};
|
|
386
|
+
if (h.subHooks && h.subHooks.length > 0) {
|
|
387
|
+
result.subHooks = parseHooks(h.subHooks) as { name: string; value: unknown }[];
|
|
388
|
+
}
|
|
389
|
+
return result;
|
|
390
|
+
});
|
|
391
|
+
}
|