npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-hud

v2.5.0

Published

SPP-native zero-GC canvas overlay for the @zakkster profiler suite.

Downloads

748

Readme

@zakkster/lite-hud

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

SPP-native zero-GC canvas overlay for the @zakkster profiler suite.

Single-file ESM, no runtime dependencies, phosphor-green oscilloscope aesthetic. Designed as the read-side consumer of @zakkster/lite-scope's mux registry. Also works as a drop-in stats.js replacement via hud.channel().

v2.0.0 is a breaking rewrite. See the migration guide below.


Install

npm install @zakkster/lite-hud

Scope-driven mode (primary)

import { createHud } from '@zakkster/lite-hud';
import { createScope } from '@zakkster/lite-scope';

const scope = createScope();
const hud   = createHud(document.body, { windowSec: 5, position: 'top-right' });

hud.attach(scope, {
  budgets: [
    { channel: 'frame', threshold: 16.67, label: '60fps budget' },
  ],
});

// Rendering loop -- call at ~10-15 Hz
function loop() {
  requestAnimationFrame(loop);
  if (frameCount++ % 4 === 0) hud.render();
}
loop();

attach() reads scope.streams(), builds an O(1) LUT per stream, and registers the HUD as a live mux sink via scope.addSink(). From that point every SPP record emitted by any probe flows directly into the HUD with no copy.


Manual channel mode (drop-in stats.js replacement)

No scope setup required.

const hud   = createHud(document.body);
const fps   = hud.channel({ name: 'fps',       kind: 0 }); // LEVEL
const gc    = hud.channel({ name: 'gc',        kind: 1 }); // INSTANT
const frame = hud.channel({ name: 'frame',     kind: 2 }); // SPAN (complete)
const draws = hud.channel({ name: 'draw calls', kind: 3 }); // COUNTER

// Anywhere in your code:
fps.push(59.8);
gc.push();
frame.push(12.4);   // duration in ms; t_start = now - 12.4  (D5 layout)
draws.push(312);

push() synthesizes a valid SPP record and calls hud.write() directly -- the same demux path used by scope probes. There is no separate ring or draw path.


API reference

createHud(mountEl, opts?): Hud

| Option | Type | Default | Description | |--------|------|---------|-------------| | windowSec | number | 5 | Scrolling time window width in seconds | | position | 'top-right' \| 'top-left' \| 'bottom-right' \| 'bottom-left' | 'top-right' | Canvas corner | | hotkey | string | '`' | Key that toggles visibility. '' to disable | | zIndex | number | 9999 | CSS z-index of the canvas | | viewport | Viewport CLASS | undefined | Optional DPR-aware renderer (see below). Must be a constructor function, else throws | | maxDpr | number | Infinity | Cap devicePixelRatio for the backing store. Finite >= 1 or Infinity, else throws | | stats | { quantiles?, rolling?, crossCheck?, dedup?, topk?, distinct?, freq? } | undefined | Optional injected analytics factories: quantiles (M2, see Percentiles), rolling (M4), crossCheck / dedup (M5), topk / distinct / freq (M3, see Hot spots + cardinality). Each must be a function (or the dedup bag), else throws |

Pass null as mountEl for headless / test mode. All DOM and canvas operations are skipped; the full state layer remains active.

DPR-correct rendering (viewport + maxDpr)

By default the HUD sizes its own canvas from devicePixelRatio (capped at maxDpr) and resets the transform before scaling on every resize, so repeated resizes never compound.

Inject a @zakkster/lite-viewport Viewport class for a resize-aware, DPR-change-aware backing store:

import { createHud } from '@zakkster/lite-hud';
import { Viewport } from '@zakkster/lite-viewport';

const hud = createHud(document.body, { viewport: Viewport, maxDpr: 2 });

The HUD owns a sized wrapper <div> (positioning moves to it, the canvas sits inside) so the viewport measures the HUD, not the page; it constructs new Viewport({ canvas, maxDpr, onResize }), renders through vp.ctx / vp.dpr, calls vp.resize() synchronously when the row count grows, and tears the viewport down in destroy(). @zakkster/lite-viewport is an optional peer dependency: with none installed, the inline fallback path above is used and nothing regresses.

viewport must be a function (a class); maxDpr must be a finite number >= 1 or Infinity. Either violation throws with the @zakkster/lite-hud: prefix before any DOM work (fail-closed).


hud.attach(scope, opts?)

Reads scope.streams(), builds the LUT, and calls scope.addSink(hud).

opts.budgets is an array of inline threshold lines applied at attach time:

{ channel: 'fps', threshold: 60, label: 'min fps' }

