Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

@joint-ops/hitlimit-bun

Rate limiting built for Bun. Not ported — built.

7.73M ops/sec at single IP. 5.57M at 10K unique IPs. Native bun:sqlite. Atomic Lua. Postgres via Bun SQL. Zero dependencies.

bun add @joint-ops/hitlimit-bun
import { hitlimit } from '@joint-ops/hitlimit-bun'

Bun.serve({
  fetch: hitlimit({}, (req) => new Response('Hello!'))
})

100 req/min per IP. Done. Works with Bun.serve, Elysia, and Hono — no adapter packages, no wrappers.

Full Docs · GitHub


Frameworks

Bun.serve

import { hitlimit } from '@joint-ops/hitlimit-bun'

Bun.serve({
  fetch: hitlimit({ limit: 100, window: '1m' }, (req) => {
    return new Response('Hello!')
  })
})

Elysia

import { Elysia } from 'elysia'
import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'

new Elysia()
  .use(hitlimit({ limit: 100, window: '1m' }))
  .get('/', () => 'Hello!')
  .listen(3000)

Hono

import { Hono } from 'hono'
import { hitlimit } from '@joint-ops/hitlimit-bun/hono'

const app = new Hono()
app.use(hitlimit({ limit: 100, window: '1m' }))
app.get('/', (c) => c.text('Hello!'))
Bun.serve({ port: 3000, fetch: app.fetch })

8 Storage Backends

One line to swap. Your rate limiting logic stays exactly the same.

               Single Server                          Multi-Server
          ┌──────────────────────┐          ┌──────────────────────────┐
          │  Memory  │  SQLite   │          │  Redis   │  Postgres    │
          │ (default) (bun:sqlite)          │  Valkey  │  MongoDB     │
          │                      │          │ Dragonfly│  MySQL       │
          └──────────────────────┘          └──────────────────────────┘
Store Ops/sec Latency When to use
Memory 5,574,103 179ns Single server, maximum speed
bun:sqlite 372,247 2.7μs Single server, need persistence
MongoDB 2,132 469μs Multi-server / NoSQL infrastructure

Redis, Valkey, DragonflyDB, Postgres, and MySQL are network-bound. Full numbers at hitlimit.jointops.dev/docs/benchmarks.

Memory — default, zero config, zero dependencies

Bun.serve({ fetch: hitlimit({}, handler) })

bun:sqlite — native, no N-API, no C++ bindings, survives restarts

import { sqliteStore } from '@joint-ops/hitlimit-bun/stores/sqlite'

Bun.serve({ fetch: hitlimit({ store: sqliteStore({ path: './ratelimit.db' }) }, handler) })

No peer dependency — bun:sqlite is built into Bun.

Redis — distributed, atomic Lua scripts, single round-trip

import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'

Bun.serve({ fetch: hitlimit({ store: redisStore({ url: 'redis://localhost:6379' }) }, handler) })

Peer dep: ioredis

Valkey — open-source Redis fork (BSD-3), drop-in replacement

import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'

Bun.serve({ fetch: hitlimit({ store: valkeyStore({ url: 'redis://localhost:6379' }) }, handler) })

Peer dep: ioredis

DragonflyDB — Redis-compatible, handles more throughput

import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'

Bun.serve({ fetch: hitlimit({ store: dragonflyStore({ url: 'redis://localhost:6379' }) }, handler) })

Peer dep: ioredis

PostgreSQL — Bun native SQL, no extra driver needed

Connection string (recommended):

import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'

Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: process.env.DATABASE_URL }) }, handler) })

Caller-owned Bun.SQL client:

import { SQL } from 'bun'
import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'

const sql = new SQL(process.env.DATABASE_URL)
Bun.serve({ fetch: hitlimit({ store: postgresStore({ client: sql }) }, handler) })

Legacy pg.Pool (deprecated — url or client preferred):

import pg from 'pg'
import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
Bun.serve({ fetch: hitlimit({ store: postgresStore({ pool }) }, handler) })

Optional peer dep: pg — only needed if you use the deprecated { pool } option.

MongoDB — TTL indexes, MEAN/MERN stacks

import { MongoClient } from 'mongodb'
import { mongoStore } from '@joint-ops/hitlimit-bun/stores/mongodb'

