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-leakforge

v1.9.1

Published

Leak specimens, CI harness, and dashboard for @zakkster/lite-leak. Zero-GC diagnostic toolkit.

Readme

@zakkster/lite-leakforge

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads lite-signal peer TypeScript License: MIT

Leak specimens, CI harness, and diagnostic toolkit for @zakkster/lite-leak.

lite-leakforge is the product layer above lite-leak's primitive tracker. lite-leak detects leaks; leakforge proves it, shows it, and gates it.

  • Prove it -- deterministic specimens that trigger each kernel's detection path, with a 3-channel verify contract diffing expected vs actual across leak reports, warnings, and findings.
  • Show it -- ASCII formatters, a ghost-safe dashboard data model, and a 6-scene oscilloscope demo.
  • Gate it -- a CI harness with assertNoLeaks() and leakSuite(), plus a npx leakforge CLI, all producing exit 0 (clean), exit 1 (confirmed leak), or exit 3 (inconclusive / recapture).
npm install @zakkster/lite-leakforge

Peer dependency: @zakkster/lite-signal >=1.5.0-beta.3 <2.0.0

Quick start

CI gate

// test/leak.test.js
// Run with: node --expose-gc --test test/leak.test.js
import { describe, it } from 'node:test';
import { assertNoLeaks } from '@zakkster/lite-leakforge';

describe('my library', () => {
  it('does not leak', async () => {
    await assertNoLeaks((tracker) => {
      const resource = createMyResource();
      const handle = tracker.track(resource, () => {}, 'my-resource');
      destroyMyResource(resource);
      tracker.untrack(handle);
    });
  });
});

CLI (npx leakforge)

Run a leak-suite file in CI and let the exit code gate the build. A suite file default-exports { name, checks: [{ name, run(tracker) }], options? }; each check runs under one shared gate.

// app.leak.mjs
export default {
  name: 'my-app',
  checks: [
    { name: 'mounts and unmounts cleanly', run: () => { mount(); unmount(); } },
    { name: 'detail panel', run: (tracker) => {
        const panel = openDetailPanel();
        tracker.track(panel, () => {}, 'detail-panel');
        closeDetailPanel(panel);
    } },
  ],
};
npx leakforge app.leak.mjs            # 0 clean, 1 leak, 3 inconclusive
npx leakforge app.leak.mjs --json leaks.json   # + machine-readable artifact
npx leakforge app.leak.mjs --junit leaks.xml   # + JUnit XML for CI dashboards
npx leakforge app.leak.mjs --baseline .leakforge-baseline.json --update-baseline  # capture
npx leakforge app.leak.mjs --baseline .leakforge-baseline.json                    # gate on NEW leaks only
npx leakforge --specimens             # verify every built-in specimen (kernel acceptance)
npx leakforge --specimens raf-orphan  # a single specimen
npx leakforge app.leak.mjs --measure  # how big is each leak? (per-call bytes)

Adopting on a codebase that already leaks

A gate that fails on every existing leak never gets turned on -- fixing them all first is too big a cliff. --baseline removes the cliff: capture the current state once, commit it, and CI then fails only on leaks you add.

# once, capturing today's leaks as the accepted baseline
npx leakforge app.leak.mjs --baseline .leakforge-baseline.json --update-baseline
git add .leakforge-baseline.json

# in CI, from then on
npx leakforge app.leak.mjs --baseline .leakforge-baseline.json

A finding cluster (kind:reason) regresses when it is new, or when its count grows past the baseline. A cluster that shrank or disappeared is an improvement -- reported, never a failure -- and refreshing the baseline (--update-baseline) locks in the win. Keys are origin-free so the baseline survives ordinary code edits; a missing or malformed baseline fails closed (exit 3), never a silent pass.

How big is the leak? (--measure)

--measure runs each check repeatedly under a forced GC and reports the per-call retained bytes -- the number that turns "there's a leak" into "there's a 3.8 KB leak per mount, fix it".