Budget thresholds are also transported at runtime via the BUDGET_SET (0x0F41) meta opcode: a = interned_name_id, b = threshold_value.


hud.channel(desc): { push(v?) }

Creates a manual channel. Synthetic stream IDs are assigned from 0x8000 and never conflict with scope-registered stream IDs (dense from 1).

| Field | Type | Default | |-------|------|---------| | name | string | 'ch<n>' | | unit | string | '' | | hz | number | null (256-record ring) | | kind | 0\|1\|2\|3 | 0 (LEVEL) | | quantiles | (() => DDSketch) \| false | inherit default | Per-channel analytics override: a factory (opt IN; SPAN or LEVEL) or false (opt OUT). See Percentiles | | topk | (() => SpaceSaving) \| false | inherit default | Per-channel top-k override (M3): a zero-arg factory or false. Rides TAG channels only (a manual INSTANT, never manual LEVEL/COUNTER/SPAN). See Hot spots + cardinality | | distinct | (() => HyperLogLog) \| false | inherit default | Per-channel distinct-count override (M3): a zero-arg factory or false. Rides a TAG channel or a paired SPAN (its correlId) | | freq | (() => CountMinSketch) \| false | inherit default | Per-channel per-tag frequency override (M3): a zero-arg factory or false. Requires topk on the same channel |

push() semantics by kind:

| Kind | Call | What is stored | |------|------|----------------| | LEVEL (0) | push(value) | t=now, a=value | | INSTANT (1) | push() | t=now, a=0 | | SPAN (2) | push(durationMs) | t=now-dur, a=dur (D5) | | COUNTER (3) | push(value) | t=now, a=value |


hud.write(packed, t, a, b)

Duck-typed SPP sink entry point. Called automatically by the scope mux after attach(); also used directly by push() for manual channels.

Packed field decoding uses arithmetic (not bitwise) to handle the full u16 stream ID range:

const sid = (packed / 65536) | 0;
const op  = (packed - sid * 65536) | 0;

hud.stats()

{
  drops:        number;    // unrouted records (LUT miss or pool eviction)
  channels:     number;
  epoch:        number | null;  // t from last EPOCH (0x0F00) meta record
  verdicts:     number;    // gate verdicts stored (capped at 64)
  budgets:      number;    // total threshold lines across all channels
  quantileDrops: number;   // values rejected by the quantile pre-check (0 if off)
  rollingDrops: number;    // records rejected by the rolling pre-check (0 if off)
  channelStats: Array<{ name: string; count: number; head: number }>;
  dedupFirst?: number;        // M5: first-seen events marked NEW (wired only)
  dedupSuppressed?: number;   // M5: repeats suppressed from drawing (wired only)
  dedupSkips?: number;        // M5: dedup records drawn but not deduped (wired only)
  topkDrops?: number;         // M3: records dropped by the top-k pre-check (wired only)
  distinctDrops?: number;     // M3: records dropped by the distinct pre-check (wired only)
  freqDrops?: number;         // M3: records dropped before the freq add (wired only)
}

The three dedup* keys appear ONLY once a dedup channel has wired the peers (a stats.dedup bag with no dedup channel adds none), so a no-factory stats() is byte-identical to 2.3.0. The three M3 keys (topkDrops / distinctDrops / freqDrops) appear ONLY once that peer is wired on a channel, so a no-factory stats() is byte-identical to 2.4.0.

hud.inspect(name): { count, last: { t, a, b }, quantiles?, rolling?, crossCheck?, dedup?, topk?, distinct? } | null

Returns the most recent ring record for a named channel. Cold path -- allocates. Do not call from a frame loop. For a sketched channel it also returns quantiles: { p50, p90, p99, p999, n } (n:0 + NaN quantiles on an empty window). For a rolling channel it also returns rolling: { count, sum, mean, min, max, rate } (count:0, sum:0, mean/min/max NaN, rate:0 on an empty covered window). With a WaveletTree cross-check wired it returns crossCheck: { exact, sketch, relErr, n } | null (M5); with dedup wired it returns dedup: { firstSeen, suppressed, skips } (M5). For a top-k channel it returns topk: { rows: [{ key, label, weight, count?, err }], n } and for a distinct channel distinct: { n, se } (M3; n:0 on an empty window).

hud.range(name, t0, t1): { count, sum, mean, min, max } | null

EXACT sub-window aggregate (M5), by a cold ring scan in sequence order: O(ring capacity), no peer, no hot-path cost, no memory, and correct with out-of-order record times. The per-kind key is LEVEL/COUNTER t, a complete SPAN's end time t + a, a paired SPAN's close time; the value is the level/counter value or the span duration. A record counts iff t0 <= key <= t1 and the value is finite with |v| <= 1e150. An empty window reads count 0, sum 0, mean/min/max NaN. An INSTANT channel reports only count (the rest NaN). Returns null for a CONT-wide channel, an unknown name, or a HUD with no such channel. Throws (@zakkster/lite-hud: prefix) on a non-number, a NaN, or t0 > t1. It always exists -- no factory, no peer, no stats() key.

