feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system (#388)

* feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system

- 新增令牌桶策略 (TokenBucketStrategy) - 推荐用于一般场景
- 新增滑动窗口策略 (SlidingWindowStrategy) - 精确跟踪
- 新增固定窗口策略 (FixedWindowStrategy) - 简单高效
- 新增房间速率限制 mixin (withRateLimit)
- 新增速率限制装饰器 (@rateLimit, @noRateLimit)
- 新增按消息类型限流装饰器 (@rateLimitMessage, @noRateLimitMessage)
- 支持与认证系统组合使用
- 添加中英文文档
- 导出路径: @esengine/server/ratelimit

* docs: 更新 README 添加新模块 | update README with new modules

- 添加程序化生成 (procgen) 模块
- 添加 RPC 框架模块
- 添加游戏服务器 (server) 模块
- 添加事务系统 (transaction) 模块
- 添加世界流送 (world-streaming) 模块
- 更新网络模块描述
- 更新项目结构目录
This commit is contained in:
YHH
2025-12-29 17:12:54 +08:00
committed by GitHub
parent 764ce67742
commit afdeb00b4d
23 changed files with 3467 additions and 6 deletions

View File

@@ -19,6 +19,10 @@
"import": "./dist/auth/testing/index.js",
"types": "./dist/auth/testing/index.d.ts"
},
"./ratelimit": {
"import": "./dist/ratelimit/index.js",
"types": "./dist/ratelimit/index.d.ts"
},
"./testing": {
"import": "./dist/testing/index.js",
"types": "./dist/testing/index.d.ts"

View File

@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RateLimitContext } from '../context';
import { TokenBucketStrategy } from '../strategies/TokenBucket';
import { FixedWindowStrategy } from '../strategies/FixedWindow';
describe('RateLimitContext', () => {
let globalStrategy: TokenBucketStrategy;
let context: RateLimitContext;
beforeEach(() => {
globalStrategy = new TokenBucketStrategy({
rate: 10,
capacity: 20
});
context = new RateLimitContext('player-123', globalStrategy);
});
describe('check', () => {
it('should check without consuming', () => {
const result1 = context.check();
const result2 = context.check();
expect(result1.remaining).toBe(result2.remaining);
});
it('should use global strategy by default', () => {
const result = context.check();
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(20);
});
});
describe('consume', () => {
it('should consume from global strategy', () => {
const result = context.consume();
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(19);
});
it('should track consecutive limits', () => {
for (let i = 0; i < 25; i++) {
context.consume();
}
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
});
it('should reset consecutive count on success', () => {
// Consume all 20 tokens plus some more to trigger rate limiting
for (let i = 0; i < 25; i++) {
context.consume();
}
// After consuming 25 tokens (20 capacity), 5 should be rate limited
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
context.reset();
const result = context.consume();
expect(result.allowed).toBe(true);
expect(context.consecutiveLimitCount).toBe(0);
});
});
describe('reset', () => {
it('should reset global strategy', () => {
for (let i = 0; i < 15; i++) {
context.consume();
}
context.reset();
const status = context.check();
expect(status.remaining).toBe(20);
});
it('should reset specific message type', () => {
const msgStrategy = new FixedWindowStrategy({ rate: 5, capacity: 5 });
context.setMessageStrategy('Trade', msgStrategy);
for (let i = 0; i < 5; i++) {
context.consume('Trade');
}
context.reset('Trade');
const status = context.check('Trade');
expect(status.remaining).toBe(5);
});
});
describe('message strategies', () => {
it('should use message-specific strategy', () => {
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
context.setMessageStrategy('Trade', tradeStrategy);
const result1 = context.consume('Trade');
expect(result1.allowed).toBe(true);
const result2 = context.consume('Trade');
expect(result2.allowed).toBe(false);
});
it('should fall back to global strategy for unknown types', () => {
const result = context.consume('UnknownType');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(19);
});
it('should check if message strategy exists', () => {
expect(context.hasMessageStrategy('Trade')).toBe(false);
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
context.setMessageStrategy('Trade', tradeStrategy);
expect(context.hasMessageStrategy('Trade')).toBe(true);
});
it('should remove message strategy', () => {
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
context.setMessageStrategy('Trade', tradeStrategy);
context.removeMessageStrategy('Trade');
expect(context.hasMessageStrategy('Trade')).toBe(false);
});
});
describe('resetConsecutiveCount', () => {
it('should reset consecutive limit count', () => {
for (let i = 0; i < 25; i++) {
context.consume();
}
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
context.resetConsecutiveCount();
expect(context.consecutiveLimitCount).toBe(0);
});
});
});

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
rateLimit,
noRateLimit,
rateLimitMessage,
noRateLimitMessage,
getRateLimitMetadata,
RATE_LIMIT_METADATA_KEY
} from '../decorators/rateLimit';
describe('rateLimitMessage decorator', () => {
class TestClass {
@rateLimitMessage('Trade', { messagesPerSecond: 1, burstSize: 2 })
handleTrade() {
return 'trade';
}
@rateLimitMessage('Move', { cost: 2 })
handleMove() {
return 'move';
}
undecorated() {
return 'undecorated';
}
}
describe('metadata storage', () => {
it('should store rate limit metadata on target', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
expect(metadata).toBeDefined();
expect(metadata?.enabled).toBe(true);
});
it('should store config in metadata', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
expect(metadata?.config?.messagesPerSecond).toBe(1);
expect(metadata?.config?.burstSize).toBe(2);
});
it('should store cost in metadata', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Move');
expect(metadata?.config?.cost).toBe(2);
});
it('should return undefined for unregistered message types', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Unknown');
expect(metadata).toBeUndefined();
});
});
describe('method behavior', () => {
it('should not alter method behavior', () => {
const instance = new TestClass();
expect(instance.handleTrade()).toBe('trade');
expect(instance.handleMove()).toBe('move');
});
});
});
describe('noRateLimitMessage decorator', () => {
class TestClass {
@noRateLimitMessage('Heartbeat')
handleHeartbeat() {
return 'heartbeat';
}
@noRateLimitMessage('Ping')
handlePing() {
return 'ping';
}
}
describe('metadata storage', () => {
it('should mark message as exempt', () => {
const metadata = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
expect(metadata?.exempt).toBe(true);
expect(metadata?.enabled).toBe(false);
});
it('should store for multiple messages', () => {
const heartbeatMeta = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
const pingMeta = getRateLimitMetadata(TestClass.prototype, 'Ping');
expect(heartbeatMeta?.exempt).toBe(true);
expect(pingMeta?.exempt).toBe(true);
});
});
describe('method behavior', () => {
it('should not alter method behavior', () => {
const instance = new TestClass();
expect(instance.handleHeartbeat()).toBe('heartbeat');
expect(instance.handlePing()).toBe('ping');
});
});
});
describe('combined decorators', () => {
class CombinedTestClass {
@rateLimitMessage('SlowAction', { messagesPerSecond: 1 })
handleSlow() {
return 'slow';
}
@noRateLimitMessage('FastAction')
handleFast() {
return 'fast';
}
@rateLimitMessage('ExpensiveAction', { cost: 10 })
handleExpensive() {
return 'expensive';
}
}
it('should handle multiple different decorators', () => {
const slowMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'SlowAction');
const fastMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'FastAction');
const expensiveMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'ExpensiveAction');
expect(slowMeta?.enabled).toBe(true);
expect(slowMeta?.config?.messagesPerSecond).toBe(1);
expect(fastMeta?.exempt).toBe(true);
expect(fastMeta?.enabled).toBe(false);
expect(expensiveMeta?.enabled).toBe(true);
expect(expensiveMeta?.config?.cost).toBe(10);
});
});
describe('RATE_LIMIT_METADATA_KEY', () => {
it('should be a symbol', () => {
expect(typeof RATE_LIMIT_METADATA_KEY).toBe('symbol');
});
it('should be used for metadata storage', () => {
class TestClass {
@rateLimitMessage('Test', {})
handleTest() {}
}
const metadataMap = (TestClass.prototype as any)[RATE_LIMIT_METADATA_KEY];
expect(metadataMap).toBeInstanceOf(Map);
});
});
describe('rateLimit decorator (auto-detect)', () => {
it('should be a decorator function', () => {
expect(typeof rateLimit).toBe('function');
expect(typeof rateLimit()).toBe('function');
});
it('should accept config', () => {
class TestClass {
@rateLimit({ messagesPerSecond: 5 })
someMethod() {
return 'test';
}
}
const instance = new TestClass();
expect(instance.someMethod()).toBe('test');
});
});
describe('noRateLimit decorator (auto-detect)', () => {
it('should be a decorator function', () => {
expect(typeof noRateLimit).toBe('function');
expect(typeof noRateLimit()).toBe('function');
});
it('should work as decorator', () => {
class TestClass {
@noRateLimit()
someMethod() {
return 'test';
}
}
const instance = new TestClass();
expect(instance.someMethod()).toBe('test');
});
});

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Room } from '../../room/Room';
import { Player } from '../../room/Player';
import { withRateLimit, getPlayerRateLimitContext } from '../mixin/withRateLimit';
import { noRateLimitMessage, rateLimitMessage } from '../decorators/rateLimit';
import { onMessage } from '../../room/decorators';
describe('withRateLimit mixin', () => {
let RateLimitedRoom: ReturnType<typeof withRateLimit>;
beforeEach(() => {
RateLimitedRoom = withRateLimit(Room, {
messagesPerSecond: 10,
burstSize: 20
});
});
describe('basic functionality', () => {
it('should create a rate limited room class', () => {
expect(RateLimitedRoom).toBeDefined();
});
it('should have rateLimitStrategy property', () => {
class TestRoom extends RateLimitedRoom {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
expect(room.rateLimitStrategy.name).toBe('token-bucket');
});
});
describe('strategy selection', () => {
it('should use token-bucket by default', () => {
class TestRoom extends withRateLimit(Room) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy.name).toBe('token-bucket');
});
it('should use sliding-window when specified', () => {
class TestRoom extends withRateLimit(Room, { strategy: 'sliding-window' }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy.name).toBe('sliding-window');
});
it('should use fixed-window when specified', () => {
class TestRoom extends withRateLimit(Room, { strategy: 'fixed-window' }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy.name).toBe('fixed-window');
});
});
describe('configuration', () => {
it('should use default values', () => {
class TestRoom extends withRateLimit(Room) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
});
it('should accept custom messagesPerSecond', () => {
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 5 }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
});
it('should accept custom burstSize', () => {
class TestRoom extends withRateLimit(Room, { burstSize: 50 }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
});
});
describe('dispose', () => {
it('should clean up on dispose', () => {
class TestRoom extends RateLimitedRoom {
onCreate() {}
}
const room = new TestRoom();
room._init({
id: 'test-room',
sendFn: vi.fn(),
broadcastFn: vi.fn(),
disposeFn: vi.fn()
});
expect(() => room.dispose()).not.toThrow();
});
});
});
describe('withRateLimit with auth', () => {
it('should be composable with other mixins', () => {
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 10 }) {
onCreate() {}
}
const room = new TestRoom();
expect(room.rateLimitStrategy).toBeDefined();
});
});
describe('getPlayerRateLimitContext', () => {
it('should return null for player without context', () => {
const mockPlayer = {
id: 'player-1',
roomId: 'room-1',
data: {},
send: vi.fn(),
leave: vi.fn()
} as unknown as Player;
const context = getPlayerRateLimitContext(mockPlayer);
expect(context).toBeNull();
});
});
describe('decorator metadata', () => {
it('rateLimitMessage should set metadata', () => {
class TestRoom extends withRateLimit(Room) {
@rateLimitMessage('Trade', { messagesPerSecond: 1 })
@onMessage('Trade')
handleTrade() {}
}
expect(TestRoom).toBeDefined();
});
it('noRateLimitMessage should set exempt metadata', () => {
class TestRoom extends withRateLimit(Room) {
@noRateLimitMessage('Heartbeat')
@onMessage('Heartbeat')
handleHeartbeat() {}
}
expect(TestRoom).toBeDefined();
});
});

