Compare commits

...

2 Commits

Author SHA1 Message Date
github-actions[bot]
4cf868a769 chore: release packages (#389)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 17:14:53 +08:00
YHH
afdeb00b4d 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) 模块
- 更新网络模块描述
- 更新项目结构目录
2025-12-29 17:12:54 +08:00
25 changed files with 3476 additions and 8 deletions

View File

@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
| **Timer** | Timer and cooldown systems | No |
| **Spatial** | Spatial indexing and queries (QuadTree, Grid) | No |
| **Pathfinding** | A* and navigation mesh pathfinding | No |
| **Network** | Client/server networking with TSRPC | No |
| **Procgen** | Procedural generation (noise, random, sampling) | No |
| **RPC** | High-performance RPC communication framework | No |
| **Server** | Game server framework with rooms, auth, rate limiting | No |
| **Network** | Client networking with prediction, AOI, delta compression | No |
| **Transaction** | Game transaction system with Redis/Memory storage | No |
| **World Streaming** | Open world chunk loading and streaming | No |
> All framework modules can be used standalone with any rendering engine.
@@ -199,7 +204,12 @@ npm install @esengine/fsm # State machines
npm install @esengine/timer # Timers & cooldowns
npm install @esengine/spatial # Spatial indexing
npm install @esengine/pathfinding # Pathfinding
npm install @esengine/network # Networking
npm install @esengine/procgen # Procedural generation
npm install @esengine/rpc # RPC framework
npm install @esengine/server # Game server
npm install @esengine/network # Client networking
npm install @esengine/transaction # Transaction system
npm install @esengine/world-streaming # World streaming
```
### ESEngine Runtime (Optional)
@@ -235,7 +245,11 @@ esengine/
│ │ ├── spatial/ # Spatial queries
│ │ ├── pathfinding/ # Pathfinding
│ │ ├── procgen/ # Procedural generation
│ │ ── network/ # Networking
│ │ ── rpc/ # RPC framework
│ │ ├── server/ # Game server
│ │ ├── network/ # Client networking
│ │ ├── transaction/ # Transaction system
│ │ └── world-streaming/ # World streaming
│ │
│ ├── engine/ # ESEngine runtime
│ ├── rendering/ # Rendering modules

View File

@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
| **定时器** | 定时器和冷却系统 | 否 |
| **空间索引** | 空间查询(四叉树、网格) | 否 |
| **寻路** | A* 和导航网格寻路 | 否 |
| **网络** | 客户端/服务端网络通信 (TSRPC) | 否 |
| **程序化生成** | 噪声、随机、采样等生成算法 | 否 |
| **RPC** | 高性能 RPC 通信框架 | 否 |
| **服务端** | 游戏服务器框架,支持房间、认证、速率限制 | 否 |
| **网络** | 客户端网络支持预测、AOI、增量压缩 | 否 |
| **事务系统** | 游戏事务系统,支持 Redis/内存存储 | 否 |
| **世界流送** | 开放世界分块加载和流送 | 否 |
> 所有框架模块都可以独立使用,无需依赖特定渲染引擎。
@@ -199,7 +204,12 @@ npm install @esengine/fsm # 状态机
npm install @esengine/timer # 定时器和冷却
npm install @esengine/spatial # 空间索引
npm install @esengine/pathfinding # 寻路
npm install @esengine/network # 网络
npm install @esengine/procgen # 程序化生成
npm install @esengine/rpc # RPC 框架
npm install @esengine/server # 游戏服务器
npm install @esengine/network # 客户端网络
npm install @esengine/transaction # 事务系统
npm install @esengine/world-streaming # 世界流送
```
### ESEngine 运行时(可选)
@@ -235,7 +245,11 @@ esengine/
│ │ ├── spatial/ # 空间查询
│ │ ├── pathfinding/ # 寻路
│ │ ├── procgen/ # 程序化生成
│ │ ── network/ # 网络
│ │ ── rpc/ # RPC 框架
│ │ ├── server/ # 游戏服务器
│ │ ├── network/ # 客户端网络
│ │ ├── transaction/ # 事务系统
│ │ └── world-streaming/ # 世界流送
│ │
│ ├── engine/ # ESEngine 运行时
│ ├── rendering/ # 渲染模块

View File

