{
  "$schema": "https://hyperjs.ai/schema/registry-item.json",
  "name": "rate-limit",
  "version": "0.1.0",
  "description": "Token-bucket rate limiting for Hyper. In-memory + pluggable stores.",
  "readme": "# @hyper/rate-limit\n\nToken-bucket rate limiting for Hyper. In-memory + pluggable stores.\n\n## Install\n\nComponents are installed as source into your repo, not pulled from npm:\n\n```bash\nbunx hyper add rate-limit\n```\n\nWires the alias `@hyper/rate-limit` to `src/hyper/rate-limit/` (configurable in `hyper.config.json`). See [hyperjs.ai](https://hyperjs.ai) for the full registry.\n\n## Usage\n\n```ts\nimport { Hyper } from \"@hyper/core\"\nimport { authRateLimitPlugin, rateLimit } from \"@hyper/rate-limit\"\n\nexport default new Hyper()\n  .use(rateLimit({ max: 100, windowMs: 60_000 }))\n  .use(authRateLimitPlugin())\n  .listen(3000)\n```\n\n## Docs\n\nSee the [main README](../../README.md) and [docs/](../../docs) for guides and integration recipes.\n\n## License\n\nMIT\n",
  "registryDeps": [
    "core"
  ],
  "peerDeps": {},
  "optionalPeerDeps": {},
  "files": [
    {
      "path": "rate-limit/index.ts",
      "contents": "/**\n * @hyper/rate-limit — token-bucket rate limiting.\n *\n * Pluggable store (default: in-memory). Default key extractor uses\n * the X-Forwarded-For / client IP; consumers can override (session id,\n * api key, etc.).\n *\n *   use(rateLimit({ window: \"1m\", limit: 60 }))\n *\n * Adds standard headers: `RateLimit-Limit`, `RateLimit-Remaining`,\n * `RateLimit-Reset`. Responds 429 with `Retry-After` on exhaustion.\n */\n\nimport { HyperError, type HyperPlugin, type Middleware, coerce } from \"@hyper/core\"\n\nexport interface RateLimitStore {\n  take(key: string, limit: number, windowMs: number): Promise<RateLimitResult>\n}\n\nexport interface RateLimitResult {\n  readonly allowed: boolean\n  readonly remaining: number\n  readonly resetMs: number\n}\n\ninterface Bucket {\n  count: number\n  reset: number\n}\n\nexport function memoryLimiter(): RateLimitStore {\n  const buckets = new Map<string, Bucket>()\n  return {\n    async take(key, limit, windowMs) {\n      const now = Date.now()\n      let b = buckets.get(key)\n      if (!b || b.reset <= now) {\n        b = { count: 0, reset: now + windowMs }\n        buckets.set(key, b)\n      }\n      b.count += 1\n      const allowed = b.count <= limit\n      const remaining = Math.max(0, limit - b.count)\n      return { allowed, remaining, resetMs: b.reset - now }\n    },\n  }\n}\n\nexport interface RateLimitConfig {\n  readonly store?: RateLimitStore\n  readonly limit: number\n  /** ms or short string (\"1m\", \"10s\", \"1h\"). */\n  readonly window: number | string\n  readonly key?: (req: Request) => string\n}\n\nexport function rateLimit(config: RateLimitConfig): Middleware {\n  const store = config.store ?? memoryLimiter()\n  const windowMs = typeof config.window === \"string\" ? parseDuration(config.window) : config.window\n  const keyFn = config.key ?? defaultKey\n\n  return async ({ req, next }) => {\n    const key = keyFn(req)\n    const r = await store.take(key, config.limit, windowMs)\n    if (!r.allowed) {\n      const retryAfter = Math.ceil(r.resetMs / 1000)\n      return new Response(JSON.stringify({ error: \"rate_limit_exceeded\", retryAfter }), {\n        status: 429,\n        headers: {\n          \"content-type\": \"application/json\",\n          \"retry-after\": retryAfter.toString(),\n          \"ratelimit-limit\": config.limit.toString(),\n          \"ratelimit-remaining\": \"0\",\n          \"ratelimit-reset\": retryAfter.toString(),\n        },\n      })\n    }\n    const out = await next()\n    const res = out instanceof Response ? out : coerce(out)\n    res.headers.set(\"ratelimit-limit\", config.limit.toString())\n    res.headers.set(\"ratelimit-remaining\", r.remaining.toString())\n    res.headers.set(\"ratelimit-reset\", Math.ceil(r.resetMs / 1000).toString())\n    return res\n  }\n}\n\nfunction defaultKey(req: Request): string {\n  return (\n    req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ??\n    req.headers.get(\"x-real-ip\") ??\n    \"anonymous\"\n  )\n}\n\n/**\n * Auto-rate-limit plugin for auth endpoints.\n *\n * Any route carrying `meta.authEndpoint === true` gets a default rate\n * limit applied without the author having to chain `.use(rateLimit(...))`\n * on every login/reset/verify handler. Route-level limits still win:\n * if the route declares its own rateLimit middleware, this plugin no-ops.\n *\n *   app({\n *     routes: [loginRoute, resetPasswordRoute],\n *     plugins: [authRateLimitPlugin({ limit: 10, window: \"1m\" })],\n *   })\n *\n * Default key: caller IP + route path. Credential-stuffing attackers\n * spraying hundreds of accounts from one IP hit the limit immediately.\n */\nexport interface AuthRateLimitConfig {\n  readonly limit?: number\n  readonly window?: number | string\n  readonly store?: RateLimitStore\n  readonly key?: (req: Request) => string\n}\n\nconst AUTH_STATE = new WeakMap<\n  Request,\n  { path: string; limit: number; remaining: number; resetMs: number }\n>()\n\nexport function authRateLimitPlugin(config: AuthRateLimitConfig = {}): HyperPlugin {\n  const limit = config.limit ?? 10\n  const window = config.window ?? \"1m\"\n  const windowMs = typeof window === \"string\" ? parseDuration(window) : window\n  const store = config.store ?? memoryLimiter()\n  const keyFn = config.key ?? defaultKey\n\n  return {\n    name: \"@hyper/rate-limit:auth\",\n    request: {\n      async before({ req, route }) {\n        if (!route?.meta?.authEndpoint) return\n        const key = `auth:${route.path}:${keyFn(req)}`\n        const r = await store.take(key, limit, windowMs)\n        AUTH_STATE.set(req, { path: route.path, limit, remaining: r.remaining, resetMs: r.resetMs })\n        if (!r.allowed) {\n          const retryAfter = Math.ceil(r.resetMs / 1000)\n          throw new HyperError({\n            status: 429,\n            code: \"rate_limit_exceeded\",\n            message: \"Too many auth attempts. Back off and retry.\",\n            why: `More than ${limit} auth attempts in the current window.`,\n            fix: `Wait ${retryAfter} seconds and retry. Consider adding CAPTCHA or progressive delays on the client.`,\n            details: { retryAfter, limit },\n          })\n        }\n      },\n      after({ req, res, route }) {\n        if (!route?.meta?.authEndpoint) return\n        const s = AUTH_STATE.get(req)\n        if (!s) return\n        AUTH_STATE.delete(req)\n        res.headers.set(\"ratelimit-limit\", s.limit.toString())\n        res.headers.set(\"ratelimit-remaining\", s.remaining.toString())\n        res.headers.set(\"ratelimit-reset\", Math.ceil(s.resetMs / 1000).toString())\n      },\n    },\n  }\n}\n\nfunction parseDuration(s: string): number {\n  const m = /^(\\d+)\\s*(ms|s|m|h|d)$/.exec(s)\n  if (!m) throw new Error(`rate-limit: invalid duration \"${s}\"`)\n  const n = Number.parseInt(m[1]!, 10)\n  switch (m[2]) {\n    case \"ms\":\n      return n\n    case \"s\":\n      return n * 1000\n    case \"m\":\n      return n * 60 * 1000\n    case \"h\":\n      return n * 60 * 60 * 1000\n    case \"d\":\n      return n * 24 * 60 * 60 * 1000\n    default:\n      throw new Error(`rate-limit: invalid unit \"${m[2]}\"`)\n  }\n}\n",
      "sha256": "29e7982259e47e287c6d420726e53eaca974b546023b2a1ff963f612879027f3"
    },
    {
      "path": "rate-limit/sqlite.ts",
      "contents": "/**\n * bun:sqlite-backed RateLimitStore — survives process restart; well-\n * suited for single-node deployments. For multi-node, use Redis.\n */\n\nimport { Database } from \"bun:sqlite\"\nimport type { RateLimitResult, RateLimitStore } from \"./index.ts\"\n\nexport interface SqliteRateLimitOptions {\n  readonly path?: string\n}\n\nexport function sqliteRateLimit(opts: SqliteRateLimitOptions = {}): RateLimitStore & {\n  readonly sweep: () => void\n  readonly close: () => void\n} {\n  const path = opts.path ?? \"./.hyper/rate-limit.sqlite\"\n  const db = new Database(path, { create: true })\n  db.exec(\"PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;\")\n  db.exec(`\n    CREATE TABLE IF NOT EXISTS hyper_rl (\n      k TEXT PRIMARY KEY,\n      count INTEGER NOT NULL,\n      reset_at INTEGER NOT NULL\n    );\n  `)\n  const stmtGet = db.prepare(\"SELECT count, reset_at AS resetAt FROM hyper_rl WHERE k = ?\")\n  const stmtInit = db.prepare(\n    \"INSERT OR REPLACE INTO hyper_rl (k, count, reset_at) VALUES (?, 1, ?)\",\n  )\n  const stmtBump = db.prepare(\"UPDATE hyper_rl SET count = count + 1 WHERE k = ?\")\n  const stmtSweep = db.prepare(\"DELETE FROM hyper_rl WHERE reset_at < ?\")\n\n  return {\n    async take(key, limit, windowMs): Promise<RateLimitResult> {\n      const now = Date.now()\n      const row = stmtGet.get(key) as { count: number; resetAt: number } | undefined\n      if (!row || row.resetAt <= now) {\n        stmtInit.run(key, now + windowMs)\n        return { allowed: true, remaining: Math.max(0, limit - 1), resetMs: windowMs }\n      }\n      stmtBump.run(key)\n      const count = row.count + 1\n      const allowed = count <= limit\n      return { allowed, remaining: Math.max(0, limit - count), resetMs: row.resetAt - now }\n    },\n    sweep: () => {\n      stmtSweep.run(Date.now())\n    },\n    close: () => db.close(),\n  }\n}\n",
      "sha256": "9254dfa14d9959b31e2420880b2cf3797d4efd4beaf38355ba0d176194b61c1a"
    }
  ],
  "subpaths": {}
}