npx leakforge app.leak.mjs --measure            # default 200 iterations
npx leakforge app.leak.mjs --measure -n 500     # more iterations, less noise

The figure is honest and aggregate, not fake per-object attribution: a least-squares slope of heapUsed against iteration count, so one-time setup falls into the intercept and transient garbage is collected before each sample. Three rules the output holds to: it requires --expose-gc (without it, retention cannot be measured, so it refuses rather than guess); an off-heap leak (socket, worker, child process, fd, GL resource) reads as "not measurable on the JS heap", never as 0 bytes, because heapUsed genuinely cannot see it; and a slope under the noise floor is "below the measurable threshold", not a confident zero. --measure reports -- it does not gate.

The gate needs manual GC; the CLI re-execs itself with --expose-gc automatically, so plain npx leakforge works. Exit codes aggregate across checks with evidence-wins precedence: any confirmed leak wins the run. --specimens completes the CLI trilogy with litecap and gc-profiler.

leakSuite (node:test-native)

import { describe, it } from 'node:test';
import { leakSuite } from '@zakkster/lite-leakforge';

leakSuite(describe, it, 'my-module', (measure) => {
  measure('create and dispose', (tracker) => {
    const r = { x: 1 };
    const h = tracker.track(r, () => {}, 'test');
    tracker.untrack(h);
  });

  measure('no-op is clean', (_tracker) => {
    // nothing to leak
  });
});

Specimen verification

import { verify, createTimerOrphanSpecimen } from '@zakkster/lite-leakforge';

const result = await verify(createTimerOrphanSpecimen());
console.log(result.pass);        // true
console.log(result.warnings);    // { pass: true, actual: [...], ... }
console.log(result.findings);    // { pass: true, actual: [...], ... }

Formatters

import {
  formatReport, formatOwnerPath, summarize, formatSummary
} from '@zakkster/lite-leakforge/formatters';

const path = [{ id: 3, kind: 'effect' }, { id: 1, kind: 'computed' }];
formatOwnerPath(path, 1);
// '[3 effect] -> [1 computed] *BROKEN*'

const groups = summarize(events);
formatSummary(groups);
// '3x timer-orphan (no-owner-set)\n1x listener-orphan (no-owner-set)'

Dashboard data model

import { createDashboardModel, createDashboard } from '@zakkster/lite-leakforge/panels';
import { effect } from '@zakkster/lite-signal';
import { createLeakTracker } from '@zakkster/lite-leak';

const model = createDashboardModel({ logCapacity: 128 });
const tracker = createLeakTracker({
  name: 'my-app',
  onLeak: model.onLeak,
  onWarning: model.onWarning,
  onFinding: model.onFinding,
  onError: model.onError,
});

// Mount the full dashboard DOM (browser only)
const dashboard = createDashboard({
  container: document.getElementById('dashboard'),
  model: model,
  kernels: [timerK, listenerK],   // installed kernel objects
  maxLogRows: 60,                  // pre-allocated row pool
});

// Or use the model directly for custom UIs
effect(() => {
  const v = model.logVersion();    // triggers on every event
  const entries = model.getEntries();
  renderLog(entries);
});

// Kind filter
model.filterKind.set('timer-orphan');

API

Subpath exports

| Import | Contents | |---|---| | @zakkster/lite-leakforge | Everything (barrel) | | @zakkster/lite-leakforge/harness | settleFinalizers, settleTracker, createLeakGate, assertNoLeaks, leakSuite, EXIT_* | | @zakkster/lite-leakforge/formatters | formatReport, formatFinding, formatWarning, formatOwnerPath, summarize, formatSummary, formatVerifyResult | | @zakkster/lite-leakforge/scenarios | verify, composeScenario, all specimen factories | | @zakkster/lite-leakforge/panels | createDashboardModel, createDashboard, CHANNEL_* constants |

All subpaths ship their own .d.ts (types resolve for deep imports, not just the barrel).

Specimens

