Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,255 changes: 1,793 additions & 2,462 deletions package-lock.json

Large diffs are not rendered by default.

69 changes: 35 additions & 34 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,52 +44,53 @@
"!*/__tests__"
],
"dependencies": {
"@loopback/boot": "^8.0.4",
"@loopback/context": "^8.0.3",
"@loopback/core": "^7.0.3",
"@loopback/repository": "^8.0.3",
"@loopback/rest": "^15.0.4",
"express-rate-limit": "^6.4.0",
"rate-limit-memcached": "^0.6.0",
"@loopback/boot": "^8.0.11",
"@loopback/context": "^8.0.10",
"@loopback/core": "^7.0.10",
"@loopback/repository": "^8.0.10",
"@loopback/rest": "^15.0.11",
"express-rate-limit": "^8.3.1",
"rate-limit-memcached": "^1.0.1",
"rate-limit-mongo": "^2.3.2",
"rate-limit-redis": "^3.0.1"
"rate-limit-redis": "^4.3.1"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@loopback/build": "^12.0.3",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@loopback/build": "^12.0.10",
"@loopback/eslint-config": "^16.0.1",
"@loopback/testlab": "^8.0.3",
"@semantic-release/changelog": "^6.0.1",
"@semantic-release/commit-analyzer": "^9.0.2",
"@loopback/testlab": "^8.0.10",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.0",
"@semantic-release/npm": "^13.1.1",
"@semantic-release/release-notes-generator": "^10.0.3",
"@types/express-rate-limit": "^5.0.0",
"@types/memcached": "^2.2.6",
"@types/node": "^18.11.9",
"@types/proxyquire": "^1.3.28",
"@types/rate-limit-redis": "^1.7.4",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@semantic-release/github": "^12.0.6",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.0",
"@types/express-rate-limit": "^6.0.2",
"@types/ioredis": "^4.28.10",
"@types/memcached": "^2.2.10",
"@types/node": "^25.5.0",
"@types/proxyquire": "^1.3.31",
"@types/rate-limit-redis": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"cz-conventional-changelog": "^3.3.0",
"cz-customizable": "^6.3.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"cz-customizable": "^7.5.1",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-eslint-plugin": "^5.5.1",
"eslint-plugin-mocha": "^10.4.3",
"fs-extra": "^11.2.0",
"eslint-plugin-mocha": "^10.5.0",
"fs-extra": "^11.3.4",
"git-release-notes": "^5.0.0",
"husky": "^7.0.4",
"jsdom": "^21.0.0",
"husky": "^9.1.7",
"jsdom": "^29.0.0",
"loopback-connector-kv-redis": "^4.0.0",
"memcached": "^2.2.2",
"proxyquire": "^2.1.3",
"semantic-release": "^25.0.1",
"simple-git": "^3.15.1",
"semantic-release": "^25.0.3",
"simple-git": "^3.33.0",
"source-map-support": "^0.5.21",
"typescript": "~5.2.2"
"typescript": "~5.5.4"
},
"overrides": {
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Client} from '@loopback/testlab';
import {memoryStore} from '../store.provider';
import {TestApplication} from './fixtures/application';
import {setUpApplication} from './helper';
import {clearRateLimitCache} from '../../../providers/ratelimit-action.provider';

const OK_STATUS_CODE = 200;
const TOO_MANY_REQS_CODE = 429;
Expand All @@ -15,6 +16,7 @@ describe('Acceptance Test Cases', () => {
});
afterEach(async () => {
await clearStore();
clearRateLimitCache();
});

after(async () => app.stop());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {Client} from '@loopback/testlab';
import {memoryStore} from '../store.provider';
import {TestApplication} from './fixtures/application';
import {setUpApplication} from './helper';
import {clearRateLimitCache} from '../../../middleware/ratelimit.middleware';