@@ -268,6 +268,7 @@ export default defineConfig({
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },

View File

@@ -0,0 +1,458 @@
---
title: "Rate Limiting"
description: "Protect your game server from abuse with configurable rate limiting"
---
The `@esengine/server` package includes a pluggable rate limiting system to protect against DDoS attacks, message flooding, and other abuse.
## Installation
Rate limiting is included in the server package:
```bash
npm install @esengine/server
```
## Quick Start
```typescript
import { Room, onMessage } from '@esengine/server'
import { withRateLimit, rateLimit, noRateLimit } 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 (10 msg/s default)
}
@rateLimit({ messagesPerSecond: 1 })
@onMessage('Trade')
handleTrade(data: TradeData, player: Player) {
// Stricter limit for trading
}
@noRateLimit()
@onMessage('Heartbeat')
handleHeartbeat(data: any, player: Player) {
// No rate limit for heartbeat
}
}
```
## Rate Limit Strategies
### Token Bucket (Default)
The token bucket algorithm allows burst traffic while maintaining long-term rate limits. Tokens are added at a fixed rate, and each request consumes tokens.
```typescript
import { withRateLimit } from '@esengine/server/ratelimit'
class GameRoom extends withRateLimit(Room, {
strategy: 'token-bucket',
messagesPerSecond: 10, // Refill rate
burstSize: 20, // Bucket capacity
}) { }
```
**How it works:**
```
Config: rate=10/s, burstSize=20
[0s] Bucket full: 20 tokens
[0s] 15 messages → allowed, 5 remaining
[0.5s] Refill 5 tokens → 10 tokens
[0.5s] 8 messages → allowed, 2 remaining
[0.6s] Refill 1 token → 3 tokens
[0.6s] 5 messages → 3 allowed, 2 rejected
```
**Best for:** Most general use cases, balances burst tolerance with protection.
### Sliding Window
The sliding window algorithm precisely tracks requests within a time window. More accurate than fixed window but uses slightly more memory.
```typescript
class GameRoom extends withRateLimit(Room, {
strategy: 'sliding-window',
messagesPerSecond: 10,
burstSize: 10,
}) { }
```
**Best for:** When you need precise rate limiting without burst tolerance.
### Fixed Window
The fixed window algorithm divides time into fixed intervals and counts requests per interval. Simple and memory-efficient but allows 2x burst at window boundaries.
```typescript
class GameRoom extends withRateLimit(Room, {
strategy: 'fixed-window',
messagesPerSecond: 10,
burstSize: 10,
}) { }
```
**Best for:** Simple scenarios where boundary burst is acceptable.
## Configuration
### Room Configuration
```typescript
import { withRateLimit } from '@esengine/server/ratelimit'
class GameRoom extends withRateLimit(Room, {
// Messages allowed per second (default: 10)
messagesPerSecond: 10,
// Burst capacity / bucket size (default: 20)
burstSize: 20,
// Strategy: 'token-bucket' | 'sliding-window' | 'fixed-window'
strategy: 'token-bucket',
// Callback when rate limited
onLimited: (player, messageType, result) => {
player.send('RateLimited', {
type: messageType,
retryAfter: result.retryAfter,
})
},
// Disconnect on rate limit (default: false)
disconnectOnLimit: false,
// Disconnect after N consecutive limits (0 = never)
maxConsecutiveLimits: 10,
// Custom key function (default: player.id)
getKey: (player) => player.id,
// Cleanup interval in ms (default: 60000)
cleanupInterval: 60000,
}) { }
```
### Per-Message Configuration
Use decorators to configure rate limits for specific messages:
```typescript
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
class GameRoom extends withRateLimit(Room) {
// Custom rate limit for this message
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
@onMessage('Trade')
handleTrade(data: TradeData, player: Player) { }
// This message costs 5 tokens
@rateLimit({ cost: 5 })
@onMessage('ExpensiveAction')
handleExpensive(data: any, player: Player) { }
// Exempt from rate limiting
@noRateLimit()
@onMessage('Heartbeat')
handleHeartbeat(data: any, player: Player) { }
// Alternative: specify message type explicitly
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
@onMessage('SpecialAction')
handleSpecial(data: any, player: Player) { }
}
```
## Combining with Authentication
Rate limiting works seamlessly with the authentication system:
```typescript
import { withRoomAuth } from '@esengine/server/auth'
import { withRateLimit } from '@esengine/server/ratelimit'
// Apply both mixins
class GameRoom extends withRateLimit(
withRoomAuth(Room, { requireAuth: true }),
{ messagesPerSecond: 10 }
) {
onJoin(player: AuthPlayer) {
console.log(`${player.user?.name} joined with rate limit protection`)
}
}
```
## Rate Limit Result
When a message is rate limited, the callback receives a result object:
```typescript
interface RateLimitResult {
// Whether the request was allowed
allowed: boolean
// Remaining quota
remaining: number
// When the quota resets (timestamp)
resetAt: number
// How long to wait before retrying (ms)
retryAfter?: number
}
```
## Accessing Rate Limit Context
You can access the rate limit context for any player:
```typescript
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
class GameRoom extends withRateLimit(Room) {
someMethod(player: Player) {
const context = this.getRateLimitContext(player)
// Check without consuming
const status = context?.check()
console.log(`Remaining: ${status?.remaining}`)
// Get consecutive limit count
console.log(`Consecutive limits: ${context?.consecutiveLimitCount}`)
}
}
// Or use the standalone function
const context = getPlayerRateLimitContext(player)
```
## Custom Strategies
You can use the strategies directly for custom implementations:
```typescript
import {
TokenBucketStrategy,
SlidingWindowStrategy,
FixedWindowStrategy,
createTokenBucketStrategy,
} from '@esengine/server/ratelimit'
// Create strategy directly
const strategy = createTokenBucketStrategy({
rate: 10, // tokens per second
capacity: 20, // max tokens
})
// Check and consume
const result = strategy.consume('player-123')
if (result.allowed) {
// Process message
} else {
// Rate limited, wait result.retryAfter ms
}
// Check without consuming
const status = strategy.getStatus('player-123')
// Reset a key
strategy.reset('player-123')
// Cleanup expired records
strategy.cleanup()
```
## Rate Limit Context
The `RateLimitContext` class manages rate limiting for a single player:
```typescript
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
const context = new RateLimitContext('player-123', strategy)
// Check without consuming
context.check()
// Consume quota
context.consume()
// Consume with cost
context.consume(undefined, 5)
// Consume for specific message type
context.consume('Trade')
// Set per-message strategy
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
// Reset
context.reset()
// Get consecutive limit count
console.log(context.consecutiveLimitCount)
```
## Room Lifecycle Hook
You can override the `onRateLimited` hook for custom handling:
```typescript
class GameRoom extends withRateLimit(Room) {
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
// Log the event
console.log(`Player ${player.id} rate limited on ${messageType}`)
// Send custom error
player.send('SystemMessage', {
type: 'warning',
message: `Slow down! Try again in ${result.retryAfter}ms`,
})
}
}
```
## Best Practices
1. **Start with token bucket**: It's the most flexible algorithm for games.
2. **Set appropriate limits**: Consider your game's mechanics:
- Movement messages: Higher limits (20-60/s)
- Chat messages: Lower limits (1-5/s)
- Trade/purchase: Very low limits (0.5-1/s)
3. **Use burst capacity**: Allow short bursts for responsive gameplay:
```typescript
messagesPerSecond: 10,
burstSize: 30, // Allow 3s worth of burst
```
4. **Exempt critical messages**: Don't rate limit heartbeats or system messages:
```typescript
@noRateLimit()
@onMessage('Heartbeat')
handleHeartbeat() { }
```
5. **Combine with auth**: Rate limit by user ID for authenticated users:
```typescript
getKey: (player) => player.auth?.userId ?? player.id
```
6. **Monitor and adjust**: Log rate limit events to tune your limits:
```typescript
onLimited: (player, type, result) => {
metrics.increment('rate_limit', { messageType: type })
}
```
7. **Graceful degradation**: Send informative errors instead of just disconnecting:
```typescript
onLimited: (player, type, result) => {
player.send('Error', {
code: 'RATE_LIMITED',
message: 'Too many requests',
retryAfter: result.retryAfter,
})
}
```
## Complete Example
```typescript
import { Room, onMessage, type Player } from '@esengine/server'
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
import {
withRateLimit,
rateLimit,
noRateLimit,
type RateLimitResult,
} from '@esengine/server/ratelimit'
interface User {
id: string
name: string
premium: boolean
}
// Combine auth and rate limit
class GameRoom extends withRateLimit(
withRoomAuth<User>(Room, { requireAuth: true }),
{
messagesPerSecond: 10,
burstSize: 30,
strategy: 'token-bucket',
// Use user ID for rate limiting
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
// Handle rate limits
onLimited: (player, type, result) => {
player.send('Error', {
code: 'RATE_LIMITED',
messageType: type,
retryAfter: result.retryAfter,
})
},
// Disconnect after 20 consecutive rate limits
maxConsecutiveLimits: 20,
}
) {
onCreate() {
console.log('Room created with auth + rate limit protection')
}
onJoin(player: AuthPlayer<User>) {
this.broadcast('PlayerJoined', { name: player.user?.name })
}
// High-frequency movement (default rate limit)
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
this.broadcast('PlayerMoved', { id: player.id, ...data })
}
// Low-frequency trading (strict limit)
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
@onMessage('Trade')
handleTrade(data: TradeData, player: AuthPlayer<User>) {
// Process trade...
}
// Chat with moderate limit
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
@onMessage('Chat')
handleChat(data: { text: string }, player: AuthPlayer<User>) {
this.broadcast('Chat', {
from: player.user?.name,
text: data.text,
})
}
// System messages - no limit
@noRateLimit()
@onMessage('Heartbeat')
handleHeartbeat(data: any, player: Player) {
player.send('Pong', { time: Date.now() })
}
// Custom rate limit handling
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
console.warn(`[RateLimit] Player ${player.id} limited on ${messageType}`)
}
}
```

View File

@@ -0,0 +1,458 @@
---
title: "速率限制"
description: "使用可配置的速率限制保护你的游戏服务器免受滥用"
---
`@esengine/server` 包含可插拔的速率限制系统,用于防止 DDoS 攻击、消息洪水和其他滥用行为。
## 安装
速率限制包含在 server 包中:
```bash
npm install @esengine/server
```
## 快速开始
```typescript
import { Room, onMessage } from '@esengine/server'
import { withRateLimit, rateLimit, noRateLimit } 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) {
// 受速率限制保护(默认 10 msg/s
}
@rateLimit({ messagesPerSecond: 1 })
@onMessage('Trade')
handleTrade(data: TradeData, player: Player) {
// 交易使用更严格的限制
}
@noRateLimit()
@onMessage('Heartbeat')
handleHeartbeat(data: any, player: Player) {
// 心跳不限制
}
}
```
## 速率限制策略
### 令牌桶(默认)
令牌桶算法允许突发流量,同时保持长期速率限制。令牌以固定速率添加,每个请求消耗令牌。
```typescript
import { withRateLimit } from '@esengine/server/ratelimit'
class GameRoom extends withRateLimit(Room, {
strategy: 'token-bucket',
messagesPerSecond: 10, // 补充速率
burstSize: 20, // 桶容量
}) { }
```
**工作原理:**
```
配置: rate=10/s, burstSize=20
[0s] 桶满: 20 令牌
[0s] 收到 15 条消息 → 允许,剩余 5
[0.5s] 补充 5 令牌 → 10 令牌
[0.5s] 收到 8 条消息 → 允许,剩余 2
[0.6s] 补充 1 令牌 → 3 令牌
[0.6s] 收到 5 条消息 → 允许 3拒绝 2
```
**最适合:** 大多数通用场景,平衡突发容忍度与保护。
### 滑动窗口
滑动窗口算法精确跟踪时间窗口内的请求。比固定窗口更准确,但内存使用稍多。
```typescript
class GameRoom extends withRateLimit(Room, {
strategy: 'sliding-window',
messagesPerSecond: 10,
burstSize: 10,
}) { }
```
**最适合:** 需要精确限流且不需要突发容忍的场景。
### 固定窗口
固定窗口算法将时间划分为固定间隔,并计算每个间隔内的请求数。简单且内存高效,但在窗口边界允许 2 倍突发。
```typescript
class GameRoom extends withRateLimit(Room, {
strategy: 'fixed-window',
messagesPerSecond: 10,
burstSize: 10,
}) { }
```
**最适合:** 简单场景,可接受边界突发。
## 配置
### 房间配置
```typescript
import { withRateLimit } from '@esengine/server/ratelimit'
class GameRoom extends withRateLimit(Room, {
// 每秒允许的消息数(默认: 10
messagesPerSecond: 10,
// 突发容量 / 桶大小(默认: 20
burstSize: 20,
// 策略: 'token-bucket' | 'sliding-window' | 'fixed-window'
strategy: 'token-bucket',
// 被限流时的回调
onLimited: (player, messageType, result) => {
player.send('RateLimited', {
type: messageType,
retryAfter: result.retryAfter,
})
},
// 限流时断开连接(默认: false
disconnectOnLimit: false,
// 连续 N 次限流后断开0 = 永不)
maxConsecutiveLimits: 10,
// 自定义键函数(默认: player.id
getKey: (player) => player.id,
// 清理间隔(毫秒,默认: 60000
cleanupInterval: 60000,
}) { }
```
### 单消息配置
使用装饰器为特定消息配置速率限制:
```typescript
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
class GameRoom extends withRateLimit(Room) {
// 此消息使用自定义速率限制
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
@onMessage('Trade')
handleTrade(data: TradeData, player: Player) { }
// 此消息消耗 5 个令牌
@rateLimit({ cost: 5 })
@onMessage('ExpensiveAction')
handleExpensive(data: any, player: Player) { }
// 豁免速率限制
@noRateLimit()
@onMessage('Heartbeat')
handleHeartbeat(data: any, player: Player) { }
// 替代方案:显式指定消息类型
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
@onMessage('SpecialAction')
handleSpecial(data: any, player: Player) { }
}
```
## 与认证系统组合
速率限制可与认证系统无缝配合:
```typescript
import { withRoomAuth } from '@esengine/server/auth'
import { withRateLimit } from '@esengine/server/ratelimit'
// 同时应用两个 mixin
class GameRoom extends withRateLimit(
withRoomAuth(Room, { requireAuth: true }),
{ messagesPerSecond: 10 }
) {
onJoin(player: AuthPlayer) {
console.log(`${player.user?.name} 已加入,受速率限制保护`)
}
}
```
## 速率限制结果
当消息被限流时,回调会收到结果对象:
```typescript
interface RateLimitResult {
// 是否允许请求
allowed: boolean
// 剩余配额
remaining: number
// 配额重置时间(时间戳)
resetAt: number
// 重试等待时间(毫秒)
retryAfter?: number
}
```
## 访问速率限制上下文
你可以访问任何玩家的速率限制上下文:
```typescript
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
class GameRoom extends withRateLimit(Room) {
someMethod(player: Player) {
const context = this.getRateLimitContext(player)
// 检查但不消费
const status = context?.check()
console.log(`剩余: ${status?.remaining}`)
// 获取连续限流次数
console.log(`连续限流: ${context?.consecutiveLimitCount}`)
}
}
// 或使用独立函数
const context = getPlayerRateLimitContext(player)
```
## 自定义策略
你可以直接使用策略进行自定义实现:
```typescript
import {
TokenBucketStrategy,
SlidingWindowStrategy,
FixedWindowStrategy,
createTokenBucketStrategy,
} from '@esengine/server/ratelimit'
// 直接创建策略
const strategy = createTokenBucketStrategy({
rate: 10, // 每秒令牌数
capacity: 20, // 最大令牌数
})
// 检查并消费
const result = strategy.consume('player-123')
if (result.allowed) {
// 处理消息
} else {
// 被限流,等待 result.retryAfter 毫秒
}
// 检查但不消费
const status = strategy.getStatus('player-123')
// 重置某个键
strategy.reset('player-123')
// 清理过期记录
strategy.cleanup()
```
## 速率限制上下文
`RateLimitContext` 类管理单个玩家的速率限制:
```typescript
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
const context = new RateLimitContext('player-123', strategy)
// 检查但不消费
context.check()
// 消费配额
context.consume()
// 带消耗量消费
context.consume(undefined, 5)
// 为特定消息类型消费
context.consume('Trade')
// 设置单消息策略
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
// 重置
context.reset()
// 获取连续限流次数
console.log(context.consecutiveLimitCount)
```
## 房间生命周期钩子
你可以重写 `onRateLimited` 钩子进行自定义处理:
```typescript
class GameRoom extends withRateLimit(Room) {
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
// 记录事件
console.log(`玩家 ${player.id}${messageType} 上被限流`)
// 发送自定义错误
player.send('SystemMessage', {
type: 'warning',
message: `请慢一点!${result.retryAfter}ms 后重试`,
})
}
}
```
## 最佳实践
1. **从令牌桶开始**:对于游戏来说是最灵活的算法。
2. **设置合适的限制**:考虑你的游戏机制:
- 移动消息较高限制20-60/s
- 聊天消息较低限制1-5/s
- 交易/购买非常低的限制0.5-1/s
3. **使用突发容量**:允许短暂突发以获得响应式体验:
```typescript
messagesPerSecond: 10,
burstSize: 30, // 允许 3 秒的突发
```
4. **豁免关键消息**:不要限制心跳或系统消息:
```typescript
@noRateLimit()
@onMessage('Heartbeat')
handleHeartbeat() { }
```
5. **与认证结合**:对已认证用户按用户 ID 限流:
```typescript
getKey: (player) => player.auth?.userId ?? player.id
```
6. **监控和调整**:记录限流事件以调整限制:
```typescript
onLimited: (player, type, result) => {
metrics.increment('rate_limit', { messageType: type })
}
```
7. **优雅降级**:发送信息性错误而不是直接断开:
```typescript
onLimited: (player, type, result) => {
player.send('Error', {
code: 'RATE_LIMITED',
message: '请求过于频繁',
retryAfter: result.retryAfter,
})
}
```
## 完整示例
```typescript
import { Room, onMessage, type Player } from '@esengine/server'
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
import {
withRateLimit,
rateLimit,
noRateLimit,
type RateLimitResult,
} from '@esengine/server/ratelimit'
interface User {
id: string
name: string
premium: boolean
}
// 组合认证和速率限制
class GameRoom extends withRateLimit(
withRoomAuth<User>(Room, { requireAuth: true }),
{
messagesPerSecond: 10,
burstSize: 30,
strategy: 'token-bucket',
// 使用用户 ID 进行限流
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
// 处理限流
onLimited: (player, type, result) => {
player.send('Error', {
code: 'RATE_LIMITED',
messageType: type,
retryAfter: result.retryAfter,
})
},
// 连续 20 次限流后断开
maxConsecutiveLimits: 20,
}
) {
onCreate() {
console.log('房间已创建,具有认证 + 速率限制保护')
}
onJoin(player: AuthPlayer<User>) {
this.broadcast('PlayerJoined', { name: player.user?.name })
}
// 高频移动(默认速率限制)
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
this.broadcast('PlayerMoved', { id: player.id, ...data })
}
// 低频交易(严格限制)
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
@onMessage('Trade')
handleTrade(data: TradeData, player: AuthPlayer<User>) {
// 处理交易...
}
// 聊天使用中等限制
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
@onMessage('Chat')
handleChat(data: { text: string }, player: AuthPlayer<User>) {
this.broadcast('Chat', {
from: player.user?.name,
text: data.text,
})
}
// 系统消息 - 不限制
@noRateLimit()
@onMessage('Heartbeat')
handleHeartbeat(data: any, player: Player) {
player.send('Pong', { time: Date.now() })
}
// 自定义限流处理
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
console.warn(`[限流] 玩家 ${player.id} 在 ${messageType} 上被限流`)
}
}
```

View File

@@ -1,5 +1,19 @@
# @esengine/server
## 1.3.0
### Minor Changes
- [#388](https://github.com/esengine/esengine/pull/388) [`afdeb00`](https://github.com/esengine/esengine/commit/afdeb00b4df9427e7f03b91558bf95804a837b70) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system
- 新增令牌桶策略 (`TokenBucketStrategy`) - 推荐用于一般场景
- 新增滑动窗口策略 (`SlidingWindowStrategy`) - 精确跟踪
- 新增固定窗口策略 (`FixedWindowStrategy`) - 简单高效
- 新增房间速率限制 mixin (`withRateLimit`)
- 新增速率限制装饰器 (`@rateLimit`, `@noRateLimit`)
- 新增按消息类型限流装饰器 (`@rateLimitMessage`, `@noRateLimitMessage`)
- 支持与认证系统组合使用
- 导出路径: `@esengine/server/ratelimit`
## 1.2.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/server",
"version": "1.2.0",
"version": "1.3.0",
"description": "Game server framework for ESEngine with file-based routing",
"type": "module",
"main": "./dist/index.js",
@@ -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'],

View File

@@ -1,5 +1,12 @@
# @esengine/transaction
## 2.0.2
### Patch Changes
- Updated dependencies [[`afdeb00`](https://github.com/esengine/esengine/commit/afdeb00b4df9427e7f03b91558bf95804a837b70)]:
- @esengine/server@1.3.0
## 2.0.1
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/transaction",
"version": "2.0.1",
"version": "2.0.2",
"description": "Game transaction system with distributed support | 游戏事务系统,支持分布式事务",
"type": "module",
"main": "./dist/index.js",