const client = new MongoClient('mongodb://localhost:27017')
await client.connect()
Bun.serve({ fetch: hitlimit({ store: mongoStore({ db: client.db('myapp') }) }, handler) })

Peer dep: mongodb

MySQL / MariaDB — LAMP stacks, PlanetScale, RDS

import mysql from 'mysql2/promise'
import { mysqlStore } from '@joint-ops/hitlimit-bun/stores/mysql'

const pool = mysql.createPool({ host: 'localhost', database: 'myapp', user: 'root', password: '' })
Bun.serve({ fetch: hitlimit({ store: mysqlStore({ pool }) }, handler) })

Peer dep: mysql2


Features

Tiered limits — Free, Pro, Enterprise in one config

hitlimit({
  tiers: {
    free:       { limit: 100,   window: '1h' },
    pro:        { limit: 5000,  window: '1h' },
    enterprise: { limit: 50000, window: '1h' }
  },
  tier: (req) => req.headers.get('x-tier') || 'free'
}, handler)

Auto-ban — block repeat offenders automatically

hitlimit({
  limit: 100,
  window: '1m',
  ban: {
    threshold: 5,   // ban after 5 violations
    duration: '1h'  // ban lasts 1 hour
  }
}, handler)

When a client is banned: response includes X-RateLimit-Ban: true and X-RateLimit-Ban-Expires, body includes banned: true.

Group limits — shared quotas across clients

hitlimit({
  limit: 10000,
  window: '1h',
  group: (req) => new URL(req.url).searchParams.get('teamId') || 'default'
}, handler)

Custom rate limit key

hitlimit({
  key: (req) => req.headers.get('x-api-key') || 'anon'
}, handler)

Skip rules — whitelist whatever you want

hitlimit({
  skip: (req) => new URL(req.url).pathname === '/health'
}, handler)

Custom response body

hitlimit({
  response: (info) => ({
    error: 'RATE_LIMITED',
    message: `Slow down. Try again in ${info.resetIn}s.`,
    limit: info.limit,
    remaining: info.remaining
  })
}, handler)

Rate limit headers

hitlimit({
  headers: {
    standard: true,   // RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (IETF)
    legacy: true,     // X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
    retryAfter: true  // Retry-After on 429 responses
  }
}, handler)

Store error handling — allow or deny on failure

hitlimit({
  onStoreError: (err, req) => {
    if (new URL(req.url).pathname.startsWith('/admin')) return 'deny'
    return 'allow'
  }
}, handler)

Built-in logger

import { consoleLogger } from '@joint-ops/hitlimit-bun/loggers/console'

hitlimit({ logger: consoleLogger() }, handler)

createHitLimit — Manual Control

For when you need to check the limit yourself and handle the rest:

import { createHitLimit } from '@joint-ops/hitlimit-bun'

const limiter = createHitLimit({ limit: 100, window: '1m' })

Bun.serve({
  fetch: async (req, server) => {
    const blocked = await limiter.check(req, server)
    if (blocked) return blocked  // 429 Response

    // Your logic here
    return new Response('OK')
  }
})

check() returns a Response if the request is blocked, null if it's allowed. Reset a key manually:

await limiter.reset('some-key')

Default 429 Response

{
  "hitlimit": true,
  "message": "Whoa there! Rate limit exceeded.",
  "limit": 100,
  "remaining": 0,
  "resetIn": 42
}

All Options

hitlimit({
  limit: 100,              // max requests per window (default: 100)
  window: '1m',            // time window: 's', 'm', 'h', 'd' or milliseconds (default: '1m')
  key: (req) => req.headers.get('x-api-key') || 'anon',  // what to rate limit by (default: IP)

  tiers: { free: { limit: 100, window: '1h' } },
  tier:  (req) => req.headers.get('x-tier') || 'free',

  ban: { threshold: 5, duration: '1h' },

  group: (req) => req.headers.get('x-team-id') || 'default',

  skip: (req) => new URL(req.url).pathname === '/health',

  response: (info) => ({ error: 'Too many requests', retryAfter: info.resetIn }),

  headers: { standard: true, legacy: false, retryAfter: true },

  store: redisStore({ url: 'redis://localhost:6379' }),

  onStoreError: (err, req) => 'allow',

  logger: consoleLogger()
}, handler)

Related

Full Documentation · GitHub

License

MIT