pulse-js-framework 1.7.8 → 1.7.10
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/cli/lint.js +442 -3
- package/compiler/lexer.js +6 -0
- package/compiler/parser.js +144 -1
- package/compiler/transformer/imports.js +15 -0
- package/compiler/transformer/index.js +46 -0
- package/compiler/transformer/view.js +180 -5
- package/package.json +14 -2
- package/runtime/a11y.js +1005 -0
- package/runtime/devtools/a11y-audit.js +442 -0
- package/runtime/devtools/diagnostics.js +403 -0
- package/runtime/devtools/index.js +53 -0
- package/runtime/devtools/time-travel.js +189 -0
- package/runtime/devtools.js +138 -497
- package/runtime/dom-binding.js +7 -4
- package/runtime/dom-element.js +192 -1
- package/runtime/dom.js +8 -2
- package/runtime/http.js +837 -0
- package/runtime/index.js +2 -0
- package/runtime/native.js +2 -2
- package/runtime/security.js +461 -0
- package/runtime/utils.js +37 -16
- package/types/a11y.d.ts +336 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DevTools - Diagnostics Module
|
|
3
|
+
* @module pulse-js-framework/runtime/devtools/diagnostics
|
|
4
|
+
*
|
|
5
|
+
* Reactive dependency graph inspection, performance monitoring,
|
|
6
|
+
* and pulse/effect tracking.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pulse, effect, context } from '../pulse.js';
|
|
10
|
+
import { createLogger } from '../logger.js';
|
|
11
|
+
|
|
12
|
+
// Lazy import to avoid circular dependency
|
|
13
|
+
let getSnapshotCountFn = null;
|
|
14
|
+
export function _setSnapshotCountFn(fn) {
|
|
15
|
+
getSnapshotCountFn = fn;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const log = createLogger('DevTools');
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// CONFIGURATION
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Diagnostics configuration
|
|
26
|
+
*/
|
|
27
|
+
export const config = {
|
|
28
|
+
enabled: false,
|
|
29
|
+
logUpdates: false,
|
|
30
|
+
logEffects: false,
|
|
31
|
+
warnOnSlowEffects: true,
|
|
32
|
+
slowEffectThreshold: 16 // ms (one frame at 60fps)
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// REGISTRIES
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Registry of all tracked pulses
|
|
41
|
+
* @type {Map<string, {pulse: Pulse, name: string, createdAt: number}>}
|
|
42
|
+
*/
|
|
43
|
+
export const pulseRegistry = new Map();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Registry of all tracked effects
|
|
47
|
+
* @type {Map<string, {effect: Object, name: string, createdAt: number, runCount: number, totalTime: number}>}
|
|
48
|
+
*/
|
|
49
|
+
export const effectRegistry = new Map();
|
|
50
|
+
|
|
51
|
+
let pulseIdCounter = 0;
|
|
52
|
+
let trackedEffectIdCounter = 0;
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// DIAGNOSTICS API
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} DiagnosticsStats
|
|
60
|
+
* @property {number} pulseCount - Total number of active pulses
|
|
61
|
+
* @property {number} effectCount - Total number of active effects
|
|
62
|
+
* @property {number} totalEffectRuns - Total effect executions
|
|
63
|
+
* @property {number} avgEffectTime - Average effect execution time (ms)
|
|
64
|
+
* @property {number} pendingEffects - Effects waiting to run
|
|
65
|
+
* @property {number} batchDepth - Current batch nesting depth
|
|
66
|
+
* @property {Object} memoryEstimate - Estimated memory usage
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get current diagnostics statistics
|
|
71
|
+
* @returns {DiagnosticsStats}
|
|
72
|
+
*/
|
|
73
|
+
export function getDiagnostics() {
|
|
74
|
+
let totalRuns = 0;
|
|
75
|
+
let totalTime = 0;
|
|
76
|
+
|
|
77
|
+
for (const entry of effectRegistry.values()) {
|
|
78
|
+
totalRuns += entry.runCount;
|
|
79
|
+
totalTime += entry.totalTime;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const snapshotCount = getSnapshotCountFn ? getSnapshotCountFn() : 0;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
pulseCount: pulseRegistry.size,
|
|
86
|
+
effectCount: effectRegistry.size,
|
|
87
|
+
totalEffectRuns: totalRuns,
|
|
88
|
+
avgEffectTime: totalRuns > 0 ? totalTime / totalRuns : 0,
|
|
89
|
+
pendingEffects: context.pendingEffects.size,
|
|
90
|
+
batchDepth: context.batchDepth,
|
|
91
|
+
snapshotCount,
|
|
92
|
+
memoryEstimate: {
|
|
93
|
+
pulses: pulseRegistry.size * 100,
|
|
94
|
+
effects: effectRegistry.size * 200,
|
|
95
|
+
history: snapshotCount * 500
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get detailed effect statistics
|
|
102
|
+
* @returns {Array<{id: string, name: string, runCount: number, avgTime: number, lastRun: number}>}
|
|
103
|
+
*/
|
|
104
|
+
export function getEffectStats() {
|
|
105
|
+
return [...effectRegistry.entries()].map(([id, entry]) => ({
|
|
106
|
+
id,
|
|
107
|
+
name: entry.name,
|
|
108
|
+
runCount: entry.runCount,
|
|
109
|
+
avgTime: entry.runCount > 0 ? entry.totalTime / entry.runCount : 0,
|
|
110
|
+
totalTime: entry.totalTime,
|
|
111
|
+
createdAt: entry.createdAt
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get list of all tracked pulses
|
|
117
|
+
* @returns {Array<{id: string, name: string, value: any, subscriberCount: number}>}
|
|
118
|
+
*/
|
|
119
|
+
export function getPulseList() {
|
|
120
|
+
return [...pulseRegistry.entries()].map(([id, entry]) => ({
|
|
121
|
+
id,
|
|
122
|
+
name: entry.name,
|
|
123
|
+
value: entry.pulse.peek(),
|
|
124
|
+
subscriberCount: entry.pulse._subscribers?.size || 0,
|
|
125
|
+
createdAt: entry.createdAt
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// =============================================================================
|
|
130
|
+
// REACTIVE GRAPH INSPECTION
|
|
131
|
+
// =============================================================================
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @typedef {Object} DependencyNode
|
|
135
|
+
* @property {string} id - Node identifier
|
|
136
|
+
* @property {string} type - 'pulse' | 'effect' | 'computed'
|
|
137
|
+
* @property {string} name - Display name
|
|
138
|
+
* @property {any} value - Current value (for pulses)
|
|
139
|
+
* @property {string[]} dependencies - IDs of nodes this depends on
|
|
140
|
+
* @property {string[]} dependents - IDs of nodes that depend on this
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Find pulse ID from pulse instance
|
|
145
|
+
* @private
|
|
146
|
+
*/
|
|
147
|
+
function findPulseId(pulseInstance) {
|
|
148
|
+
for (const [id, entry] of pulseRegistry) {
|
|
149
|
+
if (entry.pulse === pulseInstance) {
|
|
150
|
+
return id;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the reactive dependency graph
|
|
158
|
+
* @returns {{nodes: DependencyNode[], edges: Array<{from: string, to: string}>}}
|
|
159
|
+
*/
|
|
160
|
+
export function getDependencyGraph() {
|
|
161
|
+
const nodes = [];
|
|
162
|
+
const edges = [];
|
|
163
|
+
const nodeMap = new Map();
|
|
164
|
+
|
|
165
|
+
// Add pulse nodes
|
|
166
|
+
for (const [id, entry] of pulseRegistry) {
|
|
167
|
+
const node = {
|
|
168
|
+
id,
|
|
169
|
+
type: 'pulse',
|
|
170
|
+
name: entry.name,
|
|
171
|
+
value: entry.pulse.peek(),
|
|
172
|
+
dependencies: [],
|
|
173
|
+
dependents: []
|
|
174
|
+
};
|
|
175
|
+
nodes.push(node);
|
|
176
|
+
nodeMap.set(id, node);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Add effect nodes and build edges
|
|
180
|
+
for (const [id, entry] of effectRegistry) {
|
|
181
|
+
const node = {
|
|
182
|
+
id,
|
|
183
|
+
type: 'effect',
|
|
184
|
+
name: entry.name,
|
|
185
|
+
value: null,
|
|
186
|
+
dependencies: [],
|
|
187
|
+
dependents: []
|
|
188
|
+
};
|
|
189
|
+
nodes.push(node);
|
|
190
|
+
nodeMap.set(id, node);
|
|
191
|
+
|
|
192
|
+
// Get effect's dependencies
|
|
193
|
+
if (entry.effect?.dependencies) {
|
|
194
|
+
for (const dep of entry.effect.dependencies) {
|
|
195
|
+
const depId = findPulseId(dep);
|
|
196
|
+
if (depId) {
|
|
197
|
+
node.dependencies.push(depId);
|
|
198
|
+
edges.push({ from: depId, to: id });
|
|
199
|
+
|
|
200
|
+
const depNode = nodeMap.get(depId);
|
|
201
|
+
if (depNode) {
|
|
202
|
+
depNode.dependents.push(id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { nodes, edges };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Export graph in DOT format for Graphviz visualization
|
|
214
|
+
* @returns {string} DOT format graph
|
|
215
|
+
*/
|
|
216
|
+
export function exportGraphAsDot() {
|
|
217
|
+
const { nodes, edges } = getDependencyGraph();
|
|
218
|
+
|
|
219
|
+
let dot = 'digraph ReactiveGraph {\n';
|
|
220
|
+
dot += ' rankdir=LR;\n';
|
|
221
|
+
dot += ' node [shape=box];\n\n';
|
|
222
|
+
|
|
223
|
+
// Add nodes with styling
|
|
224
|
+
for (const node of nodes) {
|
|
225
|
+
const color = node.type === 'pulse' ? 'lightblue' : 'lightgreen';
|
|
226
|
+
const label = `${node.name}\\n${node.type}`;
|
|
227
|
+
dot += ` "${node.id}" [label="${label}" fillcolor="${color}" style="filled"];\n`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
dot += '\n';
|
|
231
|
+
|
|
232
|
+
// Add edges
|
|
233
|
+
for (const edge of edges) {
|
|
234
|
+
dot += ` "${edge.from}" -> "${edge.to}";\n`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
dot += '}\n';
|
|
238
|
+
return dot;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// =============================================================================
|
|
242
|
+
// PULSE & EFFECT TRACKING
|
|
243
|
+
// =============================================================================
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Create a tracked pulse (for dev tools)
|
|
247
|
+
* @param {any} initialValue - Initial value
|
|
248
|
+
* @param {string} [name] - Display name for debugging
|
|
249
|
+
* @param {Object} [options] - Additional options
|
|
250
|
+
* @param {function} [options.onSnapshot] - Callback when snapshot should be taken
|
|
251
|
+
* @returns {Pulse} Tracked pulse
|
|
252
|
+
*/
|
|
253
|
+
export function trackedPulse(initialValue, name, options = {}) {
|
|
254
|
+
const p = pulse(initialValue);
|
|
255
|
+
const id = `pulse_${++pulseIdCounter}`;
|
|
256
|
+
|
|
257
|
+
pulseRegistry.set(id, {
|
|
258
|
+
pulse: p,
|
|
259
|
+
name: name || id,
|
|
260
|
+
createdAt: Date.now()
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Wrap set to record snapshots
|
|
264
|
+
const originalSet = p.set.bind(p);
|
|
265
|
+
p.set = (value) => {
|
|
266
|
+
const result = originalSet(value);
|
|
267
|
+
if (config.enabled && config.logUpdates) {
|
|
268
|
+
log.info(`${name || id} updated:`, value);
|
|
269
|
+
}
|
|
270
|
+
if (config.enabled && options.onSnapshot) {
|
|
271
|
+
options.onSnapshot(`${name || id} = ${JSON.stringify(value)}`);
|
|
272
|
+
}
|
|
273
|
+
return result;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Add dispose method
|
|
277
|
+
p.dispose = () => {
|
|
278
|
+
pulseRegistry.delete(id);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return p;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Create a tracked effect (for dev tools)
|
|
286
|
+
* @param {function} fn - Effect function
|
|
287
|
+
* @param {string} [name] - Display name for debugging
|
|
288
|
+
* @returns {function} Dispose function
|
|
289
|
+
*/
|
|
290
|
+
export function trackedEffect(fn, name) {
|
|
291
|
+
const id = `effect_${++trackedEffectIdCounter}`;
|
|
292
|
+
const startTime = Date.now();
|
|
293
|
+
|
|
294
|
+
const entry = {
|
|
295
|
+
effect: null,
|
|
296
|
+
name: name || id,
|
|
297
|
+
createdAt: startTime,
|
|
298
|
+
runCount: 0,
|
|
299
|
+
totalTime: 0
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
effectRegistry.set(id, entry);
|
|
303
|
+
|
|
304
|
+
const wrappedFn = () => {
|
|
305
|
+
const runStart = performance.now();
|
|
306
|
+
|
|
307
|
+
if (config.enabled && config.logEffects) {
|
|
308
|
+
log.info(`${name || id} running...`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const result = fn();
|
|
312
|
+
|
|
313
|
+
const runTime = performance.now() - runStart;
|
|
314
|
+
entry.runCount++;
|
|
315
|
+
entry.totalTime += runTime;
|
|
316
|
+
|
|
317
|
+
if (config.enabled && config.warnOnSlowEffects && runTime > config.slowEffectThreshold) {
|
|
318
|
+
log.warn(`${name || id} took ${runTime.toFixed(2)}ms (slow)`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const dispose = effect(wrappedFn, { id });
|
|
325
|
+
|
|
326
|
+
// Store reference to effect for graph building
|
|
327
|
+
entry.effect = context.currentEffect;
|
|
328
|
+
|
|
329
|
+
return () => {
|
|
330
|
+
dispose();
|
|
331
|
+
effectRegistry.delete(id);
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// =============================================================================
|
|
336
|
+
// PERFORMANCE PROFILING
|
|
337
|
+
// =============================================================================
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Profile a section of code
|
|
341
|
+
* @param {string} name - Profile name
|
|
342
|
+
* @param {function} fn - Function to profile
|
|
343
|
+
* @returns {any} Result of fn
|
|
344
|
+
*/
|
|
345
|
+
export function profile(name, fn) {
|
|
346
|
+
const start = performance.now();
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
return fn();
|
|
350
|
+
} finally {
|
|
351
|
+
const duration = performance.now() - start;
|
|
352
|
+
log.info(`[Profile] ${name}: ${duration.toFixed(2)}ms`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Create a performance marker
|
|
358
|
+
* @param {string} name - Marker name
|
|
359
|
+
* @returns {{end: function(): number}} Marker with end method
|
|
360
|
+
*/
|
|
361
|
+
export function mark(name) {
|
|
362
|
+
const start = performance.now();
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
end() {
|
|
366
|
+
const duration = performance.now() - start;
|
|
367
|
+
if (config.enabled) {
|
|
368
|
+
log.info(`[Mark] ${name}: ${duration.toFixed(2)}ms`);
|
|
369
|
+
}
|
|
370
|
+
return duration;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// =============================================================================
|
|
376
|
+
// RESET
|
|
377
|
+
// =============================================================================
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Reset all diagnostics data
|
|
381
|
+
*/
|
|
382
|
+
export function resetDiagnostics() {
|
|
383
|
+
pulseRegistry.clear();
|
|
384
|
+
effectRegistry.clear();
|
|
385
|
+
pulseIdCounter = 0;
|
|
386
|
+
trackedEffectIdCounter = 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export default {
|
|
390
|
+
config,
|
|
391
|
+
getDiagnostics,
|
|
392
|
+
getEffectStats,
|
|
393
|
+
getPulseList,
|
|
394
|
+
getDependencyGraph,
|
|
395
|
+
exportGraphAsDot,
|
|
396
|
+
trackedPulse,
|
|
397
|
+
trackedEffect,
|
|
398
|
+
profile,
|
|
399
|
+
mark,
|
|
400
|
+
resetDiagnostics,
|
|
401
|
+
pulseRegistry,
|
|
402
|
+
effectRegistry
|
|
403
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DevTools - Module Index
|
|
3
|
+
* @module pulse-js-framework/runtime/devtools
|
|
4
|
+
*
|
|
5
|
+
* Re-exports all devtools functionality from sub-modules:
|
|
6
|
+
* - diagnostics: Reactive graph inspection, performance monitoring
|
|
7
|
+
* - time-travel: State snapshots and time-travel debugging
|
|
8
|
+
* - a11y-audit: Accessibility validation and reporting
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Diagnostics
|
|
12
|
+
export {
|
|
13
|
+
config,
|
|
14
|
+
getDiagnostics,
|
|
15
|
+
getEffectStats,
|
|
16
|
+
getPulseList,
|
|
17
|
+
getDependencyGraph,
|
|
18
|
+
exportGraphAsDot,
|
|
19
|
+
trackedPulse,
|
|
20
|
+
trackedEffect,
|
|
21
|
+
profile,
|
|
22
|
+
mark,
|
|
23
|
+
resetDiagnostics,
|
|
24
|
+
pulseRegistry,
|
|
25
|
+
effectRegistry
|
|
26
|
+
} from './diagnostics.js';
|
|
27
|
+
|
|
28
|
+
// Time-travel
|
|
29
|
+
export {
|
|
30
|
+
timeTravelConfig,
|
|
31
|
+
getIsTimeTraveling,
|
|
32
|
+
takeSnapshot,
|
|
33
|
+
getHistory,
|
|
34
|
+
getHistoryIndex,
|
|
35
|
+
getSnapshotCount,
|
|
36
|
+
travelTo,
|
|
37
|
+
back,
|
|
38
|
+
forward,
|
|
39
|
+
clearHistory
|
|
40
|
+
} from './time-travel.js';
|
|
41
|
+
|
|
42
|
+
// A11y Audit
|
|
43
|
+
export {
|
|
44
|
+
a11yAuditConfig,
|
|
45
|
+
runA11yAudit,
|
|
46
|
+
getA11yIssues,
|
|
47
|
+
getA11yStats,
|
|
48
|
+
enableA11yAudit,
|
|
49
|
+
disableA11yAudit,
|
|
50
|
+
toggleA11yHighlights,
|
|
51
|
+
exportA11yReport,
|
|
52
|
+
resetA11yAudit
|
|
53
|
+
} from './a11y-audit.js';
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DevTools - Time Travel Module
|
|
3
|
+
* @module pulse-js-framework/runtime/devtools/time-travel
|
|
4
|
+
*
|
|
5
|
+
* Time-travel debugging with state snapshots.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { batch } from '../pulse.js';
|
|
9
|
+
import { createLogger } from '../logger.js';
|
|
10
|
+
import { pulseRegistry, config } from './diagnostics.js';
|
|
11
|
+
|
|
12
|
+
const log = createLogger('DevTools:TimeTravel');
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// TIME-TRAVEL STATE
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Time-travel configuration
|
|
20
|
+
*/
|
|
21
|
+
export const timeTravelConfig = {
|
|
22
|
+
maxSnapshots: 50
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Time-travel state history
|
|
27
|
+
* @type {Array<{timestamp: number, state: Object, action: string}>}
|
|
28
|
+
*/
|
|
29
|
+
const stateHistory = [];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Current position in history (for time-travel)
|
|
33
|
+
*/
|
|
34
|
+
let historyIndex = -1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Flag to prevent recording during time-travel
|
|
38
|
+
*/
|
|
39
|
+
let isTimeTraveling = false;
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// TIME-TRAVEL API
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} StateSnapshot
|
|
47
|
+
* @property {number} timestamp - When snapshot was taken
|
|
48
|
+
* @property {Object} state - Serialized state
|
|
49
|
+
* @property {string} action - Description of what caused the snapshot
|
|
50
|
+
* @property {number} index - Position in history
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if currently time-traveling
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function getIsTimeTraveling() {
|
|
58
|
+
return isTimeTraveling;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Take a snapshot of current state
|
|
63
|
+
* @param {string} [action='manual'] - Description of the action
|
|
64
|
+
* @returns {StateSnapshot|null}
|
|
65
|
+
*/
|
|
66
|
+
export function takeSnapshot(action = 'manual') {
|
|
67
|
+
if (isTimeTraveling) return null;
|
|
68
|
+
|
|
69
|
+
const state = {};
|
|
70
|
+
for (const [id, entry] of pulseRegistry) {
|
|
71
|
+
try {
|
|
72
|
+
// Deep clone to prevent mutation
|
|
73
|
+
state[id] = JSON.parse(JSON.stringify(entry.pulse.peek()));
|
|
74
|
+
} catch {
|
|
75
|
+
// Non-serializable value
|
|
76
|
+
state[id] = '[Non-serializable]';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const snapshot = {
|
|
81
|
+
timestamp: Date.now(),
|
|
82
|
+
state,
|
|
83
|
+
action,
|
|
84
|
+
index: stateHistory.length
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Trim history if too long
|
|
88
|
+
if (stateHistory.length >= timeTravelConfig.maxSnapshots) {
|
|
89
|
+
stateHistory.shift();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
stateHistory.push(snapshot);
|
|
93
|
+
historyIndex = stateHistory.length - 1;
|
|
94
|
+
|
|
95
|
+
return snapshot;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get state history
|
|
100
|
+
* @returns {StateSnapshot[]}
|
|
101
|
+
*/
|
|
102
|
+
export function getHistory() {
|
|
103
|
+
return [...stateHistory];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get current history position
|
|
108
|
+
* @returns {number}
|
|
109
|
+
*/
|
|
110
|
+
export function getHistoryIndex() {
|
|
111
|
+
return historyIndex;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get snapshot count
|
|
116
|
+
* @returns {number}
|
|
117
|
+
*/
|
|
118
|
+
export function getSnapshotCount() {
|
|
119
|
+
return stateHistory.length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Travel to a specific point in history
|
|
124
|
+
* @param {number} index - History index to travel to
|
|
125
|
+
* @returns {boolean} Success
|
|
126
|
+
*/
|
|
127
|
+
export function travelTo(index) {
|
|
128
|
+
if (index < 0 || index >= stateHistory.length) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const snapshot = stateHistory[index];
|
|
133
|
+
isTimeTraveling = true;
|
|
134
|
+
|
|
135
|
+
batch(() => {
|
|
136
|
+
for (const [id, value] of Object.entries(snapshot.state)) {
|
|
137
|
+
const entry = pulseRegistry.get(id);
|
|
138
|
+
if (entry && value !== '[Non-serializable]') {
|
|
139
|
+
entry.pulse.set(value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
historyIndex = index;
|
|
145
|
+
isTimeTraveling = false;
|
|
146
|
+
|
|
147
|
+
if (config.enabled) {
|
|
148
|
+
log.info(`Traveled to snapshot ${index}: ${snapshot.action}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Go back one step in history
|
|
156
|
+
* @returns {boolean} Success
|
|
157
|
+
*/
|
|
158
|
+
export function back() {
|
|
159
|
+
return travelTo(historyIndex - 1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Go forward one step in history
|
|
164
|
+
* @returns {boolean} Success
|
|
165
|
+
*/
|
|
166
|
+
export function forward() {
|
|
167
|
+
return travelTo(historyIndex + 1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Clear all history
|
|
172
|
+
*/
|
|
173
|
+
export function clearHistory() {
|
|
174
|
+
stateHistory.length = 0;
|
|
175
|
+
historyIndex = -1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default {
|
|
179
|
+
timeTravelConfig,
|
|
180
|
+
getIsTimeTraveling,
|
|
181
|
+
takeSnapshot,
|
|
182
|
+
getHistory,
|
|
183
|
+
getHistoryIndex,
|
|
184
|
+
getSnapshotCount,
|
|
185
|
+
travelTo,
|
|
186
|
+
back,
|
|
187
|
+
forward,
|
|
188
|
+
clearHistory
|
|
189
|
+
};
|