UNPKG

@adonisjs/limiter

Version:

Rate limiting package for AdonisJS framework

910 lines (899 loc) 26.4 kB
import { LimiterMemoryStore } from "./chunk-UY55CR5H.js"; import { E_TOO_MANY_REQUESTS, debug_default } from "./chunk-3YOYZ7DU.js"; // configure.ts import string from "@adonisjs/core/helpers/string"; // stubs/main.ts var stubsRoot = import.meta.dirname; // configure.ts var KNOWN_STORES = ["database", "redis"]; async function configure(command) { let selectedStore = command.parsedFlags.store; if (!selectedStore) { selectedStore = await command.prompt.choice( "Select the storage layer you want to use", KNOWN_STORES, { validate(value) { return !value ? "Please select a store" : true; } } ); } if (!KNOWN_STORES.includes(selectedStore)) { command.exitCode = 1; command.logger.logError( `Invalid limiter store "${selectedStore}". Supported stores are: ${string.sentence( KNOWN_STORES )}` ); return; } const codemods = await command.createCodemods(); await codemods.makeUsingStub(stubsRoot, "config/limiter.stub", { store: selectedStore }); await codemods.makeUsingStub(stubsRoot, "start/limiter.stub", {}); await codemods.updateRcFile((rcFile) => { rcFile.addProvider("@adonisjs/limiter/limiter_provider"); }); if (selectedStore === "database") { await codemods.makeUsingStub(stubsRoot, "make/migration/rate_limits.stub", { entity: command.app.generators.createEntity("rate_limits"), migration: { folder: "database/migrations", fileName: `${(/* @__PURE__ */ new Date()).getTime()}_create_rate_limits_table.ts` } }); } await codemods.defineEnvVariables({ LIMITER_STORE: selectedStore }); await codemods.defineEnvValidations({ leadingComment: "Variables for configuring the limiter package", variables: { LIMITER_STORE: `Env.schema.enum(['${selectedStore}', 'memory'] as const)` } }); } // src/limiter.ts var Limiter = class { #store; get name() { return this.#store.name; } /** * The number of configured requests on the store */ get requests() { return this.#store.requests; } /** * The duration (in seconds) for which the requests are configured */ get duration() { return this.#store.duration; } /** * The duration (in seconds) for which to block the key */ get blockDuration() { return this.#store.blockDuration; } constructor(store) { this.#store = store; } /** * 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 */ consume(key, amount) { return this.#store.consume(key, amount); } /** * 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 * @param amount - Number of requests to increment (default: 1) */ increment(key, amount) { return this.#store.increment(key, amount); } /** * Decrements the consumed request count for the given key. * Will not decrement below zero. * * @param key - Unique identifier for the rate limit * @param amount - Number of requests to decrement (default: 1) */ decrement(key, amount) { return this.#store.decrement(key, amount); } /** * Attempts to consume one request and execute the callback if successful. * Returns undefined if the rate limit is exceeded. * * @param key - Unique identifier for the rate limit * @param callback - Function to execute if rate limit allows * * @example * ```ts * const result = await limiter.attempt('user:123', async () => { * return await performExpensiveOperation() * }) * * if (!result) { * console.log('Rate limit exceeded') * } * ``` */ async attempt(key, callback, amount) { const response = await this.get(key); if (response && response.consumed > response.limit) { return; } try { await this.consume(key, amount); return callback(); } catch (error) { if (error instanceof E_TOO_MANY_REQUESTS === false) { throw error; } } } /** * Executes the callback and penalizes on failure by consuming a request. * Useful for rate limiting failed operations (e.g., login attempts). * * - Returns error if rate limit is exhausted * - Executes callback if requests are available * - Increments counter and blocks key on callback failure * - Resets key on callback success * * @param key - Unique identifier for the rate limit * @param callback - Function to execute * * @example * ```ts * const [error, user] = await limiter.penalize('login:user@example.com', async () => { * return await attemptLogin(credentials) * }) * * if (error) { * throw error * } * ``` */ async penalize(key, callback, amount) { const response = await this.get(key); if (response && response.remaining <= 0) { return [new E_TOO_MANY_REQUESTS(response), null]; } let callbackResult; let callbackError; try { callbackResult = await callback(); } catch (error) { callbackError = error; } if (callbackError) { const { consumed, limit } = await this.increment(key, amount); if (consumed >= limit && this.blockDuration) { await this.block(key, this.blockDuration); } throw callbackError; } await this.delete(key); return [null, callbackResult]; } /** * 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 */ block(key, duration) { return this.#store.block(key, duration); } /** * 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 */ set(key, requests, duration) { return this.#store.set(key, requests, duration); } /** * Deletes the given key, resetting its rate limit state. * * @param key - Unique identifier for the rate limit */ delete(key) { return this.#store.delete(key); } /** * Deletes all keys that are blocked in memory. * Only applicable for stores with in-memory blocking enabled. */ deleteInMemoryBlockedKeys() { return this.#store.deleteInMemoryBlockedKeys?.(); } /** * Retrieves the current rate limit state for the given key. * * @param key - Unique identifier for the rate limit */ get(key) { return this.#store.get(key); } /** * Returns the number of remaining requests for the given key. * * @param key - Unique identifier for the rate limit * * @example * ```ts * const remaining = await limiter.remaining('user:123') * console.log(`You have ${remaining} requests left`) * ``` */ async remaining(key) { const response = await this.get(key); if (!response) { return this.requests; } return response.remaining; } /** * Returns the number of seconds until the key will be available for new requests. * Returns 0 if requests are currently available. * * @param key - Unique identifier for the rate limit * * @example * ```ts * const seconds = await limiter.availableIn('user:123') * console.log(`Try again in ${seconds} seconds`) * ``` */ async availableIn(key) { const response = await this.get(key); if (!response) { return 0; } return response.remaining === 0 ? response.availableIn : 0; } /** * Checks if the given key is currently blocked (rate limit exceeded). * * @param key - Unique identifier for the rate limit * * @example * ```ts * if (await limiter.isBlocked('user:123')) { * console.log('Rate limit exceeded') * } * ``` */ async isBlocked(key) { const response = await this.get(key); if (!response) { return false; } return response.consumed >= response.limit; } /** * Clears the entire storage, removing all rate limit data. */ clear() { return this.#store.clear(); } }; // src/http_limiter.ts import { RuntimeException } from "@adonisjs/core/exceptions"; var HttpLimiter = class { /** * The manager reference to create limiter instances * for a given store */ #manager; /** * The runtime options configured using the fluent * API */ #options; /** * The selected store. Otherwise the default store will * be used */ #store; /** * The key to unique identify the user. Defaults to "request.ip" */ #key; /** * A custom callback function to modify error messages. */ #exceptionModifier = () => { }; constructor(manager, options) { this.#manager = manager; this.#options = options || {}; } /** * Specifies which store to use for this rate limiter. * * @param store - Name of the configured store * * @example * ```ts * limiter * .allowRequests(100) * .every('1 hour') * .store('redis') * ``` */ store(store) { this.#store = store; return this; } /** * Sets the number of requests to allow during the specified duration. * * @param requests - Maximum number of requests * * @example * ```ts * limiter.allowRequests(100) * ``` */ allowRequests(requests) { this.#options.requests = requests; return this; } /** * Sets the duration window for the rate limit. * * @param duration - Duration in seconds or time expression (e.g., '1 minute', '1 hour') * * @example * ```ts * limiter.allowRequests(100).every('1 hour') * limiter.allowRequests(10).every(60) // 60 seconds * ``` */ every(duration) { this.#options.duration = duration; return this; } /** * Sets a custom key to uniquely identify the requester. * By default, the request IP address is used. * * @param key - Custom identifier (e.g., user ID, API key) * * @example * ```ts * limiter * .allowRequests(100) * .every('1 hour') * .usingKey(ctx.auth.user.id) * ``` */ usingKey(key) { this.#key = key; return this; } /** * Registers a callback to customize the ThrottleException before it's thrown. * Useful for setting custom error messages or translations. * * @param callback - Function to modify the exception * * @example * ```ts * limiter * .allowRequests(100) * .every('1 hour') * .limitExceeded((error) => { * error.setMessage('Too many requests. Please slow down!') * error.t('errors.rate_limit_exceeded') * }) * ``` */ limitExceeded(callback) { this.#exceptionModifier = callback; return this; } /** * Sets the block duration to penalize users who exceed the rate limit. * The key will be blocked for this duration after exhausting all requests. * * @param duration - Block duration in seconds or time expression * * @example * ```ts * limiter * .allowRequests(100) * .every('1 hour') * .blockFor('15 mins') * ``` */ blockFor(duration) { this.#options.blockDuration = duration; return this; } /** * Returns a JSON representation of the HTTP limiter configuration. */ toJSON() { return { store: this.#store, ...this.#options }; } /** * Throttles the HTTP request using the configured options. * Throws a ThrottleException if the rate limit is exceeded. * * @param prefix - Key prefix to namespace the limiter * @param ctx - HTTP context * * @example * ```ts * const response = await httpLimiter.throttle('api', ctx) * console.log(`Remaining: ${response.remaining}`) * ``` */ async throttle(prefix, ctx) { if (!this.#options.requests || !this.#options.duration) { throw new RuntimeException( `Cannot throttle requests for "${prefix}" limiter. Make sure to define the allowed requests and duration` ); } const limiter = this.#store ? this.#manager.use(this.#store, this.#options) : this.#manager.use(this.#options); const key = `${prefix}_${this.#key || `ip_${ctx.request.ip()}`}`; debug_default('throttling HTTP request for key "%s"', key); const limiterResponse = await limiter.get(key); if (limiterResponse && limiterResponse.consumed > limiterResponse.limit) { debug_default('requests exhausted for key "%s"', key); const error = new E_TOO_MANY_REQUESTS(limiterResponse); this.#exceptionModifier(error); throw error; } try { const consumeResponse = await limiter.consume(key); return consumeResponse; } catch (error) { if (error instanceof E_TOO_MANY_REQUESTS) { debug_default('requests exhausted for key "%s"', key); this.#exceptionModifier(error); } throw error; } } }; // src/limiter_manager.ts import string2 from "@adonisjs/core/helpers/string"; import { RuntimeException as RuntimeException2 } from "@adonisjs/core/exceptions"; // src/multi_limiter.ts var MultiLimiter = class { #limiters; constructor(limiters) { this.#limiters = limiters; } /** * Returns the list of configured limiters with their keys. */ list() { return this.#limiters; } /** * Consumes one request across all limiters sequentially. * Throws a ThrottleException if any limiter exceeds its rate limit. * * @example * ```ts * const multi = limiter.multi([ * { key: 'user:123', requests: 100, duration: '1 hour' }, * { key: 'ip:192.168.1.1', requests: 1000, duration: '1 hour' } * ]) * * const responses = await multi.consume() * ``` */ async consume() { const responses = []; for (let { key, limiter } of this.#limiters) { const response = await limiter.consume(key); responses.push(response); } return responses; } /** * Increments the consumed request count across all limiters. * Does not throw when limits are reached. */ async increment() { return Promise.all(this.#limiters.map(({ key, limiter }) => limiter.increment(key))); } /** * Decrements the consumed request count across all limiters. * Will not decrement below zero. */ async decrement() { return Promise.all(this.#limiters.map(({ key, limiter }) => limiter.decrement(key))); } /** * Retrieves the current rate limit state for all limiters. */ get() { return Promise.all(this.#limiters.map(({ key, limiter }) => limiter.get(key))); } /** * Sets the number of consumed requests for all limiters. * * @param requests - Number of requests consumed * @param duration - Optional duration in seconds or time expression */ set(requests, duration) { return Promise.all( this.#limiters.map(({ key, limiter }) => limiter.set(key, requests, duration)) ); } /** * Deletes all limiter keys, resetting their rate limit states. */ delete() { return Promise.all(this.#limiters.map(({ key, limiter }) => limiter.delete(key))); } /** * Attempts to consume requests across all limiters and execute the callback if successful. * Returns undefined if any rate limit is exceeded. * * @param callback - Function to execute if all rate limits allow * * @example * ```ts * const result = await multi.attempt(async () => { * return await performOperation() * }) * * if (!result) { * console.log('Rate limit exceeded on one or more limiters') * } * ``` */ async attempt(callback) { try { await this.consume(); return callback(); } catch (error) { if (error instanceof E_TOO_MANY_REQUESTS === false) { throw error; } } } /** * Executes the callback and penalizes on failure by consuming requests across all limiters. * Useful for rate limiting failed operations across multiple dimensions. * * - Returns error if any rate limit is exhausted * - Executes callback if all limiters have available requests * - Increments counters and blocks keys on callback failure * - Resets all keys on callback success * * @param callback - Function to execute * * @example * ```ts * const [error, result] = await multi.penalize(async () => { * return await attemptLogin(credentials) * }) * * if (error) { * throw error * } * ``` */ async penalize(callback) { const responses = await this.get(); const exhaustedResponse = responses.find((response) => response && response.remaining <= 0); if (exhaustedResponse) { return [new E_TOO_MANY_REQUESTS(exhaustedResponse), null]; } let callbackResult; let callbackError; try { callbackResult = await callback(); } catch (error) { callbackError = error; } if (callbackError) { const incrementResponses = await this.increment(); let index = -1; for (const response of incrementResponses) { index++; const { key, limiter } = this.#limiters[index]; if (limiter.blockDuration && response.consumed >= response.limit) { await limiter.block(key, limiter.blockDuration); } } throw callbackError; } await this.delete(); return [null, callbackResult]; } }; // src/limiter_manager.ts var LimiterManager = class { constructor(config) { this.config = config; this.config = config; } /** * Cached limiters. One limiter is created for a unique combination * of "store,requests,duration,blockDuration" options */ #limiters = /* @__PURE__ */ new Map(); /** * Generates a unique cache key for a limiter instance based on its configuration. * Used internally to cache and reuse limiter instances. * * @param store - The store name * @param options - Consumption options for the limiter */ makeLimiterKey(store, options) { const chunks = [`s:${String(store)}`, `r:${options.requests}`, `d:${options.duration}`]; if (options.blockDuration) { chunks.push(`bd:${options.blockDuration}`); } if (options.inMemoryBlockOnConsumed) { chunks.push(`mbc:${options.inMemoryBlockOnConsumed}`); } if (options.inMemoryBlockDuration) { chunks.push(`mbd:${options.inMemoryBlockDuration}`); } return chunks.join(","); } multi(store, options) { let storeToUse = typeof store === "string" ? store : this.config.default; let optionsToUse = Array.isArray(store) ? store : options; if (!optionsToUse) { throw new RuntimeException2( "Specify config for one or more limiters to create a multi limiter" ); } return new MultiLimiter( optionsToUse.map((limiterOptions) => { return { key: limiterOptions.key, limiter: this.use(storeToUse, limiterOptions) }; }) ); } use(store, options) { let storeToUse = typeof store === "string" ? store : this.config.default; let optionsToUse = typeof store === "object" ? store : options; if (!optionsToUse) { throw new RuntimeException2( "Specify the number of allowed requests and duration to create a limiter" ); } optionsToUse.duration = string2.seconds.parse(optionsToUse.duration); if (optionsToUse.blockDuration) { optionsToUse.blockDuration = string2.seconds.parse(optionsToUse.blockDuration); } if (optionsToUse.inMemoryBlockDuration) { optionsToUse.inMemoryBlockDuration = string2.seconds.parse(optionsToUse.inMemoryBlockDuration); } if (!this.#limiters.has(storeToUse)) { this.#limiters.set(storeToUse, /* @__PURE__ */ new Map()); } const storeLimiters = this.#limiters.get(storeToUse); const limiterKey = this.makeLimiterKey(storeToUse, optionsToUse); debug_default('created limiter key "%s"', limiterKey); if (storeLimiters.has(limiterKey)) { debug_default('re-using cached limiter store "%s", options %O', storeToUse, optionsToUse); return storeLimiters.get(limiterKey); } const limiter = new Limiter(this.config.stores[storeToUse](optionsToUse)); debug_default('creating new limiter instance "%s", options %O', storeToUse, optionsToUse); storeLimiters.set(limiterKey, limiter); return limiter; } /** * Clears rate limit data from the specified stores or all stores. * * @param stores - Optional array of store names to clear. Clears all stores if not specified. * * @example * ```ts * // Clear all stores * await limiterManager.clear() * * // Clear specific stores * await limiterManager.clear(['redis', 'memory']) * ``` */ async clear(stores2) { const storesToUse = stores2 || Object.keys(this.config.stores); for (let store of storesToUse) { const storeLimiters = this.#limiters.get(store); if (storeLimiters) { if (store === "memory") { for (let limiter of storeLimiters.values()) { await limiter.clear(); } } else { const [limiter] = storeLimiters.values(); limiter && await limiter.clear(); } } } } /** * Creates an HTTP limiter builder with the specified number of allowed requests. * This is the starting point for defining HTTP rate limiting middleware. * * @param requests - Number of requests to allow * * @example * ```ts * const httpLimiter = limiterManager * .allowRequests(100) * .every('1 hour') * ``` */ allowRequests(requests) { return new HttpLimiter(this).allowRequests(requests); } /** * Returns null to disable rate limiting for specific routes or users. * Useful in middleware when you want to conditionally skip rate limiting. * * @example * ```ts * router.get('/api/data', [ * limiter.define('api', async (ctx) => { * if (ctx.auth.user?.isAdmin) { * return limiter.noLimit() * } * return limiter.allowRequests(100).every('1 hour') * }) * ]) * ``` */ noLimit() { return null; } /** * Defines a named rate limiting middleware for HTTP routes. * The builder function is called for each request to determine rate limiting behavior. * * @param name - Unique name for the middleware (used as key prefix) * @param builder - Function that returns an HttpLimiter or null to skip limiting * * @example * ```ts * export const apiLimiter = limiter.define('api', (ctx) => { * return limiter * .allowRequests(100) * .every('1 hour') * .usingKey(ctx.auth.user.id) * }) * * // Apply to routes * router.get('/api/data', [apiLimiter], controller.index) * ``` */ define(name, builder) { const middlewareFn = async (ctx, next) => { const limiter = await builder(ctx); if (!limiter) { return next(); } const limiterResponse = await limiter.throttle(name, ctx); const response = await next(); ctx.response.header("X-RateLimit-Limit", limiterResponse.limit); ctx.response.header("X-RateLimit-Remaining", limiterResponse.remaining); return response; }; Object.defineProperty(middlewareFn, "name", { value: `${name}Throttle` }); return middlewareFn; } }; // src/define_config.ts import { configProvider } from "@adonisjs/core"; import { InvalidArgumentsException, RuntimeException as RuntimeException3 } from "@adonisjs/core/exceptions"; function defineConfig(config) { if (!config.stores) { throw new InvalidArgumentsException('Missing "stores" property in limiter config'); } if (!config.default) { throw new InvalidArgumentsException(`Missing "default" store in limiter config`); } if (!config.stores[config.default]) { throw new InvalidArgumentsException( `Missing "stores.${String( config.default )}" in limiter config. It is referenced by the "default" property` ); } return configProvider.create(async (app) => { debug_default("resolving limiter config"); const storesList = Object.keys(config.stores); const stores2 = {}; for (let storeName of storesList) { const store = config.stores[storeName]; if (typeof store === "function") { stores2[storeName] = store; } else { stores2[storeName] = await store.resolver(app); } } return { default: config.default, stores: stores2 }; }); } var stores = { redis: (config) => { return configProvider.create(async (app) => { const redis = await app.container.make("redis"); const { default: LimiterRedisStore } = await import("./src/stores/redis.js"); return (consumptionOptions) => new LimiterRedisStore(redis.connection(config.connectionName), { ...config, ...consumptionOptions }); }); }, database: (config) => { return configProvider.create(async (app) => { const db = await app.container.make("lucid.db"); const { default: LimiterDatabaseStore } = await import("./src/stores/database.js"); config.connectionName = config.connectionName || db.primaryConnectionName; const connection = db.manager.get(config.connectionName); if (!connection) { throw new RuntimeException3( `Invalid connection name "${config.connectionName}" referenced by "config/limiter.ts" file. First register the connection inside "config/database.ts" file` ); } if (!config.dbName && connection.config.connection && typeof connection.config.connection !== "string" && "database" in connection.config.connection) { config.dbName = connection.config.connection.database; } return (consumptionOptions) => new LimiterDatabaseStore(db.connection(config.connectionName), { ...config, ...consumptionOptions }); }); }, memory: (config) => { return (consumptionOptions) => new LimiterMemoryStore({ ...config, ...consumptionOptions }); } }; export { stubsRoot, configure, Limiter, HttpLimiter, LimiterManager, defineConfig, stores };