Files
esengine/packages/framework/server/src/ratelimit/mixin/withRateLimit.ts

386 lines
12 KiB
TypeScript
Raw Normal View History

/**
* @zh Mixin
* @en Room rate limit mixin
*/
import type { Player, Room } from '../../room/index.js';
import { RateLimitContext } from '../context.js';
import { getRateLimitMetadata, RATE_LIMIT_METADATA_KEY } from '../decorators/rateLimit.js';
import { FixedWindowStrategy } from '../strategies/FixedWindow.js';
import { SlidingWindowStrategy } from '../strategies/SlidingWindow.js';
import { TokenBucketStrategy } from '../strategies/TokenBucket.js';
import type {
IRateLimitContext,
IRateLimitStrategy,
RateLimitConfig,
RateLimitMetadata,
RateLimitResult
} from '../types.js';
/**
* @zh
* @en Player rate limit context storage
*/
const PLAYER_RATE_LIMIT_CONTEXT = Symbol('playerRateLimitContext');
/**
* @zh
* @en Player with rate limit
*/
export interface RateLimitedPlayer<TData = Record<string, unknown>> extends Player<TData> {
/**
* @zh
* @en Rate limit context
*/
readonly rateLimit: IRateLimitContext;
}
/**
* @zh
* @en Room with rate limit interface
*/
export interface IRateLimitRoom {
/**
* @zh
* @en Get rate limit context for player
*/
getRateLimitContext(player: Player): IRateLimitContext | null;
/**
* @zh
* @en Global rate limit strategy
*/
readonly rateLimitStrategy: IRateLimitStrategy;
/**
* @zh
* @en Rate limit hook (called when rate limited)
*/
onRateLimited?(player: Player, messageType: string, result: RateLimitResult): void;
}
/**
* @zh
* @en Rate limit room constructor type
*/
export type RateLimitRoomClass = new (...args: any[]) => Room & IRateLimitRoom;
/**
* @zh
* @en Create strategy instance
*/
function createStrategy(config: RateLimitConfig): IRateLimitStrategy {
const rate = config.messagesPerSecond ?? 10;
const capacity = config.burstSize ?? rate * 2;
switch (config.strategy) {
case 'sliding-window':
return new SlidingWindowStrategy({ rate, capacity });
case 'fixed-window':
return new FixedWindowStrategy({ rate, capacity });
case 'token-bucket':
default:
return new TokenBucketStrategy({ rate, capacity });
}
}
/**
* @zh
* @en Get rate limit context for player
*/
export function getPlayerRateLimitContext(player: Player): IRateLimitContext | null {
const data = player as unknown as Record<symbol, unknown>;
return (data[PLAYER_RATE_LIMIT_CONTEXT] as IRateLimitContext) ?? null;
}
/**
* @zh
* @en Set rate limit context for player
*/
function setPlayerRateLimitContext(player: Player, context: IRateLimitContext): void {
const data = player as unknown as Record<symbol, unknown>;
data[PLAYER_RATE_LIMIT_CONTEXT] = context;
Object.defineProperty(player, 'rateLimit', {
get: () => data[PLAYER_RATE_LIMIT_CONTEXT],
enumerable: true,
configurable: false
});
}
/**
* @zh
* @en Wrap room class with rate limit functionality
*
* @zh 使 mixin
* @en Uses mixin pattern to add rate limit to room, validates rate before processing messages
*
* @example
* ```typescript
* import { Room, onMessage } from '@esengine/server';
* import { withRateLimit } from '@esengine/server/ratelimit';
*
* class GameRoom extends withRateLimit(Room, {
* messagesPerSecond: 10,
* burstSize: 20,
* onLimited: (player, type, result) => {
* player.send('Error', {
* code: 'RATE_LIMITED',
* retryAfter: result.retryAfter
* });
* }
* }) {
* @onMessage('Move')
* handleMove(data: { x: number, y: number }, player: Player) {
* // Protected by rate limit
* }
* }
* ```
*
* @example
* // Combine with auth
* ```typescript
* class GameRoom extends withRateLimit(
* withRoomAuth(Room, { requireAuth: true }),
* { messagesPerSecond: 10 }
* ) {
* // Both auth and rate limit active
* }
* ```
*/
export function withRateLimit<TBase extends new (...args: any[]) => Room = new (...args: any[]) => Room>(
Base: TBase,
config: RateLimitConfig = {}
): TBase & (new (...args: any[]) => IRateLimitRoom) {
const {
messagesPerSecond = 10,
burstSize = 20,
strategy = 'token-bucket',
onLimited,
disconnectOnLimit = false,
maxConsecutiveLimits = 0,
getKey = (player: Player) => player.id,
cleanupInterval = 60000
} = config;
abstract class RateLimitRoom extends (Base as new (...args: any[]) => Room) implements IRateLimitRoom {
private _rateLimitStrategy: IRateLimitStrategy;
private _playerContexts: WeakMap<Player, RateLimitContext> = new WeakMap();
private _cleanupTimer: ReturnType<typeof setInterval> | null = null;
private _messageStrategies: Map<string, IRateLimitStrategy> = new Map();
constructor(...args: any[]) {
super(...args);
this._rateLimitStrategy = createStrategy({
messagesPerSecond,
burstSize,
strategy
});
this._startCleanup();
this._initMessageStrategies();
}
/**
* @zh
* @en Global rate limit strategy
*/
get rateLimitStrategy(): IRateLimitStrategy {
return this._rateLimitStrategy;
}
/**
* @zh
* @en Rate limit hook (can be overridden)
*/
onRateLimited?(player: Player, messageType: string, result: RateLimitResult): void;
/**
* @zh
* @en Get rate limit context for player
*/
getRateLimitContext(player: Player): IRateLimitContext | null {
return this._playerContexts.get(player) ?? null;
}
/**
* @internal
* @zh
* @en Override message handling to add rate limit check
*/
_handleMessage(type: string, data: unknown, playerId: string): void {
const player = this.getPlayer(playerId);
if (!player) return;
let context = this._playerContexts.get(player);
if (!context) {
context = this._createPlayerContext(player);
}
const metadata = this._getMessageMetadata(type);
if (metadata?.exempt) {
super._handleMessage(type, data, playerId);
return;
}
const cost = metadata?.config?.cost ?? 1;
let result: RateLimitResult;
if (metadata?.config && (metadata.config.messagesPerSecond || metadata.config.burstSize)) {
if (!context.hasMessageStrategy(type)) {
const msgStrategy = createStrategy({
messagesPerSecond: metadata.config.messagesPerSecond ?? messagesPerSecond,
burstSize: metadata.config.burstSize ?? burstSize,
strategy
});
context.setMessageStrategy(type, msgStrategy);
}
result = context.consume(type, cost);
} else {
result = context.consume(undefined, cost);
}
if (!result.allowed) {
this._handleRateLimited(player, type, result, context);
return;
}
super._handleMessage(type, data, playerId);
}
/**
* @internal
* @zh _addPlayer
* @en Override _addPlayer to initialize rate limit context
*/
async _addPlayer(id: string, conn: any): Promise<Player | null> {
const player = await super._addPlayer(id, conn);
if (player) {
this._createPlayerContext(player);
}
return player;
}
/**
* @internal
* @zh _removePlayer
* @en Override _removePlayer to cleanup rate limit context
*/
async _removePlayer(id: string, reason?: string): Promise<void> {
const player = this.getPlayer(id);
if (player) {
const context = this._playerContexts.get(player);
if (context) {
context.reset();
}
this._playerContexts.delete(player);
}
await super._removePlayer(id, reason);
}
/**
* @zh dispose
* @en Override dispose to cleanup timer
*/
dispose(): void {
this._stopCleanup();
super.dispose();
}
/**
* @zh
* @en Create rate limit context for player
*/
private _createPlayerContext(player: Player): RateLimitContext {
const key = getKey(player);
const context = new RateLimitContext(key, this._rateLimitStrategy);
this._playerContexts.set(player, context);
setPlayerRateLimitContext(player, context);
return context;
}
/**
* @zh
* @en Handle rate limited situation
*/
private _handleRateLimited(
player: Player,
messageType: string,
result: RateLimitResult,
context: RateLimitContext
): void {
if (this.onRateLimited) {
this.onRateLimited(player, messageType, result);
}
onLimited?.(player, messageType, result);
if (disconnectOnLimit) {
this.kick(player as any, 'rate_limited');
return;
}
if (maxConsecutiveLimits > 0 && context.consecutiveLimitCount >= maxConsecutiveLimits) {
this.kick(player as any, 'too_many_rate_limits');
}
}
/**
* @zh
* @en Get message metadata
*/
private _getMessageMetadata(type: string): RateLimitMetadata | undefined {
return getRateLimitMetadata(this.constructor.prototype, type);
}
/**
* @zh
* @en Initialize message strategies (from decorator metadata)
*/
private _initMessageStrategies(): void {
const metadataMap = (this.constructor.prototype as any)[RATE_LIMIT_METADATA_KEY];
if (metadataMap instanceof Map) {
for (const [msgType, metadata] of metadataMap) {
if (metadata.config && (metadata.config.messagesPerSecond || metadata.config.burstSize)) {
const msgStrategy = createStrategy({
messagesPerSecond: metadata.config.messagesPerSecond ?? messagesPerSecond,
burstSize: metadata.config.burstSize ?? burstSize,
strategy
});
this._messageStrategies.set(msgType, msgStrategy);
}
}
}
}
/**
* @zh
* @en Start cleanup timer
*/
private _startCleanup(): void {
if (cleanupInterval > 0) {
this._cleanupTimer = setInterval(() => {
this._rateLimitStrategy.cleanup();
for (const strategy of this._messageStrategies.values()) {
strategy.cleanup();
}
}, cleanupInterval);
}
}
/**
* @zh
* @en Stop cleanup timer
*/
private _stopCleanup(): void {
if (this._cleanupTimer) {
clearInterval(this._cleanupTimer);
this._cleanupTimer = null;
}
}
}
return RateLimitRoom as unknown as TBase & (new (...args: any[]) => IRateLimitRoom);
}