View File

@@ -0,0 +1,249 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { TokenBucketStrategy, createTokenBucketStrategy } from '../strategies/TokenBucket';
import { SlidingWindowStrategy, createSlidingWindowStrategy } from '../strategies/SlidingWindow';
import { FixedWindowStrategy, createFixedWindowStrategy } from '../strategies/FixedWindow';
describe('TokenBucketStrategy', () => {
let strategy: TokenBucketStrategy;
beforeEach(() => {
strategy = createTokenBucketStrategy({
rate: 10,
capacity: 20
});
});
describe('consume', () => {
it('should allow requests when tokens available', () => {
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(19);
});
it('should consume multiple tokens', () => {
const result = strategy.consume('user-1', 5);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(15);
});
it('should deny when not enough tokens', () => {
for (let i = 0; i < 20; i++) {
strategy.consume('user-1');
}
const result = strategy.consume('user-1');
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.retryAfter).toBeGreaterThan(0);
});
it('should refill tokens over time', async () => {
for (let i = 0; i < 20; i++) {
strategy.consume('user-1');
}
await new Promise(resolve => setTimeout(resolve, 150));
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
});
it('should handle different keys independently', () => {
for (let i = 0; i < 20; i++) {
strategy.consume('user-1');
}
const result1 = strategy.consume('user-1');
const result2 = strategy.consume('user-2');
expect(result1.allowed).toBe(false);
expect(result2.allowed).toBe(true);
});
});
describe('getStatus', () => {
it('should return full capacity for new key', () => {
const status = strategy.getStatus('new-user');
expect(status.remaining).toBe(20);
expect(status.allowed).toBe(true);
});
it('should not consume tokens', () => {
strategy.getStatus('user-1');
const status = strategy.getStatus('user-1');
expect(status.remaining).toBe(20);
});
});
describe('reset', () => {
it('should reset key to full capacity', () => {
for (let i = 0; i < 15; i++) {
strategy.consume('user-1');
}
strategy.reset('user-1');
const status = strategy.getStatus('user-1');
expect(status.remaining).toBe(20);
});
});
describe('cleanup', () => {
it('should clean up full buckets', async () => {
strategy.consume('user-1');
await new Promise(resolve => setTimeout(resolve, 100));
strategy.cleanup();
});
});
});
describe('SlidingWindowStrategy', () => {
let strategy: SlidingWindowStrategy;
beforeEach(() => {
strategy = createSlidingWindowStrategy({
rate: 10,
capacity: 10
});
});
describe('consume', () => {
it('should allow requests within capacity', () => {
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(9);
});
it('should deny when capacity exceeded', () => {
for (let i = 0; i < 10; i++) {
strategy.consume('user-1');
}
const result = strategy.consume('user-1');
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
});
it('should allow after window expires', async () => {
for (let i = 0; i < 10; i++) {
strategy.consume('user-1');
}
await new Promise(resolve => setTimeout(resolve, 1100));
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
});
});
describe('getStatus', () => {
it('should return full capacity for new key', () => {
const status = strategy.getStatus('new-user');
expect(status.remaining).toBe(10);
expect(status.allowed).toBe(true);
});
});
describe('reset', () => {
it('should clear timestamps', () => {
for (let i = 0; i < 5; i++) {
strategy.consume('user-1');
}
strategy.reset('user-1');
const status = strategy.getStatus('user-1');
expect(status.remaining).toBe(10);
});
});
});
describe('FixedWindowStrategy', () => {
let strategy: FixedWindowStrategy;
beforeEach(() => {
strategy = createFixedWindowStrategy({
rate: 10,
capacity: 10
});
});
describe('consume', () => {
it('should allow requests within capacity', () => {
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(9);
});
it('should deny when capacity exceeded', () => {
for (let i = 0; i < 10; i++) {
strategy.consume('user-1');
}
const result = strategy.consume('user-1');
expect(result.allowed).toBe(false);
expect(result.retryAfter).toBeGreaterThanOrEqual(0);
});
it('should reset at window boundary', async () => {
for (let i = 0; i < 10; i++) {
strategy.consume('user-1');
}
await new Promise(resolve => setTimeout(resolve, 1100));
const result = strategy.consume('user-1');
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(9);
});
});
describe('getStatus', () => {
it('should return full capacity for new key', () => {
const status = strategy.getStatus('new-user');
expect(status.remaining).toBe(10);
});
});
describe('reset', () => {
it('should reset count', () => {
for (let i = 0; i < 5; i++) {
strategy.consume('user-1');
}
strategy.reset('user-1');
const status = strategy.getStatus('user-1');
expect(status.remaining).toBe(10);
});
});
describe('cleanup', () => {
it('should clean up old windows', async () => {
strategy.consume('user-1');
await new Promise(resolve => setTimeout(resolve, 2100));
strategy.cleanup();
});
});
});
describe('Factory functions', () => {
it('createTokenBucketStrategy should create TokenBucketStrategy', () => {
const strategy = createTokenBucketStrategy({ rate: 5, capacity: 10 });
expect(strategy.name).toBe('token-bucket');
});
it('createSlidingWindowStrategy should create SlidingWindowStrategy', () => {
const strategy = createSlidingWindowStrategy({ rate: 5, capacity: 5 });
expect(strategy.name).toBe('sliding-window');
});
it('createFixedWindowStrategy should create FixedWindowStrategy', () => {
const strategy = createFixedWindowStrategy({ rate: 5, capacity: 5 });
expect(strategy.name).toBe('fixed-window');
});
});

