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:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
146
packages/framework/server/src/ratelimit/context.ts
Normal file
146
packages/framework/server/src/ratelimit/context.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
packages/framework/server/src/ratelimit/decorators/index.ts
Normal file
13
packages/framework/server/src/ratelimit/decorators/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @zh 速率限制装饰器
|
||||
* @en Rate limit decorators
|
||||
*/
|
||||
|
||||
export {
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
rateLimitMessage,
|
||||
noRateLimitMessage,
|
||||
getRateLimitMetadata,
|
||||
RATE_LIMIT_METADATA_KEY
|
||||
} from './rateLimit.js';
|
||||
246
packages/framework/server/src/ratelimit/decorators/rateLimit.ts
Normal file
246
packages/framework/server/src/ratelimit/decorators/rateLimit.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
91
packages/framework/server/src/ratelimit/index.ts
Normal file
91
packages/framework/server/src/ratelimit/index.ts
Normal 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';
|
||||
12
packages/framework/server/src/ratelimit/mixin/index.ts
Normal file
12
packages/framework/server/src/ratelimit/mixin/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @zh 速率限制 Mixin
|
||||
* @en Rate limit mixin
|
||||
*/
|
||||
|
||||
export {
|
||||
withRateLimit,
|
||||
getPlayerRateLimitContext,
|
||||
type RateLimitedPlayer,
|
||||
type IRateLimitRoom,
|
||||
type RateLimitRoomClass
|
||||
} from './withRateLimit.js';
|
||||
385
packages/framework/server/src/ratelimit/mixin/withRateLimit.ts
Normal file
385
packages/framework/server/src/ratelimit/mixin/withRateLimit.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
267
packages/framework/server/src/ratelimit/types.ts
Normal file
267
packages/framework/server/src/ratelimit/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user