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/throttle

v0.4.1

Published

An app-wide rate limit whose answer to going over is a price, not a 429. 100 requests a minute free (times 5 headroom), then x402 Payment Required with an offer, settled by CoinPay. Meters every route, so a scraper wearing a browser user agent is charged

Readme

@profullstack/throttle

An app-wide rate limit whose answer to going over is a price, not a 429.

100 requests a minute, free. Then 402 Payment Required with an x402 offer, settled by CoinPay in USDC. It meters every route, so a scraper wearing a browser user agent is charged like any other crawler.

npm i @profullstack/throttle

Why

Every site in our fleet had a rate limiter in its middleware. Each one was hand-rolled, and each one was scoped to /api/.

Then a headless browser in Singapore found a block explorer that had shipped fifteen hours earlier, and spent two days walking it: 19,000 URLs a day, a fresh visitor id on every hit, a plain Chrome/145 user agent, every page rendered server-side against a metered upstream API.

Nothing stopped it.

  • It was not on a crawler list, because it declared nothing.
  • It was not rate limited, because it never touched /api/.
  • The analytics counted every hit as a human visit.

The limiter we had was protecting the routes nobody was attacking. So: meter everything, and when a caller goes over, sell it a pass.

429 is the right answer when you have nothing to sell. We do. The moment the allowance runs out is the best sales pitch a site will ever get, because the caller has just demonstrated it wants more than the free tier and is still holding the request open.

Use it

// src/proxy.ts — Next 16 middleware
import { createGateway } from '@profullstack/x402-gateway';
import { createThrottle } from '@profullstack/throttle';

const gateway = createGateway({
  siteUrl: 'https://example.com',
  coinpay: { apiKey: process.env.COINPAY_X402_KEY },
  payTo: process.env.CRAWL_PAY_TO,
});

const throttle = createThrottle({
  gateway,                       // over the limit → 402 with an offer
  limit: 100,                    // the default; per caller, per minute
  exempt: (request) => hasSession(request),
  rules: [
    { path: '/api/auth/', limit: 10, credential: false },
    { path: '/health', open: true },
  ],
});

export async function proxy(request) {
  const answer = await throttle.handle(request);
  if (answer) return answer;
  return NextResponse.next();
}

handle returns a Response to send, or null to carry on. Adapters for Next and Hono are one line on top of it.

Without a gateway the same throttle answers 429 with Retry-After. That is the fallback, not the goal.

Headroom: only offenders are refused

Since 0.4.0 every number a site configures is multiplied by headroom (default 5): the default allowance, every rule, the credential budget and ceiling, and the subresource bucket. Write the limits as "what a normal reader uses"; the throttle refuses only a caller five times past that. People were being refused for ordinary heavy use when the limits had no room above typical consumption.

Brute-force guards keep the number as written. A rule is a guard when it is marked guard: true, marked credential: false (the sign-in pattern), or its path looks like auth (/auth/, /login, /signup, *-password, /reset, /otp, /verify-*, /oauth/, 2fa/mfa). So { path: '/api/auth/', limit: 10, credential: false } stays 10 a minute while /feed/ at 30 becomes 150.

What a refused caller sees

HTTP/1.1 402 Payment Required
ratelimit-limit: 100
ratelimit-remaining: 0
ratelimit-reset: 41
{
  "x402Version": 2,
  "accepts": [{ "scheme": "exact", "network": "base", "amount": "1000000", "…": "…" }],
  "error": "Free allowance used: 100 requests per 60s. It resets in 41s. A pass removes the limit for 1.00 USD a day.",
  "pass": { "price": "1.00 USD", "minutes": 1440, "buy": "https://example.com/crawl" }
}

Ask for text/html and you get the sales page instead, which is what makes this survivable for a person who tripped it by accident.

Pay it, and the pass comes back as the body of a 200:

coinpay x402 pay https://example.com/crawl
curl -H "x-crawl-pass: $PASS" https://example.com/whatever

A caller holding a live pass is not metered at all. The pass is the thing being sold; counting it against an allowance it bought its way out of would be selling the same minute twice.

