UNPKG

@adonisjs/limiter

Version:

Rate limiting package for AdonisJS framework

434 lines (427 loc) 12.1 kB
import { __export } from "./chunk-MLKGABMK.js"; // src/stores/bridge.ts import string from "@adonisjs/core/helpers/string"; import { RateLimiterRes } from "rate-limiter-flexible"; // src/debug.ts import { debuglog } from "util"; var debug_default = debuglog("adonisjs:limiter"); // src/response.ts var LimiterResponse = class { /** * Allowed number of requests for a pre-defined * duration */ limit; /** * Requests remaining for the pre-defined duration */ remaining; /** * Requests consumed for the pre-defined duration */ consumed; /** * Number of seconds after which the requests count will * reset */ availableIn; constructor(rawResponse) { this.limit = rawResponse.limit; this.remaining = rawResponse.remaining; this.consumed = rawResponse.consumed; this.availableIn = rawResponse.availableIn; } /** * Returns a JSON representation of the limiter response. * * @example * ```ts * const response = limiter.get('user:1') * console.log(response.toJSON()) * // { limit: 10, remaining: 5, consumed: 5, availableIn: 30 } * ``` */ toJSON() { return { limit: this.limit, remaining: this.remaining, consumed: this.consumed, availableIn: this.availableIn }; } }; // src/errors.ts var errors_exports = {}; __export(errors_exports, { E_TOO_MANY_REQUESTS: () => E_TOO_MANY_REQUESTS, ThrottleException: () => ThrottleException }); import { Exception } from "@adonisjs/core/exceptions"; var ThrottleException = class extends Exception { constructor(response, options) { super("Too many requests", options); this.response = response; } message = "Too many requests"; status = 429; code = "E_TOO_MANY_REQUESTS"; /** * Error identifier to lookup translation message */ identifier = "errors.E_TOO_MANY_REQUESTS"; /** * The response headers to set when converting exception * to response */ headers; /** * Translation identifier to use for creating throttle * response. */ translation; /** * Returns the default rate limit headers that will be sent in the HTTP response. * Includes limit information, remaining requests, retry-after time, and reset time. */ getDefaultHeaders() { return { "X-RateLimit-Limit": this.response.limit, "X-RateLimit-Remaining": this.response.remaining, "Retry-After": this.response.availableIn, "X-RateLimit-Reset": new Date(Date.now() + this.response.availableIn * 1e3).toISOString() }; } /** * Returns the message to be sent in the HTTP response. * Supports i18n translations when the i18n package is available. * * @param ctx - The HTTP context */ getResponseMessage(ctx) { if ("i18n" in ctx) { const identifier = this.translation?.identifier || this.identifier; const data = this.translation?.data || {}; return ctx.i18n.t(identifier, data, this.message); } return this.message; } /** * Updates the default error message. * * @param message - The new error message * * @example * ```ts * throw new ThrottleException(response) * .setMessage('Rate limit exceeded. Please try again later.') * ``` */ setMessage(message) { this.message = message; return this; } /** * Updates the default error status code. * * @param status - The HTTP status code * * @example * ```ts * throw new ThrottleException(response) * .setStatus(503) * ``` */ setStatus(status) { this.status = status; return this; } /** * Defines custom response headers. This will replace the default headers. * * @param headers - Custom headers to set * * @example * ```ts * throw new ThrottleException(response) * .setHeaders({ * 'X-Custom-Header': 'value', * 'Retry-After': 60 * }) * ``` */ setHeaders(headers) { this.headers = headers; return this; } /** * Defines the i18n translation identifier for the throttle response message. * * @param identifier - The translation key * @param data - Optional translation data * * @example * ```ts * throw new ThrottleException(response) * .t('errors.rate_limit_exceeded', { minutes: 5 }) * ``` */ t(identifier, data) { this.translation = { identifier, data }; return this; } /** * Converts the throttle exception to an HTTP response. * Automatically sets appropriate headers and formats the response * based on the Accept header (HTML, JSON, or JSON:API). * * @param error - The throttle exception instance * @param ctx - The HTTP context */ async handle(error, ctx) { const status = error.status; const message = this.getResponseMessage(ctx); const headers = this.headers || this.getDefaultHeaders(); Object.keys(headers).forEach((header) => ctx.response.header(header, headers[header])); switch (ctx.request.accepts(["html", "application/vnd.api+json", "json"])) { case "html": case null: ctx.response.status(status).send(message); break; case "json": ctx.response.status(status).send({ errors: [ { message, retryAfter: this.response.availableIn } ] }); break; case "application/vnd.api+json": ctx.response.status(status).send({ errors: [ { code: this.code, title: message, meta: { retryAfter: this.response.availableIn } } ] }); break; } } }; var E_TOO_MANY_REQUESTS = ThrottleException; // src/stores/bridge.ts var RateLimiterBridge = class { rateLimiter; /** * The number of configured requests on the store */ get requests() { return this.rateLimiter.points; } /** * The duration (in seconds) for which the requests are configured */ get duration() { return this.rateLimiter.duration; } /** * The duration (in seconds) for which to block the key */ get blockDuration() { return this.rateLimiter.blockDuration; } constructor(rateLimiter) { this.rateLimiter = rateLimiter; } /** * Transforms a rate-limiter-flexible response into an AdonisJS LimiterResponse. * * @param response - Raw response from rate-limiter-flexible */ makeLimiterResponse(response) { return new LimiterResponse({ limit: this.rateLimiter.points, remaining: response.remainingPoints, consumed: response.consumedPoints, availableIn: Math.ceil(response.msBeforeNext / 1e3) }); } /** * Consumes one request for the given key. Throws a ThrottleException * when the rate limit is exceeded or the key is blocked. * * @param key - Unique identifier for the rate limit (e.g., user ID, IP address) * * @example * ```ts * const response = await limiter.consume('user:123') * console.log(`Remaining: ${response.remaining}`) * ``` */ async consume(key, amount) { const consumeAmount = amount !== void 0 && amount > 0 ? amount : 1; try { const response = await this.rateLimiter.consume(key, consumeAmount); debug_default("request consumed for key %s with amount %d", key, consumeAmount); return this.makeLimiterResponse(response); } catch (errorResponse) { debug_default("unable to consume request for key %s, %O", key, errorResponse); if (errorResponse instanceof RateLimiterRes) { throw new E_TOO_MANY_REQUESTS(this.makeLimiterResponse(errorResponse)); } throw errorResponse; } } /** * Increments the consumed request count for the given key. * Unlike consume(), this method does not throw when the limit is reached. * * @param key - Unique identifier for the rate limit * * @example * ```ts * const response = await limiter.increment('user:123') * ``` */ async increment(key, amount = 1) { if (amount <= 0) { debug_default('invalid increment amount "%d" provided. Falling back to 1', amount); amount = 1; } const response = await this.rateLimiter.penalty(key, amount); debug_default("increased requests count for key %s", key); return this.makeLimiterResponse(response); } /** * Decrements the consumed request count for the given key. * Will not decrement below zero. * * @param key - Unique identifier for the rate limit * * @example * ```ts * const response = await limiter.decrement('user:123') * ``` */ async decrement(key, amount = 1) { const existingKey = await this.rateLimiter.get(key); if (!existingKey) { return this.set(key, 0, this.duration); } if (amount <= 0) { debug_default('invalid decrement amount "%d" provided. Falling back to 1', amount); amount = 1; } if (existingKey.consumedPoints <= 0) { return this.makeLimiterResponse(existingKey); } if (amount > existingKey.consumedPoints) { amount = existingKey.consumedPoints; } const response = await this.rateLimiter.reward(key, amount); debug_default("decreased requests count for key %s", key); return this.makeLimiterResponse(response); } /** * Blocks the given key for the specified duration, preventing any requests. * * @param key - Unique identifier for the rate limit * @param duration - Block duration in seconds or as a time expression (e.g., '5 mins') * * @example * ```ts * await limiter.block('user:123', '10 mins') * await limiter.block('ip:192.168.1.1', 600) * ``` */ async block(key, duration) { const response = await this.rateLimiter.block(key, string.seconds.parse(duration)); debug_default("blocked key %s", key); return this.makeLimiterResponse(response); } /** * Manually sets the number of consumed requests for a given key. * * @param key - Unique identifier for the rate limit * @param requests - Number of requests consumed * @param duration - Optional duration in seconds or time expression * * @example * ```ts * // Set that user has consumed 20 requests out of 25 allowed * await limiter.set('user:123', 20, '1 minute') * ``` */ async set(key, requests, duration) { const response = await this.rateLimiter.set( key, requests, duration ? string.seconds.parse(duration) : this.duration ); debug_default("updated key %s with requests: %s, duration: %s", key, requests, duration); const remaining = this.requests - response.consumedPoints; const limiterResponse = this.makeLimiterResponse(response); limiterResponse.remaining = remaining < 0 ? 0 : remaining; return limiterResponse; } /** * Deletes the given key, resetting its rate limit state. * * @param key - Unique identifier for the rate limit * * @example * ```ts * await limiter.delete('user:123') * ``` */ delete(key) { debug_default("deleting key %s", key); return this.rateLimiter.delete(key); } /** * Deletes all keys that are blocked in memory. * Only applicable for stores with in-memory blocking enabled. */ deleteInMemoryBlockedKeys() { if ("deleteInMemoryBlockedAll" in this.rateLimiter) { return this.rateLimiter.deleteInMemoryBlockedAll(); } } /** * Retrieves the current rate limit state for the given key. * * @param key - Unique identifier for the rate limit * * @example * ```ts * const response = await limiter.get('user:123') * if (response) { * console.log(`Remaining: ${response.remaining}`) * } * ``` */ async get(key) { const response = await this.rateLimiter.get(key); debug_default("fetching key %s, %O", key, response); if (!response || Number.isNaN(response.remainingPoints)) { return null; } return this.makeLimiterResponse(response); } }; export { E_TOO_MANY_REQUESTS, errors_exports, LimiterResponse, debug_default, RateLimiterBridge };