View File

@@ -0,0 +1,146 @@
/**
* @zh 速率限制上下文
* @en Rate limit context
*/
import type {
IRateLimitContext,
IRateLimitStrategy,
RateLimitResult,
MessageRateLimitConfig
} from './types.js';
/**
* @zh 速率限制上下文
* @en Rate limit context
*
* @zh 管理单个玩家的速率限制状态,支持全局限制和按消息类型限制
* @en Manages rate limit status for a single player, supports global and per-message-type limits
*
* @example
* ```typescript
* const context = new RateLimitContext('player-123', globalStrategy);
*
* // Check global rate limit
* const result = context.consume();
*
* // Check per-message rate limit
* const tradeResult = context.consume('Trade', 1);
* ```
*/
export class RateLimitContext implements IRateLimitContext {
private _key: string;
private _globalStrategy: IRateLimitStrategy;
private _messageStrategies: Map<string, IRateLimitStrategy> = new Map();
private _consecutiveLimitCount: number = 0;
/**
* @zh 创建速率限制上下文
* @en Create rate limit context
*
* @param key - @zh 限流键通常是玩家ID@en Rate limit key (usually player ID)
* @param globalStrategy - @zh 全局限流策略 @en Global rate limit strategy
*/
constructor(key: string, globalStrategy: IRateLimitStrategy) {
this._key = key;
this._globalStrategy = globalStrategy;
}
/**
* @zh 获取连续被限流次数
* @en Get consecutive limit count
*/
get consecutiveLimitCount(): number {
return this._consecutiveLimitCount;
}
/**
* @zh 检查是否允许(不消费)
* @en Check if allowed (without consuming)
*/
check(messageType?: string): RateLimitResult {
if (messageType && this._messageStrategies.has(messageType)) {
return this._messageStrategies.get(messageType)!.getStatus(this._key);
}
return this._globalStrategy.getStatus(this._key);
}
/**
* @zh 消费配额
* @en Consume quota
*/
consume(messageType?: string, cost: number = 1): RateLimitResult {
let result: RateLimitResult;
if (messageType && this._messageStrategies.has(messageType)) {
result = this._messageStrategies.get(messageType)!.consume(this._key, cost);
} else {
result = this._globalStrategy.consume(this._key, cost);
}
if (result.allowed) {
this._consecutiveLimitCount = 0;
} else {
this._consecutiveLimitCount++;
}
return result;
}
/**
* @zh 重置限流状态
* @en Reset rate limit status
*/
reset(messageType?: string): void {
if (messageType) {
if (this._messageStrategies.has(messageType)) {
this._messageStrategies.get(messageType)!.reset(this._key);
}
} else {
this._globalStrategy.reset(this._key);
for (const strategy of this._messageStrategies.values()) {
strategy.reset(this._key);
}
}
}
/**
* @zh 重置连续限流计数
* @en Reset consecutive limit count
*/
resetConsecutiveCount(): void {
this._consecutiveLimitCount = 0;
}
/**
* @zh 为特定消息类型设置独立的限流策略
* @en Set independent rate limit strategy for specific message type
*
* @param messageType - @zh 消息类型 @en Message type
* @param strategy - @zh 限流策略 @en Rate limit strategy
*/
setMessageStrategy(messageType: string, strategy: IRateLimitStrategy): void {
this._messageStrategies.set(messageType, strategy);
}
/**
* @zh 移除特定消息类型的限流策略
* @en Remove rate limit strategy for specific message type
*
* @param messageType - @zh 消息类型 @en Message type
*/
removeMessageStrategy(messageType: string): void {
this._messageStrategies.delete(messageType);
}
/**
* @zh 检查是否有特定消息类型的限流策略
* @en Check if has rate limit strategy for specific message type
*
* @param messageType - @zh 消息类型 @en Message type
*/
hasMessageStrategy(messageType: string): boolean {
return this._messageStrategies.has(messageType);
}
}

