@adonisjs/limiter
Version:
Rate limiting package for AdonisJS framework
572 lines (563 loc) • 16.6 kB
JavaScript
import {
LimiterMemoryStore
} from "./chunk-C22TWIEE.js";
import {
E_TOO_MANY_REQUESTS,
debug_default
} from "./chunk-RWNA5JOS.js";
// configure.ts
import string from "@adonisjs/core/helpers/string";
// stubs/main.ts
import { getDirname } from "@adonisjs/core/helpers";
var stubsRoot = getDirname(import.meta.url);
// 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;
/**
* The number of configured requests on the 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;
}
/**
* Consume 1 request for a given key. An exception is raised
* when all the requests have already been consumed or if
* the key is blocked.
*/
consume(key) {
return this.#store.consume(key);
}
/**
* Increment the number of consumed requests for a given key.
* No errors are thrown when limit has reached
*/
increment(key) {
return this.#store.increment(key);
}
/**
* Decrement the number of consumed requests for a given key.
*/
decrement(key) {
return this.#store.decrement(key);
}
/**
* Consume 1 request for a given key and execute the provided
* callback.
*/
async attempt(key, callback) {
const response = await this.get(key);
if (response && response.consumed > response.limit) {
return;
}
try {
await this.consume(key);
return callback();
} catch (error) {
if (error instanceof E_TOO_MANY_REQUESTS === false) {
throw error;
}
}
}
/**
* Consume 1 request for a given key when the executed method throws
* an error.
*
* - Check if all the requests have been exhausted. If yes, throw limiter
* error.
* - Otherwise, execute the provided callback.
* - Increment the requests counter, if provided callback throws an error and rethrow
* the error
* - Delete key, if the provided callback succeeds and return the results.
*/
async penalize(key, callback) {
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);
if (consumed >= limit && this.blockDuration) {
await this.block(key, this.blockDuration);
}
throw callbackError;
}
await this.delete(key);
return [null, callbackResult];
}
/**
* Block a given key for the given duration. The duration must be
* a value in seconds or a string expression.
*/
block(key, duration) {
return this.#store.block(key, duration);
}
/**
* Manually set the number of requests exhausted for
* a given key for the given time duration.
*
* For example: "ip_127.0.0.1" has made "20 requests" in "1 minute".
* Now, if you allow 25 requests in 1 minute, then only 5 requests
* are left.
*
* The duration must be a value in seconds or a string expression.
*/
set(key, requests, duration) {
return this.#store.set(key, requests, duration);
}
/**
* Delete a given key
*/
delete(key) {
return this.#store.delete(key);
}
/**
* Delete all keys blocked within the memory
*/
deleteInMemoryBlockedKeys() {
return this.#store.deleteInMemoryBlockedKeys?.();
}
/**
* Get limiter response for a given key. Returns null when
* key doesn't exist.
*/
get(key) {
return this.#store.get(key);
}
/**
* Find the number of remaining requests for a given key
*/
async remaining(key) {
const response = await this.get(key);
if (!response) {
return this.requests;
}
return response.remaining;
}
/**
* Find the number of seconds remaining until the key will
* be available for new request
*/
async availableIn(key) {
const response = await this.get(key);
if (!response) {
return 0;
}
return response.remaining === 0 ? response.availableIn : 0;
}
/**
* Find if the current key is blocked. This method checks
* if the consumed points are equal to or greater than
* the allowed limit.
*/
async isBlocked(key) {
const response = await this.get(key);
if (!response) {
return false;
}
return response.consumed >= response.limit;
}
/**
* Clear the storage database
*/
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 || {};
}
/**
* Specify the store you want to use during
* the request
*/
store(store) {
this.#store = store;
return this;
}
/**
* Specify the number of requests to allow
*/
allowRequests(requests) {
this.#options.requests = requests;
return this;
}
/**
* Specify the duration in seconds or a time expression
* for which the requests to allow.
*
* For example: allowRequests(10).every('1 minute')
*/
every(duration) {
this.#options.duration = duration;
return this;
}
/**
* Specify a custom unique key to identify the user.
* Defaults to: request.ip()
*/
usingKey(key) {
this.#key = key;
return this;
}
/**
* Register a callback function to modify the ThrottleException.
*/
limitExceeded(callback) {
this.#exceptionModifier = callback;
return this;
}
/**
* Define the block duration. The key will be blocked for the
* specified duration after all the requests have been
* exhausted
*/
blockFor(duration) {
this.#options.blockDuration = duration;
return this;
}
/**
* JSON representation of the HTTP limiter
*/
toJSON() {
return {
store: this.#store,
...this.#options
};
}
/**
* Throttle request using the pre-defined options. Returns
* LimiterResponse when request is allowed or throws
* an exception.
*/
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";
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();
/**
* Creates a unique key for a limiter instance. Since, we allow creating
* limiters with runtime options for "requests", "duration" and "blockDuration".
* The limiterKey is used to identify a limiter instance.
*/
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(",");
}
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;
}
/**
* Clear stored data with the stores
*/
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 HTTP limiter instance
*/
allowRequests(requests) {
return new HttpLimiter(this).allowRequests(requests);
}
/**
* A shorthand method that returns null to disable
* rate limiting
*/
noLimit() {
return null;
}
/**
* Define a named HTTP middleware to apply rate
* limits on specific routes
*/
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
};