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.
@@ -0,0 +1,495 @@
1
+ import type { ComponentNode, ComponentType } from './types.js';
2
+
3
+ /**
4
+ * React DevTools operations encoding (protocol v2):
5
+ * Operations is a flat array of numbers representing tree mutations.
6
+ *
7
+ * Format: [rendererID, rootFiberID, stringTableSize, ...stringTable, ...ops]
8
+ *
9
+ * The string table encodes display names and keys. Each entry is:
10
+ * [length, ...charCodes]
11
+ * String ID 0 = null. String ID 1 = first entry, etc.
12
+ *
13
+ * Operation types (from React DevTools source):
14
+ */
15
+ const TREE_OPERATION_ADD = 1;
16
+ const TREE_OPERATION_REMOVE = 2;
17
+ const TREE_OPERATION_REORDER_CHILDREN = 3;
18
+ const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4;
19
+ const TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS = 5;
20
+ const TREE_OPERATION_REMOVE_ROOT = 6;
21
+ const TREE_OPERATION_SET_SUBTREE_MODE = 7;
22
+
23
+ /**
24
+ * Suspense tree operations (newer React DevTools backends, e.g. browser extension):
25
+ */
26
+ const SUSPENSE_TREE_OPERATION_ADD = 8;
27
+ const SUSPENSE_TREE_OPERATION_REMOVE = 9;
28
+ const SUSPENSE_TREE_OPERATION_REORDER_CHILDREN = 10;
29
+ const SUSPENSE_TREE_OPERATION_RESIZE = 11;
30
+ const SUSPENSE_TREE_OPERATION_SUSPENDERS = 12;
31
+ const TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE = 13;
32
+
33
+ /**
34
+ * Element types from React DevTools (react-devtools-shared/src/frontend/types.js)
35
+ */
36
+ const ELEMENT_TYPE_CLASS = 1;
37
+ // const ELEMENT_TYPE_CONTEXT = 2;
38
+ const ELEMENT_TYPE_FUNCTION = 5;
39
+ const ELEMENT_TYPE_FORWARD_REF = 6;
40
+ const ELEMENT_TYPE_HOST = 7;
41
+ const ELEMENT_TYPE_MEMO = 8;
42
+ // const ELEMENT_TYPE_OTHER = 9;
43
+ const ELEMENT_TYPE_PROFILER = 10;
44
+ const ELEMENT_TYPE_ROOT = 11;
45
+ const ELEMENT_TYPE_SUSPENSE = 12;
46
+
47
+ function toComponentType(elementType: number): ComponentType {
48
+ switch (elementType) {
49
+ case ELEMENT_TYPE_CLASS:
50
+ return 'class';
51
+ case ELEMENT_TYPE_FUNCTION:
52
+ return 'function';
53
+ case ELEMENT_TYPE_FORWARD_REF:
54
+ return 'forwardRef';
55
+ case ELEMENT_TYPE_HOST:
56
+ return 'host';
57
+ case ELEMENT_TYPE_MEMO:
58
+ return 'memo';
59
+ case ELEMENT_TYPE_PROFILER:
60
+ return 'profiler';
61
+ case ELEMENT_TYPE_SUSPENSE:
62
+ return 'suspense';
63
+ case ELEMENT_TYPE_ROOT:
64
+ return 'other'; // roots are internal, map to 'other'
65
+ default:
66
+ return 'other';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Skip a variable-length rect encoding in the operations array.
72
+ * Rects are encoded as: count, then count * 4 values (x, y, w, h each × 1000).
73
+ * A count of -1 means null (no rects).
74
+ * Returns the new index after skipping.
75
+ */
76
+ function skipRects(operations: number[], i: number): number {
77
+ const count = operations[i++];
78
+ if (count === -1) return i;
79
+ return i + count * 4;
80
+ }
81
+
82
+ export interface TreeNode {
83
+ id: number;
84
+ label: string;
85
+ displayName: string;
86
+ type: ComponentType;
87
+ key: string | null;
88
+ parentId: number | null;
89
+ children: number[];
90
+ depth: number;
91
+ }
92
+
93
+ export class ComponentTree {
94
+ private nodes = new Map<number, ComponentNode>();
95
+ private roots: number[] = [];
96
+ /** Index: lowercase display name → set of node ids */
97
+ private nameIndex = new Map<string, Set<number>>();
98
+ /** Label → real node ID (e.g., "@c1" → 10) */
99
+ private labelToId = new Map<string, number>();
100
+ /** Real node ID → label */
101
+ private idToLabel = new Map<number, string>();
102
+ /**
103
+ * Whether the backend uses the extended ADD format (8 fields with namePropStringID).
104
+ * Auto-detected from the presence of SUSPENSE_TREE_OPERATION opcodes.
105
+ */
106
+ private extendedAddFormat = false;
107
+
108
+ applyOperations(operations: number[]): Array<{ id: number; displayName: string }> {
109
+ if (operations.length < 2) return [];
110
+
111
+ const added: Array<{ id: number; displayName: string }> = [];
112
+ const rendererId = operations[0];
113
+ // operations[1] is the root fiber ID
114
+ let i = 2;
115
+
116
+ // Parse the string table (protocol v2)
117
+ const stringTable: Array<string | null> = [null]; // ID 0 = null
118
+ const stringTableSize = operations[i++];
119
+ const stringTableEnd = i + stringTableSize;
120
+ while (i < stringTableEnd) {
121
+ const strLen = operations[i++];
122
+ let str = '';
123
+ for (let j = 0; j < strLen; j++) {
124
+ str += String.fromCodePoint(operations[i++]);
125
+ }
126
+ stringTable.push(str);
127
+ }
128
+
129
+ // Parse operations
130
+ while (i < operations.length) {
131
+ const op = operations[i];
132
+
133
+ switch (op) {
134
+ case TREE_OPERATION_ADD: {
135
+ const id = operations[i + 1];
136
+ const elementType = operations[i + 2];
137
+ i += 3;
138
+
139
+ if (elementType === ELEMENT_TYPE_ROOT) {
140
+ // Root node: isStrictModeCompliant, supportsProfiling,
141
+ // supportsStrictMode, hasOwnerMetadata
142
+ i += 4;
143
+
144
+ const node: ComponentNode = {
145
+ id,
146
+ displayName: 'Root',
147
+ type: 'other',
148
+ key: null,
149
+ parentId: null,
150
+ children: [],
151
+ rendererId,
152
+ };
153
+ this.nodes.set(id, node);
154
+ added.push({ id, displayName: node.displayName });
155
+ if (!this.roots.includes(id)) {
156
+ this.roots.push(id);
157
+ }
158
+ } else {
159
+ const parentId = operations[i++];
160
+ i++; // ownerID
161
+ const displayNameStringId = operations[i++];
162
+ const keyStringId = operations[i++];
163
+ if (this.extendedAddFormat) {
164
+ i++; // namePropStringID (added in newer backends)
165
+ }
166
+
167
+ const displayName =
168
+ (displayNameStringId > 0 ? stringTable[displayNameStringId] : null) ||
169
+ (elementType === ELEMENT_TYPE_HOST ? 'HostComponent' : 'Anonymous');
170
+ const key = keyStringId > 0 ? stringTable[keyStringId] || null : null;
171
+
172
+ const node: ComponentNode = {
173
+ id,
174
+ displayName,
175
+ type: toComponentType(elementType),
176
+ key,
177
+ parentId: parentId === 0 ? null : parentId,
178
+ children: [],
179
+ rendererId,
180
+ };
181
+
182
+ this.nodes.set(id, node);
183
+ added.push({ id, displayName });
184
+
185
+ // Add to parent's children
186
+ if (parentId === 0) {
187
+ if (!this.roots.includes(id)) {
188
+ this.roots.push(id);
189
+ }
190
+ } else {
191
+ const parent = this.nodes.get(parentId);
192
+ if (parent) {
193
+ parent.children.push(id);
194
+ }
195
+ }
196
+
197
+ // Update name index
198
+ if (displayName) {
199
+ const lower = displayName.toLowerCase();
200
+ let set = this.nameIndex.get(lower);
201
+ if (!set) {
202
+ set = new Set();
203
+ this.nameIndex.set(lower, set);
204
+ }
205
+ set.add(id);
206
+ }
207
+ }
208
+ break;
209
+ }
210
+
211
+ case TREE_OPERATION_REMOVE: {
212
+ const numRemoved = operations[i + 1];
213
+ for (let j = 0; j < numRemoved; j++) {
214
+ const id = operations[i + 2 + j];
215
+ this.removeNode(id);
216
+ }
217
+ i += 2 + numRemoved;
218
+ break;
219
+ }
220
+
221
+ case TREE_OPERATION_REORDER_CHILDREN: {
222
+ const id = operations[i + 1];
223
+ const numChildren = operations[i + 2];
224
+ const newChildren: number[] = [];
225
+ for (let j = 0; j < numChildren; j++) {
226
+ newChildren.push(operations[i + 3 + j]);
227
+ }
228
+ const node = this.nodes.get(id);
229
+ if (node) {
230
+ node.children = newChildren;
231
+ }
232
+ i += 3 + numChildren;
233
+ break;
234
+ }
235
+
236
+ case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: {
237
+ // id, baseDuration — skip
238
+ i += 3;
239
+ break;
240
+ }
241
+
242
+ case TREE_OPERATION_UPDATE_ERRORS_OR_WARNINGS: {
243
+ // id, numErrors, numWarnings
244
+ i += 4;
245
+ break;
246
+ }
247
+
248
+ case TREE_OPERATION_REMOVE_ROOT: {
249
+ const rootId = operations[i + 1];
250
+ this.removeNode(rootId);
251
+ i += 2;
252
+ break;
253
+ }
254
+
255
+ case TREE_OPERATION_SET_SUBTREE_MODE: {
256
+ // id, mode
257
+ i += 3;
258
+ break;
259
+ }
260
+
261
+ // ── Suspense tree operations (newer backends) ──
262
+
263
+ case SUSPENSE_TREE_OPERATION_ADD: {
264
+ // Presence of suspense ops means the backend also uses 8-field ADD
265
+ this.extendedAddFormat = true;
266
+ // fiberID, parentID, nameStringID, isSuspended, rects
267
+ i += 5; // opcode + 4 fields
268
+ i = skipRects(operations, i);
269
+ break;
270
+ }
271
+
272
+ case SUSPENSE_TREE_OPERATION_REMOVE: {
273
+ this.extendedAddFormat = true;
274
+ // numIDs, then that many IDs
275
+ const numIds = operations[i + 1];
276
+ i += 2 + numIds;
277
+ break;
278
+ }
279
+
280
+ case SUSPENSE_TREE_OPERATION_REORDER_CHILDREN: {
281
+ this.extendedAddFormat = true;
282
+ // parentID, numChildren, then that many child IDs
283
+ const numSuspenseChildren = operations[i + 2];
284
+ i += 3 + numSuspenseChildren;
285
+ break;
286
+ }
287
+
288
+ case SUSPENSE_TREE_OPERATION_RESIZE: {
289
+ this.extendedAddFormat = true;
290
+ // fiberID, rects
291
+ i += 2; // opcode + fiberID
292
+ i = skipRects(operations, i);
293
+ break;
294
+ }
295
+
296
+ case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
297
+ this.extendedAddFormat = true;
298
+ // numChanges, then numChanges * 4 values
299
+ const numChanges = operations[i + 1];
300
+ i += 2 + numChanges * 4;
301
+ break;
302
+ }
303
+
304
+ case TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE: {
305
+ this.extendedAddFormat = true;
306
+ // id
307
+ i += 2;
308
+ break;
309
+ }
310
+
311
+ default:
312
+ // Unknown operation — skip one value and try to continue.
313
+ // Future protocol additions may cause brief misalignment but
314
+ // subsequent operations batches will self-correct.
315
+ i++;
316
+ break;
317
+ }
318
+ }
319
+
320
+ return added;
321
+ }
322
+
323
+ private removeNode(id: number): void {
324
+ const node = this.nodes.get(id);
325
+ if (!node) return;
326
+
327
+ // Remove from parent's children
328
+ if (node.parentId !== null) {
329
+ const parent = this.nodes.get(node.parentId);
330
+ if (parent) {
331
+ parent.children = parent.children.filter((c) => c !== id);
332
+ }
333
+ }
334
+
335
+ // Remove from roots
336
+ this.roots = this.roots.filter((r) => r !== id);
337
+
338
+ // Remove from name index
339
+ if (node.displayName) {
340
+ const lower = node.displayName.toLowerCase();
341
+ const set = this.nameIndex.get(lower);
342
+ if (set) {
343
+ set.delete(id);
344
+ if (set.size === 0) this.nameIndex.delete(lower);
345
+ }
346
+ }
347
+
348
+ // Recursively remove children
349
+ for (const childId of node.children) {
350
+ this.removeNode(childId);
351
+ }
352
+
353
+ this.nodes.delete(id);
354
+ }
355
+
356
+ getNode(id: number): ComponentNode | undefined {
357
+ return this.nodes.get(id);
358
+ }
359
+
360
+ getTree(maxDepth?: number): TreeNode[] {
361
+ const result: TreeNode[] = [];
362
+
363
+ // Rebuild label maps on every getTree() call
364
+ this.labelToId.clear();
365
+ this.idToLabel.clear();
366
+ let labelCounter = 1;
367
+
368
+ const walk = (id: number, depth: number) => {
369
+ const node = this.nodes.get(id);
370
+ if (!node) return;
371
+ if (maxDepth !== undefined && depth > maxDepth) return;
372
+
373
+ const label = `@c${labelCounter++}`;
374
+ this.labelToId.set(label, node.id);
375
+ this.idToLabel.set(node.id, label);
376
+
377
+ result.push({
378
+ id: node.id,
379
+ label,
380
+ displayName: node.displayName,
381
+ type: node.type,
382
+ key: node.key,
383
+ parentId: node.parentId,
384
+ children: node.children,
385
+ depth,
386
+ });
387
+
388
+ for (const childId of node.children) {
389
+ walk(childId, depth + 1);
390
+ }
391
+ };
392
+
393
+ for (const rootId of this.roots) {
394
+ walk(rootId, 0);
395
+ }
396
+ return result;
397
+ }
398
+
399
+ findByName(name: string, exact?: boolean): TreeNode[] {
400
+ const results: TreeNode[] = [];
401
+
402
+ if (exact) {
403
+ const lower = name.toLowerCase();
404
+ const ids = this.nameIndex.get(lower);
405
+ if (ids) {
406
+ for (const id of ids) {
407
+ const node = this.nodes.get(id);
408
+ if (node && node.displayName.toLowerCase() === lower) {
409
+ results.push(this.toTreeNode(node));
410
+ }
411
+ }
412
+ }
413
+ } else {
414
+ const lower = name.toLowerCase();
415
+ for (const [indexName, ids] of this.nameIndex) {
416
+ if (indexName.includes(lower)) {
417
+ for (const id of ids) {
418
+ const node = this.nodes.get(id);
419
+ if (node) {
420
+ results.push(this.toTreeNode(node));
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ return results;
428
+ }
429
+
430
+ getComponentCount(): number {
431
+ return this.nodes.size;
432
+ }
433
+
434
+ getCountByType(): Record<string, number> {
435
+ const counts: Record<string, number> = {};
436
+ for (const node of this.nodes.values()) {
437
+ counts[node.type] = (counts[node.type] || 0) + 1;
438
+ }
439
+ return counts;
440
+ }
441
+
442
+ getAllNodeIds(): number[] {
443
+ return Array.from(this.nodes.keys());
444
+ }
445
+
446
+ getRootIds(): number[] {
447
+ return [...this.roots];
448
+ }
449
+
450
+ removeRoot(rootId: number): void {
451
+ this.removeNode(rootId);
452
+ }
453
+
454
+ /**
455
+ * Resolve a label like "@c3" to a real node ID.
456
+ * Returns undefined if label not found.
457
+ */
458
+ resolveLabel(label: string): number | undefined {
459
+ return this.labelToId.get(label);
460
+ }
461
+
462
+ /**
463
+ * Resolve either a label string ("@c3") or a numeric ID to a real node ID.
464
+ */
465
+ resolveId(id: number | string): number | undefined {
466
+ if (typeof id === 'number') return id;
467
+ if (id.startsWith('@c')) return this.labelToId.get(id);
468
+ // Try parsing as number
469
+ const num = parseInt(id, 10);
470
+ return isNaN(num) ? undefined : num;
471
+ }
472
+
473
+ private toTreeNode(node: ComponentNode): TreeNode {
474
+ // Calculate depth by walking up the tree
475
+ let depth = 0;
476
+ let current = node;
477
+ while (current.parentId !== null) {
478
+ depth++;
479
+ const parent = this.nodes.get(current.parentId);
480
+ if (!parent) break;
481
+ current = parent;
482
+ }
483
+
484
+ return {
485
+ id: node.id,
486
+ label: this.idToLabel.get(node.id) || `@c?`,
487
+ displayName: node.displayName,
488
+ type: node.type,
489
+ key: node.key,
490
+ parentId: node.parentId,
491
+ children: node.children,
492
+ depth,
493
+ };
494
+ }
495
+ }
@@ -0,0 +1,144 @@
1
+ import net from 'node:net';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import type { IpcCommand, IpcResponse, DaemonInfo } from './types.js';
6
+
7
+ const DEFAULT_STATE_DIR = path.join(
8
+ process.env.HOME || process.env.USERPROFILE || '/tmp',
9
+ '.agent-react-devtools',
10
+ );
11
+
12
+ let stateDir = DEFAULT_STATE_DIR;
13
+
14
+ export function setStateDir(dir: string): void {
15
+ stateDir = dir;
16
+ }
17
+
18
+ function getDaemonInfoPath(): string {
19
+ return path.join(stateDir, 'daemon.json');
20
+ }
21
+
22
+ function getSocketPath(): string {
23
+ return path.join(stateDir, 'daemon.sock');
24
+ }
25
+
26
+ export function readDaemonInfo(): DaemonInfo | null {
27
+ try {
28
+ const raw = fs.readFileSync(getDaemonInfoPath(), 'utf-8');
29
+ return JSON.parse(raw) as DaemonInfo;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function isDaemonAlive(info: DaemonInfo): boolean {
36
+ try {
37
+ // Signal 0 doesn't kill, just checks if process exists
38
+ process.kill(info.pid, 0);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export async function ensureDaemon(port?: number): Promise<void> {
46
+ const info = readDaemonInfo();
47
+ if (info && isDaemonAlive(info)) {
48
+ return; // Already running
49
+ }
50
+
51
+ // Clean up stale files
52
+ try {
53
+ fs.unlinkSync(getDaemonInfoPath());
54
+ } catch {
55
+ // ignore
56
+ }
57
+ try {
58
+ fs.unlinkSync(getSocketPath());
59
+ } catch {
60
+ // ignore
61
+ }
62
+
63
+ // Start daemon as detached child process
64
+ const daemonScript = path.join(
65
+ path.dirname(new URL(import.meta.url).pathname),
66
+ 'daemon.js',
67
+ );
68
+
69
+ const args = [];
70
+ if (port) args.push(`--port=${port}`);
71
+ if (stateDir !== DEFAULT_STATE_DIR) args.push(`--state-dir=${stateDir}`);
72
+
73
+ const child = spawn(process.execPath, [daemonScript, ...args], {
74
+ detached: true,
75
+ stdio: 'ignore',
76
+ });
77
+ child.unref();
78
+
79
+ // Wait for daemon to be ready (up to 5 seconds)
80
+ const deadline = Date.now() + 5000;
81
+ while (Date.now() < deadline) {
82
+ await new Promise((r) => setTimeout(r, 100));
83
+ try {
84
+ await sendCommand({ type: 'ping' });
85
+ return;
86
+ } catch {
87
+ // not ready yet
88
+ }
89
+ }
90
+ throw new Error('Daemon failed to start within 5 seconds');
91
+ }
92
+
93
+ export function stopDaemon(): boolean {
94
+ const info = readDaemonInfo();
95
+ if (!info) return false;
96
+
97
+ try {
98
+ process.kill(info.pid, 'SIGTERM');
99
+ // Clean up files
100
+ try {
101
+ fs.unlinkSync(getDaemonInfoPath());
102
+ } catch {
103
+ // ignore
104
+ }
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ export function sendCommand(cmd: IpcCommand): Promise<IpcResponse> {
112
+ return new Promise((resolve, reject) => {
113
+ const socketPath = getSocketPath();
114
+
115
+ const conn = net.createConnection(socketPath, () => {
116
+ conn.write(JSON.stringify(cmd) + '\n');
117
+ });
118
+
119
+ let buffer = '';
120
+ conn.on('data', (chunk) => {
121
+ buffer += chunk.toString();
122
+ const newlineIdx = buffer.indexOf('\n');
123
+ if (newlineIdx !== -1) {
124
+ const line = buffer.slice(0, newlineIdx);
125
+ conn.end();
126
+ try {
127
+ resolve(JSON.parse(line) as IpcResponse);
128
+ } catch {
129
+ reject(new Error('Invalid response from daemon'));
130
+ }
131
+ }
132
+ });
133
+
134
+ conn.on('error', (err) => {
135
+ reject(new Error(`Cannot connect to daemon: ${err.message}`));
136
+ });
137
+
138
+ // Timeout after 30 seconds
139
+ conn.setTimeout(30_000, () => {
140
+ conn.destroy();
141
+ reject(new Error('Command timed out'));
142
+ });
143
+ });
144
+ }