hud.render()

Draws all visible channels. Caller-throttled to ~10--15 Hz. No-op when mountEl is null or overlay is hidden.

hud.show() / hud.hide() / hud.destroy()

destroy() removes the canvas from the DOM, removes the keydown listener, and calls scope.removeSink() if the scope provides it.


Percentiles (DDSketch, optional peer)

Inject a () => DDSketch factory and the HUD draws per-channel p50 / p90 / p99 / p99.9 + N tiles and a shaded p50-p99 band behind LEVEL traces -- the number a profiler HUD exists to show. The HUD never imports @zakkster/lite-sketch; you inject it (an optional peer dependency).

import { createHud } from '@zakkster/lite-hud';
import { DDSketch } from '@zakkster/lite-sketch';

const hud = createHud(document.body, { stats: { quantiles: () => new DDSketch(0.01) } });
// per-channel override: a factory opts IN (SPAN or LEVEL), false opts OUT
const rtt = hud.channel({ name: 'rtt', kind: 2, quantiles: () => new DDSketch(0.005) });
// read from code (cold): { p50, p90, p99, p999, n }
hud.inspect('rtt').quantiles;

What is sketched: complete + paired SPAN durations always (when a factory is injected); LEVEL opt-in (a per-channel quantiles factory, or a scope op with quantiles: true); COUNTER / INSTANT never.

Zero-box hot path (addFrom). lite-sketch >= 1.1.0 exposes DDSketch.addFrom(buf, i): the value is read UNBOXED from a caller-owned Float64Array inside the peer, so a fractional value never boxes at the call boundary (add(fractionalDouble) would box a ~16 B transient when the call is not inlined). The HUD stores the value into a per-channel Float64Array(1) in write()'s own frame, then feeds the peer via addFrom -- so the analytics write path adds no allocation over the caller's own baseline, for integer AND fractional values.

Pre-check law (peers never throw into the write path). addFrom throws on a NaN / negative / +-Infinity / out-of-range value. The hot path rejects exactly that set -- plus v>0 outside the accepted band minIndexable < v <= maxIndexable (EXCLUSIVE low floor, INCLUSIVE high ceiling) -- counts a stats().quantileDrops, and never lets addFrom throw into write(). There is no try/catch on the hot path. v === 0 is legal; -0 sums as 0. The bounds and the strict check come from the sketch's own getters (strict, minIndexable, maxIndexable) read once, cold, when the channel sketch is created (no probe). A strict-range DDSketch (new DDSketch(a, { range })) has strict === true and is rejected @zakkster/lite-hud: fail-closed -- inject a default collapsing sketch (no range), so out-of-range values collapse instead of throw. A factory missing addFrom or with non-finite indexable getters also fails closed.

Window. Two sketches A/B rotate every windowSec/2 by record time (O(1) on the hot path; the retiring sketch is clear()ed); render/inspect clear() a scratch and merge() A+B into it, then quantile(). merge / clear are 0-alloc. Coverage is between windowSec/2 and windowSec.

Memory cost: 3 x maxBins x 8 B per sketched channel (the A/B window pair + the render scratch) -- 48 KB at 2048 bins (the DDSketch default), owned by the injected factory. With no factory injected, none of this exists and behavior + stats() + the render call trace are byte-identical to 2.1.0.


Rolling aggregates + partial redraw (SlidingAggregate, optional peer)

Inject a (W) => SlidingAggregate factory (@zakkster/lite-adaptive >= 1.11.0) and the HUD maintains EXACT time-windowed count / sum / mean / min / max per channel, drawing a rolling tile (mean / min / max + rate) and driving autoscale -- replacing the O(n) render-time ring rescan. The HUD never imports lite-adaptive.

import { createHud } from '@zakkster/lite-hud';
import { SlidingAggregate } from '@zakkster/lite-adaptive';

const hud = createHud(document.body, {
  windowSec: 5,
  stats: { rolling: (W) => new SlidingAggregate(W, { panes: 32 }) },
});
hud.attach(scope);
hud.inspect('rtt').rolling; // { count, sum, mean, min, max, rate }

The factory receives W. It is called with W = windowSec * 1000 (record-time ms), so the HUD and the peer can never disagree on the window.

What is fed. Every width-1 channel + paired SPAN when a factory is injected (CONT-wide channels are never fed). Feed rules:

