@everystate/perf 1.0.0 → 1.0.2
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 +21 -0
- package/README.md +17 -2
- package/index.js +2 -4
- package/overlay.js +540 -0
- package/package.json +12 -4
- package/perfMonitor.js +356 -0
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 {
|
|
16
|
+
import { createEveryState } from '@everystate/core';
|
|
17
17
|
import { createPerfMonitor, mountOverlay } from '@everystate/perf';
|
|
18
18
|
|
|
19
|
-
const store =
|
|
19
|
+
const store = createEveryState({ count: 0 });
|
|
20
20
|
|
|
21
21
|
// Wrap store with performance monitoring
|
|
22
22
|
const monitor = createPerfMonitor(store, {
|
|
@@ -59,6 +59,21 @@ store.set = function(path, value) {
|
|
|
59
59
|
|
|
60
60
|
Fully reversible via `monitor.destroy()`.
|
|
61
61
|
|
|
62
|
+
## Ecosystem
|
|
63
|
+
|
|
64
|
+
| Package | Description | License |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| [@everystate/aliases](https://www.npmjs.com/package/@everystate/aliases) | Ergonomic single-character and short-name DOM aliases for vanilla JS | MIT |
|
|
67
|
+
| [@everystate/core](https://www.npmjs.com/package/@everystate/core) | Path-based state management with wildcard subscriptions and async support. Core state engine (you are here). | MIT |
|
|
68
|
+
| [@everystate/css](https://www.npmjs.com/package/@everystate/css) | Reactive CSSOM engine: design tokens, typed validation, WCAG enforcement, all via path-based state | MIT |
|
|
69
|
+
| [@everystate/examples](https://www.npmjs.com/package/@everystate/examples) | Example applications and patterns | MIT |
|
|
70
|
+
| [@everystate/perf](https://www.npmjs.com/package/@everystate/perf) | Performance monitoring overlay | MIT |
|
|
71
|
+
| [@everystate/react](https://www.npmjs.com/package/@everystate/react) | React hooks adapter: `usePath`, `useIntent`, `useAsync` hooks and `EveryStateProvider` | MIT |
|
|
72
|
+
| [@everystate/renderer](https://www.npmjs.com/package/@everystate/renderer) | Direct-binding reactive renderer: `bind-*`, `set`, `each` attributes. Zero build step | Proprietary |
|
|
73
|
+
| [@everystate/router](https://www.npmjs.com/package/@everystate/router) | SPA routing as state | MIT |
|
|
74
|
+
| [@everystate/test](https://www.npmjs.com/package/@everystate/test) | Event-sequence testing for EveryState stores. Zero dependency. | Proprietary |
|
|
75
|
+
| [@everystate/view](https://www.npmjs.com/package/@everystate/view) | State-driven view: DOMless resolve + surgical DOM projector. View tree as first-class state | MIT |
|
|
76
|
+
|
|
62
77
|
## License
|
|
63
78
|
|
|
64
79
|
MIT © Ajdin Imsirovic
|
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
|
|
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.
|
|
3
|
+
"version": "1.0.2",
|
|
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
|
-
"
|
|
29
|
-
|
|
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
|
+
}
|