View File

@@ -0,0 +1,13 @@
/**
* @zh 速率限制装饰器
* @en Rate limit decorators
*/
export {
rateLimit,
noRateLimit,
rateLimitMessage,
noRateLimitMessage,
getRateLimitMetadata,
RATE_LIMIT_METADATA_KEY
} from './rateLimit.js';

View File

@@ -0,0 +1,246 @@
/**
* @zh 速率限制装饰器
* @en Rate limit decorators
*/
import type { MessageRateLimitConfig, RateLimitMetadata } from '../types.js';
/**
* @zh 速率限制元数据存储键
* @en Rate limit metadata storage key
*/
export const RATE_LIMIT_METADATA_KEY = Symbol('rateLimitMetadata');
/**
* @zh 获取速率限制元数据
* @en Get rate limit metadata
*
* @param target - @zh 目标对象 @en Target object
* @param messageType - @zh 消息类型 @en Message type
* @returns @zh 元数据 @en Metadata
*/
export function getRateLimitMetadata(target: any, messageType: string): RateLimitMetadata | undefined {
const metadataMap = target[RATE_LIMIT_METADATA_KEY] as Map<string, RateLimitMetadata> | undefined;
return metadataMap?.get(messageType);
}
/**
* @zh 设置速率限制元数据
* @en Set rate limit metadata
*
* @param target - @zh 目标对象 @en Target object
* @param messageType - @zh 消息类型 @en Message type
* @param metadata - @zh 元数据 @en Metadata
*/
function setRateLimitMetadata(target: any, messageType: string, metadata: RateLimitMetadata): void {
if (!target[RATE_LIMIT_METADATA_KEY]) {
target[RATE_LIMIT_METADATA_KEY] = new Map<string, RateLimitMetadata>();
}
const metadataMap = target[RATE_LIMIT_METADATA_KEY] as Map<string, RateLimitMetadata>;
const existing = metadataMap.get(messageType) ?? { enabled: true };
metadataMap.set(messageType, { ...existing, ...metadata });
}
/**
* @zh 从方法获取消息类型
* @en Get message type from method
*
* @zh 通过查找 onMessage 装饰器设置的元数据来获取消息类型
* @en Gets message type by looking up metadata set by onMessage decorator
*/
function getMessageTypeFromMethod(target: any, methodName: string): string | undefined {
const messageHandlers = Symbol.for('messageHandlers');
for (const sym of Object.getOwnPropertySymbols(target.constructor)) {
const desc = Object.getOwnPropertyDescriptor(target.constructor, sym);
if (desc?.value && Array.isArray(desc.value)) {
for (const handler of desc.value) {
if (handler.method === methodName) {
return handler.type;
}
}
}
}
const handlers = target.constructor[Symbol.for('messageHandlers')] as { type: string; method: string }[] | undefined;
if (handlers) {
for (const handler of handlers) {
if (handler.method === methodName) {
return handler.type;
}
}
}
return undefined;
}
/**
* @zh 速率限制装饰器
* @en Rate limit decorator
*
* @zh 为消息处理器设置独立的速率限制配置
* @en Set independent rate limit configuration for message handler
*
* @example
* ```typescript
* class GameRoom extends withRateLimit(Room) {
* @rateLimit({ messagesPerSecond: 1, burstSize: 2 })
* @onMessage('Trade')
* handleTrade(data: TradeData, player: Player) {
* // This message has stricter rate limit
* }
*
* @rateLimit({ cost: 5 })
* @onMessage('ExpensiveAction')
* handleExpensiveAction(data: any, player: Player) {
* // This message consumes 5 tokens
* }
* }
* ```
*/
export function rateLimit(config?: MessageRateLimitConfig): MethodDecorator {
return function (
target: Object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const methodName = String(propertyKey);
queueMicrotask(() => {
const msgType = getMessageTypeFromMethod(target, methodName);
if (msgType) {
setRateLimitMetadata(target, msgType, {
enabled: true,
config
});
}
});
const metadata: RateLimitMetadata = {
enabled: true,
config
};
if (!target.hasOwnProperty(RATE_LIMIT_METADATA_KEY)) {
Object.defineProperty(target, RATE_LIMIT_METADATA_KEY, {
value: new Map<string, RateLimitMetadata>(),
writable: false,
enumerable: false
});
}
return descriptor;
};
}
/**
* @zh 豁免速率限制装饰器
* @en Exempt from rate limit decorator
*
* @zh 标记消息处理器不受速率限制
* @en Mark message handler as exempt from rate limit
*
* @example
* ```typescript
* class GameRoom extends withRateLimit(Room) {
* @noRateLimit()
* @onMessage('Heartbeat')
* handleHeartbeat(data: any, player: Player) {
* // This message is not rate limited
* }
*
* @noRateLimit()
* @onMessage('Ping')
* handlePing(data: any, player: Player) {
* player.send('Pong', {});
* }
* }
* ```
*/
export function noRateLimit(): MethodDecorator {
return function (
target: Object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const methodName = String(propertyKey);
queueMicrotask(() => {
const msgType = getMessageTypeFromMethod(target, methodName);
if (msgType) {
setRateLimitMetadata(target, msgType, {
enabled: false,
exempt: true
});
}
});
return descriptor;
};
}
/**
* @zh 速率限制消息装饰器(直接指定消息类型)
* @en Rate limit message decorator (directly specify message type)
*
* @zh 当无法自动获取消息类型时使用此装饰器
* @en Use this decorator when message type cannot be obtained automatically
*
* @example
* ```typescript
* class GameRoom extends withRateLimit(Room) {
* @rateLimitMessage('Trade', { messagesPerSecond: 1 })
* @onMessage('Trade')
* handleTrade(data: TradeData, player: Player) {
* // Explicitly rate limited
* }
* }
* ```
*/
export function rateLimitMessage(
messageType: string,
config?: MessageRateLimitConfig
): MethodDecorator {
return function (
target: Object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
): PropertyDescriptor {
setRateLimitMetadata(target, messageType, {
enabled: true,
config
});
return descriptor;
};
}
/**
* @zh 豁免速率限制消息装饰器(直接指定消息类型)
* @en Exempt rate limit message decorator (directly specify message type)
*
* @example
* ```typescript
* class GameRoom extends withRateLimit(Room) {
* @noRateLimitMessage('Heartbeat')
* @onMessage('Heartbeat')
* handleHeartbeat(data: any, player: Player) {
* // Explicitly exempted
* }
* }
* ```
*/
export function noRateLimitMessage(messageType: string): MethodDecorator {
return function (
target: Object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
): PropertyDescriptor {
setRateLimitMetadata(target, messageType, {
enabled: false,
exempt: true
});
return descriptor;
};
}