describe('Acceptance Test Cases', () => {
let app: TestApplication;
let client: Client;
Expand All @@ -11,6 +13,7 @@ describe('Acceptance Test Cases', () => {
});
afterEach(async () => {
await clearStore();
clearRateLimitCache();
});

after(async () => app.stop());
Expand Down
34 changes: 19 additions & 15 deletions src/__tests__/acceptance/store.provider.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import {inject, Provider, ValueOrPromise} from '@loopback/core';
import {Provider, ValueOrPromise} from '@loopback/core';
import {Store} from 'express-rate-limit';
import {RateLimitSecurityBindings} from '../../keys';
import {RateLimitOptions} from '../../types';
import {InMemoryStore} from './in-memory-store';
export const memoryStore = new InMemoryStore();
export class StoreProvider implements Provider<Store> {
constructor(
@inject(RateLimitSecurityBindings.CONFIG, {optional: true})
private readonly config?: RateLimitOptions,
) {}
value(): ValueOrPromise<Store> {
const DEFAULT_WINDOW_MS = 60000;
const windowMs = this.config?.windowMs ?? DEFAULT_WINDOW_MS;
memoryStore.setInterval(windowMs);
return memoryStore;

// InMemoryStore is no longer used as a shared store in v8
// express-rate-limit v8 creates its own InMemoryStore instances internally
// This provider now returns null to use the default InMemoryStore

export class StoreProvider implements Provider<Store | null> {
value(): ValueOrPromise<Store | null> {
// Return null to let express-rate-limit v8 create its own InMemoryStore
// Each RateLimit instance will have its own store with proper state management
return null;
}
}

// Export empty memoryStore object for backward compatibility with tests
export const memoryStore = {
resetAll: (): void => {
// No-op since stores are managed by express-rate-limit v8
// Rate limit state will be reset between tests via cache clearing
},
};
48 changes: 44 additions & 4 deletions src/middleware/ratelimit.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ import * as RateLimit from 'express-rate-limit';
import {RateLimitSecurityBindings} from '../keys';
import {RateLimitMetadata, RateLimitOptions} from '../types';
import {RatelimitActionMiddlewareGroup} from './middleware.enum';

// Cache for RateLimit instances to avoid store reuse error in v8
const rateLimitCache = new Map<string, RateLimit.RateLimitRequestHandler>();

function getRateLimiterKey(opts: Partial<RateLimitOptions>): string {
return JSON.stringify(opts);
}

// Export function to clear cache for testing
export function clearRateLimitCache(): void {
rateLimitCache.clear();
}

@injectable(
asMiddleware({
group: RatelimitActionMiddlewareGroup.RATELIMIT,
Expand All @@ -26,7 +39,7 @@ import {RatelimitActionMiddlewareGroup} from './middleware.enum';
export class RatelimitMiddlewareProvider implements Provider<Middleware> {
constructor(
@inject.getter(RateLimitSecurityBindings.DATASOURCEPROVIDER)
private readonly getDatastore: Getter<RateLimit.Store>,
private readonly getDatastore: Getter<RateLimit.Store | null>,
@inject.getter(RateLimitSecurityBindings.METADATA)
private readonly getMetadata: Getter<RateLimitMetadata>,
@inject(CoreBindings.APPLICATION_INSTANCE)
Expand Down Expand Up @@ -60,17 +73,44 @@ export class RatelimitMiddlewareProvider implements Provider<Middleware> {
const operationMetadata = metadata ? metadata.options : {};

// Create options based on global config and method level config
const opts = {...this.config, ...operationMetadata};
const rawOpts = {...this.config, ...operationMetadata};

// Filter out unsupported options for express-rate-limit v8
// 'name' is no longer supported in v8
// 'client', 'type', 'uri', 'collectionName' are custom DataSourceConfig options
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
name,
client,
type,
uri,
collectionName,
store: originalStore,
...opts
} = rawOpts as RateLimitOptions & {store?: unknown};
/* eslint-enable @typescript-eslint/no-unused-vars */

// If dataStore is null or undefined, don't set the store property
// express-rate-limit v8 will create its own InMemoryStore
if (dataStore) {
opts.store = dataStore;
(opts as RateLimit.Options).store = dataStore;
}

opts.message = new HttpErrors.TooManyRequests(
opts.message?.toString() ?? 'Method rate limit reached !',
);

const limiter = RateLimit.default(opts);
// Get or create a RateLimit instance for this configuration
// This avoids the "store reuse" error in express-rate-limit v8
// Note: We exclude 'store' from cache key since each store instance is unique
const cacheKey = getRateLimiterKey(opts);
let limiter = rateLimitCache.get(cacheKey);

if (!limiter) {
limiter = RateLimit.default(opts);
rateLimitCache.set(cacheKey, limiter);
}

limiter(request, response, (err: unknown) => {
if (err) {
reject(err);
Expand Down
47 changes: 43 additions & 4 deletions src/providers/ratelimit-action.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,22 @@ import * as RateLimit from 'express-rate-limit';
import {RateLimitSecurityBindings} from '../keys';
import {RateLimitAction, RateLimitMetadata, RateLimitOptions} from '../types';

// Cache for RateLimit instances to avoid store reuse error in v8
const rateLimitCache = new Map<string, RateLimit.RateLimitRequestHandler>();

function getRateLimiterKey(opts: Partial<RateLimitOptions>): string {
return JSON.stringify(opts);
}

// Export function to clear cache for testing
export function clearRateLimitCache(): void {
rateLimitCache.clear();
}

export class RatelimitActionProvider implements Provider<RateLimitAction> {
constructor(
@inject.getter(RateLimitSecurityBindings.DATASOURCEPROVIDER)
private readonly getDatastore: Getter<RateLimit.Store>,
private readonly getDatastore: Getter<RateLimit.Store | null>,
@inject.getter(RateLimitSecurityBindings.METADATA)
private readonly getMetadata: Getter<RateLimitMetadata>,
@inject(CoreBindings.APPLICATION_INSTANCE)
Expand Down Expand Up @@ -39,17 +51,44 @@ export class RatelimitActionProvider implements Provider<RateLimitAction> {
const operationMetadata = metadata ? metadata.options : {};

// Create options based on global config and method level config
const opts = {...this.config, ...operationMetadata};
const rawOpts = {...this.config, ...operationMetadata};

// Filter out unsupported options for express-rate-limit v8
// 'name' is no longer supported in v8
// 'client', 'type', 'uri', 'collectionName' are custom DataSourceConfig options
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
name,
client,
type,
uri,
collectionName,
store: originalStore,
...opts
} = rawOpts as RateLimitOptions & {store?: unknown};
/* eslint-enable @typescript-eslint/no-unused-vars */

// If dataStore is null or undefined, don't set the store property
// express-rate-limit v8 will create its own InMemoryStore
if (dataStore) {
opts.store = dataStore;
(opts as RateLimit.Options).store = dataStore;
}

opts.message = new HttpErrors.TooManyRequests(
opts.message?.toString() ?? 'Method rate limit reached !',
);

const limiter = RateLimit.default(opts);
// Get or create a RateLimit instance for this configuration
// This avoids the "store reuse" error in express-rate-limit v8
// Note: We exclude 'store' from cache key since each store instance is unique
const cacheKey = getRateLimiterKey(opts);
let limiter = rateLimitCache.get(cacheKey);

if (!limiter) {
limiter = RateLimit.default(opts);
rateLimitCache.set(cacheKey, limiter);
}

limiter(request, response, (err: unknown) => {
if (err) {
reject(err);
Expand Down
19 changes: 13 additions & 6 deletions src/providers/ratelimit-datasource.provider.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import {CoreBindings, inject, Provider} from '@loopback/core';
import {Getter, juggler} from '@loopback/repository';
import {HttpErrors, RestApplication} from '@loopback/rest';
import MemcachedStore from 'rate-limit-memcached';
import MongoStore from 'rate-limit-mongo';
import RedisStore, {RedisReply} from 'rate-limit-redis';
import {MemcachedStore} from 'rate-limit-memcached';
import MongoStore = require('rate-limit-mongo');
import {RedisStore, type RedisReply} from 'rate-limit-redis';
import {TextDecoder} from 'util';
import {RateLimitSecurityBindings} from '../keys';
import {RateLimitMetadata, RateLimitOptions, Store} from '../types';
import {
MemcachedClient,
RateLimitMetadata,
RateLimitOptions,
Store,
} from '../types';

const decoder = new TextDecoder('utf-8');
export class RatelimitDatasourceProvider implements Provider<Store> {
Expand Down Expand Up @@ -34,8 +39,10 @@ export class RatelimitDatasourceProvider implements Provider<Store> {
const var2 = 1000;

if (this.config?.type === 'MemcachedStore') {
const expiration = (opts.windowMs ?? var1 * var2) / var2;
return new MemcachedStore({client: this.config?.client, expiration});
return new MemcachedStore({
client: this.config?.client as MemcachedClient,
prefix: '',
});
}
if (this.config?.type === 'MongoStore') {
const expireTimeMs = (opts.windowMs ?? var1 * var2) / var2;
Expand Down
20 changes: 13 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import {Request, Response} from '@loopback/rest';
import {Options} from 'express-rate-limit';
import MemcachedStore from 'rate-limit-memcached';
import MongoStore from 'rate-limit-mongo';
import RedisStore from 'rate-limit-redis';
import {RedisClient} from 'redis';
import IORedis = require('ioredis');
import {MemcachedStore} from 'rate-limit-memcached';
import MongoStore = require('rate-limit-mongo');
import {RedisStore} from 'rate-limit-redis';
import type {Redis as IORedis} from 'ioredis';
import type Memcached from 'memcached';

export type RedisClientType = IORedis.Redis | RedisClient;
export type RedisClientType = IORedis;

// Extract only the methods we need from Memcached class
export type MemcachedClient = Pick<
Memcached,
'get' | 'set' | 'add' | 'del' | 'incr' | 'decr'
>;

export interface DataSourceConfig {
name: string;
client?: string | RedisClientType;
client?: string | RedisClientType | MemcachedClient;
type?: string;
uri?: string;
collectionName?: string;
Expand Down
Loading