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

@profullstack/leaderboard

v0.3.1

Published

A public leaderboard with badges, streaks and a commission ladder that drops into any site. Ranks either side of a transaction, earners and spenders and usage, kept segregated. Zero dependencies, Fetch API core, Hono and Next adapters.

Readme

@profullstack/leaderboard

A public leaderboard that drops into any site. Score events in, ranked boards out: all time, week, month, streaks, badges, a commission ladder, RSS, a share card per player and a widget any page can embed with one script tag.

Zero dependencies. The core is a Fetch API handler, so it runs in Node, Bun, Deno and at the edge. Adapters for Hono and Next.js. Stores: in-memory, or any SQL client with execute({ sql, args }) (libsql/Turso, sqlite, Postgres).

It ranks either side of a transaction. Sellers earning, buyers spending, and plain usage are separate boards that never blend, because a publisher earning $900 and a crawler spending $900 are not the same fact.

Built for marketplaces where people sell through properties and niches and earn a growing cut, but every board, badge and metric is yours to define.

Install

npm i @profullstack/leaderboard

Sixty seconds

import { createLeaderboard, sqlStore, commissionLadder } from '@profullstack/leaderboard';
import { leaderboard } from '@profullstack/leaderboard/hono';

const store = sqlStore({ execute: (q) => db.execute(q) });   // @libsql/client shape
await store.migrate();

const lb = createLeaderboard({
  siteName: 'NicheDB',
  siteUrl: 'https://nichedb.dev',
  store,
  boards: {
    earnings: { label: 'Top earners', metric: 'cents', format: 'usd', unit: 'Earned' },
    sales:    { label: 'Most sales',  metric: 'sales', tiebreak: 'cents' },
    streaks:  { label: 'Longest streaks', metric: 'streak', min: 1 },
  },
  ladder: commissionLadder(),   // 20% -> 80%
});

app.use('*', leaderboard(lb));

// When something happens:
await lb.record({ player: user.id, name: user.name, metrics: { cents: 1900, sales: 1 } });
await lb.set({ player: user.id, metrics: { properties: 3, niches: 5 } });

That gives the site, under /leaderboard:

| Path | What | | --- | --- | | /leaderboard | A full leaderboard page (?board=&period=&me=&theme=) | | /leaderboard.json | Boards, periods, badges and the ladder | | /leaderboard/earnings.json?period=week&limit=10&me=u_42 | Ranked rows, plus me (my standing, on the board or not) | | /leaderboard/earnings.xml?period=week | RSS of the board | | /leaderboard/u/u_42 | A share card with Open Graph tags: ranks, streaks, badges, commission | | /leaderboard/u/u_42.json | The same as data | | /leaderboard/embed?board=sales&sticky=bottom-right | An iframe-able page | | /leaderboard/embed.js | The widget script |

JSON answers carry Access-Control-Allow-Origin: *, so the widget works on any domain.

The widget

On any page, yours or a partner's:

<div data-leaderboard></div>
<script src="https://nichedb.dev/leaderboard/embed.js"
        data-board="earnings" data-period="week" data-limit="10" data-me="u_42"></script>

Period tabs, badges next to names, the commission rate, streak flames, a highlighted row for data-me (and a "you" row below the cut when they are not in the top N), a share link per row that copies the player's card URL, RSS and full-board links, and a refresh every 60 seconds while the tab is visible.

Sticky mode is the retention feature. Add data-sticky="bottom-right" (or bottom-left, top-right, top-left) and the board becomes a corner pill reading "You are #4 this week" that opens the panel on click. It follows the reader around the site.

| Attribute | Default | Meaning | | --- | --- | --- | | data-board | first board | Board id | | data-period | site default | all, day, week, month | | data-limit | 10 | Rows | | data-me | | Player id to highlight and report | | data-sticky | | Corner pill mode | | data-theme | auto | light, dark, auto | | data-title | board label | Heading | | data-refresh | 60 | Seconds between refreshes, 0 to disable | | data-target | | CSS selector of the container | | data-base | from src | The leaderboard path when the script is served elsewhere |

The widget fires leaderboard:render on its container with the JSON as detail, for anything you want to do with the numbers.

Sides of the transaction

Every board declares a side:

| side | Money | Default label | Example | | --- | --- | --- | --- | | sell | to the actor | Earning | Publishers by revenue, affiliates by commission | | buy | from the actor | Spending | Crawlers by what they paid for passes | | use | none | Usage | Pages crawled, API calls, bytes served |

boards: {
  earners:  { label: 'Top earners',        metric: 'cents', format: 'usd', side: 'sell', actor: 'Publisher' },
  spenders: { label: 'Biggest spenders',   metric: 'spent', format: 'usd', side: 'buy',  actor: 'Crawler' },
  crawled:  { label: 'Most pages crawled', metric: 'pages', format: 'integer', side: 'use', actor: 'Crawler' },
}