View File

@@ -0,0 +1,91 @@
/**
* @zh 速率限制模块
* @en Rate limit module
*
* @zh 提供可插拔的速率限制系统,支持多种限流算法
* @en Provides pluggable rate limit system with multiple algorithms
*
* @example
* ```typescript
* import { Room, onMessage } from '@esengine/server';
* import {
* withRateLimit,
* rateLimit,
* noRateLimit
* } from '@esengine/server/ratelimit';
*
* class GameRoom extends withRateLimit(Room, {
* messagesPerSecond: 10,
* burstSize: 20,
* strategy: 'token-bucket',
* 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 default rate limit
* }
*
* @rateLimit({ messagesPerSecond: 1 })
* @onMessage('Trade')
* handleTrade(data: TradeData, player: Player) {
* // Stricter rate limit for trading
* }
*
* @noRateLimit()
* @onMessage('Heartbeat')
* handleHeartbeat(data: any, player: Player) {
* // No rate limit for heartbeat
* }
* }
* ```
*/
// Types
export type {
RateLimitResult,
IRateLimitStrategy,
RateLimitStrategyType,
RateLimitConfig,
MessageRateLimitConfig,
RateLimitMetadata,
IRateLimitContext,
RateLimitedRoom,
StrategyConfig
} from './types.js';
// Strategies
export {
TokenBucketStrategy,
createTokenBucketStrategy,
SlidingWindowStrategy,
createSlidingWindowStrategy,
FixedWindowStrategy,
createFixedWindowStrategy
} from './strategies/index.js';
// Context
export { RateLimitContext } from './context.js';
// Mixin
export {
withRateLimit,
getPlayerRateLimitContext,
type RateLimitedPlayer,
type IRateLimitRoom,
type RateLimitRoomClass
} from './mixin/index.js';
// Decorators
export {
rateLimit,
noRateLimit,
rateLimitMessage,
noRateLimitMessage,
getRateLimitMetadata,
RATE_LIMIT_METADATA_KEY
} from './decorators/index.js';

View File

@@ -0,0 +1,12 @@
/**
* @zh 速率限制 Mixin
* @en Rate limit mixin
*/
export {
withRateLimit,
getPlayerRateLimitContext,
type RateLimitedPlayer,
type IRateLimitRoom,
type RateLimitRoomClass
} from './withRateLimit.js';

View File

