{"_id":"@acbp/rate-limiter","_rev":"2-ff838f36d6a75f78e7a57b5ad19fd918","name":"@acbp/rate-limiter","dist-tags":{"latest":"0.1.1"},"versions":{"0.1.0":{"name":"@acbp/rate-limiter","version":"0.1.0","keywords":["rate-limiter","rate-limit","ratelimit","express","middleware","security","api","throttle","brute-force","redis","valkey"],"author":{"url":"https://github.com/acbp","name":"acbp"},"license":"MIT","_id":"@acbp/rate-limiter@0.1.0","maintainers":[{"name":"acbp","email":"paravanimobile@gmail.com"}],"homepage":"https://github.com/acbp/openspec_projects/tree/main/packages/rate-limiter","bugs":{"url":"https://github.com/acbp/openspec_projects/issues"},"dist":{"shasum":"0636fafb84debf74a810b502cfba951ff4500947","tarball":"https://registry.npmjs.org/@acbp/rate-limiter/-/rate-limiter-0.1.0.tgz","fileCount":103,"integrity":"sha512-+lpAsSKeLmBy+0hf5RA4xKDRiVfF8kHIX7lgvh7xckFgBORkRTVZRyQ41k1sBbsGODKUHBQCEI3VZiZrh0Am2A==","signatures":[{"sig":"MEQCIGBckIn61LiOsLk9j0M7lp/2oZC6BOWWyjeKav2Nr2dDAiAOpVYnu2Nswmy9QayX58cyHhErSSp0TC8a0L8ZK4Kn6g==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":228538},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">= 20.19.2"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"gitHead":"5cd2fd94289d37956f787860ebedbd9619390e25","scripts":{"test":"vitest run","build":"tsc","smoke":"npm run smoke:memory && npm run smoke:redis","load:all":"npm run load:burst && npm run load:accuracy && npm run load:failover && npm run load:mixed-cost","demo:redis":"tsx demo/redis-demo.ts","load:burst":"COMPOSE_PROFILES=burst docker compose -f docker/docker-compose.yml -p load-test up --abort-on-container-exit","test:watch":"vitest","demo:memory":"tsx demo/memory-demo.ts","smoke:redis":"tsx src/__tests__/smoke-test.ts --redis","smoke:memory":"tsx src/__tests__/smoke-test.ts --memory","load:accuracy":"COMPOSE_PROFILES=accuracy docker compose -f docker/docker-compose.yml -p load-test up --abort-on-container-exit","load:failover":"bash docker/run-failover.sh","prepublishOnly":"npm run build","load:mixed-cost":"COMPOSE_PROFILES=mixed-cost docker compose -f docker/docker-compose.yml -p load-test up --abort-on-container-exit"},"_npmUser":{"name":"acbp","email":"paravanimobile@gmail.com"},"repository":{"url":"git+https://github.com/acbp/openspec_projects.git","type":"git"},"_npmVersion":"11.13.0","description":"API rate limiter with pluggable storage backends and strategies — in-memory, Redis, Valkey, and more","directories":{},"sideEffects":false,"_nodeVersion":"26.1.0","dependencies":{},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.22.3","vitest":"^1.2.0","express":"^4.18.2","typescript":"^5.3.3","@types/node":"^20.11.0","@types/express":"^4.17.21"},"peerDependencies":{"ioredis":"^5.4.1"},"peerDependenciesMeta":{"ioredis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/rate-limiter_0.1.0_1780240527909_0.4986821596172897","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@acbp/rate-limiter","version":"0.1.1","description":"API rate limiter with pluggable storage backends and strategies — in-memory, Redis, Valkey, and more","license":"MIT","author":{"name":"acbp","url":"https://github.com/acbp"},"publishConfig":{"access":"public"},"homepage":"https://github.com/acbp/openspec_projects/tree/main/packages/rate-limiter","repository":{"type":"git","url":"git+https://github.com/acbp/openspec_projects.git"},"bugs":{"url":"https://github.com/acbp/openspec_projects/issues"},"keywords":["rate-limiter","rate-limit","ratelimit","express","middleware","security","api","throttle","brute-force","redis","valkey"],"sideEffects":false,"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"}},"main":"dist/index.js","types":"dist/index.d.ts","engines":{"node":">= 20.19.2"},"scripts":{"build":"tsc","prepublishOnly":"npm run build","test":"vitest run","test:watch":"vitest","smoke":"npm run smoke:memory && npm run smoke:redis","smoke:memory":"tsx src/__tests__/smoke-test.ts --memory","smoke:redis":"tsx src/__tests__/smoke-test.ts --redis","demo:memory":"tsx demo/memory-demo.ts","demo:redis":"tsx demo/redis-demo.ts","load:burst":"COMPOSE_PROFILES=burst docker compose -f docker/docker-compose.yml -p load-test up --abort-on-container-exit","load:accuracy":"COMPOSE_PROFILES=accuracy docker compose -f docker/docker-compose.yml -p load-test up --abort-on-container-exit","load:failover":"bash docker/run-failover.sh","load:mixed-cost":"COMPOSE_PROFILES=mixed-cost docker compose -f docker/docker-compose.yml -p load-test up --abort-on-container-exit","load:all":"npm run load:burst && npm run load:accuracy && npm run load:failover && npm run load:mixed-cost"},"dependencies":{},"peerDependencies":{"ioredis":"^5.4.1"},"peerDependenciesMeta":{"ioredis":{"optional":true}},"devDependencies":{"@types/express":"^4.17.21","@types/node":"^20.11.0","express":"^4.18.2","tsx":"^4.22.3","typescript":"^5.3.3","vitest":"^1.2.0"},"gitHead":"9fe77f37e3a3f11c4c76906689b5cad100f93e49","_id":"@acbp/rate-limiter@0.1.1","_nodeVersion":"26.1.0","_npmVersion":"11.13.0","dist":{"integrity":"sha512-V3Z/Kk7DoRfocVCjUvN5yMmBWhaIeC2hYBrMCZADCfFY71eIdVQEEXHAIPL0ToV4CibOZ5tsHqjIah4vz11XOg==","shasum":"3a08c2cc1fcc366354afda34d70961c8cd7f3e83","tarball":"https://registry.npmjs.org/@acbp/rate-limiter/-/rate-limiter-0.1.1.tgz","fileCount":35,"unpackedSize":46974,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDBBjNKrw2CnKAgWGNVdLFuLzBmfY16cXTkk6GUFOgpgAIgFlwE4oEhO/b/6snbM+nzxXxsAbrrsMzxfSh1p1srq4U="}]},"_npmUser":{"name":"acbp","email":"paravanimobile@gmail.com"},"directories":{},"maintainers":[{"name":"acbp","email":"paravanimobile@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/rate-limiter_0.1.1_1780240921853_0.4562259944031035"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-31T15:15:27.703Z","modified":"2026-05-31T15:22:02.089Z","0.1.0":"2026-05-31T15:15:28.088Z","0.1.1":"2026-05-31T15:22:01.988Z"},"bugs":{"url":"https://github.com/acbp/openspec_projects/issues"},"author":{"name":"acbp","url":"https://github.com/acbp"},"license":"MIT","homepage":"https://github.com/acbp/openspec_projects/tree/main/packages/rate-limiter","keywords":["rate-limiter","rate-limit","ratelimit","express","middleware","security","api","throttle","brute-force","redis","valkey"],"repository":{"type":"git","url":"git+https://github.com/acbp/openspec_projects.git"},"description":"API rate limiter with pluggable storage backends and strategies — in-memory, Redis, Valkey, and more","maintainers":[{"name":"acbp","email":"paravanimobile@gmail.com"}],"readme":"<p align=\"center\">\n  <h1 align=\"center\">rate-limiter</h1>\n  <p align=\"center\">\n    API rate limiter with pluggable storage backends and strategies.\n    <br />\n    Zero required dependencies — in-memory, Redis, Valkey, and more.\n  </p>\n  <p align=\"center\">\n    <a href=\"#install\">Install</a>\n    ·\n    <a href=\"#quick-start\">Quick Start</a>\n    ·\n    <a href=\"#why\">Why</a>\n    ·\n    <a href=\"#usage\">Usage</a>\n    ·\n    <a href=\"#api\">API</a>\n    ·\n    <a href=\"#testing\">Testing</a>\n  </p>\n  <p align=\"center\">\n    <img src=\"https://img.shields.io/badge/node-%3E%3D%2020.19.2-brightgreen\" alt=\"Node version\">\n    <img src=\"https://img.shields.io/badge/license-MIT-blue\" alt=\"License\">\n    <img src=\"https://img.shields.io/badge/dependencies-0-brightgreen\" alt=\"Zero dependencies\">\n  </p>\n</p>\n\n---\n\n## Install\n\n```bash\nnpm install @acbp/rate-limiter\n\nFor Redis-backed storage, install the optional peer dependency:\n\n```bash\nnpm install @acbp/rate-limiter ioredis\n```\n\nNo other setup needed. The library has **zero runtime dependencies** out of the box.\n\n## Quick Start\n\n```ts\nimport express from \"express\"\nimport { rateLimit, MemoryStore, SlidingWindowStrategy } from \"@acbp/rate-limiter\"\n\nconst app = express()\n\napp.use(\n  rateLimit({\n    store: new MemoryStore(),\n    strategy: new SlidingWindowStrategy(store),\n    maxHits: 100,\n    windowMs: 60_000,          // 1 minute\n  })\n)\n\napp.listen(3000)\n```\n\nThat's it. Every IP gets 100 requests per minute. Hit the limit? 429.\n\n## Why\n\n| Problem | How rate-limiter solves it |\n|---------|---------------------------|\n| \"I need rate limiting but don't want to install a heavy framework\" | Zero deps in the base package. Just the limiter, no baggage. |\n| \"I use Redis in prod, memory in dev\" | Swap stores with one line. Both work the same way. |\n| \"My API has free/pro tiers with different limits\" | Built-in `TierResolver`. Per-request limit resolution. |\n| \"I need different limits on different endpoints\" | Declarative rules engine. Match by path, method, tier, or custom function. |\n| \"Some endpoints cost more than others\" | Weighted cost. A batch endpoint can count as 10 hits. |\n| \"I want to monitor abuse in real time\" | EventEmitter events. `rate-limit:check` and `rate-limit:blocked` fire every request. |\n| \"I use Fastify/Hono/not-Express\" | Framework-agnostic `createRateLimitHandler`. Community adapters. |\n| \"I need to change limits without restarting\" | Atomic `setConfig()`. Swap tiers, rules, or defaults at runtime. |\n\n## Usage\n\n### Basic middleware\n\n```ts\nimport { rateLimit, MemoryStore, SlidingWindowStrategy } from \"@acbp/rate-limiter\"\n\nconst store = new MemoryStore()\nconst strategy = new SlidingWindowStrategy(store)\n\napp.use(rateLimit({ store, strategy, maxHits: 100, windowMs: 60_000 }))\n```\n\n### Per-plan limits\n\nUse `resolveLimits` for per-request limits, or `TierResolver` for a declarative config map:\n\n```ts\nimport { TierResolver, rateLimit } from \"@acbp/rate-limiter\"\n\nconst resolver = new TierResolver({\n  tiers: {\n    free:  { maxHits: 10,  windowMs: 60_000 },\n    pro:   { maxHits: 100, windowMs: 60_000 },\n  },\n  defaultLimits: { maxHits: 50, windowMs: 60_000 },\n  resolveTier: (req) => req.headers[\"x-plan\"] ?? \"free\",\n})\n\napp.use(\"/api\", rateLimit({\n  resolveLimits: (req) => resolver.resolveLimits(req),\n}))\n\nresolver.setConfig({ tiers: { free: { maxHits: 5, windowMs: 60_000 } }, ... })\n```\n\n### Declarative rules\n\nFirst-match-wins. Fine-grained control by path, method, tier, or custom function:\n\n```ts\napp.use(rateLimit({\n  store, strategy,\n  rules: [\n    { match: { path: \"/login\", method: \"POST\", tier: \"free\" }, maxHits: 3,   windowMs: 60_000 },\n    { match: { path: \"/login\", method: \"POST\", tier: \"pro\" },  maxHits: 30,  windowMs: 60_000 },\n    { match: { path: \"/api/\" }, maxHits: 50, windowMs: 60_000 },\n    { match: { match: (req) => req.ip === \"192.168.1.1\" }, maxHits: 1000, windowMs: 60_000 },\n  ],\n  resolveTier: (req) => req.headers[\"x-plan\"] ?? \"free\",\n  resolveLimits: (req) => resolver.resolveLimits(req),\n}))\n```\n\n- No trailing slash (e.g. `/login`) = exact path match\n- Trailing slash (e.g. `/api/`) = prefix match\n- Rules evaluated before `resolveLimits`, before global defaults\n\n### Weighted cost\n\nEvery request counts as 1 by default. Set `cost` at any level to make a request count more:\n\n```ts\n// Global cost — all requests on this route consume 5 units\napp.use(\"/batch\", rateLimit({ maxHits: 15, windowMs: 60_000, cost: 5 }))\n\n// Dynamic cost from resolveLimits\napp.use(\"/api\", rateLimit({\n  resolveLimits: (req) => ({\n    maxHits: 100, windowMs: 60_000,\n    cost: req.method === \"POST\" ? 3 : 1,\n  }),\n}))\n\n// Cost from rules\napp.use(\"/api\", rateLimit({\n  rules: [\n    { match: { path: \"/api/upload\" }, maxHits: 10, windowMs: 60_000, cost: 10 },\n    { match: { path: \"/api/\" }, maxHits: 100, windowMs: 60_000 },\n  ],\n}))\n```\n\nCost is clamped to a minimum of 1. With `cost: 5` and `maxHits: 15`, you can send 3 requests before blocking.\n\n### Monitoring events\n\n```ts\nconst limiter = new RateLimiter({ store, strategy })\n\nlimiter.events.on(\"rate-limit:check\", (e) => {\n  console.log(`${e.allowed ? \"ALLOW\" : \"BLOCK\"} ${e.key} (${e.totalHits}/${e.maxHits})`)\n})\n\nlimiter.events.on(\"rate-limit:blocked\", (e) => {\n  console.log(`429 SENT to ${e.key}`)\n  sendAlert(e)                              // your own alerting\n})\n```\n\nEvents fire asynchronously via `process.nextTick` — zero overhead on the hot path.\n\n### Store error handling\n\nControl behaviour when the storage backend fails:\n\n```ts\napp.use(rateLimit({\n  store: new RedisStore(client),\n  strategy: new SlidingWindowStrategy(store),\n  maxHits: 100,\n  windowMs: 60_000,\n  onStoreError: \"allow\",    // default — let the request through\n  // onStoreError: \"block\", // return 429 on store failure\n}))\n```\n\nWhen a store error occurs, the limiter emits a `rate-limit:error` event so you can monitor:\n\n```ts\nlimiter.events.on(\"rate-limit:error\", (e) => {\n  console.error(\"Rate limiter store failure:\", e)\n  sendAlert(e)\n})\n```\n\n### Framework adapters\n\nThe core handler is framework-agnostic. Express middleware is built on top. For other frameworks, use `createRateLimitHandler`:\n\n```ts\nimport { createRateLimitHandler } from \"@acbp/rate-limiter\"\n\nconst handler = createRateLimitHandler({ maxHits: 100, windowMs: 60_000 })\n\n// Works with any framework:\nconst result = await handler(request)\n// result: { allowed, headers, statusCode?, body? }\n```\n\nSee [docs/community-adapters.md](docs/community-adapters.md) for the full contract.\n\n## Security\n\nSecurity is a first-class concern. Here's what rate-limiter does out of the box:\n\n### Input validation\n\nAll public API surfaces (`RateLimiter`, `createRateLimitHandler`, `rateLimit`) validate inputs at construction time. `maxHits`, `windowMs`, and `cost` must be positive finite numbers. `Infinity`, `NaN`, `0`, and negative values throw a `TypeError`.\n\nCost values are additionally clamped to a minimum of 1 at runtime via `Math.max(1, cost)`, protecting against `resolveLimits` or rule code that might return bad values.\n\n### Graceful store degradation\n\nWhen the storage backend (Redis, etc.) fails, you choose the behaviour:\n- **`onStoreError: \"allow\"`** (default) — requests pass through as if no limit was hit. Your API stays up.\n- **`onStoreError: \"block\"`** — requests receive a 429 response. Rate limits are enforced, even when the store is down.\n\nA `rate-limit:error` event fires in both cases so you can alert, log, or trigger failover.\n\n### Atomic Redis increments\n\nRedisStore uses a Lua script (`EVAL`) for atomic read-increment-write operations inside the sliding window. This eliminates TOCTOU race conditions under concurrent load (multiple requests arriving in the same millisecond).\n\nThe script is passed to Redis via the `EVAL` command — atomic by design, requiring Redis ≥ 2.6.\n\n### Prototype pollution defense\n\nThe `TierResolver` guards tier lookups with `Object.hasOwn(tiers, tier)` before bracket access, preventing prototype-pollution attacks via tier names like `__proto__`, `constructor`, or `prototype`. Resolved limits are shallow-copied before return to prevent mutation of the stored configuration.\n\n### Event listener isolation\n\nEvent listeners are fire-and-forget via `process.nextTick` and wrapped in `try/catch`. A throwing listener will never crash the process.\n\n### Minimum Node.js version\n\nRequires **Node.js ≥ 20.19.2**. This minimum patches several CVEs:\n- **CVE-2024-27982** — HTTP/2 CONTINUATION flood DoS\n- **CVE-2025-23167** — HTTP request smuggling via llhttp\n\nUsing an older version exposes your application to these known vulnerabilities.\n\n### Key safety\n\nRequest keys are capped at 1024 characters to prevent memory exhaustion from oversized identifiers. Rate limiter instances that receive requests without an identifiable IP fall back to a unique `unknown:<randomUUID>` key per instance, preventing accidental cross-instance rate limiting.\n\n## API\n\n### `rateLimit(options)`\n\nExpress middleware. Acceps the `RateLimitMiddlewareOptions`:\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `store` | `Store` | — | Storage backend |\n| `strategy` | `Strategy` | — | Rate limiting algorithm |\n| `maxHits` | `number` | — | Max requests in the window |\n| `windowMs` | `number` | — | Window duration in ms |\n| `cost` | `number` | `1` | Units consumed per request |\n| `keyGenerator` | `(req) => string` | IP address | Request identifier |\n| `resolveLimits` | `(req) => Limits` | — | Per-request limit resolver |\n| `resolveTier` | `(req) => string` | — | Tier name resolver (for rules) |\n| `rules` | `Rule[]` | — | Declarative rule list |\n| `onStoreError` | `\"allow\"` \\| `\"block\"` | `\"allow\"` | Behaviour on store failure |\n\n### Stores\n\n| Store | Backend | Requires |\n|-------|---------|----------|\n| `MemoryStore` | In-memory (local) | Nothing |\n| `RedisStore` | Redis-protocol servers | `ioredis` (optional) |\n\nBoth implement the `Store` interface. Swap them freely.\n\n### Strategies\n\n| Strategy | Algorithm | Behavior |\n|----------|-----------|----------|\n| `SlidingWindowStrategy` | Sliding window | Smooth resets, no burst edge |\n| `TokenBucketStrategy` | Token bucket | Constant rate, burst capacity |\n\nBoth implement the `Strategy` interface.\n\n### Events\n\n| Event | Payload | Fires |\n|-------|---------|-------|\n| `rate-limit:check` | `RateLimitEventPayload` | Every check |\n| `rate-limit:blocked` | `RateLimitEventPayload` | When rate limit is exceeded |\n| `rate-limit:error` | `RateLimitEventPayload` | On store failure |\n\n## Testing\n\n```bash\n# Unit tests (in-memory)\nnpm test\n\n# Memory smoke test\nnpm run smoke:memory\n\n# Redis smoke test (requires running Redis/Valkey)\ndocker compose up -d\nnpm run smoke:redis\ndocker compose down\n\n# Both in sequence\nnpm run smoke\n```\n\n## Load Testing\n\nLoad tests use [k6](https://k6.io) inside Docker Compose. Each scenario targets a specific correctness or performance dimension.\n\n### Prerequisites\n\n- Docker & Docker Compose v2.20+\n- At least 2 CPU cores and 1GB RAM available\n\n### Scenarios\n\n| Command | What it tests | Profile |\n|---------|---------------|---------|\n| `npm run load:burst` | 500 concurrent VUs × Redis SlidingWindow + TokenBucket, verify limits hold | `burst` |\n| `npm run load:accuracy` | 500 RPS × 30s sustained on MemoryStore + Redis, verify zero count drift | `accuracy` |\n| `npm run load:failover` | Kill/restart Redis mid-load, verify onStoreError behavior | `failover` |\n| `npm run load:mixed-cost` | Requests with varying costs (1, 5, 10), verify total ≤ limit | `mixed-cost` |\n| `npm run load:all` | All scenarios in sequence | — |\n\n### Running a single scenario\n\n```bash\n# Burst concurrency test\nnpm run load:burst\n\n# Failover test (orchestrated via shell script)\nnpm run load:failover\n```\n\nEach scenario prints threshold results. Exit code is non-zero if any threshold fails.\n\n### Test topology\n\n```\n┌──────────────┐    ┌──────────────────┐    ┌────────────────┐\n│ k6 container │───▶│ SUT (Express)    │───▶│ Redis (optional)│\n│ (load gen)   │    │ (rate-limiter)   │    │                │\n│ cpus: 0.5    │    │ cpus: 1.0        │    │ cpus: 0.5      │\n│ mem: 128M    │    │ mem: 256M        │    │ mem: 256M      │\n└──────────────┘    └──────────────────┘    └────────────────┘\n```\n\n## Publishing to npm\n\n```bash\n# 1. Update version\nnpm version patch|minor|major\n\n# 2. Build + publish\nnpm publish\n\n# The prepublishOnly hook runs tsc automatically\n```\n\nMake sure to replace the `repository`, `author`, `homepage`, and `bugs` fields in `package.json` with your actual GitHub details before publishing.\n\n## Project Structure\n\n```\nsrc/\n├── index.ts                Public API — barrel export\n├── rate-limiter.ts         Core limiter\n├── tier-resolver.ts        Plan-tier limit resolution\n├── rules-engine.ts         Declarative rule matching\n├── stores/\n│   ├── store.ts            Store interface — swap any backend\n│   ├── memory-store.ts     In-memory backend (zero deps)\n│   ├── redis-store.ts      Redis/Valkey backend (optional dep)\n│   └── require-dep.ts      Dynamic dep loading with helpful errors\n├── strategies/\n│   ├── strategy.ts         Strategy interface — swap any algorithm\n│   ├── sliding-window.ts   Sliding window algorithm\n│   ├── token-bucket.ts     Token bucket algorithm\n│   └── __tests__/\n├── middleware/\n│   ├── handler.ts          Framework-agnostic shared handler\n│   └── rate-limit.ts       Express middleware adapter\n└── __tests__/\n    ├── rate-limiter.test.ts\n    ├── rules-engine.test.ts\n    └── smoke-test.ts\n```\n\n## License\n\nMIT\n","readmeFilename":"README.md"}