diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c5d7fca..d525d8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,5 +28,7 @@ jobs: ${{ runner.os }}-node-v${{ matrix.node }}- - name: Install Dependencies run: npm install + - name: Build + run: npm run build - name: Run All Node.js Tests run: npm run test diff --git a/.gitignore b/.gitignore index 6704566..1560d95 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +lib/ diff --git a/README.md b/README.md index 4ee040d..beffb68 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Rate limiting utility -![version: 1.2.1](https://img.shields.io/badge/version-1.2.1-%233b82f6) +![version: 1.2.2](https://img.shields.io/badge/version-1.2.2-%233b82f6) ![test: passing](https://img.shields.io/badge/tests-passing-%2316a34a) ![coverage: 100%](https://img.shields.io/badge/coverage-100%25-%2316a34a) ![build: passing](https://img.shields.io/badge/build-passing-%2316a34a) @@ -42,6 +42,7 @@ If you want to reset the rate limit after a successful login, call [`rateLimit.r - [Static method: `RateLimit.attempt(name, source, [attempts], [callback])`](#static-method-ratelimitattemptname-source-attempts-callback) - [Static method: `RateLimit.check(name, source, [callback])`](#static-method-ratelimitcheckname-source-callback) - [Static method: `RateLimit.clear(name)`](#static-method-ratelimitclearname) + - [Static method: `RateLimit.cleanup([name])`](#static-method-ratelimitcleanupname) - [Static method: `RateLimit.create(name, limit, timeWindow)`](#static-method-ratelimitcreatename-limit-timewindow) - [Static method: `RateLimit.delete(name)`](#static-method-ratelimitdeletename) - [Static method: `RateLimit.get(name)`](#static-method-ratelimitgetname) @@ -51,6 +52,7 @@ If you want to reset the rate limit after a successful login, call [`rateLimit.r - [`rateLimit.attempt(source, [attempts], [callback])`](#ratelimitattemptsource-attempts-callback) - [`rateLimit.check(source, [callback])`](#ratelimitchecksource-callback) - [`rateLimit.clear()`](#ratelimitclear) + - [`rateLimit.cleanup()`](#ratelimitcleanup) - [`rateLimit.delete()`](#ratelimitdelete) - [`rateLimit.limit`](#ratelimitlimit) - [`rateLimit.name`](#ratelimitname) @@ -102,6 +104,13 @@ Clear rate limit attempts storage. This is equivalent to resetting all rate limi - Returns: [`void`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Undefined_type) - Throws: [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) If the rate limit does not exist + + +### Static method: `RateLimit.cleanup([name])` +Clean up rate limit attempts storage. This will remove expired entries. + +- `name` [`string`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) The name of the rate limit. Default: `undefined` + ### Static method: `RateLimit.create(name, limit, timeWindow)` Create a new rate limit @@ -154,6 +163,7 @@ Create a new rate limit - `name` [`string`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) The name of the rate limit - `limit` [`number`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) The number of attempts allowed per time window (e.g. 60) - `timeWindow` [`number`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) The time window in seconds (e.g. 60) +- `cleanupInterval` [`number`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) Cleanup interval in seconds (see [`rateLimit.cleanup()`](#ratelimitcleanup)). Set to `-1` to disable periodic cleanup. Defaults to `timeWindow` - Throws: [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) If the rate limit already exists @@ -183,6 +193,12 @@ Clear rate limit attempts storage. This is equivalent to resetting all rate limi - Returns: [`void`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Undefined_type) + +### `rateLimit.cleanup()` +Clean up rate limit attempts storage. This will remove expired entries. + +- Returns: [`void`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Undefined_type) + ### `rateLimit.delete()` Delete the rate limit instance. After it is deleted, it should not be used any further without constructing a new instance. diff --git a/README.template.md b/README.template.md index eec210b..ee66692 100644 --- a/README.template.md +++ b/README.template.md @@ -42,6 +42,7 @@ If you want to reset the rate limit after a successful login, call [`rateLimit.r - [Static method: `RateLimit.attempt(name, source, [attempts], [callback])`](#static-method-ratelimitattemptname-source-attempts-callback) - [Static method: `RateLimit.check(name, source, [callback])`](#static-method-ratelimitcheckname-source-callback) - [Static method: `RateLimit.clear(name)`](#static-method-ratelimitclearname) + - [Static method: `RateLimit.cleanup([name])`](#static-method-ratelimitcleanupname) - [Static method: `RateLimit.create(name, limit, timeWindow)`](#static-method-ratelimitcreatename-limit-timewindow) - [Static method: `RateLimit.delete(name)`](#static-method-ratelimitdeletename) - [Static method: `RateLimit.get(name)`](#static-method-ratelimitgetname) @@ -51,6 +52,7 @@ If you want to reset the rate limit after a successful login, call [`rateLimit.r - [`rateLimit.attempt(source, [attempts], [callback])`](#ratelimitattemptsource-attempts-callback) - [`rateLimit.check(source, [callback])`](#ratelimitchecksource-callback) - [`rateLimit.clear()`](#ratelimitclear) + - [`rateLimit.cleanup()`](#ratelimitcleanup) - [`rateLimit.delete()`](#ratelimitdelete) - [`rateLimit.limit`](#ratelimitlimit) - [`rateLimit.name`](#ratelimitname) @@ -102,6 +104,13 @@ Clear rate limit attempts storage. This is equivalent to resetting all rate limi - Returns: [`void`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Undefined_type) - Throws: [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) If the rate limit does not exist + + +### Static method: `RateLimit.cleanup([name])` +Clean up rate limit attempts storage. This will remove expired entries. + +- `name` [`string`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) The name of the rate limit. Default: `undefined` + ### Static method: `RateLimit.create(name, limit, timeWindow)` Create a new rate limit @@ -154,6 +163,7 @@ Create a new rate limit - `name` [`string`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type) The name of the rate limit - `limit` [`number`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) The number of attempts allowed per time window (e.g. 60) - `timeWindow` [`number`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) The time window in seconds (e.g. 60) +- `cleanupInterval` [`number`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type) Cleanup interval in seconds (see [`rateLimit.cleanup()`](#ratelimitcleanup)). Set to `-1` to disable periodic cleanup. Defaults to `timeWindow` - Throws: [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) If the rate limit already exists @@ -183,6 +193,12 @@ Clear rate limit attempts storage. This is equivalent to resetting all rate limi - Returns: [`void`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Undefined_type) + +### `rateLimit.cleanup()` +Clean up rate limit attempts storage. This will remove expired entries. + +- Returns: [`void`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Undefined_type) + ### `rateLimit.delete()` Delete the rate limit instance. After it is deleted, it should not be used any further without constructing a new instance. diff --git a/lib/AttemptResult.d.ts b/lib/AttemptResult.d.ts deleted file mode 100644 index 9c31b35..0000000 --- a/lib/AttemptResult.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { RateLimit } from "./RateLimit"; -/** - * The result from a rate limit attempt - * @interface - */ -export interface AttemptResult { - /** - * The number of requests this rate limit allows per time window - * @readonly - */ - readonly limit: number; - /** - * The number of requests remaining in the current time window - * @readonly - */ - readonly remaining: number; - /** - * The number of seconds until the current time window resets - * @readonly - */ - readonly reset: number; - /** - * The rate limit that this attempt was made on - * @readonly - */ - readonly rateLimit: RateLimit; - /** - * Whether this attempt should be allowed to proceed. If false, the attempt is rate limited. - * @readonly - */ - readonly allow: boolean; -} -//# sourceMappingURL=AttemptResult.d.ts.map \ No newline at end of file diff --git a/lib/AttemptResult.d.ts.map b/lib/AttemptResult.d.ts.map deleted file mode 100644 index 27ffbc4..0000000 --- a/lib/AttemptResult.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"AttemptResult.d.ts","sourceRoot":"","sources":["../src/AttemptResult.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,SAAS,EAAC,MAAM,aAAa,CAAC;AAEtC;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC1B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAE3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAE9B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CAC3B"} \ No newline at end of file diff --git a/lib/AttemptResult.js b/lib/AttemptResult.js deleted file mode 100644 index cb0ff5c..0000000 --- a/lib/AttemptResult.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/lib/RateLimit.d.ts b/lib/RateLimit.d.ts deleted file mode 100644 index b422dfe..0000000 --- a/lib/RateLimit.d.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { AttemptResult } from "./AttemptResult"; -/** - * Rate limit - * @class - */ -export declare class RateLimit { - #private; - /** - * Name of the rate limit - * @readonly - * @type {string} - */ - readonly name: string; - /** - * The number of requests allowed per time window - * @type {number} - */ - limit: number; - /** - * The time window in seconds (e.g. 60) - * @type {number} - */ - timeWindow: number; - /** - * Create a new rate limit - * @param {string} name - The name of the rate limit - * @param {number} limit - The number of requests allowed per time window (e.g. 60) - * @param {number} timeWindow - The time window in seconds (e.g. 60) - * @returns {RateLimit} - * @throws {Error} - If the rate limit already exists - */ - constructor(name: string, limit: number, timeWindow: number); - /** - * Check the attempt state for a source ID without decrementing the remaining attempts - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {function(AttemptResult): void} [callback] - Return data in a callback - * @returns {AttemptResult} - */ - check(source: string, callback?: (result: AttemptResult) => void): AttemptResult; - /** - * Make an attempt with a source ID - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {number} [attempts=1] - The number of attempts to make - * @param {function(AttemptResult): void} [callback] - Return data in a callback - * @returns {AttemptResult} - */ - attempt(source: string, attempts?: number, callback?: (result: AttemptResult) => void): AttemptResult; - /** - * Reset limit for a source ID. The storage entry will be deleted and a new one will be created on the next attempt. - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @returns {void} - */ - reset(source: string): void; - /** - * Set the remaining attempts for a source ID. - * > **Warning**: This is not recommended as the remaining attempts depend on the limit of the instance. - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {number} remaining - The number of remaining attempts - * @returns {void} - */ - setRemaining(source: string, remaining: number): void; - /** - * Clear rate limit attempts storage. This is equivalent to resetting all rate limits. - * @returns {void} - */ - clear(): void; - /** - * Delete the rate limit instance. After it is deleted, it should not be used any further without constructing a new instance. - * @returns {void} - */ - delete(): void; - /** - * Get a rate limit instance - * @param {string} name - The name of the rate limit - * @returns {RateLimit | null} - * @static - */ - static get(name: string): RateLimit | null; - /** - * Check the attempt state for a source ID without decrementing the remaining attempts - * @param {string} name - The name of the rate limit - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {function(AttemptResult): void} [callback] - Return data in a callback - * @returns {AttemptResult} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static check(name: string, source: string, callback?: (result: AttemptResult) => void): AttemptResult; - /** - * Make an attempt with a source ID - * @param {string} name - The name of the rate limit - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {number} [attempts=1] - The number of attempts to make - * @param {function(AttemptResult): void} [callback] - Return data in a callback - * @returns {AttemptResult} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static attempt(name: string, source: string, attempts?: number, callback?: (result: AttemptResult) => void): AttemptResult; - /** - * Reset limit for a source ID. The storage entry will be deleted and a new one will be created on the next attempt. - * @param {string} name - The name of the rate limit - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @returns {void} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static reset(name: string, source: string): void; - /** - * Set the remaining attempts for a source ID. - * > **Warning**: This is not recommended as the remaining attempts depend on the limit of the instance. - * @param {string} name - The name of the rate limit - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {number} remaining - The number of remaining attempts - * @returns {void} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static setRemaining(name: string, source: string, remaining: number): void; - /** - * Clear rate limit attempts storage. This is equivalent to resetting all rate limits. - * @param {string} name - The name of the rate limit - * @returns {void} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static clear(name: string): void; - /** - * Delete the rate limit instance. After it is deleted, it should not be used any further without constructing a new instance. - * @param {string} name - The name of the rate limit - * @returns {void} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static delete(name: string): void; - /** - * Create a new rate limit - * @param {string} name - The name of the rate limit - * @param {number} limit - The number of attempts allowed per time window (e.g. 60) - * @param {number} timeWindow - The time window in seconds (e.g. 60) - * @returns {RateLimit} - * @static - */ - static create(name: string, limit: number, timeWindow: number): RateLimit; -} -//# sourceMappingURL=RateLimit.d.ts.map \ No newline at end of file diff --git a/lib/RateLimit.d.ts.map b/lib/RateLimit.d.ts.map deleted file mode 100644 index a45ca67..0000000 --- a/lib/RateLimit.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"RateLimit.d.ts","sourceRoot":"","sources":["../src/RateLimit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,aAAa,EAAC,MAAM,iBAAiB,CAAC;AAE9C;;;GAGG;AACH,qBAAa,SAAS;;IAuBlB;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;IAEnB;;;;;;;OAOG;gBACS,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;IAQ3D;;;;;OAKG;IACH,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,GAAG,aAAa;IAgBhF;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAU,EAAE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,GAAG,aAAa;IAcxG;;;;OAIG;IACH,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAK3B;;;;;;OAMG;IACH,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAOrD;;;OAGG;IACH,KAAK,IAAI,IAAI;IAKb;;;OAGG;IACH,MAAM,IAAI,IAAI;IAMd;;;;;OAKG;IACH,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAI1C;;;;;;;;OAQG;IACH,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,GAAG,aAAa;IAMrG;;;;;;;;;OASG;IACH,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAU,EAAE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,GAAG,aAAa;IAM7H;;;;;;;OAOG;IACH,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAMhD;;;;;;;;;OASG;IACH,MAAM,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAM1E;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAMhC;;;;;;OAMG;IACH,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAMjC;;;;;;;OAOG;IACH,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS;CAK5E"} \ No newline at end of file diff --git a/lib/RateLimit.js b/lib/RateLimit.js deleted file mode 100644 index 71620f2..0000000 --- a/lib/RateLimit.js +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Rate limit - * @class - */ -export class RateLimit { - /** - * Rate limit instances - * @private - * @static - * @type {Map} - */ - static #instances = new Map(); - /** - * Whether this rate limit is deleted - * @private - * @type {boolean} - */ - #deleted = false; - /** - * Attempts memory - * @private - * @type {Map} - */ - #attempts = new Map(); - /** - * Name of the rate limit - * @readonly - * @type {string} - */ - name; - /** - * The number of requests allowed per time window - * @type {number} - */ - limit; - /** - * The time window in seconds (e.g. 60) - * @type {number} - */ - timeWindow; - /** - * Create a new rate limit - * @param {string} name - The name of the rate limit - * @param {number} limit - The number of requests allowed per time window (e.g. 60) - * @param {number} timeWindow - The time window in seconds (e.g. 60) - * @returns {RateLimit} - * @throws {Error} - If the rate limit already exists - */ - constructor(name, limit, timeWindow) { - if (RateLimit.#instances.has(name)) - throw new Error(`Rate limit with name "${name}" already exists`); - this.name = name; - this.limit = limit; - this.timeWindow = timeWindow; - RateLimit.#instances.set(name, this); - } - /** - * Check the attempt state for a source ID without decrementing the remaining attempts - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {function(AttemptResult): void} [callback] - Return data in a callback - * @returns {AttemptResult} - */ - check(source, callback) { - if (this.#deleted) - throw new Error(`Rate limit "${this.name}" has been deleted. Construct a new instance`); - const attempts = this.#attempts.get(source) ?? [0, Date.now()]; - const remaining = this.limit - attempts[0]; - const reset = Math.ceil((attempts[1] + (this.timeWindow * 1000) - Date.now()) / 1000); - const result = { - limit: this.limit, - remaining, - reset, - rateLimit: this, - allow: remaining > 0 - }; - if (callback) - callback(result); - return result; - } - /** - * Make an attempt with a source ID - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {number} [attempts=1] - The number of attempts to make - * @param {function(AttemptResult): void} [callback] - Return data in a callback - * @returns {AttemptResult} - */ - attempt(source, attempts = 1, callback) { - if (this.#deleted) - throw new Error(`Rate limit "${this.name}" has been deleted. Construct a new instance`); - const data = this.#attempts.get(source) ?? [0, Date.now()]; - // if the time window has expired, reset the attempts - if (data[1] + (this.timeWindow * 1000) < Date.now()) { - data[0] = 0; - data[1] = Date.now(); - } - // increment the attempts - data[0] += attempts; - this.#attempts.set(source, data); - return this.check(source, callback); - } - /** - * Reset limit for a source ID. The storage entry will be deleted and a new one will be created on the next attempt. - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @returns {void} - */ - reset(source) { - if (this.#deleted) - throw new Error(`Rate limit "${this.name}" has been deleted. Construct a new instance`); - this.#attempts.delete(source); - } - /** - * Set the remaining attempts for a source ID. - * > **Warning**: This is not recommended as the remaining attempts depend on the limit of the instance. - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {number} remaining - The number of remaining attempts - * @returns {void} - */ - setRemaining(source, remaining) { - if (this.#deleted) - throw new Error(`Rate limit "${this.name}" has been deleted. Construct a new instance`); - const data = this.#attempts.get(source) ?? [0, Date.now()]; - data[0] = this.limit - remaining; - this.#attempts.set(source, data); - } - /** - * Clear rate limit attempts storage. This is equivalent to resetting all rate limits. - * @returns {void} - */ - clear() { - if (this.#deleted) - throw new Error(`Rate limit "${this.name}" has been deleted. Construct a new instance`); - this.#attempts.clear(); - } - /** - * Delete the rate limit instance. After it is deleted, it should not be used any further without constructing a new instance. - * @returns {void} - */ - delete() { - this.clear(); - this.#deleted = true; - RateLimit.#instances.delete(this.name); - } - /** - * Get a rate limit instance - * @param {string} name - The name of the rate limit - * @returns {RateLimit | null} - * @static - */ - static get(name) { - return RateLimit.#instances.get(name) ?? null; - } - /** - * Check the attempt state for a source ID without decrementing the remaining attempts - * @param {string} name - The name of the rate limit - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {function(AttemptResult): void} [callback] - Return data in a callback - * @returns {AttemptResult} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static check(name, source, callback) { - const rateLimit = RateLimit.get(name); - if (!rateLimit) - throw new Error(`Rate limit with name "${name}" does not exist`); - return rateLimit.check(source, callback); - } - /** - * Make an attempt with a source ID - * @param {string} name - The name of the rate limit - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {number} [attempts=1] - The number of attempts to make - * @param {function(AttemptResult): void} [callback] - Return data in a callback - * @returns {AttemptResult} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static attempt(name, source, attempts = 1, callback) { - const rateLimit = RateLimit.get(name); - if (!rateLimit) - throw new Error(`Rate limit with name "${name}" does not exist`); - return rateLimit.attempt(source, attempts, callback); - } - /** - * Reset limit for a source ID. The storage entry will be deleted and a new one will be created on the next attempt. - * @param {string} name - The name of the rate limit - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @returns {void} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static reset(name, source) { - const rateLimit = RateLimit.get(name); - if (!rateLimit) - throw new Error(`Rate limit with name "${name}" does not exist`); - return rateLimit.reset(source); - } - /** - * Set the remaining attempts for a source ID. - * > **Warning**: This is not recommended as the remaining attempts depend on the limit of the instance. - * @param {string} name - The name of the rate limit - * @param {string} source - Unique source identifier (e.g. username, IP, etc.) - * @param {number} remaining - The number of remaining attempts - * @returns {void} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static setRemaining(name, source, remaining) { - const rateLimit = RateLimit.get(name); - if (!rateLimit) - throw new Error(`Rate limit with name "${name}" does not exist`); - return rateLimit.setRemaining(source, remaining); - } - /** - * Clear rate limit attempts storage. This is equivalent to resetting all rate limits. - * @param {string} name - The name of the rate limit - * @returns {void} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static clear(name) { - const rateLimit = RateLimit.get(name); - if (!rateLimit) - throw new Error(`Rate limit with name "${name}" does not exist`); - return rateLimit.clear(); - } - /** - * Delete the rate limit instance. After it is deleted, it should not be used any further without constructing a new instance. - * @param {string} name - The name of the rate limit - * @returns {void} - * @throws {Error} - If the rate limit does not exist - * @static - */ - static delete(name) { - const rateLimit = RateLimit.get(name); - if (!rateLimit) - throw new Error(`Rate limit with name "${name}" does not exist`); - return rateLimit.delete(); - } - /** - * Create a new rate limit - * @param {string} name - The name of the rate limit - * @param {number} limit - The number of attempts allowed per time window (e.g. 60) - * @param {number} timeWindow - The time window in seconds (e.g. 60) - * @returns {RateLimit} - * @static - */ - static create(name, limit, timeWindow) { - const existing = RateLimit.get(name); - if (existing) - return existing; - return new RateLimit(name, limit, timeWindow); - } -} diff --git a/package-lock.json b/package-lock.json index 3c2cb39..658c21a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "GPL-3.0", "devDependencies": { "c8": "^8.0.1", - "mocha": "^10.0.0" + "mocha": "^10.0.0", + "typescript": "^4.9.5" } }, "node_modules/@bcoe/v8-coverage": { @@ -1152,6 +1153,19 @@ "node": ">=8.0" } }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", @@ -2120,6 +2134,12 @@ "is-number": "^7.0.0" } }, + "typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true + }, "v8-to-istanbul": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", diff --git a/package.json b/package.json index bad904a..d23a6a3 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "test": "c8 npm run _test", "coverage": "c8 --reporter=html npm run _test", "build": "npm ci && tsc", + "prepack": "npm run build", "readme": "node scripts/generateReadme.js" }, "repository": { @@ -31,7 +32,7 @@ "homepage": "https://github.com/cloudnode-pro/ratelimit#readme", "devDependencies": { "c8": "^8.0.1", - "mocha": "^10.0.0" - }, - "dependencies": {} + "mocha": "^10.0.0", + "typescript": "^4.9.5" + } } diff --git a/src/RateLimit.ts b/src/RateLimit.ts index e8b69ac..56adbbc 100644 --- a/src/RateLimit.ts +++ b/src/RateLimit.ts @@ -22,16 +22,24 @@ export class RateLimit { */ readonly #attempts = new Map(); + /** + * Cleanup timer + * @internal + */ + readonly #cleanupTimer?: number; + /** * Create a new rate limit * @param name - The name of the rate limit * @param limit - The number of requests allowed per time window (e.g. 60) * @param timeWindow - The time window in seconds (e.g. 60) + * @param [cleanupInterval=timeWindow] - Cleanup interval in seconds (see {@link RateLimit#cleanup}). Set to `-1` to disable periodic cleanup. Defaults to `timeWindow` * @throws {Error} - If the rate limit already exists */ - public constructor(public readonly name: string, public readonly limit: number, public readonly timeWindow: number) { + public constructor(public readonly name: string, public readonly limit: number, public readonly timeWindow: number, cleanupInterval: number = timeWindow) { if (RateLimit.#instances.has(name)) throw new Error(`Rate limit with name "${name}" already exists`); RateLimit.#instances.set(name, this); + if (cleanupInterval > 0) this.#cleanupTimer = setInterval(() => this.cleanup(), cleanupInterval * 1000); } /** @@ -105,6 +113,18 @@ export class RateLimit { this.#attempts.clear(); } + /** + * Clean up rate limit attempts storage. This will remove expired entries. + * @throws {Error} - If the rate limit has been deleted + */ + public cleanup(): void { + if (this.#deleted) throw new Error(`Rate limit "${this.name}" has been deleted. Construct a new instance`); + const now = Date.now(); + for (const [source, [attempts]] of this.#attempts) { + if (attempts + (this.timeWindow * 1000) < now) this.#attempts.delete(source); + } + } + /** * Delete the rate limit instance. After it is deleted, it should not be used any further without constructing a new instance. */ @@ -112,6 +132,7 @@ export class RateLimit { this.clear(); this.#deleted = true; RateLimit.#instances.delete(this.name); + clearInterval(this.#cleanupTimer); } /** @@ -186,6 +207,20 @@ export class RateLimit { return rateLimit.clear(); } + /** + * Clean up rate limit attempts storage. This will remove expired entries. + * @param [name] - The name of the rate limit. If not provided, all rate limits will be cleaned up. + * @throws {Error} - If the rate limit does not exist + */ + public static cleanup(name?: string): void { + if (name) { + const rateLimit = RateLimit.get(name); + if (!rateLimit) throw new Error(`Rate limit with name "${name}" does not exist`); + return rateLimit.cleanup(); + } + else for (const rateLimit of RateLimit.#instances.values()) rateLimit.cleanup(); + } + /** * Delete the rate limit instance. After it is deleted, it should not be used any further without constructing a new instance. * @param name - The name of the rate limit @@ -194,7 +229,8 @@ export class RateLimit { public static delete(name: string): void { const rateLimit = RateLimit.get(name); if (!rateLimit) throw new Error(`Rate limit with name "${name}" does not exist`); - return rateLimit.delete(); + rateLimit.delete(); + RateLimit.#instances.delete(name); } /** diff --git a/test/test.js b/test/test.js index 595b9d1..5a6c5d4 100644 --- a/test/test.js +++ b/test/test.js @@ -72,6 +72,7 @@ describe("RateLimit", () => { assert.throws(() => rateLimit.reset("source1"), {message: "Rate limit \"test\" has been deleted. Construct a new instance"}); assert.throws(() => rateLimit.setRemaining("source1", 3), {message: "Rate limit \"test\" has been deleted. Construct a new instance"}); assert.throws(() => rateLimit.clear(), {message: "Rate limit \"test\" has been deleted. Construct a new instance"}); + assert.throws(() => rateLimit.cleanup(), {message: "Rate limit \"test\" has been deleted. Construct a new instance"}); assert.throws(() => rateLimit.delete(), {message: "Rate limit \"test\" has been deleted. Construct a new instance"}); }); }); @@ -123,6 +124,21 @@ describe("RateLimit", () => { assert.strictEqual(RateLimit.check("test", "source2").remaining, 5); assert.strictEqual(RateLimit.check("test", "source3").remaining, 5); }); + it("should clear expired attempts", async () => { + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + RateLimit.attempt("test", "source1"); + await sleep(1500); + RateLimit.cleanup("test"); + assert.strictEqual(RateLimit.check("test", "source1").remaining, 5); + }); + it("should clear expired attempts from all rate limits", async () => { + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + RateLimit.attempt("test", "source1"); + new RateLimit("test2", 5, 1).attempt("source1"); + await sleep(1500); + RateLimit.cleanup(); + RateLimit.delete("test2"); + }); it("should delete the RateLimit instance", () => { RateLimit.attempt("test", "source1"); RateLimit.delete("test"); @@ -132,6 +148,7 @@ describe("RateLimit", () => { assert.throws(() => RateLimit.reset("test", "source1"), {message: "Rate limit with name \"test\" does not exist"}); assert.throws(() => RateLimit.setRemaining("test", "source1", 3), {message: "Rate limit with name \"test\" does not exist"}); assert.throws(() => RateLimit.clear("test"), {message: "Rate limit with name \"test\" does not exist"}); + assert.throws(() => RateLimit.cleanup("test"), {message: "Rate limit with name \"test\" does not exist"}); assert.throws(() => RateLimit.delete("test"), {message: "Rate limit with name \"test\" does not exist"}); }); });