@@ -0,0 +1,385 @@
/**
* @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);
}

View File

@@ -0,0 +1,204 @@
/**
* @zh 固定窗口速率限制策略
* @en Fixed window rate limit strategy
*/
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
/**
* @zh 固定窗口状态
* @en Fixed window state
*/
interface WindowState {
/**
* @zh 当前窗口计数
* @en Current window count
*/
count: number;
/**
* @zh 窗口开始时间
* @en Window start time
*/
windowStart: number;
}
/**
* @zh 固定窗口速率限制策略
* @en Fixed window rate limit strategy
*
* @zh 固定窗口算法将时间划分为固定长度的窗口,在每个窗口内计数请求。
* 实现简单,内存开销小,但在窗口边界可能有两倍突发的问题。
* @en Fixed window algorithm divides time into fixed-length windows and counts requests in each window.
* Simple to implement with low memory overhead, but may have 2x burst issue at window boundaries.
*
* @example
* ```typescript
* const strategy = new FixedWindowStrategy({
* rate: 10, // 10 requests per second
* capacity: 10 // same as rate for 1-second window
* });
*
* const result = strategy.consume('player-123');
* if (result.allowed) {
* // Process message
* }
* ```
*/
export class FixedWindowStrategy implements IRateLimitStrategy {
readonly name = 'fixed-window';
private _rate: number;
private _capacity: number;
private _windowMs: number;
private _windows: Map<string, WindowState> = new Map();
/**
* @zh 创建固定窗口策略
* @en Create fixed window strategy
*
* @param config - @zh 配置 @en Configuration
* @param config.rate - @zh 每秒允许的请求数 @en Requests allowed per second
* @param config.capacity - @zh 窗口容量 @en Window capacity
*/
constructor(config: StrategyConfig) {
this._rate = config.rate;
this._capacity = config.capacity;
this._windowMs = 1000;
}
/**
* @zh 尝试消费配额
* @en Try to consume quota
*/
consume(key: string, cost: number = 1): RateLimitResult {
const now = Date.now();
const window = this._getOrCreateWindow(key, now);
this._maybeResetWindow(window, now);
if (window.count + cost <= this._capacity) {
window.count += cost;
return {
allowed: true,
remaining: this._capacity - window.count,
resetAt: window.windowStart + this._windowMs
};
}
const retryAfter = window.windowStart + this._windowMs - now;
return {
allowed: false,
remaining: 0,
resetAt: window.windowStart + this._windowMs,
retryAfter: Math.max(0, retryAfter)
};
}
/**
* @zh 获取当前状态
* @en Get current status
*/
getStatus(key: string): RateLimitResult {
const now = Date.now();
const window = this._windows.get(key);
if (!window) {
return {
allowed: true,
remaining: this._capacity,
resetAt: this._getWindowStart(now) + this._windowMs
};
}
this._maybeResetWindow(window, now);
const remaining = Math.max(0, this._capacity - window.count);
return {
allowed: remaining > 0,
remaining,
resetAt: window.windowStart + this._windowMs
};
}
/**
* @zh 重置指定键
* @en Reset specified key
*/
reset(key: string): void {
this._windows.delete(key);
}
/**
* @zh 清理所有过期记录
* @en Clean up all expired records
*/
cleanup(): void {
const now = Date.now();
const currentWindowStart = this._getWindowStart(now);
for (const [key, window] of this._windows) {
if (window.windowStart < currentWindowStart - this._windowMs) {
this._windows.delete(key);
}
}
}
/**
* @zh 获取或创建窗口
* @en Get or create window
*/
private _getOrCreateWindow(key: string, now: number): WindowState {
let window = this._windows.get(key);
if (!window) {
window = {
count: 0,
windowStart: this._getWindowStart(now)
};
this._windows.set(key, window);
}
return window;
}
/**
* @zh 如果需要则重置窗口
* @en Reset window if needed
*/
private _maybeResetWindow(window: WindowState, now: number): void {
const currentWindowStart = this._getWindowStart(now);
if (window.windowStart < currentWindowStart) {
window.count = 0;
window.windowStart = currentWindowStart;
}
}
/**
* @zh 获取窗口开始时间
* @en Get window start time
*/
private _getWindowStart(now: number): number {
return Math.floor(now / this._windowMs) * this._windowMs;
}
}
/**
* @zh 创建固定窗口策略
* @en Create fixed window strategy
*
* @example
* ```typescript
* const strategy = createFixedWindowStrategy({
* rate: 10,
* capacity: 10
* });
* ```
*/
export function createFixedWindowStrategy(config: StrategyConfig): FixedWindowStrategy {
return new FixedWindowStrategy(config);
}

View File

@@ -0,0 +1,201 @@
/**
* @zh 滑动窗口速率限制策略
* @en Sliding window rate limit strategy
*/
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
/**
* @zh 滑动窗口状态
* @en Sliding window state
*/
interface WindowState {
/**
* @zh 请求时间戳列表
* @en Request timestamp list
*/
timestamps: number[];
}
/**
* @zh 滑动窗口速率限制策略
* @en Sliding window rate limit strategy
*
* @zh 滑动窗口算法精确跟踪时间窗口内的请求数。
* 比固定窗口更精确,但内存开销稍大。
* @en Sliding window algorithm precisely tracks requests within a time window.
* More accurate than fixed window, but with slightly higher memory overhead.
*
* @example
* ```typescript
* const strategy = new SlidingWindowStrategy({
* rate: 10, // 10 requests per second
* capacity: 10 // window size (same as rate for 1-second window)
* });
*
* const result = strategy.consume('player-123');
* if (result.allowed) {
* // Process message
* }
* ```
*/
export class SlidingWindowStrategy implements IRateLimitStrategy {
readonly name = 'sliding-window';
private _rate: number;
private _capacity: number;
private _windowMs: number;
private _windows: Map<string, WindowState> = new Map();
/**
* @zh 创建滑动窗口策略
* @en Create sliding window strategy
*
* @param config - @zh 配置 @en Configuration
* @param config.rate - @zh 每秒允许的请求数 @en Requests allowed per second
* @param config.capacity - @zh 窗口容量 @en Window capacity
*/
constructor(config: StrategyConfig) {
this._rate = config.rate;
this._capacity = config.capacity;
this._windowMs = 1000;
}
/**
* @zh 尝试消费配额
* @en Try to consume quota
*/
consume(key: string, cost: number = 1): RateLimitResult {
const now = Date.now();
const window = this._getOrCreateWindow(key);
this._cleanExpiredTimestamps(window, now);
const currentCount = window.timestamps.length;
if (currentCount + cost <= this._capacity) {
for (let i = 0; i < cost; i++) {
window.timestamps.push(now);
}
return {
allowed: true,
remaining: this._capacity - window.timestamps.length,
resetAt: this._getResetAt(window, now)
};
}
const oldestTimestamp = window.timestamps[0] || now;
const retryAfter = Math.max(0, oldestTimestamp + this._windowMs - now);
return {
allowed: false,
remaining: 0,
resetAt: oldestTimestamp + this._windowMs,
retryAfter
};
}
/**
* @zh 获取当前状态
* @en Get current status
*/
getStatus(key: string): RateLimitResult {
const now = Date.now();
const window = this._windows.get(key);
if (!window) {
return {
allowed: true,
remaining: this._capacity,
resetAt: now + this._windowMs
};
}
this._cleanExpiredTimestamps(window, now);
const remaining = Math.max(0, this._capacity - window.timestamps.length);
return {
allowed: remaining > 0,
remaining,
resetAt: this._getResetAt(window, now)
};
}
/**
* @zh 重置指定键
* @en Reset specified key
*/
reset(key: string): void {
this._windows.delete(key);
}
/**
* @zh 清理所有过期记录
* @en Clean up all expired records
*/
cleanup(): void {
const now = Date.now();
for (const [key, window] of this._windows) {
this._cleanExpiredTimestamps(window, now);
if (window.timestamps.length === 0) {
this._windows.delete(key);
}
}
}
/**
* @zh 获取或创建窗口
* @en Get or create window
*/
private _getOrCreateWindow(key: string): WindowState {
let window = this._windows.get(key);
if (!window) {
window = { timestamps: [] };
this._windows.set(key, window);
}
return window;
}
/**
* @zh 清理过期的时间戳
* @en Clean expired timestamps
*/
private _cleanExpiredTimestamps(window: WindowState, now: number): void {
const cutoff = now - this._windowMs;
window.timestamps = window.timestamps.filter(ts => ts > cutoff);
}
/**
* @zh 获取重置时间
* @en Get reset time
*/
private _getResetAt(window: WindowState, now: number): number {
if (window.timestamps.length === 0) {
return now + this._windowMs;
}
return window.timestamps[0] + this._windowMs;
}
}
/**
* @zh 创建滑动窗口策略
* @en Create sliding window strategy
*
* @example
* ```typescript
* const strategy = createSlidingWindowStrategy({
* rate: 10,
* capacity: 10
* });
* ```
*/
export function createSlidingWindowStrategy(config: StrategyConfig): SlidingWindowStrategy {
return new SlidingWindowStrategy(config);
}