| Kind | Fed (now, value) | |------|--------------------| | LEVEL / COUNTER | (t, a) | | Complete SPAN | (t + a, a) -- the END time, so nested spans stay monotone | | Paired SPAN | (t, t - tOpen) | | INSTANT | (t, 1) -- so rate = count / windowSec |

A per-channel override hud.channel({ rolling }) takes a factory or false.

Zero-box hot path. The HUD writes the time + value into a per-channel Float64Array in write()'s frame and feeds the peer via the zero-box SlidingAggregate.addFrom(buf, i) (read UNBOXED inside the peer), so a fractional time / value never boxes at the call boundary. advanceFrom / into read from the same buffers on the cold render path.

Pre-check law (the peer never throws into write()). A value is kept iff |v| <= 1e150; a time iff t >= lastNow and |t| <= (W / panes) * 2^42. A NaN fails closed by construction. Every rejection increments stats().rollingDrops and never lets addFrom throw into the caller's hot loop. There is no try / catch on the hot path. nowMax and the dust tolerance are computed once at setup; the last accepted time is cached in the HUD's own slot (no getter on the hot path). A complete-SPAN end time at most (W / panes) / 1024 below the last accepted time is dust-clamped up to it (so two spans ending at "the same" instant never drop on FP rounding); anything further back drops.

Factory check (cold, fail-closed). The returned instance must duck-type addFrom / advanceFrom / into / clear, have W === requested W, an integer panes, mode === 'unset', a numeric bytes, and must not already be wired to another channel -- a shared SlidingAggregate corrupts the per-channel window and makes the peer throw into write(). An instance is claimed PROCESS-WIDE by the first channel (of any HUD) it is wired to, for its lifetime; destroy() does NOT release the claim, so the factory must return a FRESH instance per call. Any violation throws @zakkster/lite-hud:.

One clock. Once per render (and before an inspect() read) the HUD advances every rolling channel to the max record time it has seen (the idle slide), so an idle channel's readout empties instead of freezing. A channel fed on a different clock is advanced to the larger one and its own records then drop into rollingDrops -- counted, never thrown.

EPOCH resets the analytics. On an OP_EPOCH meta record the HUD clears every rolling instance (and its cached lastNow) and resets the M2 percentile window (qA/qB.clear(), q = qA, qRotAt = 0) to a fresh state. Without this, a backward record time after an epoch would stall both (the fix corrects a 2.2.0 bug where such a stream kept stale mass -- a 60-record run a fresh HUD reads as 10).

Disclosed approximations. The covered span is [W, W + W/panes], always containing the last W: autoscale over-covers the window by at most W / panes (one pane width), and the rate over-counts by at most 1 / panes of a window. count / min / max are bit-exact and |sum - exact| <= (4u) x sum|v|.

Memory cost: (panes + 1) x 48 B per rolling channel in the peer -- 1584 B at panes = 32 -- plus a length-6 + length-10 Float64Array in the HUD. With no factory injected, none of this exists and the full-frame render trace + stats() (minus the always-present rollingDrops) + inspect() (no rolling key) are byte-identical to 2.2.0.

Partial redraw (always on, no peer). render() repaints only the rows whose ring count changed, whose last paint left content in or right of the plot (live -- so a record stamped ahead of the render clock repaints when it scrolls in), or that are paired channels with open spans. A full frame is forced on the first frame, a resize / DPR change, row growth, show(), a new verdict or one in or right of the plot last frame, and EPOCH. The full-frame output is byte-identical to 2.2.0. The rolling idle slide runs for every channel each frame, so an idle channel repaints once when its window empties, then stops.


Exact range + p99 cross-check + windowed dedup (M5, optional peers)

Three additions. hud.range() needs no peer and always exists; the cross-check and dedup are injected, never imported. With no M5 factory wired, every full-frame render trace plus stats() and inspect() are byte-identical to 2.3.0 (gated by a degrade golden captured from HEAD).

Exact range. hud.range(name, t0, t1) is a cold O(ring capacity) scan (see the API reference above). It maps nothing to slots -- it just scans the live ring in sequence order -- so out-of-order record times are handled exactly, with no monotone flag and no null for that. lite-logn SegmentTree injection for an O(log n) tile is deferred to v3.0 (pointer drag selection).

p99 cross-check (@zakkster/lite-logn >= 1.5.1 WaveletTree).

import { DDSketch } from '@zakkster/lite-sketch';
import { WaveletTree } from '@zakkster/lite-logn';

const hud = createHud(el, {
  stats: {
    quantiles: () => new DDSketch(0.01),
    crossCheck: (n) => new WaveletTree(new Float64Array(n)), // n = ring capacity
  },
});
// cold, inspect only:
hud.inspect('latency').crossCheck; // { exact, sketch, relErr, n } | null

