npm package: @jellyfungus/hono-rate-limiter
Description: Production-ready rate limiting middleware for Hono web framework
Latest version: 0.6.0

# @jellyfungus/hono-rate-limiter

Production-ready rate limiting middleware for [Hono](https://hono.dev) web framework.

[![npm version](https://img.shields.io/npm/v/@jellyfungus/hono-rate-limiter.svg)](https://www.npmjs.com/package/@jellyfungus/hono-rate-limiter)
[![CI](https://github.com/rokasta12/hono-rate-limiter/actions/workflows/ci.yml/badge.svg)](https://github.com/rokasta12/hono-rate-limiter/actions/workflows/ci.yml)

## Highlights

- **Sliding Window Algorithm** - Same approach used by Cloudflare, AWS, and major CDNs. Prevents burst attacks at window boundaries.
- **Zero Dependencies** - Only Hono as a peer dependency. No bloat.
- **Works Out of the Box** - Sensible defaults with built-in IP detection. Just add `rateLimiter()` and you're done.
- **Multiple Stores** - Memory (default), Redis (with atomic Lua scripts), Cloudflare KV
- **Cloudflare Rate Limiting Binding** - Native support for Cloudflare's globally distributed rate limiter
- **WebSocket Support** - Rate limit WebSocket connections
- **Standard Rate Limit Headers** - Support for both IETF standard (`RateLimit`) and legacy (`X-RateLimit-*`) headers
- **TypeScript First** - Complete type safety

## Installation

```bash
npm install @jellyfungus/hono-rate-limiter
# or
bun add @jellyfungus/hono-rate-limiter
# or
pnpm add @jellyfungus/hono-rate-limiter
```

## Quick Start

```ts
import { Hono } from "hono";
import { rateLimiter } from "@jellyfungus/hono-rate-limiter";

const app = new Hono();

// That's it! 60 requests per minute with sliding window
app.use(rateLimiter());

app.get("/", (c) => c.text("Hello!"));

export default app;
```

## Sliding Window Algorithm

Traditional fixed-window rate limiters have a critical flaw: users can burst 2x the limit at window boundaries.

Our sliding window algorithm (same approach used by Cloudflare) smooths traffic across boundaries:

```
Fixed Window Problem:
|-------- Window 1 --------|-------- Window 2 --------|
                    [60 req][60 req]
                    └── 120 requests in seconds! ──┘

Sliding Window Solution:
|-------- Window 1 --------|-------- Window 2 --------|
        Weighted calculation prevents bursts
        └── Always respects the 60 req limit ──┘
```

## Configuration

```ts
app.use(
  rateLimiter({
    limit: 100, // Max requests per window (default: 60)
    windowMs: 60 * 1000, // Window duration in ms (default: 60000)
    algorithm: "sliding-window", // or "fixed-window" (default: sliding-window)
    headers: "legacy", // Header format (see Response Headers section)
  }),
);
```

### All Options

| Option                   | Type                                                        | Default            | Description                          |
| ------------------------ | ----------------------------------------------------------- | ------------------ | ------------------------------------ |
| `limit`                  | `number \| Function`                                        | `60`               | Max requests per window              |
| `windowMs`               | `number`                                                    | `60000`            | Window duration in ms                |
| `algorithm`              | `'sliding-window' \| 'fixed-window'`                        | `'sliding-window'` | Rate limiting algorithm              |
| `store`                  | `RateLimitStore`                                            | `MemoryStore`      | Storage backend                      |
| `keyGenerator`           | `Function`                                                  | IP detection       | Generate unique client key           |
| `handler`                | `Function`                                                  | 429 response       | Custom rate limit response           |
| `headers`                | `'legacy' \| 'draft-6' \| 'draft-7' \| 'standard' \| false` | `'legacy'`         | Header format (see below)            |
| `identifier`             | `string`                                                    | `'default'`        | Policy name for IETF headers         |
| `quotaUnit`              | `'requests' \| 'content-bytes' \| 'concurrent-requests'`    | `'requests'`       | Quota unit for IETF standard headers |
| `skip`                   | `Function`                                                  | -                  | Skip rate limiting conditionally     |
| `skipSuccessfulRequests` | `boolean`                                                   | `false`            | Don't count 2xx responses            |
| `skipFailedRequests`     | `boolean`                                                   | `false`            | Don't count 4xx/5xx responses        |
| `onRateLimited`          | `Function`                                                  | -                  | Callback when rate limited           |
| `onStoreError`           | `'allow' \| 'deny' \| Function`                             | `'allow'`          | Behavior on store failure            |
| `dryRun`                 | `boolean`                                                   | `false`            | Monitor without blocking requests    |

## Stores

### Choosing a Store

| Store                  | Best For                     | Consistency         | Scalability          |
| ---------------------- | ---------------------------- | ------------------- | -------------------- |
| **MemoryStore**        | Single instance, development | Strong              | Single instance only |
| **RedisStore**         | Multi-instance, production   | Strong (atomic Lua) | Horizontal           |
| **CloudflareKVStore**  | Cloudflare Workers           | Eventual (~60s)     | Global               |
| **Cloudflare Binding** | Enterprise Cloudflare        | Strong              | Global, managed      |

**Recommendations:**

- **Development/Testing**: Use default MemoryStore
- **Single server Node.js**: MemoryStore is fine
- **Multiple servers/containers**: Use RedisStore
- **Cloudflare Workers (strict)**: Use Rate Limiting binding or Durable Objects
- **Cloudflare Workers (relaxed)**: CloudflareKVStore is acceptable

### Memory Store (Default)

Perfect for single-instance deployments:

```ts
app.use(rateLimiter()); // Uses MemoryStore automatically
```

### Redis Store

For distributed deployments. Uses atomic Lua scripts for race-condition-free operations:

```ts
import { rateLimiter } from "@jellyfungus/hono-rate-limiter";
import { RedisStore } from "@jellyfungus/hono-rate-limiter/store/redis";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

app.use(
  rateLimiter({
    store: new RedisStore({ client: redis }),
  }),
);
```

### Cloudflare KV Store

For Cloudflare Workers with KV:

> **Warning:** Cloudflare KV is eventually consistent and does not support atomic increment operations. Under high concurrency, rate limits may be slightly exceeded. For strict rate limiting on Cloudflare, use [Durable Objects](https://developers.cloudflare.com/durable-objects/) or the native [Rate Limiting binding](#cloudflare-rate-limiting-binding) below.

```ts
import { rateLimiter } from "@jellyfungus/hono-rate-limiter";
import { CloudflareKVStore } from "@jellyfungus/hono-rate-limiter/store/cloudflare-kv";

type Bindings = { RATE_LIMIT_KV: KVNamespace };

const app = new Hono<{ Bindings: Bindings }>();

app.use("*", async (c, next) => {
  const limiter = rateLimiter({
    store: new CloudflareKVStore({ namespace: c.env.RATE_LIMIT_KV }),
  });
  return limiter(c, next);
});
```

### Cloudflare Rate Limiting Binding

For enterprise-grade, globally distributed rate limiting using Cloudflare's native Rate Limiting:

```ts
import { cloudflareRateLimiter } from "@jellyfungus/hono-rate-limiter";

type Bindings = { RATE_LIMITER: RateLimitBinding };

const app = new Hono<{ Bindings: Bindings }>();

app.use(
  cloudflareRateLimiter({
    binding: (c) => c.env.RATE_LIMITER,
    keyGenerator: (c) => c.req.header("cf-connecting-ip") ?? "unknown",
  }),
);
```

## WebSocket Rate Limiting

Rate limit WebSocket message frequency:

```ts
import { Hono } from "hono";
import { createBunWebSocket } from "hono/bun";
import { webSocketLimiter } from "@jellyfungus/hono-rate-limiter/websocket";

const { upgradeWebSocket, websocket } = createBunWebSocket();

const wsLimiter = webSocketLimiter({
  limit: 100,
  windowMs: 60_000,
  keyGenerator: (c) => c.req.header("cf-connecting-ip") ?? "unknown",
});

app.get(
  "/ws",
  upgradeWebSocket(
    wsLimiter((c) => ({
      onMessage(event, ws) {
        ws.send("Hello!");
      },
    })),
  ),
);
```

> **Note:** WebSocket rate limiting is compatible with Bun, Deno, and Cloudflare Workers. The implementation handles platform differences in event handler signatures.

### Cloudflare Workers WebSocket

```ts
import { Hono } from "hono";
import { upgradeWebSocket } from "hono/cloudflare-workers";
import { webSocketLimiter } from "@jellyfungus/hono-rate-limiter/websocket";

const app = new Hono();

const wsLimiter = webSocketLimiter({
  limit: 100,
  windowMs: 60_000,
  keyGenerator: (c) => c.req.header("cf-connecting-ip") ?? "unknown",
});

app.get(
  "/ws",
  upgradeWebSocket(
    wsLimiter((c) => ({
      onMessage(event, ws) {
        ws.send(`Echo: ${event.data}`);
      },
    })),
  ),
);

export default app;
```

### Deno WebSocket

```ts
import { Hono } from "hono";
import { upgradeWebSocket } from "hono/deno";
import { webSocketLimiter } from "@jellyfungus/hono-rate-limiter/websocket";

const app = new Hono();

const wsLimiter = webSocketLimiter({
  limit: 100,
  windowMs: 60_000,
  keyGenerator: (c) => {
    // Deno provides connection info
    const addr = c.env?.remoteAddr;
    return addr?.hostname ?? "unknown";
  },
});

app.get(
  "/ws",
  upgradeWebSocket(
    wsLimiter((c) => ({
      onMessage(event, ws) {
        ws.send(`Echo: ${event.data}`);
      },
    })),
  ),
);

Deno.serve(app.fetch);
```

## Dynamic Limits

Different limits for different users:

```ts
app.use(
  rateLimiter({
    limit: async (c) => {
      const user = c.get("user");
      if (user?.tier === "enterprise") return 10000;
      if (user?.tier === "pro") return 1000;
      return 100;
    },
  }),
);
```

## Custom Key Generator

Rate limit by API key, user ID, or any identifier:

```ts
app.use(
  rateLimiter({
    keyGenerator: (c) => {
      return c.req.header("x-api-key") ?? "anonymous";
    },
  }),
);
```

## Skip Certain Requests

Bypass rate limiting for specific routes:

```ts
app.use(
  rateLimiter({
    skip: (c) => {
      return c.req.path === "/health" || c.req.path === "/metrics";
    },
  }),
);
```

## Access Rate Limit Info

Get rate limit information in your handlers:

```ts
app.get("/api/status", (c) => {
  const info = c.get("rateLimit");
  return c.json({
    limit: info?.limit,
    remaining: info?.remaining,
    resetAt: info?.reset,
  });
});
```

## Dry-Run Mode

Monitor rate limits without blocking requests. Useful for testing limits before enforcing them:

```ts
app.use(
  rateLimiter({
    limit: 100,
    windowMs: 60_000,
    dryRun: true, // Allows all requests, but still tracks and sets headers
    onRateLimited: (c, info) => {
      // Log when limits would be exceeded
      console.log(`Would rate limit: ${c.req.url}`, info);
    },
  }),
);
```

## Manual Store Control

Access the store directly for manual operations:

```ts
app.post("/api/admin/reset-limit/:userId", async (c) => {
  const store = c.get("rateLimitStore");
  const userId = c.req.param("userId");

  await store?.resetKey(userId);
  // Or reset all: await store?.resetAll();

  return c.json({ success: true });
});
```

## Custom Handler

Customize the rate limit exceeded response:

```ts
app.use(
  rateLimiter({
    handler: (c, info) => {
      return c.json(
        {
          error: "Rate limit exceeded",
          retryAfter: Math.ceil((info.reset - Date.now()) / 1000),
        },
        429,
      );
    },
  }),
);
```

## Response Headers

The middleware supports multiple header formats. Choose based on your needs:

### `"legacy"` (default)

The widely-used `X-RateLimit-*` headers. Used by GitHub, Twitter, and most APIs:

```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1640000000
```

- **Reset** is a Unix timestamp (seconds since epoch)
- Best for broad client compatibility

### `"draft-6"`

IETF draft-06 format with individual `RateLimit-*` headers:

```
RateLimit-Policy: 60;w=60
RateLimit-Limit: 60
RateLimit-Remaining: 45
RateLimit-Reset: 30
```

- **Reset** is seconds until the window resets (not a timestamp)
- Compatible with express-rate-limit's `draft-6` option

### `"draft-7"`

IETF draft-07 format with a combined `RateLimit` header:

```
RateLimit-Policy: 60;w=60
RateLimit: limit=60, remaining=45, reset=30
```

- Uses comma-separated values in a single header
- Compatible with express-rate-limit's `draft-7` option

### `"standard"` (IETF draft-08+)

Current IETF `draft-ietf-httpapi-ratelimit-headers` specification using structured field values ([RFC 9651](https://datatracker.ietf.org/doc/rfc9651/)):

```
RateLimit-Policy: "default";q=60;w=60
RateLimit: "default";r=45;t=30
```

Parameters:

| Header           | Param | Meaning                    |
| ---------------- | ----- | -------------------------- |
| RateLimit-Policy | `q`   | quota (max requests)       |
| RateLimit-Policy | `w`   | window (seconds)           |
| RateLimit-Policy | `qu`  | quota unit (optional)      |
| RateLimit        | `r`   | remaining requests         |
| RateLimit        | `t`   | time until reset (seconds) |

Use the `identifier` option to customize the policy name:

```ts
rateLimiter({
  headers: "standard",
  identifier: "api-v1",
  // Headers: RateLimit-Policy: "api-v1";q=60;w=60
});
```

Use the `quotaUnit` option for non-request-based limits:

```ts
rateLimiter({
  headers: "standard",
  identifier: "bandwidth",
  quotaUnit: "content-bytes",
  // Headers: RateLimit-Policy: "bandwidth";q=1000000;w=60;qu="content-bytes"
});
```

### Disabled

Use `headers: false` to disable all rate limit headers.

### Retry-After Header

When a request is rate limited (429 response), the `Retry-After` header is always included with the number of seconds until the client can retry.

## Security Considerations

### IP Header Spoofing

The default key generator uses request headers (`X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`) to identify clients. **These headers can be spoofed by malicious clients** if your application is not behind a trusted reverse proxy.

**Only trust these headers when your app is behind a trusted proxy** (nginx, Cloudflare, AWS ALB, etc.) that overwrites them.

For untrusted environments or additional security, use authenticated identifiers:

```ts
app.use(
  rateLimiter({
    keyGenerator: (c) => {
      // Use authenticated user ID instead of IP
      const user = c.get("user");
      return user?.id ?? "anonymous";
    },
  }),
);
```

### Error Handling

By default, the rate limiter uses a "fail-open" strategy: if the store (Redis, KV, etc.) is unavailable, requests are allowed through. This prioritizes availability over strict rate limiting.

For stricter security, use "fail-closed":

```ts
app.use(
  rateLimiter({
    onStoreError: "deny", // Block requests when store fails
  }),
);
```

Or use a custom handler:

```ts
app.use(
  rateLimiter({
    onStoreError: (error, c) => {
      console.error("Rate limiter error:", error);
      return false; // false = deny, true = allow
    },
  }),
);
```

## License

MIT