pulse-js-framework 1.7.1 → 1.7.3

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,619 @@
1
+ /**
2
+ * Pulse Dev Tools
3
+ * @module pulse-js-framework/runtime/devtools
4
+ *
5
+ * Development tools for debugging reactive applications:
6
+ * - Reactive dependency graph inspection
7
+ * - Time-travel debugging with state snapshots
8
+ * - Performance monitoring
9
+ * - Effect tracking
10
+ */
11
+
12
+ import { pulse, effect, batch, context } from './pulse.js';
13
+
14
+ // =============================================================================
15
+ // DEV TOOLS STATE
16
+ // =============================================================================
17
+
18
+ /**
19
+ * Dev tools configuration
20
+ */
21
+ const config = {
22
+ enabled: false,
23
+ maxSnapshots: 50,
24
+ logUpdates: false,
25
+ logEffects: false,
26
+ warnOnSlowEffects: true,
27
+ slowEffectThreshold: 16 // ms (one frame at 60fps)
28
+ };
29
+
30
+ /**
31
+ * Registry of all tracked pulses
32
+ * @type {Map<string, {pulse: Pulse, name: string, createdAt: number}>}
33
+ */
34
+ const pulseRegistry = new Map();
35
+
36
+ /**
37
+ * Registry of all tracked effects
38
+ * @type {Map<string, {effect: Object, name: string, createdAt: number, runCount: number, totalTime: number}>}
39
+ */
40
+ const effectRegistry = new Map();
41
+
42
+ /**
43
+ * Time-travel state history
44
+ * @type {Array<{timestamp: number, state: Object, action: string}>}
45
+ */
46
+ const stateHistory = [];
47
+
48
+ /**
49
+ * Current position in history (for time-travel)
50
+ */
51
+ let historyIndex = -1;
52
+
53
+ /**
54
+ * Flag to prevent recording during time-travel
55
+ */
56
+ let isTimeTraveling = false;
57
+
58
+ // =============================================================================
59
+ // DIAGNOSTICS API
60
+ // =============================================================================
61
+
62
+ /**
63
+ * @typedef {Object} DiagnosticsStats
64
+ * @property {number} pulseCount - Total number of active pulses
65
+ * @property {number} effectCount - Total number of active effects
66
+ * @property {number} totalEffectRuns - Total effect executions
67
+ * @property {number} avgEffectTime - Average effect execution time (ms)
68
+ * @property {number} pendingEffects - Effects waiting to run
69
+ * @property {number} batchDepth - Current batch nesting depth
70
+ * @property {number} snapshotCount - Number of stored snapshots
71
+ * @property {Object} memoryEstimate - Estimated memory usage
72
+ */
73
+
74
+ /**
75
+ * Get current diagnostics statistics
76
+ * @returns {DiagnosticsStats}
77
+ *
78
+ * @example
79
+ * const stats = getDiagnostics();
80
+ * console.log(`Active pulses: ${stats.pulseCount}`);
81
+ * console.log(`Effect runs: ${stats.totalEffectRuns}`);
82
+ */
83
+ export function getDiagnostics() {
84
+ let totalRuns = 0;
85
+ let totalTime = 0;
86
+
87
+ for (const entry of effectRegistry.values()) {
88
+ totalRuns += entry.runCount;
89
+ totalTime += entry.totalTime;
90
+ }
91
+
92
+ return {
93
+ pulseCount: pulseRegistry.size,
94
+ effectCount: effectRegistry.size,
95
+ totalEffectRuns: totalRuns,
96
+ avgEffectTime: totalRuns > 0 ? totalTime / totalRuns : 0,
97
+ pendingEffects: context.pendingEffects.size,
98
+ batchDepth: context.batchDepth,
99
+ snapshotCount: stateHistory.length,
100
+ memoryEstimate: {
101
+ pulses: pulseRegistry.size * 100, // rough estimate in bytes
102
+ effects: effectRegistry.size * 200,
103
+ history: stateHistory.length * 500
104
+ }
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Get detailed effect statistics
110
+ * @returns {Array<{id: string, name: string, runCount: number, avgTime: number, lastRun: number}>}
111
+ */
112
+ export function getEffectStats() {
113
+ return [...effectRegistry.entries()].map(([id, entry]) => ({
114
+ id,
115
+ name: entry.name,
116
+ runCount: entry.runCount,
117
+ avgTime: entry.runCount > 0 ? entry.totalTime / entry.runCount : 0,
118
+ totalTime: entry.totalTime,
119
+ createdAt: entry.createdAt
120
+ }));
121
+ }
122
+
123
+ /**
124
+ * Get list of all tracked pulses
125
+ * @returns {Array<{id: string, name: string, value: any, subscriberCount: number}>}
126
+ */
127
+ export function getPulseList() {
128
+ return [...pulseRegistry.entries()].map(([id, entry]) => ({
129
+ id,
130
+ name: entry.name,
131
+ value: entry.pulse.peek(),
132
+ subscriberCount: entry.pulse._subscribers?.size || 0,
133
+ createdAt: entry.createdAt
134
+ }));
135
+ }
136
+
137
+ // =============================================================================
138
+ // REACTIVE GRAPH INSPECTION
139
+ // =============================================================================
140
+
141
+ /**
142
+ * @typedef {Object} DependencyNode
143
+ * @property {string} id - Node identifier
144
+ * @property {string} type - 'pulse' | 'effect' | 'computed'
145
+ * @property {string} name - Display name
146
+ * @property {any} value - Current value (for pulses)
147
+ * @property {string[]} dependencies - IDs of nodes this depends on
148
+ * @property {string[]} dependents - IDs of nodes that depend on this
149
+ */
150
+
151
+ /**
152
+ * Build the reactive dependency graph
153
+ * @returns {{nodes: DependencyNode[], edges: Array<{from: string, to: string}>}}
154
+ *
155
+ * @example
156
+ * const graph = getDependencyGraph();
157
+ * console.log('Nodes:', graph.nodes.length);
158
+ * console.log('Edges:', graph.edges.length);
159
+ * // Visualize with D3.js or similar
160
+ */
161
+ export function getDependencyGraph() {
162
+ const nodes = [];
163
+ const edges = [];
164
+ const nodeMap = new Map();
165
+
166
+ // Add pulse nodes
167
+ for (const [id, entry] of pulseRegistry) {
168
+ const node = {
169
+ id,
170
+ type: 'pulse',
171
+ name: entry.name,
172
+ value: entry.pulse.peek(),
173
+ dependencies: [],
174
+ dependents: []
175
+ };
176
+ nodes.push(node);
177
+ nodeMap.set(id, node);
178
+ }
179
+
180
+ // Add effect nodes and build edges
181
+ for (const [id, entry] of effectRegistry) {
182
+ const node = {
183
+ id,
184
+ type: 'effect',
185
+ name: entry.name,
186
+ value: null,
187
+ dependencies: [],
188
+ dependents: []
189
+ };
190
+ nodes.push(node);
191
+ nodeMap.set(id, node);
192
+
193
+ // Get effect's dependencies
194
+ if (entry.effect?.dependencies) {
195
+ for (const dep of entry.effect.dependencies) {
196
+ const depId = findPulseId(dep);
197
+ if (depId) {
198
+ node.dependencies.push(depId);
199
+ edges.push({ from: depId, to: id });
200
+
201
+ const depNode = nodeMap.get(depId);
202
+ if (depNode) {
203
+ depNode.dependents.push(id);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ return { nodes, edges };
211
+ }
212
+
213
+ /**
214
+ * Find pulse ID from pulse instance
215
+ */
216
+ function findPulseId(pulseInstance) {
217
+ for (const [id, entry] of pulseRegistry) {
218
+ if (entry.pulse === pulseInstance) {
219
+ return id;
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /**
226
+ * Export graph in DOT format for Graphviz visualization
227
+ * @returns {string} DOT format graph
228
+ */
229
+ export function exportGraphAsDot() {
230
+ const { nodes, edges } = getDependencyGraph();
231
+
232
+ let dot = 'digraph ReactiveGraph {\n';
233
+ dot += ' rankdir=LR;\n';
234
+ dot += ' node [shape=box];\n\n';
235
+
236
+ // Add nodes with styling
237
+ for (const node of nodes) {
238
+ const color = node.type === 'pulse' ? 'lightblue' : 'lightgreen';
239
+ const label = `${node.name}\\n${node.type}`;
240
+ dot += ` "${node.id}" [label="${label}" fillcolor="${color}" style="filled"];\n`;
241
+ }
242
+
243
+ dot += '\n';
244
+
245
+ // Add edges
246
+ for (const edge of edges) {
247
+ dot += ` "${edge.from}" -> "${edge.to}";\n`;
248
+ }
249
+
250
+ dot += '}\n';
251
+ return dot;
252
+ }
253
+
254
+ // =============================================================================
255
+ // TIME-TRAVEL DEBUGGING
256
+ // =============================================================================
257
+
258
+ /**
259
+ * @typedef {Object} StateSnapshot
260
+ * @property {number} timestamp - When snapshot was taken
261
+ * @property {Object} state - Serialized state
262
+ * @property {string} action - Description of what caused the snapshot
263
+ * @property {number} index - Position in history
264
+ */
265
+
266
+ /**
267
+ * Take a snapshot of current state
268
+ * @param {string} [action='manual'] - Description of the action
269
+ * @returns {StateSnapshot}
270
+ */
271
+ export function takeSnapshot(action = 'manual') {
272
+ if (isTimeTraveling) return null;
273
+
274
+ const state = {};
275
+ for (const [id, entry] of pulseRegistry) {
276
+ try {
277
+ // Deep clone to prevent mutation
278
+ state[id] = JSON.parse(JSON.stringify(entry.pulse.peek()));
279
+ } catch {
280
+ // Non-serializable value
281
+ state[id] = '[Non-serializable]';
282
+ }
283
+ }
284
+
285
+ const snapshot = {
286
+ timestamp: Date.now(),
287
+ state,
288
+ action,
289
+ index: stateHistory.length
290
+ };
291
+
292
+ // Trim history if too long
293
+ if (stateHistory.length >= config.maxSnapshots) {
294
+ stateHistory.shift();
295
+ }
296
+
297
+ stateHistory.push(snapshot);
298
+ historyIndex = stateHistory.length - 1;
299
+
300
+ return snapshot;
301
+ }
302
+
303
+ /**
304
+ * Get state history
305
+ * @returns {StateSnapshot[]}
306
+ */
307
+ export function getHistory() {
308
+ return [...stateHistory];
309
+ }
310
+
311
+ /**
312
+ * Get current history position
313
+ * @returns {number}
314
+ */
315
+ export function getHistoryIndex() {
316
+ return historyIndex;
317
+ }
318
+
319
+ /**
320
+ * Travel to a specific point in history
321
+ * @param {number} index - History index to travel to
322
+ * @returns {boolean} Success
323
+ */
324
+ export function travelTo(index) {
325
+ if (index < 0 || index >= stateHistory.length) {
326
+ return false;
327
+ }
328
+
329
+ const snapshot = stateHistory[index];
330
+ isTimeTraveling = true;
331
+
332
+ batch(() => {
333
+ for (const [id, value] of Object.entries(snapshot.state)) {
334
+ const entry = pulseRegistry.get(id);
335
+ if (entry && value !== '[Non-serializable]') {
336
+ entry.pulse.set(value);
337
+ }
338
+ }
339
+ });
340
+
341
+ historyIndex = index;
342
+ isTimeTraveling = false;
343
+
344
+ return true;
345
+ }
346
+
347
+ /**
348
+ * Go back one step in history
349
+ * @returns {boolean} Success
350
+ */
351
+ export function back() {
352
+ return travelTo(historyIndex - 1);
353
+ }
354
+
355
+ /**
356
+ * Go forward one step in history
357
+ * @returns {boolean} Success
358
+ */
359
+ export function forward() {
360
+ return travelTo(historyIndex + 1);
361
+ }
362
+
363
+ /**
364
+ * Clear all history
365
+ */
366
+ export function clearHistory() {
367
+ stateHistory.length = 0;
368
+ historyIndex = -1;
369
+ }
370
+
371
+ // =============================================================================
372
+ // PULSE & EFFECT TRACKING
373
+ // =============================================================================
374
+
375
+ let pulseIdCounter = 0;
376
+ let trackedEffectIdCounter = 0;
377
+
378
+ /**
379
+ * Create a tracked pulse (for dev tools)
380
+ * @param {any} initialValue - Initial value
381
+ * @param {string} [name] - Display name for debugging
382
+ * @returns {Pulse} Tracked pulse
383
+ */
384
+ export function trackedPulse(initialValue, name) {
385
+ const p = pulse(initialValue);
386
+ const id = `pulse_${++pulseIdCounter}`;
387
+
388
+ pulseRegistry.set(id, {
389
+ pulse: p,
390
+ name: name || id,
391
+ createdAt: Date.now()
392
+ });
393
+
394
+ // Wrap set to record snapshots
395
+ const originalSet = p.set.bind(p);
396
+ p.set = (value) => {
397
+ const result = originalSet(value);
398
+ if (config.enabled && config.logUpdates) {
399
+ console.log(`[Pulse] ${name || id} updated:`, value);
400
+ }
401
+ if (config.enabled && !isTimeTraveling) {
402
+ takeSnapshot(`${name || id} = ${JSON.stringify(value)}`);
403
+ }
404
+ return result;
405
+ };
406
+
407
+ // Add dispose method
408
+ p.dispose = () => {
409
+ pulseRegistry.delete(id);
410
+ };
411
+
412
+ return p;
413
+ }
414
+
415
+ /**
416
+ * Create a tracked effect (for dev tools)
417
+ * @param {function} fn - Effect function
418
+ * @param {string} [name] - Display name for debugging
419
+ * @returns {function} Dispose function
420
+ */
421
+ export function trackedEffect(fn, name) {
422
+ const id = `effect_${++trackedEffectIdCounter}`;
423
+ const startTime = Date.now();
424
+
425
+ const entry = {
426
+ effect: null,
427
+ name: name || id,
428
+ createdAt: startTime,
429
+ runCount: 0,
430
+ totalTime: 0
431
+ };
432
+
433
+ effectRegistry.set(id, entry);
434
+
435
+ const wrappedFn = () => {
436
+ const runStart = performance.now();
437
+
438
+ if (config.enabled && config.logEffects) {
439
+ console.log(`[Effect] ${name || id} running...`);
440
+ }
441
+
442
+ const result = fn();
443
+
444
+ const runTime = performance.now() - runStart;
445
+ entry.runCount++;
446
+ entry.totalTime += runTime;
447
+
448
+ if (config.enabled && config.warnOnSlowEffects && runTime > config.slowEffectThreshold) {
449
+ console.warn(`[Effect] ${name || id} took ${runTime.toFixed(2)}ms (slow)`);
450
+ }
451
+
452
+ return result;
453
+ };
454
+
455
+ const dispose = effect(wrappedFn, { id });
456
+
457
+ // Store reference to effect for graph building
458
+ entry.effect = context.currentEffect;
459
+
460
+ return () => {
461
+ dispose();
462
+ effectRegistry.delete(id);
463
+ };
464
+ }
465
+
466
+ // =============================================================================
467
+ // DEV TOOLS API
468
+ // =============================================================================
469
+
470
+ /**
471
+ * Enable dev tools
472
+ * @param {Object} [options] - Configuration options
473
+ */
474
+ export function enableDevTools(options = {}) {
475
+ Object.assign(config, options, { enabled: true });
476
+
477
+ if (typeof window !== 'undefined') {
478
+ // Expose to window for browser dev tools
479
+ window.__PULSE_DEVTOOLS__ = {
480
+ getDiagnostics,
481
+ getEffectStats,
482
+ getPulseList,
483
+ getDependencyGraph,
484
+ exportGraphAsDot,
485
+ takeSnapshot,
486
+ getHistory,
487
+ travelTo,
488
+ back,
489
+ forward,
490
+ clearHistory,
491
+ config
492
+ };
493
+
494
+ console.log('[Pulse DevTools] Enabled. Access via window.__PULSE_DEVTOOLS__');
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Disable dev tools
500
+ */
501
+ export function disableDevTools() {
502
+ config.enabled = false;
503
+
504
+ if (typeof window !== 'undefined') {
505
+ delete window.__PULSE_DEVTOOLS__;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Check if dev tools are enabled
511
+ * @returns {boolean}
512
+ */
513
+ export function isDevToolsEnabled() {
514
+ return config.enabled;
515
+ }
516
+
517
+ /**
518
+ * Update dev tools configuration
519
+ * @param {Object} options - Configuration options
520
+ */
521
+ export function configureDevTools(options) {
522
+ Object.assign(config, options);
523
+ }
524
+
525
+ /**
526
+ * Clear all dev tools data
527
+ */
528
+ export function resetDevTools() {
529
+ pulseRegistry.clear();
530
+ effectRegistry.clear();
531
+ stateHistory.length = 0;
532
+ historyIndex = -1;
533
+ pulseIdCounter = 0;
534
+ trackedEffectIdCounter = 0;
535
+ }
536
+
537
+ // =============================================================================
538
+ // PERFORMANCE PROFILING
539
+ // =============================================================================
540
+
541
+ /**
542
+ * Profile a section of code
543
+ * @param {string} name - Profile name
544
+ * @param {function} fn - Function to profile
545
+ * @returns {any} Result of fn
546
+ *
547
+ * @example
548
+ * const result = profile('data-processing', () => {
549
+ * return processLargeDataset();
550
+ * });
551
+ */
552
+ export function profile(name, fn) {
553
+ const start = performance.now();
554
+
555
+ try {
556
+ return fn();
557
+ } finally {
558
+ const duration = performance.now() - start;
559
+ console.log(`[Profile] ${name}: ${duration.toFixed(2)}ms`);
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Create a performance marker
565
+ * @param {string} name - Marker name
566
+ * @returns {{end: function(): number}} Marker with end method
567
+ */
568
+ export function mark(name) {
569
+ const start = performance.now();
570
+
571
+ return {
572
+ end() {
573
+ const duration = performance.now() - start;
574
+ if (config.enabled) {
575
+ console.log(`[Mark] ${name}: ${duration.toFixed(2)}ms`);
576
+ }
577
+ return duration;
578
+ }
579
+ };
580
+ }
581
+
582
+ // =============================================================================
583
+ // EXPORTS
584
+ // =============================================================================
585
+
586
+ export default {
587
+ // Diagnostics
588
+ getDiagnostics,
589
+ getEffectStats,
590
+ getPulseList,
591
+
592
+ // Graph
593
+ getDependencyGraph,
594
+ exportGraphAsDot,
595
+
596
+ // Time-travel
597
+ takeSnapshot,
598
+ getHistory,
599
+ getHistoryIndex,
600
+ travelTo,
601
+ back,
602
+ forward,
603
+ clearHistory,
604
+
605
+ // Tracking
606
+ trackedPulse,
607
+ trackedEffect,
608
+
609
+ // Configuration
610
+ enableDevTools,
611
+ disableDevTools,
612
+ isDevToolsEnabled,
613
+ configureDevTools,
614
+ resetDevTools,
615
+
616
+ // Profiling
617
+ profile,
618
+ mark
619
+ };