@zakkster/lite-profiler-signal 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE.txt +21 -0
- package/README.md +212 -0
- package/index.d.ts +59 -0
- package/index.js +205 -0
- package/llms.txt +83 -0
- package/package.json +72 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@zakkster/lite-profiler-signal` are documented here.
|
|
4
|
+
This project adheres to [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [1.0.0] - 2026-06-30
|
|
7
|
+
|
|
8
|
+
Initial release. Reactive boundary for `@zakkster/lite-profiler`.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `createProfilerView(profiler, options?)` factory returning a reactive view over a `Profiler`.
|
|
12
|
+
- Coarse frame signals: `fps`, `frameAvg`, `frameP99`, `frameMax`, `jank`, `spike`, `frameClass`.
|
|
13
|
+
- Per-phase signal bundles (`phases[tag]` / `phase(tag)`) exposing `avg`, `p99`, and `last`.
|
|
14
|
+
- `pulse()` -- a single integer tick set per frame; a `lite-throttle` window gates the recompute.
|
|
15
|
+
- `flush()` to force a pending throttled recompute synchronously.
|
|
16
|
+
- `attach()` / `detach()` convenience drivers over `requestAnimationFrame` (browser only).
|
|
17
|
+
- `onJank(handler)` -- fires when the classifier leaves `STEADY`, built on `lite-watch-ex` `watchChanged`.
|
|
18
|
+
- `onRegression(tag, handler, { factor, window })` -- per-phase rolling-baseline regression alerts, built on `lite-watch-ex` `watchPrevious`.
|
|
19
|
+
- `dispose()` -- idempotent teardown of the throttle, watchers, and every signal.
|
|
20
|
+
- `raf` option to align the pulse to `requestAnimationFrame` instead of a timer window.
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
- The hot path writes no signals and allocates nothing. The anti-trap guarantee is enforced by a test that runs 5000 full recomputes and asserts the `lite-signal` registry creates zero new nodes and never grows its pool.
|
|
24
|
+
- Peer dependency on `@zakkster/lite-signal ^1.2.0`; the same registry instance is shared with your own effects.
|
|
25
|
+
- Requires the telemetry trio at `>= 1.0.1` (transitively, via `lite-profiler`) for correct Node ESM resolution.
|
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
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
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# @zakkster/lite-profiler-signal
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@zakkster/lite-profiler-signal)
|
|
4
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
5
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-profiler-signal)
|
|
6
|
+
[](https://www.npmjs.com/package/@zakkster/lite-profiler-signal)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-profiler-signal)
|
|
8
|
+
[](./LICENSE.txt)
|
|
9
|
+
[](./index.d.ts)
|
|
10
|
+
[](#)
|
|
11
|
+
[](https://www.npmjs.com/package/@zakkster/lite-signal)
|
|
12
|
+
|
|
13
|
+
**The reactive boundary for [`@zakkster/lite-profiler`](https://www.npmjs.com/package/@zakkster/lite-profiler).** It lifts coarse frame and per-phase telemetry into [`lite-signal`](https://www.npmjs.com/package/@zakkster/lite-signal) signals you can bind to a HUD, a dashboard, or an alerting hook -- *without* paying a reactive cost on the frame loop.
|
|
14
|
+
|
|
15
|
+
This is to `lite-profiler` what [`lite-camera-max`](https://www.npmjs.com/package/@zakkster/lite-camera-max) is to `lite-camera`: a thin reactive wrapper over a fast imperative engine. The engine stays imperative and allocation-free; the wrapper hands you signals.
|
|
16
|
+
|
|
17
|
+
> **One rule, enforced by a test:** the profiler hot path never writes a signal, and steady-state pulsing creates **zero** graph nodes. See [the anti-trap proof](#the-reactive-profiler-trap).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The reactive-profiler trap
|
|
22
|
+
|
|
23
|
+
The obvious way to make a profiler "reactive" is to push every measurement into a signal: `phaseSignal.set(elapsed)` at the end of every phase, every frame. At 120fps with eight phases that is ~1000 signal writes per second, each one waking subscribers, each one potentially churning the reactive graph. You have turned a zero-GC profiler into a garbage fountain. That is the trap.
|
|
24
|
+
|
|
25
|
+
`lite-profiler-signal` refuses it. The imperative `Profiler` keeps writing frame and phase times into its zero-GC ring buffers exactly as before. The bridge adds **one** integer signal -- a frame `tick` -- and bumps it once per frame. A `lite-throttle` window over that tick gates a single recompute that, at most ~10 times per second, reads the buffers and writes a **fixed, bounded** set of output signals inside a `batch()`.
|
|
26
|
+
|
|
27
|
+
| | Naive reactive profiler | `lite-profiler-signal` |
|
|
28
|
+
| --------------------------------- | ------------------------------ | --------------------------------- |
|
|
29
|
+
| Signal writes per frame | phases x fps (hundreds+) | **1** (a tick) |
|
|
30
|
+
| Recompute / propagation cadence | every frame | throttled, ~10Hz |
|
|
31
|
+
| Graph nodes created per frame | grows with churn | **0** |
|
|
32
|
+
| Hot-path allocations | one `set()` per phase | **none** |
|
|
33
|
+
| Cost scaling | O(phases x fps) | **O(1) per frame** |
|
|
34
|
+
|
|
35
|
+
### The proof
|
|
36
|
+
|
|
37
|
+
`test/antitrap.test.js` builds a view over a four-phase profiler, warms it up, snapshots `lite-signal`'s registry via `stats()`, then runs **5000 frames** -- each one a full recompute (`intervalMs: 0`) that re-derives every output signal. After 5000 recomputes:
|
|
38
|
+
|
|
39
|
+
- `signals` created: **+0**
|
|
40
|
+
- `computeds` created: **+0**
|
|
41
|
+
- live `activeNodes`: **+0**
|
|
42
|
+
- `nodePoolCapacity`: **+0** (the pool never had to grow)
|
|
43
|
+
|
|
44
|
+
A full reactive derivation, 5000 times, allocates nothing on the graph.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npm install @zakkster/lite-profiler-signal @zakkster/lite-signal
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`@zakkster/lite-signal` (`^1.2.0`) is a **peer dependency** -- the bridge shares your registry, so the signals it hands you are the same kind your own effects already track. `@zakkster/lite-profiler`, `@zakkster/lite-stats-math`, `@zakkster/lite-throttle`, and `@zakkster/lite-watch-ex` are regular dependencies.
|
|
55
|
+
|
|
56
|
+
> **Resolution note:** the telemetry trio (`lite-ring-buffer`, `lite-stats-math`, `lite-canvas-graph`) must be at **>= 1.0.1** for native Node ESM. Earlier `1.0.0` tarballs shipped an `exports` map missing the `./` target prefix, which bundlers tolerate but Node rejects. `lite-profiler` already pins the fixed range.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { Profiler } from "@zakkster/lite-profiler";
|
|
64
|
+
import { createProfilerView } from "@zakkster/lite-profiler-signal";
|
|
65
|
+
import { effect } from "@zakkster/lite-signal";
|
|
66
|
+
|
|
67
|
+
const profiler = new Profiler(512, ["update", "render"]);
|
|
68
|
+
const view = createProfilerView(profiler); // ~10Hz by default
|
|
69
|
+
|
|
70
|
+
// 1. Your existing loop fills the profiler, then pulses the view once.
|
|
71
|
+
function frame() {
|
|
72
|
+
profiler.beginFrame();
|
|
73
|
+
|
|
74
|
+
profiler.begin("update"); simulate(); profiler.end("update");
|
|
75
|
+
profiler.begin("render"); draw(); profiler.end("render");
|
|
76
|
+
|
|
77
|
+
profiler.endFrame();
|
|
78
|
+
view.pulse(); // one cheap tick; recompute is throttled
|
|
79
|
+
requestAnimationFrame(frame);
|
|
80
|
+
}
|
|
81
|
+
requestAnimationFrame(frame);
|
|
82
|
+
|
|
83
|
+
// 2. Bind telemetry to the DOM -- this effect re-runs ~10Hz, not 120Hz.
|
|
84
|
+
effect(() => {
|
|
85
|
+
fpsEl.textContent = view.fps().toFixed(0);
|
|
86
|
+
fpsEl.dataset.state = view.frameClass(); // "steady" | "spiking" | "throttled"
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 3. Get alerted, not polled.
|
|
90
|
+
view.onJank((cls) => console.warn("frame budget missed:", cls));
|
|
91
|
+
view.onRegression("render", (e) =>
|
|
92
|
+
console.warn(`render p99 ${e.p99.toFixed(1)}ms vs baseline ${e.baseline.toFixed(1)}ms`));
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
If you do not already have a loop (e.g. a passive monitor), let the view drive itself:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
const detach = view.attach(); // pulses on requestAnimationFrame; browser only
|
|
99
|
+
// ... later
|
|
100
|
+
detach();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
### `createProfilerView(profiler, options?) -> ProfilerView`
|
|
108
|
+
|
|
109
|
+
| option | default | meaning |
|
|
110
|
+
| ------------ | ------- | -------------------------------------------------------------- |
|
|
111
|
+
| `intervalMs` | `100` | throttle window for the recompute (~10Hz) |
|
|
112
|
+
| `raf` | `false` | align the pulse to `requestAnimationFrame` instead of a timer |
|
|
113
|
+
| `leading` | `true` | emit on the leading edge of each window |
|
|
114
|
+
| `trailing` | `true` | emit the trailing value at window end (so the last frame lands)|
|
|
115
|
+
|
|
116
|
+
Throws `TypeError` if `profiler` is not a `Profiler` instance.
|
|
117
|
+
|
|
118
|
+
### Signals
|
|
119
|
+
|
|
120
|
+
Read them by calling them (`view.fps()`); track them inside any `effect`, `computed`, `watch`, or `.subscribe()`. **Do not `.set()` them** -- they are derived outputs.
|
|
121
|
+
|
|
122
|
+
| signal | type | meaning |
|
|
123
|
+
| -------------------- | --------------------------------------------- | ----------------------------------------- |
|
|
124
|
+
| `fps` | `Signal<number>` | `1000 / frameAvg` |
|
|
125
|
+
| `frameAvg` | `Signal<number>` | mean frame time (ms) |
|
|
126
|
+
| `frameP99` | `Signal<number>` | 99th percentile frame time (ms) |
|
|
127
|
+
| `frameMax` | `Signal<number>` | worst frame in the window (ms) |
|
|
128
|
+
| `jank` | `Signal<number>` | fraction of frames >= 16ms |
|
|
129
|
+
| `spike` | `Signal<number>` | fraction of frames >= 33ms |
|
|
130
|
+
| `frameClass` | `Signal<"steady" \| "spiking" \| "throttled">`| classifier verdict |
|
|
131
|
+
| `phases[tag]` | `{ avg, p99, last }` of `Signal<number>` | per-phase stats (ms) |
|
|
132
|
+
|
|
133
|
+
`view.phase(tag)` returns the bundle for a registered phase, or `null`.
|
|
134
|
+
|
|
135
|
+
### Methods
|
|
136
|
+
|
|
137
|
+
| method | description |
|
|
138
|
+
| -------------------- | --------------------------------------------------------------------------- |
|
|
139
|
+
| `pulse()` | Call once per frame, after `profiler.endFrame()`. One tick set. |
|
|
140
|
+
| `flush()` | Force any pending throttled recompute to run synchronously now. |
|
|
141
|
+
| `attach()` | Drive `pulse()` on `requestAnimationFrame`. Returns a detacher. Browser only.|
|
|
142
|
+
| `detach()` | Stop the `attach()` driver. |
|
|
143
|
+
| `dispose()` | Idempotent. Tears down the throttle, all watchers, and every signal. |
|
|
144
|
+
|
|
145
|
+
### Detectors
|
|
146
|
+
|
|
147
|
+
Both return a disposer; both are also cleaned up by `dispose()`.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
onJank(handler: (cls: FrameClassLabel) => void): () => void;
|
|
151
|
+
```
|
|
152
|
+
Fires when the classifier **leaves** `STEADY` (enters `spiking`/`throttled`). Edge-triggered: it will not spam while you stay janky, and re-arms when you recover.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
onRegression(
|
|
156
|
+
tag: string,
|
|
157
|
+
handler: (e: { tag: string; p99: number; baseline: number }) => void,
|
|
158
|
+
options?: { factor?: number; window?: number } // default factor 1.5, window 8
|
|
159
|
+
): () => void;
|
|
160
|
+
```
|
|
161
|
+
Fires when a phase's p99 exceeds `factor` times the rolling mean of its previous `window` samples. Catches "this phase quietly got 2x slower" without you picking an absolute threshold.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## How it works
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
per frame: pulse() -> tick.set(n + 1) -> throttle(tick, intervalMs)
|
|
169
|
+
|
|
|
170
|
+
at most ~10Hz: v
|
|
171
|
+
recompute(): read ring buffers (StatsMath + FrameHistogram)
|
|
172
|
+
batch(() => set ~7 + 3 * phases signals)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- The **only** per-frame graph activity is `tick.set()` and the throttle's internal lockout check -- both allocation-free (`lite-throttle` makes no per-change allocations).
|
|
176
|
+
- The recompute reuses pre-allocated scratch objects and only ever `.set()`s signals that already exist, inside a single `batch()` so subscribers wake once.
|
|
177
|
+
- `onJank` is `lite-watch-ex`'s `watchChanged` over `frameClass`; `onRegression` is `watchPrevious` (rolling history) over a phase's `p99`. No extra polling loop.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Testing
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
npm test # node --test, zero external test deps
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Three suites:
|
|
188
|
+
- **`view`** -- telemetry is lifted correctly; `frameClass` flips under load; `dispose()` is idempotent.
|
|
189
|
+
- **`antitrap`** -- 5000 full recomputes create zero graph nodes and never grow the pool (the headline guarantee).
|
|
190
|
+
- **`detectors`** -- `onJank` edge-triggers on leaving `STEADY`; `onRegression` fires on a phase p99 spike over its rolling baseline; disposers stop delivery.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Compatibility
|
|
195
|
+
|
|
196
|
+
| package | range | role |
|
|
197
|
+
| ----------------------------- | ---------------- | ----- |
|
|
198
|
+
| `@zakkster/lite-signal` | `^1.2.0` | peer |
|
|
199
|
+
| `@zakkster/lite-profiler` | `^1.0.0` | dep |
|
|
200
|
+
| `@zakkster/lite-stats-math` | `^1.0.1` | dep |
|
|
201
|
+
| `@zakkster/lite-throttle` | `^1.1.0` | dep |
|
|
202
|
+
| `@zakkster/lite-watch-ex` | `^1.1.0` | dep |
|
|
203
|
+
|
|
204
|
+
ESM only. `sideEffects: false`. Node 18+.
|
|
205
|
+
|
|
206
|
+
## Changelog
|
|
207
|
+
|
|
208
|
+
See [CHANGELOG.md](./CHANGELOG.md).
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Profiler, FrameClassLabel } from '@zakkster/lite-profiler';
|
|
2
|
+
import type { Signal } from '@zakkster/lite-signal';
|
|
3
|
+
|
|
4
|
+
export interface ProfilerViewOptions {
|
|
5
|
+
/** Throttle window in milliseconds for the recompute. Default 100 (~10Hz). */
|
|
6
|
+
intervalMs?: number;
|
|
7
|
+
/** Align the pulse to requestAnimationFrame instead of a timer window. */
|
|
8
|
+
raf?: boolean;
|
|
9
|
+
/** Emit on the leading edge of each window. Default true. */
|
|
10
|
+
leading?: boolean;
|
|
11
|
+
/** Emit the trailing value at window end. Default true. */
|
|
12
|
+
trailing?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PhaseSignals {
|
|
16
|
+
avg: Signal<number>;
|
|
17
|
+
p99: Signal<number>;
|
|
18
|
+
last: Signal<number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RegressionEvent {
|
|
22
|
+
tag: string;
|
|
23
|
+
p99: number;
|
|
24
|
+
baseline: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RegressionOptions {
|
|
28
|
+
/** Fire when current p99 exceeds factor x baseline. Default 1.5. */
|
|
29
|
+
factor?: number;
|
|
30
|
+
/** Rolling baseline window length. Default 8. */
|
|
31
|
+
window?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProfilerView {
|
|
35
|
+
readonly fps: Signal<number>;
|
|
36
|
+
readonly frameAvg: Signal<number>;
|
|
37
|
+
readonly frameP99: Signal<number>;
|
|
38
|
+
readonly frameMax: Signal<number>;
|
|
39
|
+
readonly jank: Signal<number>;
|
|
40
|
+
readonly spike: Signal<number>;
|
|
41
|
+
readonly frameClass: Signal<FrameClassLabel>;
|
|
42
|
+
readonly phases: Record<string, PhaseSignals>;
|
|
43
|
+
phase(tag: string): PhaseSignals | null;
|
|
44
|
+
/** Call once per frame, after profiler.endFrame(). One cheap tick set; the throttle gates the recompute. */
|
|
45
|
+
pulse(): void;
|
|
46
|
+
/** Force any pending throttled recompute to run synchronously now. */
|
|
47
|
+
flush(): void;
|
|
48
|
+
/** Convenience: drive pulse() on requestAnimationFrame. Returns a detacher. Browser-only. */
|
|
49
|
+
attach(): () => void;
|
|
50
|
+
detach(): void;
|
|
51
|
+
/** Fire when the classifier leaves STEADY (enters spiking or throttled). Returns a disposer. */
|
|
52
|
+
onJank(handler: (cls: FrameClassLabel) => void): () => void;
|
|
53
|
+
/** Fire when a phase's p99 exceeds `factor` times its rolling baseline. Returns a disposer. */
|
|
54
|
+
onRegression(tag: string, handler: (event: RegressionEvent) => void, options?: RegressionOptions): () => void;
|
|
55
|
+
dispose(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Create a reactive view over a lite-profiler Profiler. */
|
|
59
|
+
export declare function createProfilerView(profiler: Profiler, options?: ProfilerViewOptions): ProfilerView;
|
package/index.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-profiler-signal
|
|
3
|
+
*
|
|
4
|
+
* Reactive boundary for @zakkster/lite-profiler. The imperative Profiler hot
|
|
5
|
+
* path stays allocation-free and writes no signals; this bridge samples its
|
|
6
|
+
* ring buffers on a throttled pulse and lifts coarse telemetry into lite-signal
|
|
7
|
+
* signals. Mirrors lite-camera -> lite-camera-max.
|
|
8
|
+
*
|
|
9
|
+
* The reactive-profiler trap: never call signal.set() per phase or per frame.
|
|
10
|
+
* Here the only per-frame graph activity is one integer tick set; a lite-throttle
|
|
11
|
+
* window gates the (bounded) recompute, so graph cost is O(1) per frame
|
|
12
|
+
* regardless of phase count or frame rate. Proven by test/antitrap.test.js.
|
|
13
|
+
*
|
|
14
|
+
* Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>
|
|
15
|
+
* MIT License.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {signal, batch, dispose as disposeNode} from '@zakkster/lite-signal';
|
|
19
|
+
import {throttle, throttleRAF} from '@zakkster/lite-throttle';
|
|
20
|
+
import {watchPrevious, watchChanged} from '@zakkster/lite-watch-ex';
|
|
21
|
+
import {FrameHistogram, FrameClass} from '@zakkster/lite-profiler';
|
|
22
|
+
import {StatsMath} from '@zakkster/lite-stats-math';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_INTERVAL = 100; // ~10Hz telemetry
|
|
25
|
+
const BUDGET_60 = 1000 / 60;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a reactive view over a Profiler.
|
|
29
|
+
* @param {import('@zakkster/lite-profiler').Profiler} profiler
|
|
30
|
+
* @param {object} [options]
|
|
31
|
+
* @param {number} [options.intervalMs=100] throttle window (ms) for the recompute
|
|
32
|
+
* @param {boolean} [options.raf=false] align the pulse to requestAnimationFrame
|
|
33
|
+
* @param {boolean} [options.leading=true]
|
|
34
|
+
* @param {boolean} [options.trailing=true]
|
|
35
|
+
*/
|
|
36
|
+
export function createProfilerView(profiler, options = {}) {
|
|
37
|
+
if (!profiler || typeof profiler.beginFrame !== 'function') {
|
|
38
|
+
throw new TypeError('createProfilerView: a lite-profiler Profiler instance is required');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL;
|
|
42
|
+
const leading = options.leading !== false;
|
|
43
|
+
const trailing = options.trailing !== false;
|
|
44
|
+
|
|
45
|
+
// analysis scratch (off the hot path)
|
|
46
|
+
const stats = new StatsMath(profiler.capacity);
|
|
47
|
+
const hist = new FrameHistogram();
|
|
48
|
+
const fOut = {avg: 0, min: 0, max: 0, p01: 0, p99: 0};
|
|
49
|
+
const pOut = {avg: 0, min: 0, max: 0, p01: 0, p99: 0};
|
|
50
|
+
|
|
51
|
+
// output signals -- created once; recompute only .set()s them
|
|
52
|
+
const fps = signal(0);
|
|
53
|
+
const frameAvg = signal(0);
|
|
54
|
+
const frameP99 = signal(0);
|
|
55
|
+
const frameMax = signal(0);
|
|
56
|
+
const jank = signal(0);
|
|
57
|
+
const spike = signal(0);
|
|
58
|
+
const frameClass = signal(FrameClass.STEADY);
|
|
59
|
+
|
|
60
|
+
const tags = profiler.phaseTags ? profiler.phaseTags.slice() : [];
|
|
61
|
+
const phases = Object.create(null);
|
|
62
|
+
const phaseList = [];
|
|
63
|
+
for (let i = 0; i < tags.length; i++) {
|
|
64
|
+
const tag = tags[i];
|
|
65
|
+
const bundle = {avg: signal(0), p99: signal(0), last: signal(0)};
|
|
66
|
+
phases[tag] = bundle;
|
|
67
|
+
phaseList.push({buf: profiler.phase(tag), avg: bundle.avg, p99: bundle.p99, last: bundle.last});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// recompute: a bounded number of sets, batched into a single flush
|
|
71
|
+
function recompute() {
|
|
72
|
+
const frame = profiler.frame;
|
|
73
|
+
if (frame.count === 0) return;
|
|
74
|
+
stats.compute(frame, fOut);
|
|
75
|
+
hist.update(frame);
|
|
76
|
+
batch(() => {
|
|
77
|
+
fps.set(fOut.avg > 0 ? 1000 / fOut.avg : 0);
|
|
78
|
+
frameAvg.set(fOut.avg);
|
|
79
|
+
frameP99.set(fOut.p99);
|
|
80
|
+
frameMax.set(fOut.max);
|
|
81
|
+
jank.set(hist.jankRatio);
|
|
82
|
+
spike.set(hist.spikeRatio);
|
|
83
|
+
frameClass.set(hist.classify());
|
|
84
|
+
for (let i = 0; i < phaseList.length; i++) {
|
|
85
|
+
const ph = phaseList[i];
|
|
86
|
+
if (ph.buf.count === 0) {
|
|
87
|
+
ph.last.set(0);
|
|
88
|
+
ph.avg.set(0);
|
|
89
|
+
ph.p99.set(0);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
stats.compute(ph.buf, pOut);
|
|
93
|
+
ph.last.set(ph.buf.peekNewest());
|
|
94
|
+
ph.avg.set(pOut.avg);
|
|
95
|
+
ph.p99.set(pOut.p99);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// the pulse: one integer set per frame; the throttle gates the recompute
|
|
101
|
+
const tick = signal(0);
|
|
102
|
+
const gated = options.raf
|
|
103
|
+
? throttleRAF(() => tick(), {leading, trailing})
|
|
104
|
+
: throttle(() => tick(), intervalMs, {leading, trailing});
|
|
105
|
+
const pump = gated.subscribe(recompute); // fires on each throttled emit
|
|
106
|
+
|
|
107
|
+
let disposed = false;
|
|
108
|
+
let rafId = 0;
|
|
109
|
+
const watchers = [];
|
|
110
|
+
|
|
111
|
+
function pulse() {
|
|
112
|
+
if (disposed) return;
|
|
113
|
+
tick.set(tick.peek() + 1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function flush() {
|
|
117
|
+
if (disposed) return;
|
|
118
|
+
gated.flush();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function detach() {
|
|
122
|
+
if (rafId && typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(rafId);
|
|
123
|
+
rafId = 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function attach() {
|
|
127
|
+
if (disposed || typeof requestAnimationFrame === 'undefined') return detach;
|
|
128
|
+
const loop = () => {
|
|
129
|
+
pulse();
|
|
130
|
+
rafId = requestAnimationFrame(loop);
|
|
131
|
+
};
|
|
132
|
+
rafId = requestAnimationFrame(loop);
|
|
133
|
+
return detach;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Fire when the classifier leaves STEADY (enters spiking or throttled). */
|
|
137
|
+
function onJank(handler) {
|
|
138
|
+
const off = watchChanged(
|
|
139
|
+
() => frameClass(),
|
|
140
|
+
(c) => c !== FrameClass.STEADY,
|
|
141
|
+
(c) => handler(c)
|
|
142
|
+
);
|
|
143
|
+
watchers.push(off);
|
|
144
|
+
return off;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Fire when a phase's p99 exceeds `factor` times its rolling baseline. */
|
|
148
|
+
function onRegression(tag, handler, opts = {}) {
|
|
149
|
+
const bundle = phases[tag];
|
|
150
|
+
if (!bundle) throw new RangeError(`onRegression: unknown phase '${tag}'`);
|
|
151
|
+
const factor = opts.factor ?? 1.5;
|
|
152
|
+
const window = Math.max(2, opts.window ?? 8);
|
|
153
|
+
const off = watchPrevious(() => bundle.p99(), (cur, history) => {
|
|
154
|
+
let sum = 0, n = 0;
|
|
155
|
+
for (let i = 0; i < history.length; i++) {
|
|
156
|
+
const h = history[i];
|
|
157
|
+
if (h !== undefined) {
|
|
158
|
+
sum += h;
|
|
159
|
+
n++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (n < window - 1) return; // not enough baseline yet
|
|
163
|
+
const baseline = sum / n;
|
|
164
|
+
if (baseline > 0 && cur > baseline * factor) handler({tag, p99: cur, baseline});
|
|
165
|
+
}, {depth: window});
|
|
166
|
+
watchers.push(off);
|
|
167
|
+
return off;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function destroy() {
|
|
171
|
+
if (disposed) return;
|
|
172
|
+
disposed = true;
|
|
173
|
+
detach();
|
|
174
|
+
for (let i = 0; i < watchers.length; i++) watchers[i]();
|
|
175
|
+
watchers.length = 0;
|
|
176
|
+
pump();
|
|
177
|
+
gated.dispose();
|
|
178
|
+
disposeNode(tick);
|
|
179
|
+
disposeNode(fps);
|
|
180
|
+
disposeNode(frameAvg);
|
|
181
|
+
disposeNode(frameP99);
|
|
182
|
+
disposeNode(frameMax);
|
|
183
|
+
disposeNode(jank);
|
|
184
|
+
disposeNode(spike);
|
|
185
|
+
disposeNode(frameClass);
|
|
186
|
+
for (let i = 0; i < phaseList.length; i++) {
|
|
187
|
+
disposeNode(phaseList[i].avg);
|
|
188
|
+
disposeNode(phaseList[i].p99);
|
|
189
|
+
disposeNode(phaseList[i].last);
|
|
190
|
+
}
|
|
191
|
+
hist.destroy();
|
|
192
|
+
if (typeof stats.destroy === 'function') stats.destroy();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
fps, frameAvg, frameP99, frameMax, jank, spike, frameClass,
|
|
197
|
+
phases,
|
|
198
|
+
phase(tag) {
|
|
199
|
+
return phases[tag] || null;
|
|
200
|
+
},
|
|
201
|
+
pulse, flush, attach, detach,
|
|
202
|
+
onJank, onRegression,
|
|
203
|
+
dispose: destroy
|
|
204
|
+
};
|
|
205
|
+
}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @zakkster/lite-profiler-signal
|
|
2
|
+
|
|
3
|
+
> Reactive boundary for @zakkster/lite-profiler. Lifts coarse frame and per-phase
|
|
4
|
+
> telemetry into @zakkster/lite-signal signals via a throttled pulse, with predicate
|
|
5
|
+
> watchers for jank and per-phase regression. The Profiler hot path stays
|
|
6
|
+
> allocation-free and writes no signals (the reactive-profiler trap). Mirrors the
|
|
7
|
+
> @zakkster/lite-camera -> @zakkster/lite-camera-max relationship: a thin reactive
|
|
8
|
+
> wrapper over an imperative engine.
|
|
9
|
+
|
|
10
|
+
## Mental model
|
|
11
|
+
|
|
12
|
+
- The imperative Profiler records frame and phase times into zero-GC ring buffers.
|
|
13
|
+
- This bridge NEVER subscribes to or writes a signal on the per-frame path.
|
|
14
|
+
- pulse() (called once per frame) does exactly one thing: increments an internal
|
|
15
|
+
integer `tick` signal.
|
|
16
|
+
- A lite-throttle window over `tick` gates an effect that, at most once per window
|
|
17
|
+
(~10Hz by default), reads the ring buffers via lite-stats-math + lite-profiler's
|
|
18
|
+
FrameHistogram and writes a BOUNDED, FIXED set of output signals inside batch().
|
|
19
|
+
- Result: per-frame graph cost is O(1) regardless of phase count or frame rate, and
|
|
20
|
+
no graph nodes are created per frame. Proven by test/antitrap.test.js (5000 frames,
|
|
21
|
+
registry node counts and pool capacity flat).
|
|
22
|
+
|
|
23
|
+
## API
|
|
24
|
+
|
|
25
|
+
createProfilerView(profiler, options?) -> ProfilerView
|
|
26
|
+
options: { intervalMs = 100, raf = false, leading = true, trailing = true }
|
|
27
|
+
|
|
28
|
+
Signals (read-only; never .set() them yourself):
|
|
29
|
+
fps, frameAvg, frameP99, frameMax, jank, spike : Signal<number>
|
|
30
|
+
frameClass : Signal<"steady" | "spiking" | "throttled">
|
|
31
|
+
phases : Record<tag, { avg: Signal<number>, p99: Signal<number>, last: Signal<number> }>
|
|
32
|
+
phase(tag) -> PhaseSignals | null
|
|
33
|
+
|
|
34
|
+
Methods:
|
|
35
|
+
pulse() call once per frame after profiler.endFrame()
|
|
36
|
+
flush() force any pending throttled recompute to run now
|
|
37
|
+
attach()/detach() drive pulse() on requestAnimationFrame (browser only); attach() returns a detacher
|
|
38
|
+
dispose() idempotent teardown of throttle, watchers, and all signals
|
|
39
|
+
|
|
40
|
+
Detectors (return disposers):
|
|
41
|
+
onJank(handler) fires when frameClass leaves "steady"
|
|
42
|
+
onRegression(tag, handler, { factor=1.5, window=8 })
|
|
43
|
+
fires when a phase p99 exceeds factor x rolling baseline
|
|
44
|
+
handler receives { tag, p99, baseline }
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import { Profiler } from "@zakkster/lite-profiler";
|
|
50
|
+
import { createProfilerView } from "@zakkster/lite-profiler-signal";
|
|
51
|
+
import { effect } from "@zakkster/lite-signal";
|
|
52
|
+
|
|
53
|
+
const profiler = new Profiler(512, ["update", "render"]);
|
|
54
|
+
const view = createProfilerView(profiler);
|
|
55
|
+
|
|
56
|
+
// In your game loop:
|
|
57
|
+
function frame() {
|
|
58
|
+
profiler.beginFrame();
|
|
59
|
+
profiler.begin("update"); /* ... */ profiler.end("update");
|
|
60
|
+
profiler.begin("render"); /* ... */ profiler.end("render");
|
|
61
|
+
profiler.endFrame();
|
|
62
|
+
view.pulse(); // one tick set; recompute is throttled internally
|
|
63
|
+
requestAnimationFrame(frame);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// React to telemetry anywhere, with any lite-signal consumer:
|
|
67
|
+
effect(() => { hudEl.textContent = view.fps().toFixed(0) + " fps"; });
|
|
68
|
+
view.onJank((cls) => console.warn("frame budget missed:", cls));
|
|
69
|
+
view.onRegression("render", (e) =>
|
|
70
|
+
console.warn(`render p99 ${e.p99.toFixed(1)}ms vs baseline ${e.baseline.toFixed(1)}ms`));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
npm install @zakkster/lite-profiler-signal @zakkster/lite-signal
|
|
76
|
+
|
|
77
|
+
Peer: @zakkster/lite-signal ^1.2.0 (shared registry).
|
|
78
|
+
Dependencies: @zakkster/lite-profiler, @zakkster/lite-stats-math, @zakkster/lite-throttle, @zakkster/lite-watch-ex.
|
|
79
|
+
|
|
80
|
+
## Conventions
|
|
81
|
+
|
|
82
|
+
ESM only. sideEffects:false. node:test only. ASCII source. Zero hot-path allocations.
|
|
83
|
+
Copyright (c) Zahary Shinikchiev <shinikchiev@yahoo.com>. MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zakkster/lite-profiler-signal",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Reactive boundary for @zakkster/lite-profiler: lifts coarse frame and per-phase telemetry into lite-signal signals via a throttled pulse, with predicate watchers for jank and per-phase regression. The hot path stays allocation-free and signal-free -- the reactive-profiler trap. Mirrors lite-camera -> lite-camera-max.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"module": "./index.js",
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"node": "./index.js",
|
|
13
|
+
"import": "./index.js",
|
|
14
|
+
"default": "./index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"index.js",
|
|
19
|
+
"index.d.ts",
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"llms.txt",
|
|
23
|
+
"LICENSE.txt"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test",
|
|
27
|
+
"pack": "npm pack --dry-run",
|
|
28
|
+
"bundle-check": "npx esbuild index.js --bundle --format=esm --external:@zakkster/* --outfile=test-bundle.js",
|
|
29
|
+
"prepublishOnly": "npm run test && npm run bundle-check"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"lite-signal",
|
|
33
|
+
"profiler",
|
|
34
|
+
"reactive",
|
|
35
|
+
"signals",
|
|
36
|
+
"frame-time",
|
|
37
|
+
"telemetry",
|
|
38
|
+
"throttle",
|
|
39
|
+
"jank",
|
|
40
|
+
"regression",
|
|
41
|
+
"zero-gc",
|
|
42
|
+
"lite-tools"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@zakkster/lite-profiler": "^1.0.0",
|
|
46
|
+
"@zakkster/lite-stats-math": "^1.0.1",
|
|
47
|
+
"@zakkster/lite-throttle": "^1.1.0",
|
|
48
|
+
"@zakkster/lite-watch-ex": "^1.1.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@zakkster/lite-signal": "^1.2.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"esbuild": "^0.24.0"
|
|
55
|
+
},
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
58
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-profiler-signal#readme",
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/PeshoVurtoleta/lite-profiler-signal.git"
|
|
62
|
+
},
|
|
63
|
+
"bugs": {
|
|
64
|
+
"url": "https://github.com/PeshoVurtoleta/lite-profiler-signal/issues",
|
|
65
|
+
"email": "shinikchiev@yahoo.com"
|
|
66
|
+
},
|
|
67
|
+
"funding": {
|
|
68
|
+
"type": "github",
|
|
69
|
+
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
70
|
+
},
|
|
71
|
+
"sideEffects": false
|
|
72
|
+
}
|