Each specimen factory returns { name, kernels, expectedLeaks, expectedWarnings, expectedFindings, needsSettle, inject, release }.

| Factory | Kernel required | Detection channels | |---|---|---| | createRawFrSpecimen() | none | FR leak kind: 'unknown' | | createTimerOrphanSpecimen() | timer-orphan | warning no-owner-set, finding no-owner-pending | | createListenerOrphanSpecimen() | listener-orphan | warning no-owner-set | | createObserverOrphanSpecimen() | observer-orphan | warning no-owner-set, finding no-owner-pending | | createDetachedDomSpecimen() | detached-dom | finding detached-at-audit | | createAsyncRetentionSpecimen() | async-retention | warning no-owner-set, finding no-owner-pending | | createRafOrphanSpecimen() | raf-orphan | warning no-owner-set, finding no-owner-loop-armed | | createWorkerOrphanSpecimen() | worker-orphan | warning no-owner-set, findings no-owner-worker-live + blob-url-unrevoked | | createAudioNodeSpecimen() | audio-node | warning no-owner-connect, findings no-owner-node-connected + source-started-not-stopped | | createSocketOrphanSpecimen() | socket-orphan | warning no-owner-open, finding no-owner-socket-open | | createGlResourceOrphanSpecimen() | gl-resource-orphan | 2x warning no-owner-create, 2x finding no-owner-resource-live (distinct resourceKind) |

The 1.2.0 resource specimens (worker, audio, socket) each own a specimen-local mock host for the same reason as raf-orphan -- Node has none of those globals, and a specimen must never patch a global it shares with the test runner.

The raf-orphan specimen needs no DOM: it drives a specimen-local requestAnimationFrame host, so it runs anywhere and patches no shared global. Requires @zakkster/lite-leak >= 1.1.0.

Exit codes

| Code | Constant | Meaning | |---|---|---| | 0 | EXIT_CLEAN | No leaks detected, FR settled | | 1 | EXIT_LEAK | Confirmed leak (FR report or audit finding) | | 3 | EXIT_INCONCLUSIVE | FR did not settle; recapture recommended |

Precedence: confirmed evidence wins. A leak report or audit finding is exit 1 even when FR did not settle -- an unsettled registry never downgrades hard evidence to "recapture". Exit 3 is reserved for evidence-free unsettled runs.

verify() throws (rather than reporting a bogus FAIL) when a needsSettle specimen runs without --expose-gc; the five pre-FR specimens run anywhere, including browsers.

Kernel teardown in both createLeakGate().run() and verify() is exception-safe: kernels patch global surfaces, and the patches are removed even when user code or inject() throws.

Ghost safety

createDashboardModel() creates exactly 2 signals (logVersion, filterKind) at construction. Zero signals created per event. Verified by model.signalCount() and tested in the ghost-safety suite.

Entry formatting is lazy: entry.text, entry.ownerPath, and entry.label are memoized getters computed on first read (render time), so a warning storm costs one small entry object per event and zero formatting work.

createDashboard() renders through a dirty flag gated by a power-of-2 frame mask: model changes flip one boolean, the gated frame does the work, and the log shows the newest maxLogRows entries as a sliding window.

Framework integration

leakforge is framework-agnostic. The gate never imports your framework -- a check mounts and unmounts a component, and lite-leak's kernels plus the FR catch whatever the unmount forgot to release (a stray timer, an unremoved listener, a live observer, a detached DOM subtree). The dashboard is plain DOM, so it drops into any component's mount/unmount hooks.

Component leak checks need a DOM (jsdom or happy-dom) and --expose-gc, the same as any other suite. The shape is always the same: mount, unmount, assert nothing survived.

The gate (React / Vue / Angular)

