@everystate/perf 1.0.0 → 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ajdin Imsirovic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -13,10 +13,10 @@ npm install @everystate/perf @everystate/core
13
13
  ## Quick Start
14
14
 
15
15
  ```js
16
- import { createEventState } from '@everystate/core';
16
+ import { createEveryState } from '@everystate/core';
17
17
  import { createPerfMonitor, mountOverlay } from '@everystate/perf';
18
18
 
19
- const store = createEventState({ count: 0 });
19
+ const store = createEveryState({ count: 0 });
20
20
 
21
21
  // Wrap store with performance monitoring
22
22
  const monitor = createPerfMonitor(store, {
package/index.js CHANGED
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * @everystate/perf
3
- *
4
- * EveryState wrapper for @uistate/perf
5
- * Re-exports all functionality from the underlying @uistate/perf package
6
3
  */
7
4
 
8
- export * from '@uistate/perf';
5
+ export { createPerfMonitor } from './perfMonitor.js';
6
+ export { mountOverlay } from './overlay.js';
package/overlay.js ADDED
@@ -0,0 +1,540 @@
1
+ /**
2
+ * @everystate/perf: overlay.js
3
+ *
4
+ * Floating browser overlay that displays live perf stats from a perfMonitor.
5
+ * Inject into any EveryState-powered page with: perf.mount(document.body)
6
+ *
7
+ * Copyright (c) 2026 Ajdin Imsirovic. MIT License.
8
+ */
9
+
10
+ const STYLES = `
11
+ .uiperf-overlay {
12
+ font-family: monospace; font-size: 10px; color: #c9d1d9;
13
+ background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
14
+ box-shadow: 0 4px 24px rgba(0,0,0,0.5); width: 380px;
15
+ max-height: 80vh; display: flex; flex-direction: column;
16
+ user-select: none; overflow: hidden;
17
+ }
18
+ .uiperf-overlay.collapsed { max-height: 32px; }
19
+ .uiperf-overlay.collapsed .uiperf-body,
20
+ .uiperf-overlay.collapsed .uiperf-tabs,
21
+ .uiperf-overlay.collapsed .uiperf-tree-panel { display: none; }
22
+
23
+ .uiperf-header {
24
+ display: flex; align-items: center; gap: 6px;
25
+ padding: 6px 10px; background: #161b22; border-bottom: 1px solid #30363d;
26
+ cursor: grab; flex-shrink: 0;
27
+ }
28
+ .uiperf-header:active { cursor: grabbing; }
29
+ .uiperf-title { color: #58a6ff; font-weight: 700; font-size: 11px; flex: 1; }
30
+ .uiperf-badge {
31
+ background: #1f6feb; color: #fff; padding: 1px 6px;
32
+ border-radius: 9px; font-size: 9px;
33
+ }
34
+ .uiperf-btn {
35
+ background: #21262d; color: #c9d1d9; border: 1px solid #30363d;
36
+ padding: 2px 6px; border-radius: 3px; cursor: pointer; font-family: monospace;
37
+ font-size: 9px;
38
+ }
39
+ .uiperf-btn:hover { background: #30363d; }
40
+ .uiperf-btn.download { background: #238636; border-color: #2ea043; }
41
+ .uiperf-btn.download:hover { background: #2ea043; }
42
+
43
+ /* Tabs */
44
+ .uiperf-tabs {
45
+ display: flex; gap: 0; border-bottom: 1px solid #21262d;
46
+ background: #0d1117; flex-shrink: 0;
47
+ }
48
+ .uiperf-tab {
49
+ flex: 1; padding: 5px 0; text-align: center; font-family: monospace;
50
+ font-size: 10px; font-weight: 600; color: #8b949e; background: transparent;
51
+ border: none; border-bottom: 2px solid transparent; cursor: pointer;
52
+ transition: color 0.15s, border-color 0.15s;
53
+ }
54
+ .uiperf-tab:hover { color: #c9d1d9; }
55
+ .uiperf-tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
56
+
57
+ .uiperf-body {
58
+ overflow-y: auto; flex: 1; padding: 8px 10px;
59
+ }
60
+
61
+ .uiperf-section { margin-top: 8px; padding-top: 6px; border-top: 1px solid #21262d; }
62
+ .uiperf-section:first-child { margin-top: 0; padding-top: 0; border-top: none; }
63
+ .uiperf-section-title { color: #d2a8ff; font-weight: 600; margin-bottom: 4px; }
64
+
65
+ .uiperf-row { display: flex; justify-content: space-between; line-height: 1.6; }
66
+ .uiperf-label { color: #8b949e; }
67
+ .uiperf-val { color: #79c0ff; }
68
+ .uiperf-val.hot { color: #f0883e; }
69
+ .uiperf-val.ok { color: #3fb950; }
70
+
71
+ .uiperf-table { width: 100%; border-collapse: collapse; margin-top: 4px; }
72
+ .uiperf-table th {
73
+ text-align: left; color: #8b949e; border-bottom: 1px solid #21262d;
74
+ padding: 2px 4px; font-weight: normal;
75
+ }
76
+ .uiperf-table td { padding: 2px 4px; }
77
+ .uiperf-table tr:hover { background: #161b22; }
78
+ .uiperf-path { color: #a5d6ff; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
79
+ .uiperf-num { color: #79c0ff; text-align: right; }
80
+ .uiperf-ms { color: #3fb950; text-align: right; }
81
+
82
+ .uiperf-bar-wrap { height: 3px; background: #21262d; border-radius: 2px; margin-top: 1px; }
83
+ .uiperf-bar { height: 3px; background: #1f6feb; border-radius: 2px; transition: width 0.3s; }
84
+
85
+ /* State Tree panel */
86
+ .uiperf-tree-panel {
87
+ display: none; flex-direction: column; flex: 1; overflow: hidden;
88
+ }
89
+ .uiperf-tree-panel.visible { display: flex; }
90
+ .uiperf-tree-toolbar {
91
+ display: flex; gap: 4px; padding: 6px 10px;
92
+ border-bottom: 1px solid #21262d; flex-shrink: 0; align-items: center;
93
+ }
94
+ .uiperf-tree-toolbar .uiperf-btn { font-size: 9px; }
95
+ .uiperf-tree-toolbar .sel-label {
96
+ flex: 1; font-size: 9px; color: #8b949e;
97
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
98
+ }
99
+ .uiperf-tree-scroll {
100
+ overflow-y: auto; flex: 1; padding: 4px 0;
101
+ }
102
+ .uiperf-tnode {
103
+ line-height: 1.7; white-space: nowrap; padding: 0 10px;
104
+ cursor: default;
105
+ }
106
+ .uiperf-tnode:hover { background: #161b22; }
107
+ .uiperf-tnode.selected { background: #1c2536; }
108
+ .uiperf-tnode .arrow {
109
+ display: inline-block; width: 12px; text-align: center;
110
+ color: #484f58; cursor: pointer; user-select: none;
111
+ }
112
+ .uiperf-tnode .arrow:hover { color: #c9d1d9; }
113
+ .uiperf-tnode .tkey { color: #d2a8ff; }
114
+ .uiperf-tnode .tcolon { color: #484f58; }
115
+ .uiperf-tnode .tval { color: #a5d6ff; }
116
+ .uiperf-tnode .tval.str { color: #a5d6ff; }
117
+ .uiperf-tnode .tval.num { color: #79c0ff; }
118
+ .uiperf-tnode .tval.bool { color: #f0883e; }
119
+ .uiperf-tnode .tval.null { color: #484f58; font-style: italic; }
120
+ .uiperf-tnode .tval.obj { color: #8b949e; }
121
+ .uiperf-copy-toast {
122
+ position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
123
+ background: #238636; color: #fff; padding: 3px 12px; border-radius: 4px;
124
+ font-size: 9px; pointer-events: none; opacity: 0; transition: opacity 0.2s;
125
+ }
126
+ .uiperf-copy-toast.show { opacity: 1; }
127
+ `;
128
+
129
+ export function mountOverlay(monitor, container) {
130
+ if (typeof document === 'undefined') return () => {};
131
+
132
+ // Shadow DOM host - fully encapsulates styles
133
+ const host = document.createElement('div');
134
+ host.style.cssText = 'position:fixed;bottom:12px;right:12px;z-index:99999;';
135
+ container.appendChild(host);
136
+ const shadow = host.attachShadow({ mode: 'open' });
137
+
138
+ // Styles live inside shadow - no leaking in or out
139
+ const styleEl = document.createElement('style');
140
+ styleEl.textContent = STYLES;
141
+ shadow.appendChild(styleEl);
142
+
143
+ // Build DOM inside shadow
144
+ const overlay = document.createElement('div');
145
+ overlay.className = 'uiperf-overlay';
146
+ overlay.style.position = 'relative';
147
+ overlay.innerHTML = `
148
+ <div class="uiperf-header">
149
+ <span class="uiperf-title">@everystate/perf</span>
150
+ <span class="uiperf-badge" id="uiperf-live">0 events</span>
151
+ <button class="uiperf-btn" id="uiperf-toggle">_</button>
152
+ <button class="uiperf-btn" id="uiperf-reset">↺</button>
153
+ <button class="uiperf-btn download" id="uiperf-dl">↓ JSON</button>
154
+ </div>
155
+ <div class="uiperf-tabs">
156
+ <button class="uiperf-tab active" data-tab="perf">Perf</button>
157
+ <button class="uiperf-tab" data-tab="tree">State Tree</button>
158
+ </div>
159
+ <div class="uiperf-body" id="uiperf-body"></div>
160
+ <div class="uiperf-tree-panel" id="uiperf-tree-panel">
161
+ <div class="uiperf-tree-toolbar">
162
+ <span class="sel-label" id="uiperf-sel-label">none selected</span>
163
+ <button class="uiperf-btn" id="uiperf-copy-sub">Copy Sub</button>
164
+ <button class="uiperf-btn" id="uiperf-copy-all">Copy All</button>
165
+ <button class="uiperf-btn" id="uiperf-tree-refresh">↺</button>
166
+ </div>
167
+ <div class="uiperf-tree-scroll" id="uiperf-tree-scroll"></div>
168
+ </div>
169
+ <div class="uiperf-copy-toast" id="uiperf-toast">Copied!</div>
170
+ `;
171
+ shadow.appendChild(overlay);
172
+
173
+ const body = shadow.querySelector('#uiperf-body');
174
+ const liveEl = shadow.querySelector('#uiperf-live');
175
+ const toggleBtn = shadow.querySelector('#uiperf-toggle');
176
+ const resetBtn = shadow.querySelector('#uiperf-reset');
177
+ const dlBtn = shadow.querySelector('#uiperf-dl');
178
+ const treePanel = shadow.querySelector('#uiperf-tree-panel');
179
+ const treeScroll = shadow.querySelector('#uiperf-tree-scroll');
180
+ const selLabel = shadow.querySelector('#uiperf-sel-label');
181
+ const copySubBtn = shadow.querySelector('#uiperf-copy-sub');
182
+ const copyAllBtn = shadow.querySelector('#uiperf-copy-all');
183
+ const treeRefreshBtn = shadow.querySelector('#uiperf-tree-refresh');
184
+ const toast = shadow.querySelector('#uiperf-toast');
185
+ const tabs = shadow.querySelectorAll('.uiperf-tab');
186
+
187
+ // Toggle collapse
188
+ let collapsed = false;
189
+ toggleBtn.addEventListener('click', () => {
190
+ collapsed = !collapsed;
191
+ overlay.classList.toggle('collapsed', collapsed);
192
+ toggleBtn.textContent = collapsed ? '□' : '_';
193
+ });
194
+
195
+ // Reset
196
+ resetBtn.addEventListener('click', () => {
197
+ monitor.reset();
198
+ render();
199
+ });
200
+
201
+ // Download
202
+ dlBtn.addEventListener('click', () => {
203
+ monitor.download();
204
+ });
205
+
206
+ // ==== Tab switching ====
207
+ let activeTab = 'perf';
208
+ tabs.forEach(tab => {
209
+ tab.addEventListener('click', () => {
210
+ activeTab = tab.dataset.tab;
211
+ tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === activeTab));
212
+ body.style.display = activeTab === 'perf' ? '' : 'none';
213
+ treePanel.classList.toggle('visible', activeTab === 'tree');
214
+ if (activeTab === 'tree') renderTree();
215
+ });
216
+ });
217
+
218
+ // ==== State Tree ====
219
+ const collapsedPaths = new Set();
220
+ let selectedPath = null;
221
+
222
+ function showToast(msg) {
223
+ toast.textContent = msg || 'Copied!';
224
+ toast.classList.add('show');
225
+ setTimeout(() => toast.classList.remove('show'), 1200);
226
+ }
227
+
228
+ function copyToClipboard(text) {
229
+ if (navigator.clipboard && navigator.clipboard.writeText) {
230
+ navigator.clipboard.writeText(text).then(() => showToast(), () => fallbackCopy(text));
231
+ } else {
232
+ fallbackCopy(text);
233
+ }
234
+ }
235
+
236
+ function fallbackCopy(text) {
237
+ const ta = document.createElement('textarea');
238
+ ta.value = text;
239
+ ta.style.cssText = 'position:fixed;left:-9999px';
240
+ document.body.appendChild(ta);
241
+ ta.select();
242
+ try { document.execCommand('copy'); showToast(); } catch(e) { showToast('Copy failed'); }
243
+ document.body.removeChild(ta);
244
+ }
245
+
246
+ function getSubtreeAtPath(obj, dotPath) {
247
+ if (!dotPath) return obj;
248
+ const parts = dotPath.split('.');
249
+ let cur = obj;
250
+ for (const p of parts) {
251
+ if (cur == null || typeof cur !== 'object') return undefined;
252
+ cur = cur[p];
253
+ }
254
+ return cur;
255
+ }
256
+
257
+ function valClass(v) {
258
+ if (v === null || v === undefined) return 'null';
259
+ if (typeof v === 'string') return 'str';
260
+ if (typeof v === 'number') return 'num';
261
+ if (typeof v === 'boolean') return 'bool';
262
+ return 'obj';
263
+ }
264
+
265
+ function valDisplay(v) {
266
+ if (v === null) return 'null';
267
+ if (v === undefined) return 'undefined';
268
+ if (typeof v === 'string') return v.length > 60 ? '"' + v.slice(0,57) + '..."' : '"' + v + '"';
269
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
270
+ if (Array.isArray(v)) return `Array(${v.length})`;
271
+ if (typeof v === 'object') {
272
+ const keys = Object.keys(v);
273
+ return `{${keys.length} key${keys.length !== 1 ? 's' : ''}}`;
274
+ }
275
+ return String(v);
276
+ }
277
+
278
+ function isExpandable(v) {
279
+ return v != null && typeof v === 'object' && (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0);
280
+ }
281
+
282
+ function renderTree() {
283
+ const store = monitor.store;
284
+ if (!store) {
285
+ treeScroll.innerHTML = '<div style="padding:10px;color:#8b949e">No store reference available</div>';
286
+ return;
287
+ }
288
+ const state = store.get();
289
+ treeScroll.innerHTML = '';
290
+ buildTreeNodes(state, '', 0, treeScroll);
291
+ updateSelLabel();
292
+ }
293
+
294
+ function buildTreeNodes(obj, parentPath, depth, container) {
295
+ if (obj == null || typeof obj !== 'object') return;
296
+ const keys = Object.keys(obj);
297
+ for (const key of keys) {
298
+ const fullPath = parentPath ? parentPath + '.' + key : key;
299
+ const value = obj[key];
300
+ const expandable = isExpandable(value);
301
+ const isCollapsed = collapsedPaths.has(fullPath);
302
+
303
+ const row = document.createElement('div');
304
+ row.className = 'uiperf-tnode' + (selectedPath === fullPath ? ' selected' : '');
305
+ row.style.paddingLeft = (10 + depth * 14) + 'px';
306
+ row.dataset.path = fullPath;
307
+
308
+ // Arrow
309
+ const arrow = document.createElement('span');
310
+ arrow.className = 'arrow';
311
+ if (expandable) {
312
+ arrow.textContent = isCollapsed ? '▶' : '▼';
313
+ arrow.addEventListener('click', (e) => {
314
+ e.stopPropagation();
315
+ if (collapsedPaths.has(fullPath)) collapsedPaths.delete(fullPath);
316
+ else collapsedPaths.add(fullPath);
317
+ renderTree();
318
+ });
319
+ } else {
320
+ arrow.textContent = ' ';
321
+ }
322
+ row.appendChild(arrow);
323
+
324
+ // Key
325
+ const keySpan = document.createElement('span');
326
+ keySpan.className = 'tkey';
327
+ keySpan.textContent = key;
328
+ row.appendChild(keySpan);
329
+
330
+ // Colon
331
+ const colon = document.createElement('span');
332
+ colon.className = 'tcolon';
333
+ colon.textContent = ': ';
334
+ row.appendChild(colon);
335
+
336
+ // Value
337
+ const valSpan = document.createElement('span');
338
+ valSpan.className = 'tval ' + valClass(value);
339
+ if (expandable && !isCollapsed) {
340
+ valSpan.textContent = Array.isArray(value) ? '[' : '{';
341
+ } else {
342
+ valSpan.textContent = valDisplay(value);
343
+ }
344
+ row.appendChild(valSpan);
345
+
346
+ // Click to select
347
+ row.addEventListener('click', () => {
348
+ selectedPath = selectedPath === fullPath ? null : fullPath;
349
+ treeScroll.querySelectorAll('.uiperf-tnode').forEach(n => {
350
+ n.classList.toggle('selected', n.dataset.path === selectedPath);
351
+ });
352
+ updateSelLabel();
353
+ });
354
+
355
+ container.appendChild(row);
356
+
357
+ // Recurse if expanded
358
+ if (expandable && !isCollapsed) {
359
+ buildTreeNodes(value, fullPath, depth + 1, container);
360
+ // Closing bracket
361
+ const closer = document.createElement('div');
362
+ closer.className = 'uiperf-tnode';
363
+ closer.style.paddingLeft = (10 + depth * 14) + 'px';
364
+ closer.innerHTML = '<span class="arrow"> </span><span class="tval obj">' + (Array.isArray(value) ? ']' : '}') + '</span>';
365
+ container.appendChild(closer);
366
+ }
367
+ }
368
+ }
369
+
370
+ function updateSelLabel() {
371
+ selLabel.textContent = selectedPath ? selectedPath : 'none selected';
372
+ selLabel.title = selectedPath || '';
373
+ }
374
+
375
+ // Copy Sub
376
+ copySubBtn.addEventListener('click', () => {
377
+ const store = monitor.store;
378
+ if (!store) return;
379
+ if (!selectedPath) { showToast('Select a node first'); return; }
380
+ const sub = getSubtreeAtPath(store.get(), selectedPath);
381
+ copyToClipboard(JSON.stringify(sub, null, 2));
382
+ });
383
+
384
+ // Copy All
385
+ copyAllBtn.addEventListener('click', () => {
386
+ const store = monitor.store;
387
+ if (!store) return;
388
+ copyToClipboard(JSON.stringify(store.get(), null, 2));
389
+ });
390
+
391
+ // Tree refresh
392
+ treeRefreshBtn.addEventListener('click', () => renderTree());
393
+
394
+ // Drag support (moves the host element which has position:fixed)
395
+ let dragX = 0, dragY = 0, isDragging = false;
396
+ const header = overlay.querySelector('.uiperf-header');
397
+ header.addEventListener('mousedown', (e) => {
398
+ if (e.target.tagName === 'BUTTON') return;
399
+ isDragging = true;
400
+ dragX = e.clientX - host.offsetLeft;
401
+ dragY = e.clientY - host.offsetTop;
402
+ });
403
+ document.addEventListener('mousemove', (e) => {
404
+ if (!isDragging) return;
405
+ host.style.right = 'auto';
406
+ host.style.bottom = 'auto';
407
+ host.style.left = (e.clientX - dragX) + 'px';
408
+ host.style.top = (e.clientY - dragY) + 'px';
409
+ });
410
+ document.addEventListener('mouseup', () => { isDragging = false; });
411
+
412
+ // Render
413
+ function render() {
414
+ const r = monitor.report();
415
+
416
+ liveEl.textContent = `${r.totalEvents} events`;
417
+
418
+ let html = '';
419
+
420
+ // Summary
421
+ html += `<div class="uiperf-section">`;
422
+ html += `<div class="uiperf-section-title">Summary</div>`;
423
+ html += row('Elapsed', fmtMs(r.elapsedMs));
424
+ html += row('Total sets', r.summary.totalSets);
425
+ html += row('Total fires', r.summary.totalFires);
426
+ html += row('Sets/sec', r.summary.setsPerSec, r.summary.setsPerSec > 100 ? 'hot' : 'ok');
427
+ html += row('Unique paths', r.summary.uniquePaths);
428
+ html += row('Active subs', r.summary.activeSubscribers);
429
+ html += row('Subscribes', `${r.summary.totalSubscribes} / unsubs: ${r.summary.totalUnsubscribes}`);
430
+ html += `</div>`;
431
+
432
+ // Batches
433
+ if (r.batches.count > 0) {
434
+ html += `<div class="uiperf-section">`;
435
+ html += `<div class="uiperf-section-title">Batches</div>`;
436
+ html += row('Batch calls', r.batches.count);
437
+ html += row('Total paths', r.batches.totalPaths);
438
+ html += row('Coalesced', r.batches.totalCoalesced, r.batches.totalCoalesced > 0 ? 'ok' : '');
439
+ html += `</div>`;
440
+ }
441
+
442
+ // Hot paths (top 15)
443
+ const top = r.hotPaths.slice(0, 15);
444
+ if (top.length > 0) {
445
+ const maxSets = top[0].sets || 1;
446
+ html += `<div class="uiperf-section">`;
447
+ html += `<div class="uiperf-section-title">Hot Paths (by sets)</div>`;
448
+ html += `<table class="uiperf-table">`;
449
+ html += `<tr><th>Path</th><th style="text-align:right">Sets</th><th style="text-align:right">Fires</th><th style="text-align:right">Avg ms</th></tr>`;
450
+ for (const p of top) {
451
+ const pct = Math.round((p.sets / maxSets) * 100);
452
+ html += `<tr>`;
453
+ html += `<td><span class="uiperf-path" title="${p.path}">${truncPath(p.path)}</span>`;
454
+ html += `<div class="uiperf-bar-wrap"><div class="uiperf-bar" style="width:${pct}%"></div></div></td>`;
455
+ html += `<td class="uiperf-num">${p.sets}</td>`;
456
+ html += `<td class="uiperf-num">${p.fires}</td>`;
457
+ html += `<td class="uiperf-ms">${p.avgSetMs.toFixed(3)}</td>`;
458
+ html += `</tr>`;
459
+ }
460
+ html += `</table>`;
461
+ html += `</div>`;
462
+ }
463
+
464
+ // Hot listeners (top 10)
465
+ const topFires = r.hotListeners.slice(0, 10);
466
+ if (topFires.length > 0) {
467
+ html += `<div class="uiperf-section">`;
468
+ html += `<div class="uiperf-section-title">Hot Listeners (by fires)</div>`;
469
+ html += `<table class="uiperf-table">`;
470
+ html += `<tr><th>Path</th><th style="text-align:right">Fires</th><th style="text-align:right">/sec</th></tr>`;
471
+ for (const p of topFires) {
472
+ html += `<tr>`;
473
+ html += `<td class="uiperf-path" title="${p.path}">${truncPath(p.path)}</td>`;
474
+ html += `<td class="uiperf-num">${p.fires}</td>`;
475
+ html += `<td class="uiperf-num">${p.firesPerSec}</td>`;
476
+ html += `</tr>`;
477
+ }
478
+ html += `</table>`;
479
+ html += `</div>`;
480
+ }
481
+
482
+ // Recent events (last 20)
483
+ const recent = r.timeline.slice(-20).reverse();
484
+ if (recent.length > 0) {
485
+ html += `<div class="uiperf-section">`;
486
+ html += `<div class="uiperf-section-title">Recent Events (${r.totalEvents} total${r.dropped ? `, ${r.dropped} dropped` : ''})</div>`;
487
+ html += `<table class="uiperf-table">`;
488
+ html += `<tr><th>Type</th><th>Path</th><th style="text-align:right">ms</th></tr>`;
489
+ for (const e of recent) {
490
+ const cls = e.type === 'fire' ? 'hot' : '';
491
+ html += `<tr>`;
492
+ html += `<td class="uiperf-val ${cls}">${e.type}</td>`;
493
+ html += `<td class="uiperf-path" title="${e.path || ''}">${truncPath(e.path || e.paths?.join(', ') || '-')}</td>`;
494
+ html += `<td class="uiperf-ms">${e.dur !== undefined ? e.dur.toFixed(3) : '-'}</td>`;
495
+ html += `</tr>`;
496
+ }
497
+ html += `</table>`;
498
+ html += `</div>`;
499
+ }
500
+
501
+ body.innerHTML = html;
502
+ }
503
+
504
+ function row(label, value, cls) {
505
+ return `<div class="uiperf-row"><span class="uiperf-label">${label}</span><span class="uiperf-val ${cls || ''}">${value}</span></div>`;
506
+ }
507
+
508
+ function fmtMs(ms) {
509
+ if (ms < 1000) return ms.toFixed(0) + ' ms';
510
+ return (ms / 1000).toFixed(1) + ' s';
511
+ }
512
+
513
+ function truncPath(p) {
514
+ if (!p) return '-';
515
+ return p.length > 28 ? '…' + p.slice(-27) : p;
516
+ }
517
+
518
+ // Auto-refresh at ~2 fps
519
+ let rafId = null;
520
+ let lastRender = 0;
521
+ function tick() {
522
+ const t = Date.now();
523
+ if (t - lastRender > 500) {
524
+ lastRender = t;
525
+ if (!collapsed) {
526
+ if (activeTab === 'perf') render();
527
+ else if (activeTab === 'tree') renderTree();
528
+ }
529
+ }
530
+ rafId = requestAnimationFrame(tick);
531
+ }
532
+ rafId = requestAnimationFrame(tick);
533
+ render();
534
+
535
+ // Cleanup
536
+ return function unmount() {
537
+ cancelAnimationFrame(rafId);
538
+ host.remove();
539
+ };
540
+ }
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@everystate/perf",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "EveryState Performance Monitor: Non-invasive performance monitoring with method wrapping, path heatmaps, timeline recording, browser overlay",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
+ "types": "index.d.ts",
7
8
  "keywords": [
8
9
  "everystate",
9
10
  "performance",
@@ -25,7 +26,14 @@
25
26
  "type": "git",
26
27
  "url": "https://github.com/ImsirovicAjdin/everystate-perf"
27
28
  },
28
- "dependencies": {
29
- "@uistate/perf": "^1.0.0"
30
- }
29
+ "homepage": "https://github.com/ImsirovicAjdin/everystate-perf#readme",
30
+ "scripts": {
31
+ "test": "node self-test.js"
32
+ },
33
+ "files": [
34
+ "index.js",
35
+ "perfMonitor.js",
36
+ "overlay.js",
37
+ "README.md"
38
+ ]
31
39
  }
package/perfMonitor.js ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * @everystate/perf: perfMonitor.js
3
+ *
4
+ * Non-invasive performance recorder for any EveryState store.
5
+ * Wraps store methods (prepend pattern) to capture timing, path frequency,
6
+ * subscriber counts, batch stats, and a full event timeline.
7
+ *
8
+ * Usage:
9
+ * import { createPerfMonitor } from '@everystate/perf';
10
+ * const perf = createPerfMonitor(store);
11
+ * perf.mount(document.body); // optional browser overlay
12
+ * perf.download('report.json');
13
+ *
14
+ * Copyright (c) 2026 Ajdin Imsirovic. MIT License.
15
+ */
16
+
17
+ // == Helpers ================================================================
18
+
19
+ const now = typeof performance !== 'undefined'
20
+ ? () => performance.now()
21
+ : () => Date.now();
22
+
23
+ function uid() {
24
+ return Math.random().toString(36).slice(2, 10);
25
+ }
26
+
27
+ // == createPerfMonitor ======================================================
28
+
29
+ export function createPerfMonitor(store, options = {}) {
30
+ const {
31
+ maxEvents = 10000,
32
+ trackValues = false,
33
+ trackGets = false,
34
+ } = options;
35
+
36
+ // Ring buffer for timeline events
37
+ const timeline = [];
38
+ let totalEvents = 0;
39
+ let dropped = 0;
40
+
41
+ function record(event) {
42
+ if (timeline.length >= maxEvents) {
43
+ timeline.shift();
44
+ dropped++;
45
+ }
46
+ event._seq = totalEvents++;
47
+ timeline.push(event);
48
+ }
49
+
50
+ // Per-path stats
51
+ const pathStats = new Map(); // path -> { sets, gets, fires, totalSetMs, subscriberCount }
52
+
53
+ function ensurePath(path) {
54
+ if (!pathStats.has(path)) {
55
+ pathStats.set(path, {
56
+ sets: 0,
57
+ gets: 0,
58
+ fires: 0,
59
+ totalSetMs: 0,
60
+ peakSetMs: 0,
61
+ subscriberCount: 0,
62
+ });
63
+ }
64
+ return pathStats.get(path);
65
+ }
66
+
67
+ // Batch tracking
68
+ let batchDepth = 0;
69
+ let batchSets = [];
70
+ const batchStats = { count: 0, totalPaths: 0, totalCoalesced: 0 };
71
+
72
+ // Subscribe tracking
73
+ const activeSubs = new Map(); // subId -> { path, ts }
74
+ let totalSubscribes = 0;
75
+ let totalUnsubscribes = 0;
76
+
77
+ // Session
78
+ const sessionId = uid();
79
+ const sessionStart = now();
80
+ let destroyed = false;
81
+
82
+ // == Wrap store.set =====================================================
83
+
84
+ const _origSet = store.set;
85
+ store.set = function perfSet(path, value) {
86
+ const t0 = now();
87
+ const result = _origSet(path, value);
88
+ const dur = now() - t0;
89
+
90
+ const ps = ensurePath(path);
91
+ ps.sets++;
92
+ ps.totalSetMs += dur;
93
+ if (dur > ps.peakSetMs) ps.peakSetMs = dur;
94
+
95
+ const evt = { type: 'set', path, dur, ts: t0 };
96
+ if (trackValues) { evt.value = value; }
97
+ if (batchDepth > 0) {
98
+ evt.batched = true;
99
+ batchSets.push(path);
100
+ }
101
+ record(evt);
102
+
103
+ return result;
104
+ };
105
+
106
+ // == Wrap store.get =====================================================
107
+
108
+ const _origGet = store.get;
109
+ if (trackGets) {
110
+ store.get = function perfGet(path) {
111
+ const t0 = now();
112
+ const result = _origGet(path);
113
+ const dur = now() - t0;
114
+
115
+ const ps = ensurePath(path);
116
+ ps.gets++;
117
+
118
+ record({ type: 'get', path, dur, ts: t0 });
119
+ return result;
120
+ };
121
+ }
122
+
123
+ // == Wrap store.subscribe ===============================================
124
+
125
+ const _origSubscribe = store.subscribe;
126
+ store.subscribe = function perfSubscribe(path, handler) {
127
+ const subId = uid();
128
+ const ts = now();
129
+
130
+ totalSubscribes++;
131
+ const ps = ensurePath(path);
132
+ ps.subscriberCount++;
133
+
134
+ // Wrap handler to track fires
135
+ const wrappedHandler = function perfWrappedHandler(...args) {
136
+ // Track fire on the subscribed path
137
+ // For exact subs, the path is the subscribed path
138
+ // For wildcard subs, we track against the wildcard pattern
139
+ ps.fires++;
140
+
141
+ const t0 = now();
142
+ const result = handler.apply(this, args);
143
+ const dur = now() - t0;
144
+
145
+ record({ type: 'fire', path, dur, ts: t0, subId });
146
+ return result;
147
+ };
148
+
149
+ const unsub = _origSubscribe(path, wrappedHandler);
150
+ activeSubs.set(subId, { path, ts });
151
+ record({ type: 'subscribe', path, ts, subId });
152
+
153
+ return function perfUnsub() {
154
+ const result = unsub();
155
+ totalUnsubscribes++;
156
+ ps.subscriberCount--;
157
+ activeSubs.delete(subId);
158
+ record({ type: 'unsubscribe', path, ts: now(), subId });
159
+ return result;
160
+ };
161
+ };
162
+
163
+ // == Wrap store.batch ===================================================
164
+
165
+ const _origBatch = store.batch;
166
+ store.batch = function perfBatch(fn) {
167
+ batchDepth++;
168
+ const prevSets = batchSets.length;
169
+ const t0 = now();
170
+
171
+ const result = _origBatch(fn);
172
+
173
+ const dur = now() - t0;
174
+ batchDepth--;
175
+
176
+ if (batchDepth === 0) {
177
+ const paths = batchSets.slice(prevSets);
178
+ const unique = new Set(paths);
179
+ batchStats.count++;
180
+ batchStats.totalPaths += paths.length;
181
+ batchStats.totalCoalesced += paths.length - unique.size;
182
+
183
+ record({
184
+ type: 'batch', dur, ts: t0,
185
+ pathCount: paths.length,
186
+ uniquePaths: unique.size,
187
+ coalesced: paths.length - unique.size,
188
+ paths: [...unique],
189
+ });
190
+
191
+ batchSets = [];
192
+ }
193
+
194
+ return result;
195
+ };
196
+
197
+ // == Wrap store.setMany =================================================
198
+
199
+ const _origSetMany = store.setMany;
200
+ store.setMany = function perfSetMany(entries) {
201
+ const t0 = now();
202
+ let result;
203
+
204
+ // Always use the wrapped batch method (which handles the original batch)
205
+ store.batch(() => {
206
+ if (entries instanceof Map) {
207
+ for (const [path, value] of entries) {
208
+ store.set(path, value);
209
+ }
210
+ } else if (Array.isArray(entries)) {
211
+ for (const [path, value] of entries) {
212
+ store.set(path, value);
213
+ }
214
+ } else if (entries && typeof entries === 'object') {
215
+ for (const [path, value] of Object.entries(entries)) {
216
+ store.set(path, value);
217
+ }
218
+ }
219
+ });
220
+ result = entries;
221
+
222
+ const dur = now() - t0;
223
+
224
+ let pathCount = 0;
225
+ if (entries instanceof Map) pathCount = entries.size;
226
+ else if (Array.isArray(entries)) pathCount = entries.length;
227
+ else if (entries && typeof entries === 'object') pathCount = Object.keys(entries).length;
228
+
229
+ record({ type: 'setMany', dur, ts: t0, pathCount });
230
+ return result;
231
+ };
232
+
233
+ // == Report =============================================================
234
+
235
+ const RATE_WINDOW_MS = 5000; // 5-second sliding window for /sec rates
236
+
237
+ function windowedRate(type, path) {
238
+ const cutoff = now() - RATE_WINDOW_MS;
239
+ let count = 0;
240
+ // Walk backwards from end for efficiency
241
+ for (let i = timeline.length - 1; i >= 0; i--) {
242
+ const e = timeline[i];
243
+ if (e.ts < cutoff) break;
244
+ if (e.type === type && (!path || e.path === path)) count++;
245
+ }
246
+ return +(count / (RATE_WINDOW_MS / 1000)).toFixed(2);
247
+ }
248
+
249
+ function report() {
250
+ const elapsed = now() - sessionStart;
251
+
252
+ // Hot paths: sorted by set count
253
+ const hotPaths = [...pathStats.entries()]
254
+ .map(([path, s]) => ({
255
+ path,
256
+ sets: s.sets,
257
+ gets: s.gets,
258
+ fires: s.fires,
259
+ avgSetMs: s.sets > 0 ? +(s.totalSetMs / s.sets).toFixed(4) : 0,
260
+ peakSetMs: +s.peakSetMs.toFixed(4),
261
+ subscriberCount: s.subscriberCount,
262
+ }))
263
+ .sort((a, b) => b.sets - a.sets);
264
+
265
+ // Fire frequency: sorted by fire count, using windowed rate
266
+ const hotListeners = [...pathStats.entries()]
267
+ .filter(([, s]) => s.fires > 0)
268
+ .map(([path, s]) => ({
269
+ path,
270
+ fires: s.fires,
271
+ firesPerSec: windowedRate('fire', path),
272
+ }))
273
+ .sort((a, b) => b.fires - a.fires);
274
+
275
+ return {
276
+ sessionId,
277
+ elapsedMs: +elapsed.toFixed(2),
278
+ totalEvents,
279
+ dropped,
280
+ summary: {
281
+ totalSets: hotPaths.reduce((s, p) => s + p.sets, 0),
282
+ totalGets: hotPaths.reduce((s, p) => s + p.gets, 0),
283
+ totalFires: hotPaths.reduce((s, p) => s + p.fires, 0),
284
+ uniquePaths: pathStats.size,
285
+ totalSubscribes,
286
+ totalUnsubscribes,
287
+ activeSubscribers: activeSubs.size,
288
+ setsPerSec: windowedRate('set'),
289
+ },
290
+ batches: { ...batchStats },
291
+ hotPaths: hotPaths.slice(0, 50),
292
+ hotListeners: hotListeners.slice(0, 50),
293
+ activeSubs: [...activeSubs.entries()].map(([id, s]) => ({ id, ...s })),
294
+ timeline: timeline.slice(-200), // last 200 events in download
295
+ };
296
+ }
297
+
298
+ // == Download ===========================================================
299
+
300
+ function download(filename) {
301
+ const data = report();
302
+ data.timeline = [...timeline]; // include full timeline in download
303
+ const json = JSON.stringify(data, null, 2);
304
+
305
+ if (typeof document !== 'undefined') {
306
+ const blob = new Blob([json], { type: 'application/json' });
307
+ const url = URL.createObjectURL(blob);
308
+ const a = document.createElement('a');
309
+ a.href = url;
310
+ a.download = filename || `everystate-perf-${sessionId}.json`;
311
+ a.click();
312
+ URL.revokeObjectURL(url);
313
+ }
314
+
315
+ return data;
316
+ }
317
+
318
+ // == Reset ==============================================================
319
+
320
+ function reset() {
321
+ timeline.length = 0;
322
+ totalEvents = 0;
323
+ dropped = 0;
324
+ pathStats.clear();
325
+ batchSets = [];
326
+ batchStats.count = 0;
327
+ batchStats.totalPaths = 0;
328
+ batchStats.totalCoalesced = 0;
329
+ activeSubs.clear();
330
+ totalSubscribes = 0;
331
+ totalUnsubscribes = 0;
332
+ }
333
+
334
+ // == Destroy (unwrap) ===================================================
335
+
336
+ function destroy() {
337
+ if (destroyed) return;
338
+ destroyed = true;
339
+ store.set = _origSet;
340
+ if (trackGets) store.get = _origGet;
341
+ store.subscribe = _origSubscribe;
342
+ store.batch = _origBatch;
343
+ store.setMany = _origSetMany;
344
+ }
345
+
346
+ return {
347
+ report,
348
+ download,
349
+ reset,
350
+ destroy,
351
+ get timeline() { return timeline; },
352
+ get pathStats() { return pathStats; },
353
+ get sessionId() { return sessionId; },
354
+ get store() { return store; },
355
+ };
356
+ }