View File

@@ -0,0 +1,191 @@
/**
* @zh 令牌桶速率限制策略
* @en Token bucket rate limit strategy
*/
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
/**
* @zh 令牌桶状态
* @en Token bucket state
*/
interface BucketState {
/**
* @zh 当前令牌数
* @en Current token count
*/
tokens: number;
/**
* @zh 上次更新时间
* @en Last update time
*/
lastUpdate: number;
}
/**
* @zh 令牌桶速率限制策略
* @en Token bucket rate limit strategy
*
* @zh 令牌桶算法允许突发流量,同时保持长期速率限制。
* 令牌以固定速率添加到桶中,每个请求消耗一个或多个令牌。
* @en Token bucket algorithm allows burst traffic while maintaining long-term rate limit.
* Tokens are added to the bucket at a fixed rate, each request consumes one or more tokens.
*
* @example
* ```typescript
* const strategy = new TokenBucketStrategy({
* rate: 10, // 10 tokens per second
* capacity: 20 // bucket can hold 20 tokens max
* });
*
* const result = strategy.consume('player-123');
* if (result.allowed) {
* // Process message
* }
* ```
*/
export class TokenBucketStrategy implements IRateLimitStrategy {
readonly name = 'token-bucket';
private _rate: number;
private _capacity: number;
private _buckets: Map<string, BucketState> = new Map();
/**
* @zh 创建令牌桶策略
* @en Create token bucket strategy
*
* @param config - @zh 配置 @en Configuration
* @param config.rate - @zh 每秒添加的令牌数 @en Tokens added per second
* @param config.capacity - @zh 桶容量(最大令牌数)@en Bucket capacity (max tokens)
*/
constructor(config: StrategyConfig) {
this._rate = config.rate;
this._capacity = config.capacity;
}
/**
* @zh 尝试消费令牌
* @en Try to consume tokens
*/
consume(key: string, cost: number = 1): RateLimitResult {
const now = Date.now();
const bucket = this._getOrCreateBucket(key, now);
this._refillBucket(bucket, now);
if (bucket.tokens >= cost) {
bucket.tokens -= cost;
return {
allowed: true,
remaining: Math.floor(bucket.tokens),
resetAt: now + Math.ceil((this._capacity - bucket.tokens) / this._rate * 1000)
};
}
const tokensNeeded = cost - bucket.tokens;
const retryAfter = Math.ceil(tokensNeeded / this._rate * 1000);
return {
allowed: false,
remaining: 0,
resetAt: now + retryAfter,
retryAfter
};
}
/**
* @zh 获取当前状态
* @en Get current status
*/
getStatus(key: string): RateLimitResult {
const now = Date.now();
const bucket = this._buckets.get(key);
if (!bucket) {
return {
allowed: true,
remaining: this._capacity,
resetAt: now
};
}
this._refillBucket(bucket, now);
return {
allowed: bucket.tokens >= 1,
remaining: Math.floor(bucket.tokens),
resetAt: now + Math.ceil((this._capacity - bucket.tokens) / this._rate * 1000)
};
}
/**
* @zh 重置指定键
* @en Reset specified key
*/
reset(key: string): void {
this._buckets.delete(key);
}
/**
* @zh 清理所有记录
* @en Clean up all records
*/
cleanup(): void {
const now = Date.now();
const expireThreshold = 60000;
for (const [key, bucket] of this._buckets) {
if (now - bucket.lastUpdate > expireThreshold && bucket.tokens >= this._capacity) {
this._buckets.delete(key);
}
}
}
/**
* @zh 获取或创建桶
* @en Get or create bucket
*/
private _getOrCreateBucket(key: string, now: number): BucketState {
let bucket = this._buckets.get(key);
if (!bucket) {
bucket = {
tokens: this._capacity,
lastUpdate: now
};
this._buckets.set(key, bucket);
}
return bucket;
}
/**
* @zh 补充令牌
* @en Refill tokens
*/
private _refillBucket(bucket: BucketState, now: number): void {
const elapsed = now - bucket.lastUpdate;
const tokensToAdd = (elapsed / 1000) * this._rate;
bucket.tokens = Math.min(this._capacity, bucket.tokens + tokensToAdd);
bucket.lastUpdate = now;
}
}
/**
* @zh 创建令牌桶策略
* @en Create token bucket strategy
*
* @example
* ```typescript
* const strategy = createTokenBucketStrategy({
* rate: 10,
* capacity: 20
* });
* ```
*/
export function createTokenBucketStrategy(config: StrategyConfig): TokenBucketStrategy {
return new TokenBucketStrategy(config);
}