The population is identified by sequence, not time: qRotate records sequence watermarks (qSeqOld / qSeqCur) with no M2 output change, so an out-of-order record still belongs to the active sketch. While UNSEEDED (no rotation yet this era) the population starts at the era start qSeqEpoch (0 at attach, count at EPOCH); a rejected leading record never shifts it. The merged A+B window sketch covers records [qSeqOld, count-1]; the HUD rebuilds the WaveletTree cold from exactly those ring values that pass the M2 pre-check and reads the exact k-th value (k = floor(0.99*(m-1)), the same 0-based rank DDSketch uses). It returns null fail-closed when an accepted population record has left the ring, detected as the gathered count (the gather is clamped to the oldest live ring slot) being below the merged sketch count. No render tile (no per-frame rebuild) in M5.

Windowed dedup (@zakkster/lite-filter >= 1.2.1 Bloom + @zakkster/lite-lru >= 1.19.0 LiteLru). INSTANT channels only, opt in with dedup: windowMs:

import { Bloom } from '@zakkster/lite-filter';
import { LiteLru } from '@zakkster/lite-lru';

const hud = createHud(el, {
  stats: {
    dedup: {
      bloom: () => new Bloom(1 << 16, { keys: 'int' }),
      lru: (clock) => new LiteLru(4096, { keys: 'int', ttl: 1, clock }),
    },
  },
});
const errs = hud.channel({ name: 'errors', kind: 1, dedup: 800 }); // 800 ms window
errs.push(code); // code: integer in [0, 2^20)

One Bloom + one LiteLru per HUD. The signature is sig = (ci << 20) | code with the channel index ci < 1024 -- lossless and in [0, 2^30), so Chrome's 31-bit Smis never box it at the HUD's own Bloom/LRU call. A first-seen event is marked NEW (stroked in a bright highlight); a repeat inside its window is suppressed from drawing (still recorded, counted, and fed to rolling/quantiles). The window is fixed from first sight -- a steady repeat is not suppressed forever. On the hot path, a code that is not an integer in [0, 2^20) (a fractional code would truncate in the fold -- fail closed), a non-finite first t (it never becomes the base), a relative time floor(t - base) outside [0, 2^30), or a re-entered peer call is a counted skip, drawn normally -- the dedup write path is 0 B/op. push(code) on a dedup channel passes a non-number through as NaN (no || 0), so a bogus code is skipped, never deduped as code 0. EPOCH clears the LRU (its time base changes) and resets the base, but KEEPS the Bloom (first-seen means "ever").

Re-entrancy. The HUD wraps every Bloom/LRU call in a _dBusy guard: a dedup write re-entered from inside the user's onEvict (where lite-lru is mid-callback) is a skip, not a REENTRANT_MSG throw, and an EPOCH written from inside onEvict defers its _lru.clear() to the next dedup record (once _dBusy is clear). _dBusy is released in a try/finally, so even a user onEvict that throws cannot wedge dedup. The one caveat: a user onEvict's own throw still propagates out of write() -- it is the user's callback -- but it leaves no stuck state.

Fail-closed wiring (cold). A WaveletTree without rebuildFrom/quantileInto, a Bloom whose keysMode !== 'int', an LRU whose keysBacking !== 'int' or ttlEnabled !== true, a ci >= 1024, a non-INSTANT or CONT-wide dedup channel, a window outside [1, 2^30), a missing factory bag, or a peer instance shared across channels/HUDs (claimed by a process-wide WeakSet) all throw at wiring with the @zakkster/lite-hud: prefix -- so the HUD's OWN hot path never throws into write() (only a user onEvict's own throw can).

Memory cost: cross-check adds a Float64Array(cap) value column + a length-1 output per sketched channel, plus the WaveletTree itself (~32 B per constructed element, allocated once on the first rebuildFrom). Dedup adds a Uint8Array(cap) draw-flag per channel, plus the one Bloom + one LiteLru for the whole HUD. range() allocates nothing persistent.


Hot spots + cardinality (M3, optional peers)

Three injected sketches from @zakkster/lite-sketch >= 1.2.0 answer "which few tags dominate" and "how many distinct", per channel. All three are zero-arg factories -- no createHud closure ever reaches a peer:

import { SpaceSaving, HyperLogLog, CountMinSketch } from '@zakkster/lite-sketch';

const hud = createHud(el, {
  stats: {
    topk:     () => new SpaceSaving(64),
    distinct: () => new HyperLogLog(12),
    freq:     () => new CountMinSketch(4, 1024), // optional; requires topk
  },
});
// cold, inspect only:
hud.inspect('render').topk;     // { rows: [{ key, label, weight, count?, err }], n }
hud.inspect('render').distinct; // { n, se }