// ui.leak.mjs  --  run with: npx leakforge ui.leak.mjs   (or under node:test)
export default {
  name: 'ui',
  checks: [
    // React
    { name: 'Widget mounts and unmounts cleanly', run: () => {
        const root = createRoot(document.createElement('div'));
        root.render(h(Widget));
        root.unmount();                       // must clear timers/listeners it set
    } },

    // Vue 3
    { name: 'VueWidget mounts and unmounts cleanly', run: () => {
        const app = createApp(VueWidget);
        app.mount(document.createElement('div'));
        app.unmount();
    } },

    // Angular (TestBed component fixture)
    { name: 'NgWidget destroys cleanly', run: () => {
        const fixture = TestBed.createComponent(NgWidget);
        fixture.detectChanges();
        fixture.destroy();                    // ngOnDestroy must tear subscriptions down
    } },
  ],
};

A check that forgets to unmount -- or a component whose teardown leaks -- fails the gate (exit 1). Reach for tracker.track(handle, () => {}, 'tag') only when you hold a resource lite-leak has no kernel for and want it watched explicitly; for ordinary timer/listener/observer/DOM leaks the default kernels are enough.

Use --measure to turn "it leaks" into "it retains 3.8 KB per mount", and --baseline to adopt the gate on a UI that already leaks without fixing every component first.

The dashboard (in a component)

createDashboard() mounts into a container element and returns a handle with dispose(). Mount it in the framework's mount hook, dispose in the unmount hook so the dashboard itself never leaks.

// React
useEffect(() => {
  const dash = createDashboard({ container: ref.current, model, kernels });
  return () => dash.dispose();
}, []);

// Vue 3
onMounted(() => { dash = createDashboard({ container: el.value, model, kernels }); });
onBeforeUnmount(() => dash.dispose());

// Angular
ngAfterViewInit() { this.dash = createDashboard({ container: this.el.nativeElement, model: this.model, kernels: this.kernels }); }
ngOnDestroy()     { this.dash.dispose(); }

The model is reactive (@zakkster/lite-signal), so a custom UI can skip createDashboard() and read model.getEntries() inside an effect() (or your framework's own reactivity) keyed off model.logVersion() -- see Dashboard data model above.

Demo

Run from the package root:

npx serve .

Open http://localhost:3000/demo/. Six scenes:

  1. Tracker -- track/untrack/abandon lifecycle, gc pressure, FR leak reports, oscilloscope size() trace
  2. Kernel gallery -- five kernels installed live (scene-scoped install/uninstall), orphan injections vs an owned-timer clean path, real DOM-detachment detection
  3. Specimen lab -- verify() per specimen with PASS/FAIL badges and formatVerifyResult output, composeScenario run-all; raw-fr enables itself when the browser exposes gc()
  4. Audit console -- audit(), auditByKind(), remediate(), summarize() + formatSummary()
  5. Dashboard -- the packaged createDashboard() component fed by a live tracker, with a 500-event storm to demonstrate dirty-flag rendering (500 events, one render)
  6. Stress -- 4096 allocation-clean track/untrack cycles, Float64Array scope ring, size() return-to-zero verdict

Tests

node --expose-gc --test test/*.test.js   # 142 unit/integration
npm run torture                          # 106 adversarial

The unit suite covers settle, gate, specimens, formatters, panels, dom, cli, baseline, measure, junit and version; all GC-dependent tests skip gracefully without --expose-gc.

The torture/ suite is the adversarial regression net (not shipped in the package): overlapping gate runs and global-patch corruption, the tag-matcher false-pass classes, hostile settle/dashboard options, the CLI exit-code vocabulary end-to-end, and a soak of 300 sequential gate runs and 100k dashboard events.

Exit codes

0 clean, 1 leak, 2 usage error, 3 inconclusive. A runtime failure -- a check that throws or rejects, a suite that fails to import, an unwritable --json path -- maps to 3: no trustworthy verdict was produced, so the honest result is "recapture". Evidence wins, so a real leak anywhere in the suite outranks both errored and unsettled checks.

Architecture decisions

See WHY-1.0.md for the rationale behind key design choices. See REJECTED.md for proposals considered and declined.

License

MIT. Copyright (c) 2026 Zahary Shinikchiev.