The widget puts a tab per side across the top and the boards of that side beneath, so only one side is ever on screen. actor names the column, so the same table reads "Publisher" on one side and "Crawler" on the other. The commission rate is shown only on sell boards, since a rate against a buyer would state something false.

The same identity can hold a rank on both sides at once. Their totals are never pooled.

Rename the tabs with sides: { buy: 'Bots paying us' }.

Metrics

Three kinds, one vocabulary:

  • Counters go in through record() and are summed. Boards on a counter honour the period.
  • Gauges go in through set() and are the current value (properties owned, niches promoted, followers). Boards on a gauge show the same number in every period.
  • Derived: streak (consecutive UTC days with any record(), still current through yesterday) and bestStreak.

A board is { label, metric, format, unit, min, order, tiebreak, side, actor }. format is number, integer, usd (cents in) or percent. Players below min do not appear. Ties break on tiebreak, then on who got there first.

record() and set() return the badges newly earned, for a toast.

Badges

A badge is { id, emoji, label, describe, when(profile) }. It is awarded the first time when is true and kept forever, so "top ten this week" stays earned after the week ends. The profile carries totals, gauges, streak, bestStreak, ranks[board][period] and commission.rate.

The default set is for a marketplace: first sale, ten sales, hundred sales, seven and thirty day streaks, three properties, five niches, top ten this week, number one, and the 80% club. Pass badges: [...] for your own; onBadge({ player, name, badge }) fires on every award.

The commission ladder

const ladder = commissionLadder();          // base 20, cap 80, +5 per property (max 6), +5 per niche (max 6)
ladder.rate({ properties: 2, niches: 4 });  // 50
ladder.next({ properties: 2, niches: 4 });  // { add: 'property', rate: 55, from: 50, gain: 5, toCap: 30 }
ladder.table();                             // every step, for a pricing page

Every number is an option (base, cap, perProperty, perNiche, maxProperties, maxNiches, propertiesMetric, nichesMetric). Pass ladder: true for the defaults. The rate appears in every row and on the share card, with the next step spelled out: "Add one niche to reach 55%".

Stores

import { memoryStore, sqlStore, projectionStore } from '@profullstack/leaderboard';

memoryStore();                                           // one process, tests
sqlStore({ execute, prefix: 'lb_', dialect: 'sqlite' })  // or 'postgres'
projectionStore({ events, gauges, badges })              // rank tables you already have

Ranking data you already record

Most sites that want a leaderboard are already recording the facts it would rank. Copying those into a second set of tables is a dual write, and a dual write means the board and the ledger disagree the first time one of them fails. projectionStore reads the source instead:

const store = projectionStore({
  events: async ({ since }) => {
    const rows = await sql`select payer, total_cents, created_at from crawl_sales
                           where created_at >= ${new Date(since)}`;
    return rows.map((r) => ({
      player: r.payer, name: r.payer, metric: 'spent',
      delta: r.total_cents, at: +new Date(r.created_at),
    }));
  },
  badges: sqlStore({ execute }),   // badges are awarded, not derived, so they need a home
});

since is 0 for an all-time board, so the query has to handle "everything". The store is read-only: record() and set() throw rather than drop a write the next projection would overwrite.

sqlStore needs execute({ sql, args }) -> { rows }, which is @libsql/client as is. store.schema is the DDL for your own migration tool; store.migrate() runs it. Three tables: events, gauges, badges.

Anything else implements six methods: append, list({ since }), setGauge, gauges, awardBadge, badges. The core reads all events and aggregates in memory, cached for cacheMs (15 s); that is fine into the hundreds of thousands of events. Call lb.invalidate() after writing to the store from elsewhere.

Next.js

// app/leaderboard/[[...path]]/route.js
import { leaderboardRoute } from '@profullstack/leaderboard/next';
export const { GET } = leaderboardRoute(lb);
export const dynamic = 'force-dynamic';

That serves everything under the base path. It does not serve /leaderboard.json, because Next matches on path segments and that path is a sibling of /leaderboard rather than a child, so the catch-all never sees it. Either use /leaderboard/index.json, which is the same index, or give it a route of its own:

// app/leaderboard.json/route.js
export const { GET } = leaderboardRoute(lb);
export const dynamic = 'force-dynamic';

Testing this needs the router, not just the handler: calling the exported GET directly answers every path you hand it, including the ones Next would never route to it.

Programmatic

await lb.top({ board: 'earnings', period: 'month', limit: 25, offset: 0 });
await lb.standing({ player: 'u_42', board: 'earnings', period: 'week' });
await lb.profile('u_42');
await lb.rss({ board: 'sales' });
lb.index();

License

MIT