What is fed. The record's interned tag id b is the high-cardinality dimension; a is the stat.

| Kind | top-k over | weight | distinct over | freq | |------|------------|--------|---------------|------| | complete SPAN | tag b | integer us of a (SUM of durations) | tag b | count per tag | | LEVEL / COUNTER | tag b | 1 (frequency) | tag b | count per tag | | INSTANT | event code b | 1 | event code b | count per code | | paired SPAN | -- (b is a correlId) | -- | correlId (distinct requests) | -- |

Eligibility. Top-k rides a tag channel only: a width-1, non-paired scope channel, or a manual INSTANT (its push(code) writes the tag). A manual LEVEL / COUNTER / SPAN writes b = 0, so it has no tag dimension -- the createHud default never rides it, and an explicit topk / distinct / freq factory there throws (@zakkster/lite-hud: prefix). Distinct also rides a paired SPAN (over the safe-integer correlId). freq requires topk on the same channel; an explicit freq without topk throws. A CONT-wide channel takes neither. A manual INSTANT push(code) writes b = code on an M3-wired or dedup channel; a non-number passes through as NaN and is a counted drop. With no M3 peer, push() is byte-identical to 2.4.0.

Doors (the hot-path pre-check; peers never throw into write()). The key is a u32 tag (b === (b >>> 0)); a paired correlId takes the wider safe-integer door (key === floor(key) && |key| <= 2^53-1). The SPAN weight is us = Math.floor(a * 1000 + 0.5) (round-half-up), clamped in float to [1, 2^32-1] and written straight into the addFrom buffer -- never through | 0, never an argument. So a span > 71.58 min ranks at the 71.58-min clamp, and a span < 0.5 us is over-counted by at most 1 us (it still counts as an event). A NaN / negative / +-Infinity duration is a counted drop; -0 folds to +0. A strict 2^52 per-instance total door (the HUD keeps its own running total in a Float64Array slot) drops any record whose add would reach 2^52; one slot covers both SpaceSaving and CMS (CMS is fed only after SS accepts, adding 1 <= the SS weight), so the merged A + B stays under 2^53-1 and merge cannot throw.

Window. Two instances per peer rotate every windowSec/2 by record time, each channel on its own clock (a channel may have top-k without M2). At render / inspect the HUD clear()s a scratch and merge()s A + B into it, then reads topKInto (SS) / count() (HLL) / estimate(key) (CMS). EPOCH clears A + B of every wired peer and forces a block repaint.

Readouts + render. inspect(name).topk -> { rows, n }, with each row { key, label, weight, count?, err } (weight / err in ms on a complete SPAN, records otherwise; count only with freq; n = window total N in the row unit, 0 = empty). inspect(name).distinct -> { n, se }. On screen, each top-k channel gets a fixed block band of kN*9 + 2 px under its row (kN = min(8, capacity), sized at wiring -- no reflow): per line a label (<= 14 chars) + weight (<n>ms on SPAN, x<count> otherwise), plus with freq the count n<count> and mean u<ms> (SPAN only); the first line is -- only when there are 0 rows. The distinct count sits in the label column (D<count>+-<se>%; D -- only when the window is empty). The block repaints on rotation / EPOCH or a changed top-N and is skipped otherwise, and it paints regardless of row visibility (disclosed). Labels come from scope.label(key), cold; a label() that throws or returns a non-string shows the numeric key (String(key)) and never wedges the readout. The catch also swallows a throw from a HUD call made INSIDE label() (e.g. a write() contract throw), so keep label() a pure lookup. A label() that re-enters render() / inspect() is safe: the nested refresh is deferred to the next render, and a nested inspect() shows numeric keys instead of calling label() again. If a label() fires an EPOCH, that one frame still paints the pre-EPOCH rows; the next render clears them. An idle channel's window freezes (it moves by record time), disclosed, as M2.

Drops. stats().topkDrops / distinctDrops / freqDrops count the rejected records and appear ONLY once that peer is wired on a channel, so a no-factory stats() stays byte-identical to 2.4.0.