View File

@@ -0,0 +1,8 @@
/**
* @zh 速率限制策略
* @en Rate limit strategies
*/
export { TokenBucketStrategy, createTokenBucketStrategy } from './TokenBucket.js';
export { SlidingWindowStrategy, createSlidingWindowStrategy } from './SlidingWindow.js';
export { FixedWindowStrategy, createFixedWindowStrategy } from './FixedWindow.js';

View File

@@ -0,0 +1,267 @@
/**
* @zh 速率限制类型定义
* @en Rate limit type definitions
*/
import type { Player } from '../room/Player.js';
/**
* @zh 速率限制结果
* @en Rate limit result
*/
export interface RateLimitResult {
/**
* @zh 是否允许
* @en Whether allowed
*/
allowed: boolean;
/**
* @zh 剩余配额
* @en Remaining quota
*/
remaining: number;
/**
* @zh 配额重置时间(毫秒时间戳)
* @en Quota reset time (milliseconds timestamp)
*/
resetAt: number;
/**
* @zh 重试等待时间(毫秒),仅在被限流时返回
* @en Retry after time (milliseconds), only returned when rate limited
*/
retryAfter?: number;
}
/**
* @zh 速率限制策略接口
* @en Rate limit strategy interface
*/
export interface IRateLimitStrategy {
/**
* @zh 策略名称
* @en Strategy name
*/
readonly name: string;
/**
* @zh 尝试消费配额
* @en Try to consume quota
*
* @param key - @zh 限流键通常是玩家ID或连接ID@en Rate limit key (usually player ID or connection ID)
* @param cost - @zh 消费数量默认1@en Consumption amount (default 1)
* @returns @zh 限流结果 @en Rate limit result
*/
consume(key: string, cost?: number): RateLimitResult;
/**
* @zh 获取当前状态(不消费)
* @en Get current status (without consuming)
*
* @param key - @zh 限流键 @en Rate limit key
* @returns @zh 限流结果 @en Rate limit result
*/
getStatus(key: string): RateLimitResult;
/**
* @zh 重置指定键的限流状态
* @en Reset rate limit status for specified key
*
* @param key - @zh 限流键 @en Rate limit key
*/
reset(key: string): void;
/**
* @zh 清理所有过期的限流记录
* @en Clean up all expired rate limit records
*/
cleanup(): void;
}
/**
* @zh 速率限制策略类型
* @en Rate limit strategy type
*/
export type RateLimitStrategyType = 'token-bucket' | 'sliding-window' | 'fixed-window';
/**
* @zh 速率限制配置
* @en Rate limit configuration
*/
export interface RateLimitConfig {
/**
* @zh 每秒允许的消息数
* @en Messages allowed per second
* @defaultValue 10
*/
messagesPerSecond?: number;
/**
* @zh 突发容量(令牌桶大小)
* @en Burst capacity (token bucket size)
* @defaultValue 20
*/
burstSize?: number;
/**
* @zh 限流策略
* @en Rate limit strategy
* @defaultValue 'token-bucket'
*/
strategy?: RateLimitStrategyType;
/**
* @zh 被限流时的回调
* @en Callback when rate limited
*/
onLimited?: (player: Player, messageType: string, result: RateLimitResult) => void;
/**
* @zh 是否在限流时断开连接
* @en Whether to disconnect when rate limited
* @defaultValue false
*/
disconnectOnLimit?: boolean;
/**
* @zh 连续被限流多少次后断开连接0 表示不断开)
* @en Disconnect after how many consecutive rate limits (0 means never)
* @defaultValue 0
*/
maxConsecutiveLimits?: number;
/**
* @zh 获取限流键的函数默认使用玩家ID
* @en Function to get rate limit key (default uses player ID)
*/
getKey?: (player: Player) => string;
/**
* @zh 清理间隔(毫秒)
* @en Cleanup interval (milliseconds)
* @defaultValue 60000
*/
cleanupInterval?: number;
}
/**
* @zh 单个消息的速率限制配置
* @en Rate limit configuration for individual message
*/
export interface MessageRateLimitConfig {
/**
* @zh 每秒允许的消息数
* @en Messages allowed per second
*/
messagesPerSecond?: number;
/**
* @zh 突发容量
* @en Burst capacity
*/
burstSize?: number;
/**
* @zh 消费的令牌数默认1
* @en Tokens to consume (default 1)
*/
cost?: number;
}
/**
* @zh 速率限制元数据
* @en Rate limit metadata
*/
export interface RateLimitMetadata {
/**
* @zh 是否启用速率限制
* @en Whether rate limit is enabled
*/
enabled: boolean;
/**
* @zh 是否豁免速率限制
* @en Whether exempt from rate limit
*/
exempt?: boolean;
/**
* @zh 自定义配置
* @en Custom configuration
*/
config?: MessageRateLimitConfig;
}
/**
* @zh 速率限制上下文接口
* @en Rate limit context interface
*/
export interface IRateLimitContext {
/**
* @zh 检查是否允许(不消费)
* @en Check if allowed (without consuming)
*/
check(messageType?: string): RateLimitResult;
/**
* @zh 消费配额
* @en Consume quota
*/
consume(messageType?: string, cost?: number): RateLimitResult;
/**
* @zh 重置限流状态
* @en Reset rate limit status
*/
reset(messageType?: string): void;
/**
* @zh 获取连续被限流次数
* @en Get consecutive limit count
*/
readonly consecutiveLimitCount: number;
/**
* @zh 重置连续限流计数
* @en Reset consecutive limit count
*/
resetConsecutiveCount(): void;
}
/**
* @zh 带速率限制的 Room 接口
* @en Room interface with rate limit
*/
export interface RateLimitedRoom {
/**
* @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 strategy configuration
*/
export interface StrategyConfig {
/**
* @zh 每秒允许的请求数
* @en Requests allowed per second
*/
rate: number;
/**
* @zh 容量/窗口大小
* @en Capacity/window size
*/
capacity: number;
}

View File

@@ -5,6 +5,7 @@ export default defineConfig({
'src/index.ts',
'src/auth/index.ts',
'src/auth/testing/index.ts',
'src/ratelimit/index.ts',
'src/testing/index.ts'
],
format: ['esm'],