@adonisjs/limiter
Version:
Rate limiting package for AdonisJS framework
910 lines (899 loc) • 26.4 kB
JavaScript
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
};