@nexusts/limiter 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,7 +24,7 @@
24
24
  * counter-update + log-trim happens as a single SQL statement
25
25
  * (UPDATE with `WHERE` guard) so concurrent callers are safe.
26
26
  */
27
- import type { DrizzleService } from "../../drizzle/drizzle.service.js";
27
+ import type { DrizzleService } from "@nexusts/drizzle";
28
28
  import type { RateLimitKey, RateLimitResult, RateLimitStorage, RateLimitStrategy } from "../types.js";
29
29
  export interface DrizzleRateLimitOptions {
30
30
  db: DrizzleService;
package/dist/index.js.map CHANGED
@@ -4,7 +4,7 @@
4
4
  "sourcesContent": [
5
5
  "/**\n * `nexusjs/limiter` — rate limiting.\n *\n * Two ways to apply limits:\n *\n * 1. **Global** via `LimiterModule.forRoot({ rules: [...] })`:\n * limits matched against request path / method.\n *\n * 2. **Per-route** via the `@RateLimit` decorator:\n *\n * ```ts\n * @Controller('/auth')\n * class AuthController {\n * @Post('/login')\n * @RateLimit({ points: 5, duration: '1m' })\n * login() {}\n * }\n * ```\n *\n * Key derivation: by default we use `c.req.header('x-forwarded-for')`\n * or the remote address. Decorator `key` option overrides with a\n * function (e.g. user ID, API key).\n *\n * Backends:\n * - `MemoryStorage` (default, single-process)\n * - `RedisStorage` (optional, multi-process / multi-pod)\n */\n\nimport \"reflect-metadata\";\nimport { METADATA_KEY } from \"@nexusts/core\";\n\n/** Identifier of the request — IP, user ID, API key, etc. */\nexport type RateLimitKey = string;\n\n/** Strategy used to count requests. */\nexport type RateLimitStrategy =\n\t| \"fixed-window\"\n\t| \"sliding-window\"\n\t| \"token-bucket\";\n\n/**\n * Numeric size of a window. Either a millisecond count or one of\n * `'1s'`, `'1m'`, `'1h'`, `'1d'` for convenience.\n */\nexport type DurationLike = number | `${number}${\"s\" | \"m\" | \"h\" | \"d\"}`;\n\n/** Result of a single rate-limit check. */\nexport interface RateLimitResult {\n\t/** Whether the request is allowed. */\n\tallowed: boolean;\n\t/** Remaining points in the current window. */\n\tremaining: number;\n\t/** Total points in the current window. */\n\tlimit: number;\n\t/** Unix-ms timestamp when the window resets. */\n\tresetAt: number;\n\t/** Number of seconds the client should wait (only when `allowed=false`). */\n\tretryAfter: number;\n}\n\n/** Storage backend for limiter state. */\nexport interface RateLimitStorage {\n\t/**\n\t * Consume `points` units for `key`, allowing at most `limit` units\n\t * per `durationMs` window. Returns the limit result.\n\t * Implementations must be atomic across concurrent callers.\n\t */\n\tconsume(\n\t\tkey: RateLimitKey,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tstrategy: RateLimitStrategy,\n\t): Promise<RateLimitResult>;\n\n\t/** Reset all state for a key. Useful in tests. */\n\treset(key: RateLimitKey): Promise<void>;\n}\n\n/** Per-rule configuration. */\nexport interface RateLimitRule {\n\t/** Path pattern. Glob: `*` matches a single segment, `**` any depth. */\n\tpath: string;\n\t/** HTTP methods to apply to; default = all. */\n\tmethods?: string[];\n\t/** Number of allowed requests per window. */\n\tpoints: number;\n\t/** Window size. */\n\tduration: DurationLike;\n\t/** Override key derivation. */\n\tkey?: (c: any) => string | undefined | Promise<string | undefined>;\n\t/** Bucket strategy. Default `'sliding-window'`. */\n\tstrategy?: RateLimitStrategy;\n\t/** Custom rejection response. */\n\treject?: (c: any, result: RateLimitResult) => Response | Promise<Response>;\n\t/** Skip when this returns true. */\n\tskip?: (c: any) => boolean | Promise<boolean>;\n}\n\n/** Top-level configuration. */\nexport interface LimiterConfig {\n\t/** Storage backend. Default: in-memory. */\n\tstorage?: RateLimitStorage;\n\t/** Global rules applied before the per-route ones. */\n\trules?: RateLimitRule[];\n\t/** Default key derivation when a rule omits one. Default: IP address. */\n\tdefaultKey?: (c: any) => string | undefined | Promise<string | undefined>;\n\t/** Default response when a request is rejected. */\n\tdefaultReject?: (\n\t\tc: any,\n\t\tresult: RateLimitResult,\n\t) => Response | Promise<Response>;\n}\n\nexport const LIMITER_RULE_KEY = Symbol.for(\"nexus:RateLimitRule\");\n\n/** Decorator: attach a per-route rate limit. */\nexport function RateLimit(\n\trule: RateLimitRule,\n): MethodDecorator & ClassDecorator {\n\treturn (\n\t\ttarget: any,\n\t\tpropertyKey?: string | symbol,\n\t\tdescriptor?: PropertyDescriptor,\n\t) => {\n\t\t// Class-level: applied to all routes of the controller.\n\t\tif (descriptor === undefined) {\n\t\t\tconst existing: RateLimitRule[] =\n\t\t\t\tReflect.getMetadata(LIMITER_RULE_KEY, target) ?? [];\n\t\t\texisting.push({ ...rule, path: \"**\" });\n\t\t\tReflect.defineMetadata(LIMITER_RULE_KEY, existing, target);\n\t\t\treturn target;\n\t\t}\n\t\t// Method-level: bound to the route.\n\t\tconst existing: RateLimitRule[] =\n\t\t\tReflect.getMetadata(LIMITER_RULE_KEY, target.constructor) ?? [];\n\t\texisting.push({ ...rule, path: propertyKey === undefined ? \"**\" : `**` });\n\t\tReflect.defineMetadata(LIMITER_RULE_KEY, existing, target.constructor);\n\t};\n}\n\n/** Read all `@RateLimit` rules from a controller or method. */\nexport function getLimiterRules(target: any): RateLimitRule[] {\n\treturn Reflect.getMetadata(LIMITER_RULE_KEY, target) ?? [];\n}\n\n/** Convert a `DurationLike` to milliseconds. */\nexport function durationToMs(d: DurationLike): number {\n\tif (typeof d === \"number\") return d;\n\tconst m = /^(\\d+)([smhd])$/.exec(d);\n\tif (!m) throw new Error(`Invalid duration: ${d}`);\n\tconst n = Number(m[1]);\n\tconst unit = m[2] as \"s\" | \"m\" | \"h\" | \"d\";\n\tconst mult: Record<typeof unit, number> = {\n\t\ts: 1000,\n\t\tm: 60_000,\n\t\th: 3_600_000,\n\t\td: 86_400_000,\n\t};\n\treturn n * mult[unit];\n}\n",
6
6
  "/**\n * In-memory rate-limit storage. Sliding-window log by default.\n *\n * - `fixed-window`: counter reset every `durationMs` ms.\n * - `sliding-window`: counts requests in the trailing `durationMs` window.\n * - `token-bucket`: refills at `points / durationMs` tokens per ms.\n *\n * Not cluster-safe. For multi-pod deployments use `RedisStorage`.\n */\nimport type {\n\tRateLimitStorage,\n\tRateLimitKey,\n\tRateLimitStrategy,\n} from \"../types.js\";\n\ninterface FixedBucket {\n\tresetAt: number;\n\tcount: number;\n}\n\ninterface SlidingLog {\n\tlog: number[]; // unix-ms timestamps\n}\n\ninterface TokenBucket {\n\ttokens: number;\n\tupdatedAt: number;\n}\n\nexport class MemoryRateLimitStorage implements RateLimitStorage {\n\treadonly kind = \"memory\" as const;\n\tprivate fixed = new Map<RateLimitKey, FixedBucket>();\n\tprivate sliding = new Map<RateLimitKey, SlidingLog>();\n\tprivate token = new Map<RateLimitKey, TokenBucket>();\n\n\tasync consume(\n\t\tkey: RateLimitKey,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tstrategy: RateLimitStrategy = \"sliding-window\",\n\t) {\n\t\tconst now = Date.now();\n\t\tswitch (strategy) {\n\t\t\tcase \"fixed-window\":\n\t\t\t\treturn this.consumeFixed(key, points, limit, durationMs, now);\n\t\t\tcase \"sliding-window\":\n\t\t\t\treturn this.consumeSliding(key, points, limit, durationMs, now);\n\t\t\tcase \"token-bucket\":\n\t\t\t\treturn this.consumeToken(key, points, limit, durationMs, now);\n\t\t\tdefault: {\n\t\t\t\t// Exhaustive check\n\t\t\t\tconst _: never = strategy;\n\t\t\t\tthrow new Error(`Unknown strategy: ${_}`);\n\t\t\t}\n\t\t}\n\t}\n\n\tasync reset(key: RateLimitKey): Promise<void> {\n\t\tthis.fixed.delete(key);\n\t\tthis.sliding.delete(key);\n\t\tthis.token.delete(key);\n\t}\n\n\tprivate consumeFixed(\n\t\tkey: RateLimitKey,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tnow: number,\n\t) {\n\t\tlet b = this.fixed.get(key);\n\t\tif (!b || b.resetAt <= now) {\n\t\t\tb = { resetAt: now + durationMs, count: 0 };\n\t\t\tthis.fixed.set(key, b);\n\t\t}\n\t\tb.count += points;\n\t\tconst allowed = b.count <= limit;\n\t\treturn {\n\t\t\tallowed,\n\t\t\tremaining: Math.max(0, limit - b.count),\n\t\t\tlimit,\n\t\t\tresetAt: b.resetAt,\n\t\t\tretryAfter: allowed ? 0 : Math.ceil((b.resetAt - now) / 1000),\n\t\t};\n\t}\n\n\tprivate consumeSliding(\n\t\tkey: RateLimitKey,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tnow: number,\n\t) {\n\t\tlet s = this.sliding.get(key);\n\t\tif (!s) {\n\t\t\ts = { log: [] };\n\t\t\tthis.sliding.set(key, s);\n\t\t}\n\t\t// Drop entries outside the trailing window.\n\t\tconst cutoff = now - durationMs;\n\t\ts.log = s.log.filter((t) => t > cutoff);\n\t\tconst inWindow = s.log.length + points;\n\t\tconst allowed = inWindow <= limit;\n\t\tif (allowed) {\n\t\t\tfor (let i = 0; i < points; i++) s.log.push(now);\n\t\t}\n\t\tconst oldest = s.log[0] ?? now;\n\t\treturn {\n\t\t\tallowed,\n\t\t\tremaining: Math.max(0, limit - s.log.length),\n\t\t\tlimit,\n\t\t\tresetAt: now + durationMs,\n\t\t\tretryAfter: allowed ? 0 : Math.ceil((oldest + durationMs - now) / 1000),\n\t\t};\n\t}\n\n\tprivate consumeToken(\n\t\tkey: RateLimitKey,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tnow: number,\n\t) {\n\t\tlet b = this.token.get(key);\n\t\tconst refillPerMs = limit / durationMs;\n\t\tif (!b) {\n\t\t\tb = { tokens: limit, updatedAt: now };\n\t\t\tthis.token.set(key, b);\n\t\t} else {\n\t\t\tconst elapsed = now - b.updatedAt;\n\t\t\tb.tokens = Math.min(limit, b.tokens + elapsed * refillPerMs);\n\t\t\tb.updatedAt = now;\n\t\t}\n\t\tconst allowed = b.tokens >= points;\n\t\tif (allowed) b.tokens -= points;\n\t\treturn {\n\t\t\tallowed,\n\t\t\tremaining: Math.floor(b.tokens),\n\t\t\tlimit,\n\t\t\tresetAt: now + durationMs,\n\t\t\tretryAfter: allowed\n\t\t\t\t? 0\n\t\t\t\t: Math.ceil((points - b.tokens) / refillPerMs / 1000),\n\t\t};\n\t}\n}\n",
7
- "/**\n * `DrizzleRateLimitStorage` — rate-limit state in any Drizzle-backed DB.\n *\n * import { DrizzleService } from 'nexusjs/drizzle';\n * import { DrizzleRateLimitStorage } from 'nexusjs/limiter';\n *\n * const db = new DrizzleService({ dialect: 'postgres', connection: { ... } });\n * await db.open();\n * const storage = new DrizzleRateLimitStorage(db, { tableName: 'nexus_rate_limits' });\n *\n * LimiterModule.forRoot({ storage, rules: [...] });\n *\n * Schema:\n * CREATE TABLE nexus_rate_limits (\n * key TEXT PRIMARY KEY,\n * strategy TEXT NOT NULL,\n * limit INTEGER NOT NULL,\n * points INTEGER NOT NULL DEFAULT 0,\n * reset_at TIMESTAMP NOT NULL,\n * log JSONB\n * );\n *\n * Atomicity: each `consume()` runs inside a transaction. The\n * counter-update + log-trim happens as a single SQL statement\n * (UPDATE with `WHERE` guard) so concurrent callers are safe.\n */\nimport type { DrizzleService } from \"../../drizzle/drizzle.service.js\";\nimport type {\n\tRateLimitKey,\n\tRateLimitResult,\n\tRateLimitStorage,\n\tRateLimitStrategy,\n} from \"../types.js\";\n\nexport interface DrizzleRateLimitOptions {\n\tdb: DrizzleService;\n\ttableName?: string;\n}\n\ninterface Row {\n\tkey: string;\n\tstrategy: string;\n\tmax_points: number;\n\tpoints: number;\n\treset_at: string;\n\tlog: string | null;\n}\nvoid (null as unknown as Row[\"points\"]);\n\nexport class DrizzleRateLimitStorage implements RateLimitStorage {\n\treadonly kind = \"drizzle\" as const;\n\n\t#db: DrizzleService;\n\t#table: string;\n\n\tconstructor(\n\t\tdb: DrizzleService,\n\t\toptions: Omit<DrizzleRateLimitOptions, \"db\"> = {},\n\t) {\n\t\tthis.#db = db;\n\t\tthis.#table = options.tableName ?? \"nexus_rate_limits\";\n\t}\n\n\tasync consume(\n\t\tkey: RateLimitKey,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tstrategy: RateLimitStrategy = \"sliding-window\",\n\t): Promise<RateLimitResult> {\n\t\tconst now = Date.now();\n\t\tconst resetAt = now + durationMs;\n\n\t\t// 1. Read existing row.\n\t\tconst rows = await this.#db.rawQuery<Row>(\n\t\t\t`SELECT * FROM ${this.#table} WHERE key = ? LIMIT 1`,\n\t\t\t[key],\n\t\t);\n\t\tconst existing = rows[0];\n\n\t\tif (!existing) {\n\t\t\t// First call — create a new bucket.\n\t\t\tconst initialLog =\n\t\t\t\tstrategy === \"sliding-window\"\n\t\t\t\t\t? JSON.stringify(Array(points).fill(now))\n\t\t\t\t\t: null;\n\t\t\tawait this.#db.rawQuery(\n\t\t\t\t`INSERT INTO ${this.#table} (key, strategy, max_points, points, reset_at, log)\n\t\t\t\t VALUES (?, ?, ?, ?, ?, ?)`,\n\t\t\t\t[\n\t\t\t\t\tkey,\n\t\t\t\t\tstrategy,\n\t\t\t\t\tlimit,\n\t\t\t\t\tstrategy === \"sliding-window\" ? 0 : 1,\n\t\t\t\t\tnew Date(resetAt).toISOString(),\n\t\t\t\t\tinitialLog,\n\t\t\t\t],\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tallowed: true,\n\t\t\t\tremaining: limit - 1,\n\t\t\t\tlimit,\n\t\t\t\tresetAt,\n\t\t\t\tretryAfter: 0,\n\t\t\t};\n\t\t}\n\n\t\t// 2. Check the strategy and decide.\n\t\tconst result = await this.#applyStrategy(\n\t\t\texisting,\n\t\t\tpoints,\n\t\t\tlimit,\n\t\t\tdurationMs,\n\t\t\tnow,\n\t\t);\n\t\treturn result;\n\t}\n\n\tasync reset(key: RateLimitKey): Promise<void> {\n\t\tawait this.#db.rawQuery(`DELETE FROM ${this.#table} WHERE key = ?`, [key]);\n\t}\n\n\tasync #applyStrategy(\n\t\trow: Row,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tnow: number,\n\t): Promise<RateLimitResult> {\n\t\tconst strategy: RateLimitStrategy = row.strategy as RateLimitStrategy;\n\t\tconst resetAt = Number(new Date(row.reset_at).getTime());\n\n\t\tif (strategy === \"fixed-window\") {\n\t\t\t// Reset window if past.\n\t\t\tif (resetAt <= now) {\n\t\t\t\tawait this.#db.rawQuery(\n\t\t\t\t\t`UPDATE ${this.#table} SET points = 1, reset_at = ? WHERE key = ?`,\n\t\t\t\t\t[new Date(now + durationMs).toISOString(), row.key],\n\t\t\t\t);\n\t\t\t\treturn {\n\t\t\t\t\tallowed: true,\n\t\t\t\t\tremaining: limit - 1,\n\t\t\t\t\tlimit,\n\t\t\t\t\tresetAt: now + durationMs,\n\t\t\t\t\tretryAfter: 0,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst newPoints = (row.points ?? 0) + 1;\n\t\t\tconst allowed = newPoints <= limit;\n\t\t\tawait this.#db.rawQuery(\n\t\t\t\t`UPDATE ${this.#table} SET points = ? WHERE key = ?`,\n\t\t\t\t[newPoints, row.key],\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tallowed,\n\t\t\t\tremaining: Math.max(0, limit - newPoints),\n\t\t\t\tlimit,\n\t\t\t\tresetAt,\n\t\t\t\tretryAfter: allowed ? 0 : Math.ceil((resetAt - now) / 1000),\n\t\t\t};\n\t\t}\n\n\t\tif (strategy === \"sliding-window\") {\n\t\t\tconst log: number[] = row.log ? JSON.parse(row.log) : [];\n\t\t\t// Drop entries outside the window.\n\t\t\tconst cutoff = now - durationMs;\n\t\t\tconst fresh = log.filter((t) => t > cutoff);\n\t\t\tfresh.push(now);\n\t\t\tconst used = fresh.length;\n\t\t\tconst allowed = used <= limit;\n\t\t\tawait this.#db.rawQuery(\n\t\t\t\t`UPDATE ${this.#table} SET log = ?, points = ? WHERE key = ?`,\n\t\t\t\t[JSON.stringify(fresh), used, row.key],\n\t\t\t);\n\t\t\tconst oldest = fresh[0] ?? now;\n\t\t\treturn {\n\t\t\t\tallowed,\n\t\t\t\tremaining: Math.max(0, limit - used),\n\t\t\t\tlimit,\n\t\t\t\tresetAt: now + durationMs,\n\t\t\t\tretryAfter: allowed ? 0 : Math.ceil((oldest + durationMs - now) / 1000),\n\t\t\t};\n\t\t}\n\n\t\t// token-bucket: simple implementation as a counter with refill on first hit.\n\t\tif (strategy === \"token-bucket\") {\n\t\t\tconst elapsed = Math.max(0, now - resetAt);\n\t\t\tconst refillPerMs = limit / durationMs;\n\t\t\tlet tokens = Math.min(limit, (row.points ?? 0) + elapsed * refillPerMs);\n\t\t\tconst allowed = tokens >= 1;\n\t\t\tif (allowed) tokens -= 1;\n\t\t\tawait this.#db.rawQuery(\n\t\t\t\t`UPDATE ${this.#table} SET points = ?, reset_at = ? WHERE key = ?`,\n\t\t\t\t[tokens, new Date(now).toISOString(), row.key],\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tallowed,\n\t\t\t\tremaining: Math.floor(tokens),\n\t\t\t\tlimit,\n\t\t\t\tresetAt: now + durationMs,\n\t\t\t\tretryAfter: allowed ? 0 : Math.ceil((1 - tokens) / refillPerMs / 1000),\n\t\t\t};\n\t\t}\n\n\t\tthrow new Error(`Unknown strategy: ${strategy}`);\n\t}\n}\n",
7
+ "/**\n * `DrizzleRateLimitStorage` — rate-limit state in any Drizzle-backed DB.\n *\n * import { DrizzleService } from 'nexusjs/drizzle';\n * import { DrizzleRateLimitStorage } from 'nexusjs/limiter';\n *\n * const db = new DrizzleService({ dialect: 'postgres', connection: { ... } });\n * await db.open();\n * const storage = new DrizzleRateLimitStorage(db, { tableName: 'nexus_rate_limits' });\n *\n * LimiterModule.forRoot({ storage, rules: [...] });\n *\n * Schema:\n * CREATE TABLE nexus_rate_limits (\n * key TEXT PRIMARY KEY,\n * strategy TEXT NOT NULL,\n * limit INTEGER NOT NULL,\n * points INTEGER NOT NULL DEFAULT 0,\n * reset_at TIMESTAMP NOT NULL,\n * log JSONB\n * );\n *\n * Atomicity: each `consume()` runs inside a transaction. The\n * counter-update + log-trim happens as a single SQL statement\n * (UPDATE with `WHERE` guard) so concurrent callers are safe.\n */\nimport type { DrizzleService } from \"@nexusts/drizzle\";\nimport type {\n\tRateLimitKey,\n\tRateLimitResult,\n\tRateLimitStorage,\n\tRateLimitStrategy,\n} from \"../types.js\";\n\nexport interface DrizzleRateLimitOptions {\n\tdb: DrizzleService;\n\ttableName?: string;\n}\n\ninterface Row {\n\tkey: string;\n\tstrategy: string;\n\tmax_points: number;\n\tpoints: number;\n\treset_at: string;\n\tlog: string | null;\n}\nvoid (null as unknown as Row[\"points\"]);\n\nexport class DrizzleRateLimitStorage implements RateLimitStorage {\n\treadonly kind = \"drizzle\" as const;\n\n\t#db: DrizzleService;\n\t#table: string;\n\n\tconstructor(\n\t\tdb: DrizzleService,\n\t\toptions: Omit<DrizzleRateLimitOptions, \"db\"> = {},\n\t) {\n\t\tthis.#db = db;\n\t\tthis.#table = options.tableName ?? \"nexus_rate_limits\";\n\t}\n\n\tasync consume(\n\t\tkey: RateLimitKey,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tstrategy: RateLimitStrategy = \"sliding-window\",\n\t): Promise<RateLimitResult> {\n\t\tconst now = Date.now();\n\t\tconst resetAt = now + durationMs;\n\n\t\t// 1. Read existing row.\n\t\tconst rows = await this.#db.rawQuery<Row>(\n\t\t\t`SELECT * FROM ${this.#table} WHERE key = ? LIMIT 1`,\n\t\t\t[key],\n\t\t);\n\t\tconst existing = rows[0];\n\n\t\tif (!existing) {\n\t\t\t// First call — create a new bucket.\n\t\t\tconst initialLog =\n\t\t\t\tstrategy === \"sliding-window\"\n\t\t\t\t\t? JSON.stringify(Array(points).fill(now))\n\t\t\t\t\t: null;\n\t\t\tawait this.#db.rawQuery(\n\t\t\t\t`INSERT INTO ${this.#table} (key, strategy, max_points, points, reset_at, log)\n\t\t\t\t VALUES (?, ?, ?, ?, ?, ?)`,\n\t\t\t\t[\n\t\t\t\t\tkey,\n\t\t\t\t\tstrategy,\n\t\t\t\t\tlimit,\n\t\t\t\t\tstrategy === \"sliding-window\" ? 0 : 1,\n\t\t\t\t\tnew Date(resetAt).toISOString(),\n\t\t\t\t\tinitialLog,\n\t\t\t\t],\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tallowed: true,\n\t\t\t\tremaining: limit - 1,\n\t\t\t\tlimit,\n\t\t\t\tresetAt,\n\t\t\t\tretryAfter: 0,\n\t\t\t};\n\t\t}\n\n\t\t// 2. Check the strategy and decide.\n\t\tconst result = await this.#applyStrategy(\n\t\t\texisting,\n\t\t\tpoints,\n\t\t\tlimit,\n\t\t\tdurationMs,\n\t\t\tnow,\n\t\t);\n\t\treturn result;\n\t}\n\n\tasync reset(key: RateLimitKey): Promise<void> {\n\t\tawait this.#db.rawQuery(`DELETE FROM ${this.#table} WHERE key = ?`, [key]);\n\t}\n\n\tasync #applyStrategy(\n\t\trow: Row,\n\t\tpoints: number,\n\t\tlimit: number,\n\t\tdurationMs: number,\n\t\tnow: number,\n\t): Promise<RateLimitResult> {\n\t\tconst strategy: RateLimitStrategy = row.strategy as RateLimitStrategy;\n\t\tconst resetAt = Number(new Date(row.reset_at).getTime());\n\n\t\tif (strategy === \"fixed-window\") {\n\t\t\t// Reset window if past.\n\t\t\tif (resetAt <= now) {\n\t\t\t\tawait this.#db.rawQuery(\n\t\t\t\t\t`UPDATE ${this.#table} SET points = 1, reset_at = ? WHERE key = ?`,\n\t\t\t\t\t[new Date(now + durationMs).toISOString(), row.key],\n\t\t\t\t);\n\t\t\t\treturn {\n\t\t\t\t\tallowed: true,\n\t\t\t\t\tremaining: limit - 1,\n\t\t\t\t\tlimit,\n\t\t\t\t\tresetAt: now + durationMs,\n\t\t\t\t\tretryAfter: 0,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst newPoints = (row.points ?? 0) + 1;\n\t\t\tconst allowed = newPoints <= limit;\n\t\t\tawait this.#db.rawQuery(\n\t\t\t\t`UPDATE ${this.#table} SET points = ? WHERE key = ?`,\n\t\t\t\t[newPoints, row.key],\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tallowed,\n\t\t\t\tremaining: Math.max(0, limit - newPoints),\n\t\t\t\tlimit,\n\t\t\t\tresetAt,\n\t\t\t\tretryAfter: allowed ? 0 : Math.ceil((resetAt - now) / 1000),\n\t\t\t};\n\t\t}\n\n\t\tif (strategy === \"sliding-window\") {\n\t\t\tconst log: number[] = row.log ? JSON.parse(row.log) : [];\n\t\t\t// Drop entries outside the window.\n\t\t\tconst cutoff = now - durationMs;\n\t\t\tconst fresh = log.filter((t) => t > cutoff);\n\t\t\tfresh.push(now);\n\t\t\tconst used = fresh.length;\n\t\t\tconst allowed = used <= limit;\n\t\t\tawait this.#db.rawQuery(\n\t\t\t\t`UPDATE ${this.#table} SET log = ?, points = ? WHERE key = ?`,\n\t\t\t\t[JSON.stringify(fresh), used, row.key],\n\t\t\t);\n\t\t\tconst oldest = fresh[0] ?? now;\n\t\t\treturn {\n\t\t\t\tallowed,\n\t\t\t\tremaining: Math.max(0, limit - used),\n\t\t\t\tlimit,\n\t\t\t\tresetAt: now + durationMs,\n\t\t\t\tretryAfter: allowed ? 0 : Math.ceil((oldest + durationMs - now) / 1000),\n\t\t\t};\n\t\t}\n\n\t\t// token-bucket: simple implementation as a counter with refill on first hit.\n\t\tif (strategy === \"token-bucket\") {\n\t\t\tconst elapsed = Math.max(0, now - resetAt);\n\t\t\tconst refillPerMs = limit / durationMs;\n\t\t\tlet tokens = Math.min(limit, (row.points ?? 0) + elapsed * refillPerMs);\n\t\t\tconst allowed = tokens >= 1;\n\t\t\tif (allowed) tokens -= 1;\n\t\t\tawait this.#db.rawQuery(\n\t\t\t\t`UPDATE ${this.#table} SET points = ?, reset_at = ? WHERE key = ?`,\n\t\t\t\t[tokens, new Date(now).toISOString(), row.key],\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tallowed,\n\t\t\t\tremaining: Math.floor(tokens),\n\t\t\t\tlimit,\n\t\t\t\tresetAt: now + durationMs,\n\t\t\t\tretryAfter: allowed ? 0 : Math.ceil((1 - tokens) / refillPerMs / 1000),\n\t\t\t};\n\t\t}\n\n\t\tthrow new Error(`Unknown strategy: ${strategy}`);\n\t}\n}\n",
8
8
  "/**\n * `LimiterService` — single entry point for in-process rate-limit checks.\n *\n * Holds a `RateLimitStorage` and the global `LimiterConfig` so the\n * middleware and the `@RateLimit` decorator share one source of truth.\n *\n * const svc = new LimiterService({ storage: new MemoryRateLimitStorage() });\n * await svc.check('ip:1.2.3.4', { points: 5, duration: '1m' });\n */\nimport { Inject, Injectable } from \"@nexusts/core\";\nimport { MemoryRateLimitStorage } from \"./backends/memory.js\";\nimport type {\n\tLimiterConfig,\n\tRateLimitResult,\n\tRateLimitRule,\n\tRateLimitStorage,\n} from \"./types.js\";\nimport { durationToMs } from \"./types.js\";\n\n@Injectable()\nexport class LimiterService {\n\t/** DI token — `@Inject(LimiterService.TOKEN)`. */\n\tstatic readonly TOKEN = Symbol.for(\"nexus:LimiterService\");\n\n\tstorage: RateLimitStorage;\n\trules: RateLimitRule[];\n\tdefaultKey: NonNullable<LimiterConfig[\"defaultKey\"]>;\n\tdefaultReject: NonNullable<LimiterConfig[\"defaultReject\"]>;\n\n\tconstructor(@Inject(\"LIMITER_CONFIG\") config: LimiterConfig = {}) {\n\t\tthis.storage = config.storage ?? new MemoryRateLimitStorage();\n\t\tthis.rules = config.rules ?? [];\n\t\tthis.defaultKey =\n\t\t\tconfig.defaultKey ??\n\t\t\t((c: any) => {\n\t\t\t\tconst fwd = c?.req?.header?.(\"x-forwarded-for\");\n\t\t\t\tif (fwd) return fwd.split(\",\")[0]?.trim() ?? \"unknown\";\n\t\t\t\treturn c?.req?.raw?.[\"conn\"]?.remoteAddr?.hostname ?? \"unknown\";\n\t\t\t});\n\t\tthis.defaultReject =\n\t\t\tconfig.defaultReject ??\n\t\t\t((_c, result) =>\n\t\t\t\tnew Response(\n\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\terror: \"Too Many Requests\",\n\t\t\t\t\t\tlimit: result.limit,\n\t\t\t\t\t\tremaining: 0,\n\t\t\t\t\t\tretryAfter: result.retryAfter,\n\t\t\t\t\t}),\n\t\t\t\t\t{\n\t\t\t\t\t\tstatus: 429,\n\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t\t\t\t\t\"Retry-After\": String(result.retryAfter),\n\t\t\t\t\t\t\t\"X-RateLimit-Limit\": String(result.limit),\n\t\t\t\t\t\t\t\"X-RateLimit-Remaining\": \"0\",\n\t\t\t\t\t\t\t\"X-RateLimit-Reset\": String(Math.ceil(result.resetAt / 1000)),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t));\n\t}\n\n\t/**\n\t * Check a single rule against `key`. Always consumes one point\n\t * (or rejects).\n\t */\n\tasync check(key: string, rule: RateLimitRule): Promise<RateLimitResult> {\n\t\tconst durationMs = durationToMs(rule.duration);\n\t\treturn this.storage.consume(\n\t\t\tkey,\n\t\t\t1,\n\t\t\trule.points,\n\t\t\tdurationMs,\n\t\t\trule.strategy ?? \"sliding-window\",\n\t\t);\n\t}\n\n\t/** Reset the state for a given key. */\n\tasync reset(key: string): Promise<void> {\n\t\tawait this.storage.reset(key);\n\t}\n}\n",
9
9
  "/**\n * Hono middleware factory. Applies all matching global rules in order;\n * the first one that rejects wins. Used by the framework's mount pipeline.\n */\nimport { Inject, Injectable } from \"@nexusts/core\";\nimport { LimiterService } from \"./limiter.service.js\";\nimport type { RateLimitRule } from \"./types.js\";\n\n@Injectable()\nexport class LimiterMiddleware {\n\t/** DI token. */\n\tstatic readonly TOKEN = Symbol.for(\"nexus:LimiterMiddleware\");\n\n\tconstructor(@Inject(LimiterService.TOKEN) private readonly limiter: LimiterService) {}\n\n\t/** Returns a Hono middleware. */\n\tmiddleware() {\n\t\treturn async (c: any, next: () => Promise<any>) => {\n\t\t\tconst method = c.req.method.toUpperCase();\n\t\t\tfor (const rule of this.limiter.rules) {\n\t\t\t\tif (!this.matches(rule, method, c.req.path)) continue;\n\t\t\t\tif (rule.skip && (await rule.skip(c))) continue;\n\t\t\t\tconst keyFn = rule.key ?? this.limiter.defaultKey;\n\t\t\t\tconst key = (await keyFn(c)) ?? \"unknown\";\n\t\t\t\tconst result = await this.limiter.check(key, rule);\n\t\t\t\tc.header?.(\"X-RateLimit-Limit\", String(result.limit));\n\t\t\t\tc.header?.(\"X-RateLimit-Remaining\", String(result.remaining));\n\t\t\t\tc.header?.(\"X-RateLimit-Reset\", String(Math.ceil(result.resetAt / 1000)));\n\t\t\t\tif (!result.allowed) {\n\t\t\t\t\tconst reject = rule.reject ?? this.limiter.defaultReject;\n\t\t\t\t\treturn reject(c, result);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn next();\n\t\t};\n\t}\n\n\tprivate matches(rule: RateLimitRule, method: string, path: string): boolean {\n\t\tif (rule.methods && rule.methods.length > 0) {\n\t\t\tif (!rule.methods.map((m) => m.toUpperCase()).includes(method)) return false;\n\t\t}\n\t\tif (rule.path === \"**\") return true;\n\t\treturn matchGlob(rule.path, path);\n\t}\n}\n\n/** Glob match: `*` = one segment, `**` = any depth. */\nfunction matchGlob(pattern: string, path: string): boolean {\n\tconst regex = new RegExp(\n\t\t\"^\" +\n\t\t\tpattern\n\t\t\t\t.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\")\n\t\t\t\t.replace(/\\*\\*/g, \"::DOUBLESTAR::\")\n\t\t\t\t.replace(/\\*/g, \"[^/]+\")\n\t\t\t\t.replace(/::DOUBLESTAR::/g, \".*\") +\n\t\t\t\"/?$\",\n\t);\n\treturn regex.test(path);\n}\n",
10
10
  "/**\n * `LimiterModule` — drop-in rate limiter.\n *\n * @Module({\n * imports: [\n * LimiterModule.forRoot({\n * rules: [\n * { path: '/api/*', points: 100, duration: '1m' },\n * { path: '/login', points: 5, duration: '1m' },\n * ],\n * }),\n * ],\n * })\n * export class AppModule {}\n */\nimport \"reflect-metadata\";\nimport { Module } from \"@nexusts/core\";\nimport { LimiterService } from \"./limiter.service.js\";\nimport { LimiterMiddleware } from \"./limiter.middleware.js\";\nimport { MemoryRateLimitStorage } from \"./backends/memory.js\";\nimport type { LimiterConfig } from \"./types.js\";\n\n@Module({\n\tproviders: [\n\t\tLimiterService,\n\t\t{ provide: LimiterService.TOKEN, useExisting: LimiterService },\n\t\tLimiterMiddleware,\n\t\t{ provide: LimiterMiddleware.TOKEN, useExisting: LimiterMiddleware },\n\t],\n\texports: [\n\t\tLimiterService,\n\t\tLimiterService.TOKEN,\n\t\tLimiterMiddleware,\n\t\tLimiterMiddleware.TOKEN,\n\t],\n})\nexport class LimiterModule {\n\tstatic forRoot(config: LimiterConfig = {}) {\n\t\t// Default to an in-memory storage if the user didn't supply one.\n\t\tconst cfg: LimiterConfig = {\n\t\t\tstorage: new MemoryRateLimitStorage(),\n\t\t\t...config,\n\t\t};\n\t\t@Module({\n\t\t\tproviders: [\n\t\t\t\tLimiterService,\n\t\t\t\t{ provide: LimiterService.TOKEN, useExisting: LimiterService },\n\t\t\t\tLimiterMiddleware,\n\t\t\t\t{ provide: LimiterMiddleware.TOKEN, useExisting: LimiterMiddleware },\n\t\t\t\t{ provide: \"LIMITER_CONFIG\", useValue: cfg },\n\t\t\t],\n\t\t\texports: [\n\t\t\t\tLimiterService,\n\t\t\t\tLimiterService.TOKEN,\n\t\t\t\tLimiterMiddleware,\n\t\t\t\tLimiterMiddleware.TOKEN,\n\t\t\t],\n\t\t})\n\t\tclass ConfiguredLimiterModule {}\n\t\tObject.defineProperty(ConfiguredLimiterModule, \"name\", {\n\t\t\tvalue: \"ConfiguredLimiterModule\",\n\t\t});\n\t\treturn ConfiguredLimiterModule;\n\t}\n}\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexusts/limiter",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Rate limiting (fixed / sliding / token-bucket)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,6 +26,6 @@
26
26
  ],
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@nexusts/core": "^0.7.3"
29
+ "@nexusts/core": "^0.7.4"
30
30
  }
31
31
  }