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
|
@@ -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
|
+
}
|