Fail-closed wiring (cold). Each factory is called three times (A, B, cold merge scratch) and duck-typed for addFrom / merge / clear plus topKInto (SS) / count (HLL) / estimate (CMS); the sizing getters are read (SS integer capacity >= 1 + numeric seed; HLL integer m >= 1 + numeric seed + standardError in (0, 1); CMS integer d/w + seed + epsilon/delta in (0, 1)), with freshness (total === 0, HLL count() === 0, CMS saturated === false) and one shared prototype with equal sizing and seed (an unequal HLL seed hashes keys differently, so merge fails closed -- proven by a dry merge/clear at wiring, never deferred to render()). Each call must return a distinct instance (A, B and the merge scratch cannot be one shared sketch), and each is claimed in a process-wide WeakSet; a reused or already-wired instance, a wrong shape, or a missing method throws with the @zakkster/lite-hud: prefix at wiring.

Memory cost (defaults, per channel).

| Factory | Per instance | A/B/scratch trio | Error | |---------|--------------|------------------|-------| | SpaceSaving(64) | ~5,520 B | ~16,560 B (+ ~19 KB transient merge scratch per changed refresh) | count-err <= true <= count | | HyperLogLog(12) | ~4,320 B | ~12,960 B | se 0.01625 | | CountMinSketch(4, 1024) | 16,428 B | 49,284 B | eps 0.00265, delta 0.0183 |

Plus ~576 B/channel HUD-side (the Float64Array(8) staging buffer + the row caches). All three wired is ~78.8 KB per channel. The ~19 KB SpaceSaving merge scratch is the one disclosed cold allocation; it runs only on a refresh where the record count advanced, never on the write path.


Ring layout

| Kind | Stride | Slots | |------|--------|-------| | LEVEL / INSTANT / COUNTER (width=1) | 3 | [t, a, b] | | Complete SPAN (non-paired) | 3 | [t_start, duration_ms, 0] | | Paired SPAN (closed) | 3 | [t_open, t_close, correlId] | | width=N (CONT) | 3N | primary [t, a, b] + (N-1) CONT triplets |

Ring capacity = pow2(ceil(hz × windowSec) + 1) for LEVEL channels, 256 otherwise.


Zero-GC (witnessed)

The write path performs zero LIBRARY-OWNED allocation. Every record kind, the paired open/close path, the pool eviction churn, and meta records are 0 B/op (for SMI-domain inputs, inlined AND under --max-inlined-bytecode-size=0 -- the paired-span hashKey returns a signed int32 so its result never boxes on return from the non-inlined pool helpers) -- the rings and the paired-span open pool are fixed typed arrays allocated once at attach and never reallocated or grown. The M5 dedup write path is 0 B/op too (DedupBox.test.mjs, ON == OFF both inlined and no-inline, a one-box teeth control in its own process at delta >= 2*TOL): the record stays in a Float64Array scratch and only ch crosses the dedupStep call; the Smi signature + integer relative clock never box into the Bloom / LRU. Render is a disclosed COLD path (caller-throttled to ~10-15 Hz) and may allocate to draw; the WaveletTree cross-check is likewise COLD (inspect only).

The one caller-side caveat: a DISTINCT fractional double passed as an argument is boxed by V8 at the JS call boundary (a ~16 B transient nursery HeapNumber, never retained), exactly as for any JS function taking a double. Storing a fractional MAGNITUDE into the ring costs nothing (a reused constant is witnessed at 0 in the perf gate); only a varying distinct fractional value boxes, caller-side.

| Write-path op | Bytes/op | Witnessed by | |---------------|----------|--------------| | write() LEVEL / INSTANT / COUNTER | 0 | torture + perf gate | | write() CONT-chained wide record | 0 | torture + perf gate | | write() complete (D5) SPAN | 0 | torture + perf gate | | paired SPAN open + close | 0 | torture + perf gate | | paired pool eviction churn (backshift + FIFO) | 0 | torture + perf gate | | meta EPOCH / VERDICT / BUDGET_SET (replace) | 0 | torture + perf gate | | channel().push() (all kinds) | 0 retained | torture | | analytics-ON write() LEVEL / complete SPAN / paired (addFrom) | 0 | torture + perf gate | | rolling-ON write() every width-1 kind + paired (addFrom) | 0 | torture + perf gate | | rolling headless render() (advanceFrom + into) | 0 | torture + RollingBox | | M3 top-k / distinct / freq write() (addFrom, u32 tags near 2^32, fractional-ms weights, rotation-heavy) | 0 | torture + TopkBox | | Retained growth over fill/clear cycles | 0 | torture (arrayBuffers flat) | | GC major / minor collections over the window | 0 | torture |