Options

| Option | Default | | |---|---|---| | limit | 100 | requests per window, per caller, before headroom | | headroom | 5 | multiplier on every limit except brute-force guards; 1 uses the numbers as written | | windowSeconds | 60 | | | gateway | — | an @profullstack/x402-gateway; makes over-limit a 402 | | rules | [] | per-path allowances; most specific match wins | | credential | { limit: 600, ceiling: 1200 } | budget for callers presenting one | | exempt | — | never metered, e.g. a health check | | credentialFrom | auth headers | what counts as a credential; read the session cookie too | | openPaths | — | extra paths never metered | | identify | the caller's address | what to count against | | store | per-process | supply one to share a count across a fleet | | onThrottle | — | told about every refusal; its errors are swallowed |

Rules

A rule is a path pattern plus the numbers that replace the defaults for it. Patterns ending in / match by prefix, everything else exactly. The most specific match wins whatever order they are written in — the alternative is a config where moving two lines silently removes the brute-force limit.

rules: [
  { path: '/api/auth/', limit: 10, credential: false },  // sign-in
  { path: '/feed/', limit: 30 },
  { path: '/health', open: true },                       // never metered
]

Credentialed callers

One host running an integration is not one browser, so a request presenting a credential gets a larger budget. Both buckets are charged: the per-address ceiling is what a caller rotating junk credentials cannot escape, since nothing here has authenticated any of them.

Any auth scheme counts, not just Bearer — matching Bearer alone once dropped a signed wallet's bulk payout into the anonymous bucket, which it exhausted in seconds.

A site whose customers arrive in a browser should count its session cookie as a credential, so a signed-in merchant watching a dashboard is not metered as an anonymous scraper:

credentialFrom: (request) =>
  sessionIdFrom(request) ?? presentedCredential(request.headers),

Prefer that to exempt. Exempting sessions outright hands an unmetered site to anyone willing to sign up first.

credential: false on a rule keeps it address-bucketed however the request is authenticated. The sign-in routes need this, or a brute-force attempt bolts an Authorization header onto every guess and buys itself the integration budget.

Subresources

Images, fonts, scripts and styles (by Sec-Fetch-Dest) count against a bucket of their own, 1,000 a minute by default, so a page that pulls in hundreds of images never spends the reader's page allowance; at worst an image breaks, the page does not 402. They are metered rather than exempt because the header is the client's to write. subresources: { limit } changes the number; subresources: false counts them with pages, as 0.3 did.

Never metered

/robots.txt, /favicon.ico, /.well-known/, and — when a gateway is supplied — its sales page and open paths, read off the gateway rather than restated. A paywall you cannot reach to pay is a wall.

A caller we cannot identify is also within the allowance. Our own edge failing to hand us an address is our gap, and charging someone for it would be charging them for our gap.

Nextjs

import { throttleProxy } from '@profullstack/throttle/next';
export const proxy = throttleProxy({ gateway });
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };

Put it after the crawl gateway and before everything else: the gateway charges crawlers that declare themselves, the throttle charges the ones that do not, and there is no point running CORS on a request about to be refused.

Runs at the edge — nothing imports node:, and the pass is verified with Web Crypto rather than a database.

Hono

import { throttle } from '@profullstack/throttle/hono';
app.use('*', throttle({ gateway }));

On rotating addresses

Rotating addresses defeats an address bucket. That is not a hole to be plugged, it is the argument the 402 makes: residential proxy bandwidth is sold by the gigabyte at prices that pass a dollar inside the first day of any serious crawl, and a rotation still fetches every page one at a time.

Anyone spending more on proxies than the pass costs, in order to avoid the pass, has made an arithmetic mistake rather than beaten a detector. Detection is a race. Price is not.

Counting

The default counter lives in the process, so several instances each grant their own allowance. That errs toward generosity, which is the right direction to be wrong in for a free tier. Pass a store to share one count across a fleet — the interface is a single method, so Redis or Postgres is a dozen lines:

{ hit(key, windowSeconds) { return { count, resetSeconds } } }

License

MIT © Profullstack, LLC