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

@stone-js/rate-limit

v0.8.19

Published

Rate limiting for Stone.js: throttle the subject rather than the address, in any context, with a driver you choose.

Readme

Stone.js - Rate limit

npm license npm version npm downloads Maintenance CI Release Quality Gate Status Coverage Security Policy CodeQL Dependabot Status Conventional Commits

Rate limiting for Stone.js. A budget declared where the route is declared, enforced before authentication, authorization and validation, and counted by a limiter you choose: per process by default, shared through Redis, or a driver of your own for the store your deployment already runs on.

The rule it exists to serve: throttle the subject, not the address alone.


Installation

npm install @stone-js/rate-limit

# for the shared Redis limiter (optional):
npm install ioredis

Peer dependency: @stone-js/core. ioredis is an optional peer, imported lazily only when a Redis limiter is used.

Enable it

Declarative:

import { StoneApp } from '@stone-js/core'
import { RateLimit } from '@stone-js/rate-limit'

@RateLimit()
@StoneApp({ name: 'app' })
export class Application {}

Imperative, through stone.rateLimit:

import { defineConfig } from '@stone-js/core'

export const AppConfig = defineConfig((blueprint) => blueprint.set('stone.rateLimit', {
  default: 'shared',
  limiters: [{ name: 'shared', driver: 'redis', url: 'redis://localhost:6379' }],
  trustedAddressHeaders: ['cloudfront-viewer-address']
}))

Enabling the module limits nothing on its own. Nothing is throttled until a route says so.

Declare a budget

On a route, which is the usual place:

@Post('/auth/code', { rateLimit: { max: 3, window: 900, by: 'email' } })
sendCode (event: IncomingHttpEvent) { … }

by is required. The only default it could have is 'address', and that is the one thing this module argues against, so a rule that omitted the word would quietly mean the opposite of what is recommended here. Whoever wants the address writes 'address'.

On a group, which every child inherits:

@EventHandler('/api', { rateLimit: { max: 100, window: 60, by: 'address', scope: 'api' } })
export class ApiHandler {
  @Get('/notes', { rateLimit: { max: 20, window: 60, by: 'address' } })
  notes (event: IncomingHttpEvent) { … }
}

Both budgets are kept: the group's and the route's, counted separately, enforced in that order.

On a handler method, the form that needs no router at all (a command, a queue consumer, a single-handler application):

class AuthController {
  @Throttle({ max: 3, window: 900, by: 'email' })
  sendCode (event: IncomingEvent) { … }
}

Or once, for everything that declares nothing, through stone.rateLimit.global.

What a rule says

| Option | Meaning | |---|---| | max | How many requests the window allows. | | window | How long the window lasts, in seconds. | | by | Required. What the budget belongs to: a request field ('email'), alternatives ('phone\|email', first present wins), 'user' for the authenticated principal, 'address', or a function (event) => string \| undefined. | | backstop | The per-address bucket that runs alongside a subject budget, as a multiple of max. Defaults to ten times. false runs the subject budget alone. | | scope | A bucket shared with every rule naming it, instead of one per route. This is how a ceiling spanning several routes is expressed. | | limiter | Which configured limiter counts this rule. |

Why the subject, and not the address

Throttling by address assumes one address is one person. On mobile networks using carrier-grade NAT, the norm across much of the world, hundreds of unrelated subscribers share one public address. A per-address quota then refuses legitimate users at random, and hardest exactly where the audience is largest.

So the budget belongs to the thing being protected: the account, the mailbox, the phone number. The address keeps a much looser bucket whose only job is to stop one machine enumerating subjects in bulk.

Subjects are hashed before they are used as keys. A key is read by whoever debugs the store, and a mailbox has no business being there.

Where the subject lives

A field name covers the common cases: this module reads it from the route parameters, then the body, then the query. Anything else is a function, and that is the honest answer, because an application knows where its subject lives and this module would only be guessing:

@Post('/auth/code', { rateLimit: { max: 3, window: 900, by: (event) => event.getHeader('x-account') } })

A subject read never breaks the route. Whatever fails, a resolver that throws included, the request falls back to the address bucket at the backstop, and a warning says which rule was downgraded.

by: 'user' runs before authentication

Enforcement sits ahead of authentication on purpose, so at that point nothing has resolved a principal yet unless the application resolved one earlier. Say where yours lives:

blueprint.set('stone.rateLimit.principal', (event) => event.getUser()?.userId)

The default reads event.getUser?.() and takes its id, sub or userId. With nothing to bill, the rule falls back to the address at the backstop, ten times the limit, and warns: correct as a behaviour, unacceptable as a silence, since a budget of three would otherwise allow thirty with every test still green.

Why scope exists

A rule declared on a group is copied onto each child, and at enforcement time nothing records which ancestor a rule came from. Unscoped, a group's max: 100 is therefore a hundred per child route. Naming a scope makes it the shared ceiling it looks like, and lets unrelated routes share one budget too.

Limiters

memory is always available, needs no configuration, and counts per process: fine for one instance, and not a limit at all across several, since each grants the whole budget again.

redis is the one to use wherever the application runs as more than one process. One round trip per request, no read: the window index is part of the key, so a new window is a new key starting at zero, and the counter expires on its own.

A deployment that already has a store can plug it in, which is how a serverless application counts where its state lives. Declare it with the other limiters, not from a provider: the container is rebuilt for every event, so anything registered imperatively during one event is gone for the next.

blueprint.set('stone.rateLimit', {
  default: 'table',
  limiters: [{
    name: 'table',
    factory: () => ({ hit: async (key, limit, windowMs) => await countInMyTable(key, limit, windowMs) })
  }]
})

hit receives the limit rather than holding it, so an implementation that can refuse atomically through a conditional write has what it needs to express the condition, and pays nothing for a refusal.

What a caller is told

A refusal answers 429 with Retry-After. The error carries its own status, so an HTTP platform answers 429 and a CLI or queue consumer reads RateLimitError directly. It also carries the stable code RATE_LIMIT_EXCEEDED, so an application maps it to its own error envelope without importing this package into its error handler.

While a caller is within its budget, the response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, reporting the budget closest to being exceeded. Set stone.rateLimit.headers to false to publish nothing.

Addresses behind a proxy

No forwarded header is read unless you name it, because a forwarded header is client-spoofable unless an edge you trust overwrites it, and reading one by default would hand every caller an unlimited supply of identities. Name the one your own edge guarantees, through trustedAddressHeaders.

Documentation

See the official documentation for the full guide.

License

MIT