Injecting the analytics (a () => DDSketch factory) keeps the write path at 0 B/op for all three sketched kinds -- LEVEL, complete SPAN, and paired SPAN durations -- because the value is written into a per-channel Float64Array in write()'s frame and fed to the peer via the zero-box addFrom(buf, i); the pre-check + O(1) window rotate allocate nothing, the A/B window sketches are fixed typed arrays allocated once at attach, and merge / clear (render/inspect only) are 0-alloc. The yardstick is measured with FRACTIONAL inputs in test/perf/AnalyticsBox.test.mjs: analytics-ON minor-GC scaling equals analytics-OFF scaling (delta ~0 -- the only box is the caller passing fractional t/a into write(), present with analytics OFF too). A control (an injected sketch whose addFrom allocates per op) MUST scale above the baseline, and the perf-gate mustFail catches an allocating injected sketch -- both prove the instrument has teeth.

channel().push() reads wall-clock time (performance.now(), a fractional double); V8 boxes a fractional double as a transient nursery value when passing it to a large function, so it is gated on the RETAINED lane (torture, 0 B/op), not the scavenge lane -- the box is orthogonal to lite-hud's own allocation.

Rolling analytics is held to the same bar: test/perf/RollingBox.test.mjs drives FRACTIONAL times (at both scope-epoch ~1e5 and epoch-ms ~1.7e12 scale) and values from Float64Array slots and asserts rolling-ON minor-GC scaling stays within TOL = 5 scavenges of rolling-OFF (observed delta 0) for every kind, under BOTH the default and the --max-inlined-bytecode-size=0 process (npm run test:perf:noinline). Both --min-semi-space-size=4 and --max-semi-space-size=4 are pinned (the test fails closed if they are absent, so the one-box scale cannot drift). A ONE-BOX control (test/perf/RollingTeeth.test.mjs, own process -- a rolling peer that boxes exactly one HeapNumber per op into a PACKED_ELEMENTS slot) MUST scale above TOL -- at the shared N/k (k = 32) it reads delta ~25 at 32N (>= 2 x TOL), a non-thin proof -- and the perf-gate mustFail (RollingMustFail.test.mjs) catches an allocating injected rolling peer. The headless render() lane (the rolling step advanceFrom + into) is gated in its OWN process (test/perf/RollingRender.test.mjs) with FRACTIONAL inputs and a write only every 64th op, so the rolling step -- not the write-arg box -- is the per-op signal: baseline 0, real peer delta 0, and an into()-boxing detection control reads ~123, proving the lane can see a box.

M3 hot spots + cardinality sits on the same yardstick. The key + weight ride a per-channel Float64Array(8) in write()'s frame; only ch crosses the topkStep call, so a u32 tag near 2^32-1 and a fractional-ms duration stay unboxed as they feed all three peers via addFrom. The SpaceSaving running-total compare, the [1, 2^32-1] weight clamp and the windowSec/2 A/B rotation (clear() on SpaceSaving + HyperLogLog + CountMinSketch) all run in that frame and allocate nothing. test/perf/TopkBox.test.mjs asserts all-three-ON minor-GC scaling equals all-three-OFF with FRACTIONAL weights, u32 keys near 2^32-1 and a rotation-heavy window, inline and under --max-inlined-bytecode-size=0, with the non-vacuity that the lane actually evicts in SpaceSaving, rotates, and hits the weight clamp (asserted counts). A ONE-BOX control (test/perf/TopkTeeth.test.mjs) MUST scale above TOL. topKInto / count / estimate and the merge scratch are COLD (render / inspect only).

node --expose-gc test/torture.mjs   # lite-leak + lite-gc-profiler
npm run test:perf                    # lite-perf-gate (0 scavenges, must-fail control)
npm run test:perf:noinline           # the whole perf dir with inlining disabled
npm run verify                       # test + torture + torture:controls + test:perf(+noinline)

Test suite

npm test
# 241 tests, 0 failures

All tests run headless (mountEl = null). The mock scope factory in the test file is a useful reference for testing your own probes against the HUD.


Migration from v1

Channel creation

// v1
const fps = hud.track('fps', { hi: 120, lo: 0, warnBelow: 30 });
fps.push(59.8);

// v2 -- manual channel
const fps = hud.channel({ name: 'fps', kind: 0 });
fps.push(59.8);
// Budget lines via attach option:
hud.attach(scope, { budgets: [{ channel: 'fps', threshold: 30, label: 'min fps' }] });

Scope integration

// v1 -- HUD polled a memory sink separately
// v2 -- HUD is a live mux sink
hud.attach(scope);           // that's it

Rendering

// v1 and v2 -- same
hud.render();  // call at ~10-15Hz

Protocol reference (SPP v1 constants, inlined)

Meta stream (sid 0):  EPOCH 0x0F00 | CONT 0x0F01 | VERDICT 0x0F40 | BUDGET_SET 0x0F41
Kinds:                LEVEL 0 | INSTANT 1 | SPAN 2 | COUNTER 3

License

MIT -- Copyright (c) 2026 Zahary Shinikchiev