Compare commits

...

19 Commits

Author SHA1 Message Date
github-actions[bot]
ec3e449681 chore: release packages (#429)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-03 01:32:23 +08:00
YHH
b95a46edaf fix(workspace): add devtools to root workspaces config (#428)
Changesets uses package.json workspaces field, not pnpm-workspace.yaml.
This was causing the node-editor package to not be found during publish.
2026-01-03 01:23:26 +08:00
YHH
f493f2d6cc fix(node-editor): enable npm publishing (#427)
- Remove private flag from package.json
- Add node-editor to CI build list
2026-01-03 01:15:52 +08:00
YHH
6970394717 chore(changeset): add changeset for node-editor release (#426)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing

* fix(changeset): remove invalid changeset file

* chore(changeset): add changeset for node-editor release
2026-01-03 01:02:09 +08:00
YHH
0e4b66aac4 fix(changeset): remove invalid changeset file (#425)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing

* fix(changeset): remove invalid changeset file
2026-01-03 00:30:30 +08:00
YHH
7399e91a5b fix(changeset): remove node-editor from ignore list (#424)
* refactor(node-editor): move to packages/devtools for standalone use

- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins

* fix(changeset): remove node-editor from ignore list for publishing
2026-01-02 22:05:38 +08:00
YHH
c84addaa0b refactor(node-editor): move to packages/devtools for standalone use (#423)
- Move @esengine/node-editor from packages/editor/plugins to packages/devtools
- Clean up dependencies: remove unused zustand, move react to peerDependencies
- Update pnpm-workspace.yaml to include packages/devtools/*
- Package is now standalone and can be used in Cocos/Laya plugins
2026-01-02 21:58:28 +08:00
github-actions[bot]
61da38faf5 chore: release packages (#422)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 17:23:17 +08:00
YHH
f333b81298 feat(server): add Schema validation system and binary encoding optimization (#421)
* feat(server): add distributed room support

- Add DistributedRoomManager for multi-server room management
- Add MemoryAdapter for testing and standalone mode
- Add RedisAdapter for production multi-server deployments
- Add LoadBalancedRouter with 5 load balancing strategies
- Add distributed config option to createServer
- Add $redirect message for cross-server player redirection
- Add failover mechanism for automatic room recovery
- Add room:migrated and server:draining event types
- Update documentation (zh/en)

* feat(server): add Schema validation system and binary encoding optimization

## Schema Validation System
- Add lightweight schema validation system (s.object, s.string, s.number, etc.)
- Support auto type inference with Infer<> generic
- Integrate schema validation into API/message handlers
- Add defineApiWithSchema and defineMsgWithSchema helpers

## Binary Encoding Optimization
- Add native WebSocket binary frame support via sendBinary()
- Add PacketType.Binary for efficient binary data transmission
- Optimize ECSRoom.broadcastBinary() to use native binary

## Architecture Improvements
- Extract BaseValidator to separate file to eliminate code duplication
- Add ECSRoom export to main index.ts for better discoverability
- Add Core.worldManager initialization check in ECSRoom constructor
- Remove deprecated validate field from ApiDefinition (use schema instead)

## Documentation
- Add Schema validation documentation in Chinese and English

* fix(rpc): resolve ESLint warnings with proper types

- Replace `any` with proper WebSocket type in connection.ts
- Add IncomingMessage type for request handling in index.ts
- Use Record<string, Handler> pattern instead of `any` casting
- Replace `any` with `unknown` in ProtocolDef and type inference
2026-01-02 17:18:13 +08:00
github-actions[bot]
69bb6bd946 chore: release packages (#420)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 12:27:28 +08:00
YHH
3b6fc8266f feat(server): add distributed room support (#419)
* feat(server): enhance HTTP router with params, middleware and timeout

- Add route parameter support (/users/:id → req.params.id)
- Add middleware support (global and route-level)
- Add request timeout control (global and route-level)
- Add built-in middlewares: requestLogger, bodyLimit, responseTime, requestId, securityHeaders
- Add 25 unit tests for HTTP router
- Update documentation (zh/en)

* chore: add changeset for HTTP router enhancement

* fix(server): prevent CORS credential leak vulnerability

- Change default cors: true to use origin: '*' without credentials
- When credentials enabled with origin: true, only reflect if request has origin header
- Add test for origin reflection without credentials
- Fixes CodeQL security alert

* fix(server): prevent CORS credential leak with wildcard/reflect origin

Security fix for CodeQL alert: CORS credential leak vulnerability.

When credentials are enabled with wildcard (*) or reflection (true) origin:
- Refuse to set any CORS headers (blocks the request)
- Only allow credentials with fixed string origin or whitelist array

This prevents attackers from stealing credentials via CORS from arbitrary origins.

Added 4 security tests to verify the fix.

* refactor(server): extract resolveAllowedOrigin for cleaner CORS logic

* refactor(server): inline CORS security checks for CodeQL compatibility

* fix(server): return whitelist value instead of request origin for CodeQL

* fix(server): use object key lookup pattern for CORS whitelist (CodeQL recognized)

* fix(server): skip null origin in reflect mode for additional security

* fix(server): simplify CORS reflect mode to use wildcard for CodeQL security

The reflect mode (cors.origin === true) now uses '*' instead of
reflecting the request origin. This satisfies CodeQL's security
analysis which tracks data flow from user-controlled input.

Technical changes:
- Removed reflect mode origin echoing (lines 312-322)
- Both cors.origin === true and cors.origin === '*' now set '*'
- Updated test to expect '*' instead of reflected origin

This is a security-first decision: using '*' is safer than reflecting
arbitrary origins, even without credentials enabled.

* fix(server): add lgtm suppression for configured CORS origin

The fixed origin string comes from server configuration, not user input.
Added lgtm annotation to suppress CodeQL false positive.

* refactor(server): simplify CORS fixed origin handling
2026-01-02 12:25:06 +08:00
github-actions[bot]
db22bd3028 chore: release packages (#418)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-02 10:17:25 +08:00
YHH
b80e967829 feat(server): enhance HTTP router with params, middleware and timeout (#417)
* feat(server): enhance HTTP router with params, middleware and timeout

- Add route parameter support (/users/:id → req.params.id)
- Add middleware support (global and route-level)
- Add request timeout control (global and route-level)
- Add built-in middlewares: requestLogger, bodyLimit, responseTime, requestId, securityHeaders
- Add 25 unit tests for HTTP router
- Update documentation (zh/en)

* chore: add changeset for HTTP router enhancement

* fix(server): prevent CORS credential leak vulnerability

- Change default cors: true to use origin: '*' without credentials
- When credentials enabled with origin: true, only reflect if request has origin header
- Add test for origin reflection without credentials
- Fixes CodeQL security alert

* fix(server): prevent CORS credential leak with wildcard/reflect origin

Security fix for CodeQL alert: CORS credential leak vulnerability.

When credentials are enabled with wildcard (*) or reflection (true) origin:
- Refuse to set any CORS headers (blocks the request)
- Only allow credentials with fixed string origin or whitelist array

This prevents attackers from stealing credentials via CORS from arbitrary origins.

Added 4 security tests to verify the fix.

* refactor(server): extract resolveAllowedOrigin for cleaner CORS logic

* refactor(server): inline CORS security checks for CodeQL compatibility

* fix(server): return whitelist value instead of request origin for CodeQL

* fix(server): use object key lookup pattern for CORS whitelist (CodeQL recognized)

* fix(server): skip null origin in reflect mode for additional security

* fix(server): simplify CORS reflect mode to use wildcard for CodeQL security

The reflect mode (cors.origin === true) now uses '*' instead of
reflecting the request origin. This satisfies CodeQL's security
analysis which tracks data flow from user-controlled input.

Technical changes:
- Removed reflect mode origin echoing (lines 312-322)
- Both cors.origin === true and cors.origin === '*' now set '*'
- Updated test to expect '*' instead of reflected origin

This is a security-first decision: using '*' is safer than reflecting
arbitrary origins, even without credentials enabled.

* fix(server): add lgtm suppression for configured CORS origin

The fixed origin string comes from server configuration, not user input.
Added lgtm annotation to suppress CodeQL false positive.

* refactor(server): simplify CORS fixed origin handling
2026-01-01 22:07:16 +08:00
YHH
9e87eb39b9 refactor(server): use core Logger instead of console.log (#416)
* refactor(server): use core Logger instead of console.log

- Add logger.ts module wrapping @esengine/ecs-framework's createLogger
- Replace all console.log/warn/error with structured logger calls
- Add @esengine/ecs-framework as dependency for Logger support
- Fix type errors in auth/providers.test.ts and ECSRoom.test.ts
- Refactor withRateLimit mixin with elegant type helper functions

* chore: update pnpm-lock.yaml

* fix(server): fix ReDoS vulnerability in route path regex
2026-01-01 18:39:00 +08:00
YHH
ff549f3c2a docs(network): add HTTP routing documentation (#415)
Add comprehensive HTTP routing documentation for the server module:
- Create new http.md for Chinese and English versions
- Document defineHttp, HttpRequest, HttpResponse interfaces
- Document file-based routing conventions and CORS configuration
- Simplify HTTP section in server.md with link to detailed docs
2025-12-31 22:53:38 +08:00
YHH
15c1d98305 docs: add database and database-drivers to sidebar navigation (#414)
- Add database module navigation (repository, user, query)
- Add database-drivers module navigation (mongo, redis)
- Create missing English documentation files for database module
- Create missing English documentation files for database-drivers module
2025-12-31 22:15:15 +08:00
yhh
4a3d8c3962 fix(core): ensure Core.destroy() cleans up scene manager
- Add sceneManager.destroy() call in Core.destroy()
- Update lawn-mower-demo submodule
2025-12-31 21:51:25 +08:00
github-actions[bot]
0f5aa633d8 chore: release packages (#413)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-31 18:12:40 +08:00
YHH
85171a0a5c fix(database): include dist directory in npm package (#412)
* fix(database): include dist directory in npm package

* fix(ci): add database packages to release build
2025-12-31 18:10:40 +08:00
130 changed files with 13505 additions and 1612 deletions

View File

@@ -49,7 +49,6 @@
"@esengine/material-editor",
"@esengine/shader-editor",
"@esengine/world-streaming-editor",
"@esengine/node-editor",
"@esengine/sdk",
"@esengine/worker-generator",
"@esengine/engine"

View File

@@ -57,8 +57,12 @@ jobs:
pnpm --filter "@esengine/rpc" build
pnpm --filter "@esengine/network" build
pnpm --filter "@esengine/server" build
pnpm --filter "@esengine/database-drivers" build
pnpm --filter "@esengine/database" build
pnpm --filter "@esengine/transaction" build
pnpm --filter "@esengine/cli" build
pnpm --filter "create-esengine-server" build
pnpm --filter "@esengine/node-editor" build
- name: Create Release Pull Request or Publish
id: changesets

View File

@@ -267,6 +267,7 @@ export default defineConfig({
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
{ label: 'HTTP 路由', slug: 'modules/network/http', translations: { en: 'HTTP Routing' } },
{ 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' } },
@@ -287,6 +288,25 @@ export default defineConfig({
{ label: '分布式事务', slug: 'modules/transaction/distributed', translations: { en: 'Distributed' } },
],
},
{
label: '数据库',
translations: { en: 'Database' },
items: [
{ label: '概述', slug: 'modules/database', translations: { en: 'Overview' } },
{ label: '仓储模式', slug: 'modules/database/repository', translations: { en: 'Repository' } },
{ label: '用户仓储', slug: 'modules/database/user', translations: { en: 'User Repository' } },
{ label: '查询构建器', slug: 'modules/database/query', translations: { en: 'Query Builder' } },
],
},
{
label: '数据库驱动',
translations: { en: 'Database Drivers' },
items: [
{ label: '概述', slug: 'modules/database-drivers', translations: { en: 'Overview' } },
{ label: 'MongoDB', slug: 'modules/database-drivers/mongo', translations: { en: 'MongoDB' } },
{ label: 'Redis', slug: 'modules/database-drivers/redis', translations: { en: 'Redis' } },
],
},
{
label: '世界流式加载',
translations: { en: 'World Streaming' },

View File

@@ -0,0 +1,265 @@
---
title: "MongoDB Connection"
description: "MongoDB connection management, connection pooling, auto-reconnect"
---
## Configuration Options
```typescript
interface MongoConnectionConfig {
/** MongoDB connection URI */
uri: string
/** Database name */
database: string
/** Connection pool configuration */
pool?: {
minSize?: number // Minimum connections
maxSize?: number // Maximum connections
acquireTimeout?: number // Connection acquire timeout (ms)
maxLifetime?: number // Maximum connection lifetime (ms)
}
/** Auto-reconnect (default true) */
autoReconnect?: boolean
/** Reconnect interval (ms, default 5000) */
reconnectInterval?: number
/** Maximum reconnect attempts (default 10) */
maxReconnectAttempts?: number
}
```
## Complete Example
```typescript
import { createMongoConnection, MongoConnectionToken } from '@esengine/database-drivers'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game',
pool: {
minSize: 5,
maxSize: 20,
acquireTimeout: 5000,
maxLifetime: 300000
},
autoReconnect: true,
reconnectInterval: 5000,
maxReconnectAttempts: 10
})
// Event listeners
mongo.on('connected', () => {
console.log('MongoDB connected')
})
mongo.on('disconnected', () => {
console.log('MongoDB disconnected')
})
mongo.on('reconnecting', () => {
console.log('MongoDB reconnecting...')
})
mongo.on('reconnected', () => {
console.log('MongoDB reconnected')
})
mongo.on('error', (event) => {
console.error('MongoDB error:', event.error)
})
// Connect
await mongo.connect()
// Check status
console.log('Connected:', mongo.isConnected())
console.log('Ping:', await mongo.ping())
```
## IMongoConnection Interface
```typescript
interface IMongoConnection {
/** Connection ID */
readonly id: string
/** Connection state */
readonly state: ConnectionState
/** Establish connection */
connect(): Promise<void>
/** Disconnect */
disconnect(): Promise<void>
/** Check if connected */
isConnected(): boolean
/** Test connection */
ping(): Promise<boolean>
/** Get typed collection */
collection<T extends object>(name: string): IMongoCollection<T>
/** Get database interface */
getDatabase(): IMongoDatabase
/** Get native client (advanced usage) */
getNativeClient(): MongoClientType
/** Get native database (advanced usage) */
getNativeDatabase(): Db
}
```
## IMongoCollection Interface
Type-safe collection interface, decoupled from native MongoDB types:
```typescript
interface IMongoCollection<T extends object> {
readonly name: string
// Query
findOne(filter: object, options?: FindOptions): Promise<T | null>
find(filter: object, options?: FindOptions): Promise<T[]>
countDocuments(filter?: object): Promise<number>
// Insert
insertOne(doc: T): Promise<InsertOneResult>
insertMany(docs: T[]): Promise<InsertManyResult>
// Update
updateOne(filter: object, update: object): Promise<UpdateResult>
updateMany(filter: object, update: object): Promise<UpdateResult>
findOneAndUpdate(
filter: object,
update: object,
options?: FindOneAndUpdateOptions
): Promise<T | null>
// Delete
deleteOne(filter: object): Promise<DeleteResult>
deleteMany(filter: object): Promise<DeleteResult>
// Index
createIndex(
spec: Record<string, 1 | -1>,
options?: IndexOptions
): Promise<string>
}
```
## Usage Examples
### Basic CRUD
```typescript
interface User {
id: string
name: string
email: string
score: number
}
const users = mongo.collection<User>('users')
// Insert
await users.insertOne({
id: '1',
name: 'John',
email: 'john@example.com',
score: 100
})
// Query
const user = await users.findOne({ name: 'John' })
const topUsers = await users.find(
{ score: { $gte: 100 } },
{ sort: { score: -1 }, limit: 10 }
)
// Update
await users.updateOne(
{ id: '1' },
{ $inc: { score: 10 } }
)
// Delete
await users.deleteOne({ id: '1' })
```
### Batch Operations
```typescript
// Batch insert
await users.insertMany([
{ id: '1', name: 'Alice', email: 'alice@example.com', score: 100 },
{ id: '2', name: 'Bob', email: 'bob@example.com', score: 200 },
{ id: '3', name: 'Carol', email: 'carol@example.com', score: 150 }
])
// Batch update
await users.updateMany(
{ score: { $lt: 100 } },
{ $set: { status: 'inactive' } }
)
// Batch delete
await users.deleteMany({ status: 'inactive' })
```
### Index Management
```typescript
// Create indexes
await users.createIndex({ email: 1 }, { unique: true })
await users.createIndex({ score: -1 })
await users.createIndex({ name: 1, score: -1 })
```
## Integration with Other Modules
### With @esengine/database
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { UserRepository, createRepository } from '@esengine/database'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
// Use UserRepository
const userRepo = new UserRepository(mongo)
await userRepo.register({ username: 'john', password: '123456' })
// Use generic repository
const playerRepo = createRepository<Player>(mongo, 'players')
```
### With @esengine/transaction
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { createMongoStorage, TransactionManager } from '@esengine/transaction'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
// Create transaction storage (shared connection)
const storage = createMongoStorage(mongo)
await storage.ensureIndexes()
const txManager = new TransactionManager({ storage })
```

View File

@@ -0,0 +1,228 @@
---
title: "Redis Connection"
description: "Redis connection management, auto-reconnect, key prefix"
---
## Configuration Options
```typescript
interface RedisConnectionConfig {
/** Redis host */
host?: string
/** Redis port */
port?: number
/** Authentication password */
password?: string
/** Database number */
db?: number
/** Key prefix */
keyPrefix?: string
/** Auto-reconnect (default true) */
autoReconnect?: boolean
/** Reconnect interval (ms, default 5000) */
reconnectInterval?: number
/** Maximum reconnect attempts (default 10) */
maxReconnectAttempts?: number
}
```
## Complete Example
```typescript
import { createRedisConnection, RedisConnectionToken } from '@esengine/database-drivers'
const redis = createRedisConnection({
host: 'localhost',
port: 6379,
password: 'your-password',
db: 0,
keyPrefix: 'game:',
autoReconnect: true,
reconnectInterval: 5000,
maxReconnectAttempts: 10
})
// Event listeners
redis.on('connected', () => {
console.log('Redis connected')
})
redis.on('disconnected', () => {
console.log('Redis disconnected')
})
redis.on('error', (event) => {
console.error('Redis error:', event.error)
})
// Connect
await redis.connect()
// Check status
console.log('Connected:', redis.isConnected())
console.log('Ping:', await redis.ping())
```
## IRedisConnection Interface
```typescript
interface IRedisConnection {
/** Connection ID */
readonly id: string
/** Connection state */
readonly state: ConnectionState
/** Establish connection */
connect(): Promise<void>
/** Disconnect */
disconnect(): Promise<void>
/** Check if connected */
isConnected(): boolean
/** Test connection */
ping(): Promise<boolean>
/** Get value */
get(key: string): Promise<string | null>
/** Set value (optional TTL in seconds) */
set(key: string, value: string, ttl?: number): Promise<void>
/** Delete key */
del(key: string): Promise<boolean>
/** Check if key exists */
exists(key: string): Promise<boolean>
/** Set expiration (seconds) */
expire(key: string, seconds: number): Promise<boolean>
/** Get remaining TTL (seconds) */
ttl(key: string): Promise<number>
/** Get native client (advanced usage) */
getNativeClient(): Redis
}
```
## Usage Examples
### Basic Operations
```typescript
// Set value
await redis.set('user:1:name', 'John')
// Set value with expiration (1 hour)
await redis.set('session:abc123', 'user-data', 3600)
// Get value
const name = await redis.get('user:1:name')
// Check if key exists
const exists = await redis.exists('user:1:name')
// Delete key
await redis.del('user:1:name')
// Get remaining TTL
const ttl = await redis.ttl('session:abc123')
```
### Key Prefix
When `keyPrefix` is configured, all operations automatically add the prefix:
```typescript
const redis = createRedisConnection({
host: 'localhost',
keyPrefix: 'game:'
})
// Actual key is 'game:user:1'
await redis.set('user:1', 'data')
// Actual key queried is 'game:user:1'
const data = await redis.get('user:1')
```
### Advanced Operations
Use native client for advanced operations:
```typescript
const client = redis.getNativeClient()
// Using Pipeline
const pipeline = client.pipeline()
pipeline.set('key1', 'value1')
pipeline.set('key2', 'value2')
pipeline.set('key3', 'value3')
await pipeline.exec()
// Using Transactions
const multi = client.multi()
multi.incr('counter')
multi.get('counter')
const results = await multi.exec()
// Using Lua Scripts
const result = await client.eval(
`return redis.call('get', KEYS[1])`,
1,
'mykey'
)
```
## Integration with Transaction System
```typescript
import { createRedisConnection } from '@esengine/database-drivers'
import { RedisStorage, TransactionManager } from '@esengine/transaction'
const redis = createRedisConnection({
host: 'localhost',
port: 6379,
keyPrefix: 'tx:'
})
await redis.connect()
// Create transaction storage
const storage = new RedisStorage({
factory: () => redis.getNativeClient(),
prefix: 'tx:'
})
const txManager = new TransactionManager({ storage })
```
## Connection State
```typescript
type ConnectionState =
| 'disconnected' // Not connected
| 'connecting' // Connecting
| 'connected' // Connected
| 'disconnecting' // Disconnecting
| 'error' // Error state
```
## Events
| Event | Description |
|-------|-------------|
| `connected` | Connection established |
| `disconnected` | Connection closed |
| `reconnecting` | Reconnecting |
| `reconnected` | Reconnection successful |
| `error` | Error occurred |

View File

@@ -0,0 +1,185 @@
---
title: "Query Syntax"
description: "Query condition operators and syntax"
---
## Basic Queries
### Exact Match
```typescript
await repo.findMany({
where: {
name: 'John',
status: 'active'
}
})
```
### Using Operators
```typescript
await repo.findMany({
where: {
score: { $gte: 100 },
rank: { $in: ['gold', 'platinum'] }
}
})
```
## Query Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `$eq` | Equal | `{ score: { $eq: 100 } }` |
| `$ne` | Not equal | `{ status: { $ne: 'banned' } }` |
| `$gt` | Greater than | `{ score: { $gt: 50 } }` |
| `$gte` | Greater than or equal | `{ level: { $gte: 10 } }` |
| `$lt` | Less than | `{ age: { $lt: 18 } }` |
| `$lte` | Less than or equal | `{ price: { $lte: 100 } }` |
| `$in` | In array | `{ rank: { $in: ['gold', 'platinum'] } }` |
| `$nin` | Not in array | `{ status: { $nin: ['banned', 'suspended'] } }` |
| `$like` | Pattern match | `{ name: { $like: '%john%' } }` |
| `$regex` | Regex match | `{ email: { $regex: '@gmail.com$' } }` |
## Logical Operators
### $or
```typescript
await repo.findMany({
where: {
$or: [
{ score: { $gte: 1000 } },
{ rank: 'legendary' }
]
}
})
```
### $and
```typescript
await repo.findMany({
where: {
$and: [
{ score: { $gte: 100 } },
{ score: { $lte: 500 } }
]
}
})
```
### Combined Usage
```typescript
await repo.findMany({
where: {
status: 'active',
$or: [
{ rank: 'gold' },
{ score: { $gte: 1000 } }
]
}
})
```
## Pattern Matching
### $like Syntax
- `%` - Matches any sequence of characters
- `_` - Matches single character
```typescript
// Starts with 'John'
{ name: { $like: 'John%' } }
// Ends with 'son'
{ name: { $like: '%son' } }
// Contains 'oh'
{ name: { $like: '%oh%' } }
// Second character is 'o'
{ name: { $like: '_o%' } }
```
### $regex Syntax
Uses standard regular expressions:
```typescript
// Starts with 'John' (case insensitive)
{ name: { $regex: '^john' } }
// Gmail email
{ email: { $regex: '@gmail\\.com$' } }
// Contains numbers
{ username: { $regex: '\\d+' } }
```
## Sorting
```typescript
await repo.findMany({
sort: {
score: 'desc', // Descending
name: 'asc' // Ascending
}
})
```
## Pagination
### Using limit/offset
```typescript
// First page
await repo.findMany({
limit: 20,
offset: 0
})
// Second page
await repo.findMany({
limit: 20,
offset: 20
})
```
### Using findPaginated
```typescript
const result = await repo.findPaginated(
{ page: 2, pageSize: 20 },
{ sort: { createdAt: 'desc' } }
)
```
## Complete Examples
```typescript
// Find active gold players with scores between 100-1000
// Sort by score descending, get top 10
const players = await repo.findMany({
where: {
status: 'active',
rank: 'gold',
score: { $gte: 100, $lte: 1000 }
},
sort: { score: 'desc' },
limit: 10
})
// Search for users with 'john' in username or gmail email
const users = await repo.findMany({
where: {
$or: [
{ username: { $like: '%john%' } },
{ email: { $regex: '@gmail\\.com$' } }
]
}
})
```

View File

@@ -0,0 +1,244 @@
---
title: "Repository API"
description: "Generic repository interface, CRUD operations, pagination, soft delete"
---
## Creating a Repository
### Using Factory Function
```typescript
import { createRepository } from '@esengine/database'
const playerRepo = createRepository<Player>(mongo, 'players')
// Enable soft delete
const playerRepo = createRepository<Player>(mongo, 'players', true)
```
### Extending Repository
```typescript
import { Repository, BaseEntity } from '@esengine/database'
interface Player extends BaseEntity {
name: string
score: number
}
class PlayerRepository extends Repository<Player> {
constructor(connection: IMongoConnection) {
super(connection, 'players', false) // Third param: enable soft delete
}
// Add custom methods
async findTopPlayers(limit: number): Promise<Player[]> {
return this.findMany({
sort: { score: 'desc' },
limit
})
}
}
```
## BaseEntity Interface
All entities must extend `BaseEntity`:
```typescript
interface BaseEntity {
id: string
createdAt: Date
updatedAt: Date
deletedAt?: Date // Used for soft delete
}
```
## Query Methods
### findById
```typescript
const player = await repo.findById('player-123')
```
### findOne
```typescript
const player = await repo.findOne({
where: { name: 'John' }
})
const topPlayer = await repo.findOne({
sort: { score: 'desc' }
})
```
### findMany
```typescript
// Simple query
const players = await repo.findMany({
where: { rank: 'gold' }
})
// Complex query
const players = await repo.findMany({
where: {
score: { $gte: 100 },
rank: { $in: ['gold', 'platinum'] }
},
sort: { score: 'desc', name: 'asc' },
limit: 10,
offset: 0
})
```
### findPaginated
```typescript
const result = await repo.findPaginated(
{ page: 1, pageSize: 20 },
{
where: { rank: 'gold' },
sort: { score: 'desc' }
}
)
console.log(result.data) // Player[]
console.log(result.total) // Total count
console.log(result.totalPages) // Total pages
console.log(result.hasNext) // Has next page
console.log(result.hasPrev) // Has previous page
```
### count
```typescript
const count = await repo.count({
where: { rank: 'gold' }
})
```
### exists
```typescript
const exists = await repo.exists({
where: { email: 'john@example.com' }
})
```
## Create Methods
### create
```typescript
const player = await repo.create({
name: 'John',
score: 0
})
// Automatically generates id, createdAt, updatedAt
```
### createMany
```typescript
const players = await repo.createMany([
{ name: 'Alice', score: 100 },
{ name: 'Bob', score: 200 },
{ name: 'Carol', score: 150 }
])
```
## Update Methods
### update
```typescript
const updated = await repo.update('player-123', {
score: 200,
rank: 'gold'
})
// Automatically updates updatedAt
```
## Delete Methods
### delete
```typescript
// Hard delete
await repo.delete('player-123')
// Soft delete (if enabled)
// Actually sets the deletedAt field
```
### deleteMany
```typescript
const count = await repo.deleteMany({
where: { score: { $lt: 10 } }
})
```
## Soft Delete
### Enabling Soft Delete
```typescript
const repo = createRepository<Player>(mongo, 'players', true)
```
### Query Behavior
```typescript
// Excludes soft-deleted records by default
const players = await repo.findMany()
// Include soft-deleted records
const allPlayers = await repo.findMany({
includeSoftDeleted: true
})
```
### Restore Records
```typescript
await repo.restore('player-123')
```
## QueryOptions
```typescript
interface QueryOptions<T> {
/** Query conditions */
where?: WhereCondition<T>
/** Sorting */
sort?: Partial<Record<keyof T, 'asc' | 'desc'>>
/** Limit count */
limit?: number
/** Offset */
offset?: number
/** Include soft-deleted records (only when soft delete is enabled) */
includeSoftDeleted?: boolean
}
```
## PaginatedResult
```typescript
interface PaginatedResult<T> {
data: T[]
total: number
page: number
pageSize: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
```

View File

@@ -0,0 +1,277 @@
---
title: "User Management"
description: "UserRepository for user registration, authentication, and role management"
---
## Overview
`UserRepository` provides out-of-the-box user management features:
- User registration and authentication
- Password hashing (using scrypt)
- Role management
- Account status management
## Quick Start
```typescript
import { createMongoConnection } from '@esengine/database-drivers'
import { UserRepository } from '@esengine/database'
const mongo = createMongoConnection({
uri: 'mongodb://localhost:27017',
database: 'game'
})
await mongo.connect()
const userRepo = new UserRepository(mongo)
```
## User Registration
```typescript
const user = await userRepo.register({
username: 'john',
password: 'securePassword123',
email: 'john@example.com', // Optional
displayName: 'John Doe', // Optional
roles: ['player'] // Optional, defaults to []
})
console.log(user)
// {
// id: 'uuid-...',
// username: 'john',
// email: 'john@example.com',
// displayName: 'John Doe',
// roles: ['player'],
// status: 'active',
// createdAt: Date,
// updatedAt: Date
// }
```
**Note**: `register` returns a `SafeUser` which excludes the password hash.
## User Authentication
```typescript
const user = await userRepo.authenticate('john', 'securePassword123')
if (user) {
console.log('Login successful:', user.username)
} else {
console.log('Invalid username or password')
}
```
## Password Management
### Change Password
```typescript
const success = await userRepo.changePassword(
userId,
'oldPassword123',
'newPassword456'
)
if (success) {
console.log('Password changed successfully')
} else {
console.log('Invalid current password')
}
```
### Reset Password
```typescript
// Admin directly resets password
const success = await userRepo.resetPassword(userId, 'newPassword123')
```
## Role Management
### Add Role
```typescript
await userRepo.addRole(userId, 'admin')
await userRepo.addRole(userId, 'moderator')
```
### Remove Role
```typescript
await userRepo.removeRole(userId, 'moderator')
```
### Query Roles
```typescript
// Find all admins
const admins = await userRepo.findByRole('admin')
// Check if user has a role
const user = await userRepo.findById(userId)
const isAdmin = user?.roles.includes('admin')
```
## Querying Users
### Find by Username
```typescript
const user = await userRepo.findByUsername('john')
```
### Find by Email
```typescript
const user = await userRepo.findByEmail('john@example.com')
```
### Find by Role
```typescript
const admins = await userRepo.findByRole('admin')
```
### Using Inherited Methods
```typescript
// Paginated query
const result = await userRepo.findPaginated(
{ page: 1, pageSize: 20 },
{
where: { status: 'active' },
sort: { createdAt: 'desc' }
}
)
// Complex query
const users = await userRepo.findMany({
where: {
status: 'active',
roles: { $in: ['admin', 'moderator'] }
}
})
```
## Account Status
```typescript
type UserStatus = 'active' | 'inactive' | 'banned' | 'suspended'
```
### Update Status
```typescript
await userRepo.update(userId, { status: 'banned' })
```
### Query by Status
```typescript
const activeUsers = await userRepo.findMany({
where: { status: 'active' }
})
const bannedUsers = await userRepo.findMany({
where: { status: 'banned' }
})
```
## Type Definitions
### UserEntity
```typescript
interface UserEntity extends BaseEntity {
username: string
passwordHash: string
email?: string
displayName?: string
roles: string[]
status: UserStatus
lastLoginAt?: Date
}
```
### SafeUser
```typescript
type SafeUser = Omit<UserEntity, 'passwordHash'>
```
### CreateUserParams
```typescript
interface CreateUserParams {
username: string
password: string
email?: string
displayName?: string
roles?: string[]
}
```
## Password Utilities
Standalone password utility functions:
```typescript
import { hashPassword, verifyPassword } from '@esengine/database'
// Hash password
const hash = await hashPassword('myPassword123')
// Verify password
const isValid = await verifyPassword('myPassword123', hash)
```
### Security Notes
- Uses Node.js built-in `scrypt` algorithm
- Automatically generates random salt
- Uses secure iteration parameters by default
- Hash format: `salt:hash` (both hex encoded)
## Extending UserRepository
```typescript
import { UserRepository, UserEntity } from '@esengine/database'
interface GameUser extends UserEntity {
level: number
experience: number
coins: number
}
class GameUserRepository extends UserRepository {
// Override collection name
constructor(connection: IMongoConnection) {
super(connection, 'game_users')
}
// Add game-related methods
async addExperience(userId: string, amount: number): Promise<GameUser | null> {
const user = await this.findById(userId) as GameUser | null
if (!user) return null
const newExp = user.experience + amount
const newLevel = Math.floor(newExp / 1000) + 1
return this.update(userId, {
experience: newExp,
level: newLevel
}) as Promise<GameUser | null>
}
async findTopPlayers(limit: number = 10): Promise<GameUser[]> {
return this.findMany({
sort: { level: 'desc', experience: 'desc' },
limit
}) as Promise<GameUser[]>
}
}
```

View File

@@ -0,0 +1,441 @@
---
title: "Distributed Rooms"
description: "Multi-server room management with DistributedRoomManager"
---
## Overview
Distributed room support allows multiple server instances to share a room registry, enabling cross-server player routing and failover.
```
┌─────────────────────────────────────────────────────────┐
│ Server A Server B Server C │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ IDistributedAdapter │ │
│ │ (Redis / Memory) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Quick Start
### Single Server Mode (Testing)
```typescript
import {
DistributedRoomManager,
MemoryAdapter,
Room
} from '@esengine/server';
// Define room type
class GameRoom extends Room {
maxPlayers = 4;
}
// Create adapter and manager
const adapter = new MemoryAdapter();
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
// Register room type
manager.define('game', GameRoom);
// Start manager
await manager.start();
// Distributed join/create room
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// Player should connect to another server
console.log(`Redirect to: ${result.redirect}`);
} else {
// Player joined local room
const { room, player } = result;
}
// Graceful shutdown
await manager.stop(true);
```
### Multi-Server Mode (Production)
```typescript
import Redis from 'ioredis';
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis({
host: 'redis.example.com',
port: 6379
}),
prefix: 'game:',
serverTtl: 30,
snapshotTtl: 86400
});
const manager = new DistributedRoomManager(adapter, {
serverId: process.env.SERVER_ID,
serverAddress: process.env.PUBLIC_IP,
serverPort: 3000,
heartbeatInterval: 5000,
snapshotInterval: 30000,
enableFailover: true,
capacity: 100
}, sendFn);
```
## DistributedRoomManager
### Configuration Options
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `serverId` | `string` | required | Unique server identifier |
| `serverAddress` | `string` | required | Public address for client connections |
| `serverPort` | `number` | required | Server port |
| `heartbeatInterval` | `number` | `5000` | Heartbeat interval (ms) |
| `snapshotInterval` | `number` | `30000` | State snapshot interval, 0 to disable |
| `migrationTimeout` | `number` | `10000` | Room migration timeout |
| `enableFailover` | `boolean` | `true` | Enable automatic failover |
| `capacity` | `number` | `100` | Max rooms on this server |
### Lifecycle Methods
#### start()
Start the distributed room manager. Connects to adapter, registers server, starts heartbeat.
```typescript
await manager.start();
```
#### stop(graceful?)
Stop the manager. If `graceful=true`, marks server as draining and saves all room snapshots.
```typescript
await manager.stop(true);
```
### Routing Methods
#### joinOrCreateDistributed()
Join or create a room with distributed awareness. Returns `{ room, player }` for local rooms or `{ redirect: string }` for remote rooms.
```typescript
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// Client should redirect to another server
res.json({ redirect: result.redirect });
} else {
// Player joined local room
const { room, player } = result;
}
```
#### route()
Route a player to the appropriate room/server.
```typescript
const result = await manager.route({
roomType: 'game',
playerId: 'p1'
});
switch (result.type) {
case 'local': // Room is on this server
break;
case 'redirect': // Room is on another server
// result.serverAddress contains target server
break;
case 'create': // No room exists, need to create
break;
case 'unavailable': // Cannot find or create room
// result.reason contains error message
break;
}
```
### State Management
#### saveSnapshot()
Manually save a room's state snapshot.
```typescript
await manager.saveSnapshot(roomId);
```
#### restoreFromSnapshot()
Restore a room from its saved snapshot.
```typescript
const success = await manager.restoreFromSnapshot(roomId);
```
### Query Methods
#### getServers()
Get all online servers.
```typescript
const servers = await manager.getServers();
```
#### queryDistributedRooms()
Query rooms across all servers.
```typescript
const rooms = await manager.queryDistributedRooms({
roomType: 'game',
hasSpace: true,
notLocked: true
});
```
## IDistributedAdapter
Interface for distributed backends. Implement this to add support for Redis, message queues, etc.
### Built-in Adapters
#### MemoryAdapter
In-memory implementation for testing and single-server mode.
```typescript
const adapter = new MemoryAdapter({
serverTtl: 15000, // Server offline after no heartbeat (ms)
enableTtlCheck: true, // Enable automatic TTL checking
ttlCheckInterval: 5000 // TTL check interval (ms)
});
```
#### RedisAdapter
Redis-based implementation for production multi-server deployments.
```typescript
import Redis from 'ioredis';
import { RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'game:', // Key prefix (default: 'dist:')
serverTtl: 30, // Server TTL in seconds (default: 30)
roomTtl: 0, // Room TTL, 0 = never expire (default: 0)
snapshotTtl: 86400, // Snapshot TTL in seconds (default: 24h)
channel: 'game:events' // Pub/Sub channel (default: 'distributed:events')
});
```
**RedisAdapter Configuration:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `factory` | `() => RedisClient` | required | Redis client factory (lazy connection) |
| `prefix` | `string` | `'dist:'` | Key prefix for all Redis keys |
| `serverTtl` | `number` | `30` | Server TTL in seconds |
| `roomTtl` | `number` | `0` | Room TTL in seconds, 0 = no expiry |
| `snapshotTtl` | `number` | `86400` | Snapshot TTL in seconds |
| `channel` | `string` | `'distributed:events'` | Pub/Sub channel name |
**Features:**
- Server registry with automatic heartbeat TTL
- Room registry with cross-server lookup
- State snapshots with configurable TTL
- Pub/Sub for cross-server events
- Distributed locks using Redis SET NX
### Custom Adapters
```typescript
import type { IDistributedAdapter } from '@esengine/server';
class MyAdapter implements IDistributedAdapter {
// Lifecycle
async connect(): Promise<void> { }
async disconnect(): Promise<void> { }
isConnected(): boolean { return true; }
// Server Registry
async registerServer(server: ServerRegistration): Promise<void> { }
async unregisterServer(serverId: string): Promise<void> { }
async heartbeat(serverId: string): Promise<void> { }
async getServers(): Promise<ServerRegistration[]> { return []; }
// Room Registry
async registerRoom(room: RoomRegistration): Promise<void> { }
async unregisterRoom(roomId: string): Promise<void> { }
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
// State Snapshots
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
// Pub/Sub
async publish(event: DistributedEvent): Promise<void> { }
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
// Distributed Locks
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
async releaseLock(key: string): Promise<void> { }
}
```
## Player Routing Flow
```
Client Server A Server B
│ │ │
│─── joinOrCreate ────────►│ │
│ │ │
│ │── findAvailableRoom() ───►│
│ │◄──── room on Server B ────│
│ │ │
│◄─── redirect: B:3001 ────│ │
│ │ │
│───────────────── connect to Server B ───────────────►│
│ │ │
│◄─────────────────────────────── joined ─────────────│
```
## Event Types
The distributed system publishes these events:
| Event | Description |
|-------|-------------|
| `server:online` | Server came online |
| `server:offline` | Server went offline |
| `server:draining` | Server is draining |
| `room:created` | Room was created |
| `room:disposed` | Room was disposed |
| `room:updated` | Room info updated |
| `room:message` | Cross-server room message |
| `room:migrated` | Room migrated to another server |
| `player:joined` | Player joined room |
| `player:left` | Player left room |
## Best Practices
1. **Use Unique Server IDs** - Use hostname, container ID, or UUID
2. **Configure Proper Heartbeat** - Balance between freshness and network overhead
3. **Enable Snapshots for Stateful Rooms** - Ensure room state survives server restarts
4. **Handle Redirects Gracefully** - Client should reconnect to target server
```typescript
// Client handling redirect
if (response.redirect) {
await client.disconnect();
await client.connect(response.redirect);
await client.joinRoom(roomId);
}
```
5. **Use Distributed Locks** - Prevent race conditions in joinOrCreate
## Using createServer Integration
The simplest way to use distributed rooms is through `createServer`'s `distributed` config:
```typescript
import { createServer } from '@esengine/server';
import { RedisAdapter, Room } from '@esengine/server';
import Redis from 'ioredis';
class GameRoom extends Room {
maxPlayers = 4;
}
const server = await createServer({
port: 3000,
distributed: {
enabled: true,
adapter: new RedisAdapter({ factory: () => new Redis() }),
serverId: 'server-1',
serverAddress: 'ws://192.168.1.100',
serverPort: 3000,
enableFailover: true,
capacity: 100
}
});
server.define('game', GameRoom);
await server.start();
```
When clients call the `JoinRoom` API, the server will automatically:
1. Find available rooms (local or remote)
2. If room is on another server, send `$redirect` message to client
3. Client receives redirect and connects to target server
## Load Balancing
Use `LoadBalancedRouter` for server selection:
```typescript
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
// Using factory function
const router = createLoadBalancedRouter('least-players');
// Or create directly
const router = new LoadBalancedRouter({
strategy: 'least-rooms', // Select server with fewest rooms
preferLocal: true // Prefer local server
});
// Available strategies
// - 'round-robin': Round robin selection
// - 'least-rooms': Fewest rooms
// - 'least-players': Fewest players
// - 'random': Random selection
// - 'weighted': Weighted by capacity usage
```
## Failover
When a server goes offline with `enableFailover` enabled, the system will automatically:
1. Detect server offline (via heartbeat timeout)
2. Query all rooms on that server
3. Use distributed lock to prevent multiple servers recovering same room
4. Restore room state from snapshot
5. Publish `room:migrated` event to notify other servers
```typescript
// Ensure periodic snapshots
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000,
snapshotInterval: 30000, // Save snapshot every 30 seconds
enableFailover: true // Enable failover
}, sendFn);
```
## Future Releases
- Redis Cluster support
- More load balancing strategies (geo-location, latency-aware)

View File

@@ -0,0 +1,679 @@
---
title: "HTTP Routing"
description: "HTTP REST API routing with WebSocket port sharing support"
---
`@esengine/server` includes a lightweight HTTP routing feature that can share the same port with WebSocket services, making it easy to implement REST APIs.
## Quick Start
### Inline Route Definition
The simplest way is to define HTTP routes directly when creating the server:
```typescript
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
http: {
'/api/health': (req, res) => {
res.json({ status: 'ok', time: Date.now() })
},
'/api/users': {
GET: (req, res) => {
res.json({ users: [] })
},
POST: async (req, res) => {
const body = req.body as { name: string }
res.status(201).json({ id: '1', name: body.name })
}
}
},
cors: true // Enable CORS
})
await server.start()
```
### File-based Routing
For larger projects, file-based routing is recommended. Create a `src/http` directory where each file corresponds to a route:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body as LoginBody
// Validate user...
if (username === 'admin' && password === '123456') {
res.json({ token: 'jwt-token-here', userId: 'user-1' })
} else {
res.error(401, 'Invalid username or password')
}
}
})
```
```typescript
// server.ts
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP routes directory
httpPrefix: '/api', // Route prefix
cors: true
})
await server.start()
// Route: POST /api/login
```
## defineHttp Definition
`defineHttp` is used to define type-safe HTTP handlers:
```typescript
import { defineHttp } from '@esengine/server'
interface CreateUserBody {
username: string
email: string
password: string
}
export default defineHttp<CreateUserBody>({
// HTTP method (default POST)
method: 'POST',
// Handler function
handler(req, res) {
const body = req.body as CreateUserBody
// Handle request...
res.status(201).json({ id: 'new-user-id' })
}
})
```
### Supported HTTP Methods
```typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
```
## HttpRequest Object
The HTTP request object contains the following properties:
```typescript
interface HttpRequest {
/** Raw Node.js IncomingMessage */
raw: IncomingMessage
/** HTTP method */
method: string
/** Request path */
path: string
/** Route parameters (extracted from URL path, e.g., /users/:id) */
params: Record<string, string>
/** Query parameters */
query: Record<string, string>
/** Request headers */
headers: Record<string, string | string[] | undefined>
/** Parsed request body */
body: unknown
/** Client IP */
ip: string
}
```
### Usage Examples
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Get query parameters
const page = parseInt(req.query.page ?? '1')
const limit = parseInt(req.query.limit ?? '10')
// Get request headers
const authHeader = req.headers.authorization
// Get client IP
console.log('Request from:', req.ip)
res.json({ page, limit })
}
})
```
### Body Parsing
The request body is automatically parsed based on `Content-Type`:
- `application/json` - Parsed as JSON object
- `application/x-www-form-urlencoded` - Parsed as key-value object
- Others - Kept as raw string
```typescript
export default defineHttp<{ name: string; age: number }>({
method: 'POST',
handler(req, res) {
// body is already parsed
const { name, age } = req.body as { name: string; age: number }
res.json({ received: { name, age } })
}
})
```
## HttpResponse Object
The HTTP response object provides a chainable API:
```typescript
interface HttpResponse {
/** Raw Node.js ServerResponse */
raw: ServerResponse
/** Set status code */
status(code: number): HttpResponse
/** Set response header */
header(name: string, value: string): HttpResponse
/** Send JSON response */
json(data: unknown): void
/** Send text response */
text(data: string): void
/** Send error response */
error(code: number, message: string): void
}
```
### Usage Examples
```typescript
export default defineHttp({
method: 'POST',
handler(req, res) {
// Set status code and custom headers
res
.status(201)
.header('X-Custom-Header', 'value')
.json({ created: true })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Send error response
res.error(404, 'Resource not found')
// Equivalent to: res.status(404).json({ error: 'Resource not found' })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// Send plain text
res.text('Hello, World!')
}
})
```
## File Routing Conventions
### Name Conversion
File names are automatically converted to route paths:
| File Path | Route Path (prefix=/api) |
|-----------|-------------------------|
| `login.ts` | `/api/login` |
| `users/profile.ts` | `/api/users/profile` |
| `users/[id].ts` | `/api/users/:id` |
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
### Dynamic Route Parameters
Use `[param]` syntax to define dynamic parameters:
```typescript
// src/http/users/[id].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
// Get route parameter directly from params
const { id } = req.params
res.json({ userId: id })
}
})
```
Multiple parameters:
```typescript
// src/http/users/[userId]/posts/[postId].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
const { userId, postId } = req.params
res.json({ userId, postId })
}
})
```
### Skip Rules
The following files are automatically skipped:
- Files starting with `_` (e.g., `_helper.ts`)
- `index.ts` / `index.js` files
- Non `.ts` / `.js` / `.mts` / `.mjs` files
### Directory Structure Example
```
src/
└── http/
├── _utils.ts # Skipped (underscore prefix)
├── index.ts # Skipped (index file)
├── health.ts # GET /api/health
├── login.ts # POST /api/login
├── register.ts # POST /api/register
└── users/
├── index.ts # Skipped
├── list.ts # GET /api/users/list
└── [id].ts # GET /api/users/:id
```
## CORS Configuration
### Quick Enable
```typescript
const server = await createServer({
port: 3000,
cors: true // Use default configuration
})
```
### Custom Configuration
```typescript
const server = await createServer({
port: 3000,
cors: {
// Allowed origins
origin: ['http://localhost:5173', 'https://myapp.com'],
// Or use wildcard
// origin: '*',
// origin: true, // Reflect request origin
// Allowed HTTP methods
methods: ['GET', 'POST', 'PUT', 'DELETE'],
// Allowed request headers
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
// Allow credentials (cookies)
credentials: true,
// Preflight cache max age (seconds)
maxAge: 86400
}
})
```
### CorsOptions Type
```typescript
interface CorsOptions {
/** Allowed origins: string, string array, true (reflect) or '*' */
origin?: string | string[] | boolean
/** Allowed HTTP methods */
methods?: string[]
/** Allowed request headers */
allowedHeaders?: string[]
/** Allow credentials */
credentials?: boolean
/** Preflight cache max age (seconds) */
maxAge?: number
}
```
## Route Merging
File routes and inline routes can be used together, with inline routes having higher priority:
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http',
httpPrefix: '/api',
// Inline routes merge with file routes
http: {
'/health': (req, res) => res.json({ status: 'ok' }),
'/api/special': (req, res) => res.json({ special: true })
}
})
```
## Sharing Port with WebSocket
HTTP routes automatically share the same port with WebSocket services:
```typescript
const server = await createServer({
port: 3000,
// WebSocket related config
apiDir: './src/api',
msgDir: './src/msg',
// HTTP related config
httpDir: './src/http',
httpPrefix: '/api',
cors: true
})
await server.start()
// Same port 3000:
// - WebSocket: ws://localhost:3000
// - HTTP API: http://localhost:3000/api/*
```
## Complete Examples
### Game Server Login API
```typescript
// src/http/auth/login.ts
import { defineHttp } from '@esengine/server'
import { createJwtAuthProvider } from '@esengine/server/auth'
interface LoginRequest {
username: string
password: string
}
interface LoginResponse {
token: string
userId: string
expiresAt: number
}
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600
})
export default defineHttp<LoginRequest>({
method: 'POST',
async handler(req, res) {
const { username, password } = req.body as LoginRequest
// Validate user
const user = await db.users.findByUsername(username)
if (!user || !await verifyPassword(password, user.passwordHash)) {
res.error(401, 'Invalid username or password')
return
}
// Generate JWT
const token = jwtProvider.sign({
sub: user.id,
name: user.username,
roles: user.roles
})
const response: LoginResponse = {
token,
userId: user.id,
expiresAt: Date.now() + 3600 * 1000
}
res.json(response)
}
})
```
### Game Data Query API
```typescript
// src/http/game/leaderboard.ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
async handler(req, res) {
const limit = parseInt(req.query.limit ?? '10')
const offset = parseInt(req.query.offset ?? '0')
const players = await db.players.findMany({
sort: { score: 'desc' },
limit,
offset
})
res.json({
data: players,
pagination: { limit, offset }
})
}
})
```
## Middleware
### Middleware Type
Middleware are functions that execute before and after route handlers:
```typescript
type HttpMiddleware = (
req: HttpRequest,
res: HttpResponse,
next: () => Promise<void>
) => void | Promise<void>
```
### Built-in Middleware
```typescript
import {
requestLogger,
bodyLimit,
responseTime,
requestId,
securityHeaders
} from '@esengine/server'
const server = await createServer({
port: 3000,
http: { /* ... */ },
// Global middleware configured via createHttpRouter
})
```
#### requestLogger - Request Logging
```typescript
import { requestLogger } from '@esengine/server'
// Log request and response time
requestLogger()
// Also log request body
requestLogger({ logBody: true })
```
#### bodyLimit - Request Body Size Limit
```typescript
import { bodyLimit } from '@esengine/server'
// Limit request body to 1MB
bodyLimit(1024 * 1024)
```
#### responseTime - Response Time Header
```typescript
import { responseTime } from '@esengine/server'
// Automatically add X-Response-Time header
responseTime()
```
#### requestId - Request ID
```typescript
import { requestId } from '@esengine/server'
// Auto-generate and add X-Request-ID header
requestId()
// Custom header name
requestId('X-Trace-ID')
```
#### securityHeaders - Security Headers
```typescript
import { securityHeaders } from '@esengine/server'
// Add common security response headers
securityHeaders()
// Custom configuration
securityHeaders({
hidePoweredBy: true,
frameOptions: 'DENY',
noSniff: true
})
```
### Custom Middleware
```typescript
import type { HttpMiddleware } from '@esengine/server'
// Authentication middleware
const authMiddleware: HttpMiddleware = async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
res.error(401, 'Unauthorized')
return // Don't call next(), terminate request
}
// Validate token...
(req as any).userId = 'decoded-user-id'
await next() // Continue to next middleware and handler
}
```
### Using Middleware
#### With createHttpRouter
```typescript
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
const router = createHttpRouter({
'/api/users': (req, res) => res.json([]),
'/api/admin': {
GET: {
handler: (req, res) => res.json({ admin: true }),
middlewares: [adminAuthMiddleware] // Route-level middleware
}
}
}, {
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // Global middleware
timeout: 30000 // Global timeout 30 seconds
})
```
## Request Timeout
### Global Timeout
```typescript
import { createHttpRouter } from '@esengine/server'
const router = createHttpRouter({
'/api/data': async (req, res) => {
// If processing exceeds 30 seconds, auto-return 408 Request Timeout
await someSlowOperation()
res.json({ data: 'result' })
}
}, {
timeout: 30000 // 30 seconds
})
```
### Route-level Timeout
```typescript
const router = createHttpRouter({
'/api/quick': (req, res) => res.json({ fast: true }),
'/api/slow': {
POST: {
handler: async (req, res) => {
await verySlowOperation()
res.json({ done: true })
},
timeout: 120000 // This route allows 2 minutes
}
}
}, {
timeout: 10000 // Global 10 seconds (overridden by route-level)
})
```
## Best Practices
1. **Use defineHttp** - Get better type hints and code organization
2. **Unified Error Handling** - Use `res.error()` to return consistent error format
3. **Enable CORS** - Required for frontend-backend separation
4. **Directory Organization** - Organize HTTP route files by functional modules
5. **Validate Input** - Always validate `req.body` and `req.query` content
6. **Status Code Standards** - Follow HTTP status code conventions (200, 201, 400, 401, 404, 500, etc.)
7. **Use Middleware** - Implement cross-cutting concerns like auth, logging, rate limiting via middleware
8. **Set Timeouts** - Prevent slow requests from blocking the server

View File

@@ -147,6 +147,7 @@ service.on('chat', (data) => {
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
- [Server Side](/en/modules/network/server/) - GameServer and Room management
- [Distributed Rooms](/en/modules/network/distributed/) - Multi-server room management and player routing
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization

View File

@@ -90,128 +90,21 @@ await server.start()
Supports HTTP API sharing the same port with WebSocket, ideal for login, registration, and similar scenarios.
### File-based Routing
Create route files in the `httpDir` directory, automatically mapped to HTTP endpoints:
```
src/http/
├── login.ts → POST /api/login
├── register.ts → POST /api/register
├── health.ts → GET /api/health (set method: 'GET')
└── users/
└── [id].ts → POST /api/users/:id (dynamic route)
```
### Define Routes
Use `defineHttp` to define type-safe route handlers:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP routes directory
httpPrefix: '/api', // Route prefix
cors: true,
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST', // Default POST, options: GET/PUT/DELETE/PATCH
handler(req, res) {
const { username, password } = req.body
// Validate credentials...
if (!isValid(username, password)) {
res.error(401, 'Invalid credentials')
return
}
// Generate token...
res.json({ token: '...', userId: '...' })
// Or inline definition
http: {
'/health': (req, res) => res.json({ status: 'ok' })
}
})
```
### Request Object (HttpRequest)
```typescript
interface HttpRequest {
raw: IncomingMessage // Node.js raw request
method: string // Request method
path: string // Request path
query: Record<string, string> // Query parameters
headers: Record<string, string | string[] | undefined>
body: unknown // Parsed JSON body
ip: string // Client IP
}
```
### Response Object (HttpResponse)
```typescript
interface HttpResponse {
raw: ServerResponse // Node.js raw response
status(code: number): HttpResponse // Set status code (chainable)
header(name: string, value: string): HttpResponse // Set header (chainable)
json(data: unknown): void // Send JSON
text(data: string): void // Send text
error(code: number, message: string): void // Send error
}
```
### Usage Example
```typescript
// Complete login server example
import { createServer, defineHttp } from '@esengine/server'
import { createJwtAuthProvider, withAuth } from '@esengine/server/auth'
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600 * 24,
})
const server = await createServer({
port: 8080,
httpDir: 'src/http',
httpPrefix: '/api',
cors: true,
})
// Wrap with auth (WebSocket connections validate token)
const authServer = withAuth(server, {
provider: jwtProvider,
extractCredentials: (req) => {
const url = new URL(req.url, 'http://localhost')
return url.searchParams.get('token')
},
})
await authServer.start()
// HTTP: http://localhost:8080/api/*
// WebSocket: ws://localhost:8080?token=xxx
```
### Inline Routes
Routes can also be defined directly in configuration (merged with file routes, inline takes priority):
```typescript
const server = await createServer({
port: 8080,
http: {
'/health': {
GET: (req, res) => res.json({ status: 'ok' }),
},
'/webhook': async (req, res) => {
// Accepts all methods
await handleWebhook(req.body)
res.json({ received: true })
},
},
})
```
> For detailed documentation, see [HTTP Routing](/en/modules/network/http)
## Room System
@@ -373,6 +266,122 @@ class GameRoom extends Room {
}
```
## Schema Validation
Use the built-in Schema validation system for runtime type validation:
### Basic Usage
```typescript
import { s, defineApiWithSchema } from '@esengine/server'
// Define schema
const MoveSchema = s.object({
x: s.number(),
y: s.number(),
speed: s.number().optional()
})
// Auto type inference
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
// Use schema to define API (auto validation)
export default defineApiWithSchema(MoveSchema, {
handler(req, ctx) {
// req is validated, type-safe
console.log(req.x, req.y)
}
})
```
### Validator Types
| Type | Example | Description |
|------|---------|-------------|
| `s.string()` | `s.string().min(1).max(50)` | String with length constraints |
| `s.number()` | `s.number().min(0).int()` | Number with range and integer constraints |
| `s.boolean()` | `s.boolean()` | Boolean |
| `s.literal()` | `s.literal('admin')` | Literal type |
| `s.object()` | `s.object({ name: s.string() })` | Object |
| `s.array()` | `s.array(s.number())` | Array |
| `s.enum()` | `s.enum(['a', 'b'] as const)` | Enum |
| `s.union()` | `s.union([s.string(), s.number()])` | Union type |
| `s.record()` | `s.record(s.any())` | Record type |
### Modifiers
```typescript
// Optional field
s.string().optional()
// Default value
s.number().default(0)
// Nullable
s.string().nullable()
// String validation
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
// Number validation
s.number().min(0).max(100).int().positive()
// Array validation
s.array(s.string()).min(1).max(10).nonempty()
// Object validation
s.object({ ... }).strict() // No extra fields allowed
s.object({ ... }).partial() // All fields optional
s.object({ ... }).pick('name', 'age') // Pick fields
s.object({ ... }).omit('password') // Omit fields
```
### Message Validation
```typescript
import { s, defineMsgWithSchema } from '@esengine/server'
const InputSchema = s.object({
keys: s.array(s.string()),
timestamp: s.number()
})
export default defineMsgWithSchema(InputSchema, {
handler(msg, ctx) {
// msg is validated
console.log(msg.keys, msg.timestamp)
}
})
```
### Manual Validation
```typescript
import { s, parse, safeParse, createGuard } from '@esengine/server'
const UserSchema = s.object({
name: s.string(),
age: s.number().int().min(0)
})
// Throws on error
const user = parse(UserSchema, data)
// Returns result object
const result = safeParse(UserSchema, data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
// Type guard
const isUser = createGuard(UserSchema)
if (isUser(data)) {
// data is User type
}
```
## Protocol Definition
Define shared types in `src/shared/protocol.ts`:

View File

@@ -0,0 +1,441 @@
---
title: "分布式房间"
description: "使用 DistributedRoomManager 实现多服务器房间管理"
---
## 概述
分布式房间支持允许多个服务器实例共享房间注册表,实现跨服务器玩家路由和故障转移。
```
┌─────────────────────────────────────────────────────────┐
│ Server A Server B Server C │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Room 1 │ │ Room 3 │ │ Room 5 │ │
│ │ Room 2 │ │ Room 4 │ │ Room 6 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └─────────────────┼─────────────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ IDistributedAdapter │ │
│ │ (Redis / Memory) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## 快速开始
### 单机模式(测试用)
```typescript
import {
DistributedRoomManager,
MemoryAdapter,
Room
} from '@esengine/server';
// 定义房间类型
class GameRoom extends Room {
maxPlayers = 4;
}
// 创建适配器和管理器
const adapter = new MemoryAdapter();
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000
}, (conn, type, data) => conn.send(JSON.stringify({ type, data })));
// 注册房间类型
manager.define('game', GameRoom);
// 启动管理器
await manager.start();
// 分布式加入/创建房间
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// 玩家应连接到其他服务器
console.log(`重定向到: ${result.redirect}`);
} else {
// 玩家加入本地房间
const { room, player } = result;
}
// 优雅关闭
await manager.stop(true);
```
### 多服务器模式(生产用)
```typescript
import Redis from 'ioredis';
import { DistributedRoomManager, RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis({
host: 'redis.example.com',
port: 6379
}),
prefix: 'game:',
serverTtl: 30,
snapshotTtl: 86400
});
const manager = new DistributedRoomManager(adapter, {
serverId: process.env.SERVER_ID,
serverAddress: process.env.PUBLIC_IP,
serverPort: 3000,
heartbeatInterval: 5000,
snapshotInterval: 30000,
enableFailover: true,
capacity: 100
}, sendFn);
```
## DistributedRoomManager
### 配置选项
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `serverId` | `string` | 必填 | 服务器唯一标识 |
| `serverAddress` | `string` | 必填 | 客户端连接的公开地址 |
| `serverPort` | `number` | 必填 | 服务器端口 |
| `heartbeatInterval` | `number` | `5000` | 心跳间隔(毫秒) |
| `snapshotInterval` | `number` | `30000` | 状态快照间隔0 禁用 |
| `migrationTimeout` | `number` | `10000` | 房间迁移超时 |
| `enableFailover` | `boolean` | `true` | 启用自动故障转移 |
| `capacity` | `number` | `100` | 本服务器最大房间数 |
### 生命周期方法
#### start()
启动分布式房间管理器。连接适配器、注册服务器、启动心跳。
```typescript
await manager.start();
```
#### stop(graceful?)
停止管理器。如果 `graceful=true`,将服务器标记为 draining 并保存所有房间快照。
```typescript
await manager.stop(true);
```
### 路由方法
#### joinOrCreateDistributed()
分布式感知的加入或创建房间。返回本地房间的 `{ room, player }` 或远程房间的 `{ redirect: string }`
```typescript
const result = await manager.joinOrCreateDistributed('game', 'player-1', conn);
if ('redirect' in result) {
// 客户端应重定向到其他服务器
res.json({ redirect: result.redirect });
} else {
// 玩家加入了本地房间
const { room, player } = result;
}
```
#### route()
将玩家路由到合适的房间/服务器。
```typescript
const result = await manager.route({
roomType: 'game',
playerId: 'p1'
});
switch (result.type) {
case 'local': // 房间在本服务器
break;
case 'redirect': // 房间在其他服务器
// result.serverAddress 包含目标服务器地址
break;
case 'create': // 没有可用房间,需要创建
break;
case 'unavailable': // 无法找到或创建房间
// result.reason 包含错误信息
break;
}
```
### 状态管理
#### saveSnapshot()
手动保存房间状态快照。
```typescript
await manager.saveSnapshot(roomId);
```
#### restoreFromSnapshot()
从保存的快照恢复房间。
```typescript
const success = await manager.restoreFromSnapshot(roomId);
```
### 查询方法
#### getServers()
获取所有在线服务器。
```typescript
const servers = await manager.getServers();
```
#### queryDistributedRooms()
查询所有服务器上的房间。
```typescript
const rooms = await manager.queryDistributedRooms({
roomType: 'game',
hasSpace: true,
notLocked: true
});
```
## IDistributedAdapter
分布式后端的接口。实现此接口以支持 Redis、消息队列等。
### 内置适配器
#### MemoryAdapter
用于测试和单机模式的内存实现。
```typescript
const adapter = new MemoryAdapter({
serverTtl: 15000, // 无心跳后服务器离线时间(毫秒)
enableTtlCheck: true, // 启用自动 TTL 检查
ttlCheckInterval: 5000 // TTL 检查间隔(毫秒)
});
```
#### RedisAdapter
用于生产环境多服务器部署的 Redis 实现。
```typescript
import Redis from 'ioredis';
import { RedisAdapter } from '@esengine/server';
const adapter = new RedisAdapter({
factory: () => new Redis('redis://localhost:6379'),
prefix: 'game:', // 键前缀(默认: 'dist:'
serverTtl: 30, // 服务器 TTL默认: 30
roomTtl: 0, // 房间 TTL0 = 永不过期(默认: 0
snapshotTtl: 86400, // 快照 TTL默认: 24 小时)
channel: 'game:events' // Pub/Sub 频道(默认: 'distributed:events'
});
```
**RedisAdapter 配置:**
| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `factory` | `() => RedisClient` | 必填 | Redis 客户端工厂(惰性连接) |
| `prefix` | `string` | `'dist:'` | 所有 Redis 键的前缀 |
| `serverTtl` | `number` | `30` | 服务器 TTL |
| `roomTtl` | `number` | `0` | 房间 TTL0 = 不过期 |
| `snapshotTtl` | `number` | `86400` | 快照 TTL |
| `channel` | `string` | `'distributed:events'` | Pub/Sub 频道名 |
**功能特性:**
- 带自动心跳 TTL 的服务器注册
- 跨服务器查找的房间注册
- 可配置 TTL 的状态快照
- 跨服务器事件的 Pub/Sub
- 使用 Redis SET NX 的分布式锁
### 自定义适配器
```typescript
import type { IDistributedAdapter } from '@esengine/server';
class MyAdapter implements IDistributedAdapter {
// 生命周期
async connect(): Promise<void> { }
async disconnect(): Promise<void> { }
isConnected(): boolean { return true; }
// 服务器注册
async registerServer(server: ServerRegistration): Promise<void> { }
async unregisterServer(serverId: string): Promise<void> { }
async heartbeat(serverId: string): Promise<void> { }
async getServers(): Promise<ServerRegistration[]> { return []; }
// 房间注册
async registerRoom(room: RoomRegistration): Promise<void> { }
async unregisterRoom(roomId: string): Promise<void> { }
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> { return []; }
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> { return null; }
// 状态快照
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> { }
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> { return null; }
// 发布/订阅
async publish(event: DistributedEvent): Promise<void> { }
async subscribe(pattern: string, handler: Function): Promise<() => void> { return () => {}; }
// 分布式锁
async acquireLock(key: string, ttlMs: number): Promise<boolean> { return true; }
async releaseLock(key: string): Promise<void> { }
}
```
## 玩家路由流程
```
客户端 服务器 A 服务器 B
│ │ │
│─── joinOrCreate ────────►│ │
│ │ │
│ │── findAvailableRoom() ───►│
│ │◄──── 服务器 B 上有房间 ────│
│ │ │
│◄─── redirect: B:3001 ────│ │
│ │ │
│───────────────── 连接到服务器 B ────────────────────►│
│ │ │
│◄─────────────────────────────── 已加入 ─────────────│
```
## 事件类型
分布式系统发布以下事件:
| 事件 | 描述 |
|------|------|
| `server:online` | 服务器上线 |
| `server:offline` | 服务器离线 |
| `server:draining` | 服务器正在排空 |
| `room:created` | 房间已创建 |
| `room:disposed` | 房间已销毁 |
| `room:updated` | 房间信息已更新 |
| `room:message` | 跨服务器房间消息 |
| `room:migrated` | 房间已迁移到其他服务器 |
| `player:joined` | 玩家加入房间 |
| `player:left` | 玩家离开房间 |
## 最佳实践
1. **使用唯一服务器 ID** - 使用主机名、容器 ID 或 UUID
2. **配置合适的心跳** - 在新鲜度和网络开销之间平衡
3. **为有状态房间启用快照** - 确保房间状态在服务器重启后存活
4. **优雅处理重定向** - 客户端应重新连接到目标服务器
```typescript
// 客户端处理重定向
if (response.redirect) {
await client.disconnect();
await client.connect(response.redirect);
await client.joinRoom(roomId);
}
```
5. **使用分布式锁** - 防止 joinOrCreate 中的竞态条件
## 使用 createServer 集成
最简单的使用方式是通过 `createServer` 的 `distributed` 配置:
```typescript
import { createServer } from '@esengine/server';
import { RedisAdapter, Room } from '@esengine/server';
import Redis from 'ioredis';
class GameRoom extends Room {
maxPlayers = 4;
}
const server = await createServer({
port: 3000,
distributed: {
enabled: true,
adapter: new RedisAdapter({ factory: () => new Redis() }),
serverId: 'server-1',
serverAddress: 'ws://192.168.1.100',
serverPort: 3000,
enableFailover: true,
capacity: 100
}
});
server.define('game', GameRoom);
await server.start();
```
当客户端调用 `JoinRoom` API 时,服务器会自动:
1. 查找可用房间(本地或远程)
2. 如果房间在其他服务器,发送 `$redirect` 消息给客户端
3. 客户端收到重定向消息后连接到目标服务器
## 负载均衡
使用 `LoadBalancedRouter` 进行服务器选择:
```typescript
import { LoadBalancedRouter, createLoadBalancedRouter } from '@esengine/server';
// 使用工厂函数
const router = createLoadBalancedRouter('least-players');
// 或直接创建
const router = new LoadBalancedRouter({
strategy: 'least-rooms', // 选择房间数最少的服务器
preferLocal: true // 优先选择本地服务器
});
// 可用策略
// - 'round-robin': 轮询
// - 'least-rooms': 最少房间数
// - 'least-players': 最少玩家数
// - 'random': 随机选择
// - 'weighted': 权重(基于容量使用率)
```
## 故障转移
当服务器离线时,启用 `enableFailover` 后系统会自动:
1. 检测到服务器离线(通过心跳超时)
2. 查询该服务器上的所有房间
3. 使用分布式锁防止多服务器同时恢复
4. 从快照恢复房间状态
5. 发布 `room:migrated` 事件通知其他服务器
```typescript
// 确保定期保存快照
const manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000,
snapshotInterval: 30000, // 每 30 秒保存快照
enableFailover: true // 启用故障转移
}, sendFn);
```
## 后续版本
- Redis Cluster 支持
- 更多负载均衡策略(地理位置、延迟感知)

View File

@@ -0,0 +1,679 @@
---
title: "HTTP 路由"
description: "HTTP REST API 路由功能,支持与 WebSocket 共用端口"
---
`@esengine/server` 内置了轻量级的 HTTP 路由功能,可以与 WebSocket 服务共用同一端口,方便实现 REST API。
## 快速开始
### 内联路由定义
最简单的方式是在创建服务器时直接定义 HTTP 路由:
```typescript
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
http: {
'/api/health': (req, res) => {
res.json({ status: 'ok', time: Date.now() })
},
'/api/users': {
GET: (req, res) => {
res.json({ users: [] })
},
POST: async (req, res) => {
const body = req.body as { name: string }
res.status(201).json({ id: '1', name: body.name })
}
}
},
cors: true // 启用 CORS
})
await server.start()
```
### 文件路由
对于较大的项目,推荐使用文件路由。创建 `src/http` 目录,每个文件对应一个路由:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body as LoginBody
// 验证用户...
if (username === 'admin' && password === '123456') {
res.json({ token: 'jwt-token-here', userId: 'user-1' })
} else {
res.error(401, '用户名或密码错误')
}
}
})
```
```typescript
// server.ts
import { createServer } from '@esengine/server'
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP 路由目录
httpPrefix: '/api', // 路由前缀
cors: true
})
await server.start()
// 路由: POST /api/login
```
## defineHttp 定义
`defineHttp` 用于定义类型安全的 HTTP 处理器:
```typescript
import { defineHttp } from '@esengine/server'
interface CreateUserBody {
username: string
email: string
password: string
}
export default defineHttp<CreateUserBody>({
// HTTP 方法(默认 POST
method: 'POST',
// 处理函数
handler(req, res) {
const body = req.body as CreateUserBody
// 处理请求...
res.status(201).json({ id: 'new-user-id' })
}
})
```
### 支持的 HTTP 方法
```typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS'
```
## HttpRequest 对象
HTTP 请求对象包含以下属性:
```typescript
interface HttpRequest {
/** 原始 Node.js IncomingMessage */
raw: IncomingMessage
/** HTTP 方法 */
method: string
/** 请求路径 */
path: string
/** 路由参数(从 URL 路径提取,如 /users/:id */
params: Record<string, string>
/** 查询参数 */
query: Record<string, string>
/** 请求头 */
headers: Record<string, string | string[] | undefined>
/** 解析后的请求体 */
body: unknown
/** 客户端 IP */
ip: string
}
```
### 使用示例
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 获取查询参数
const page = parseInt(req.query.page ?? '1')
const limit = parseInt(req.query.limit ?? '10')
// 获取请求头
const authHeader = req.headers.authorization
// 获取客户端 IP
console.log('Request from:', req.ip)
res.json({ page, limit })
}
})
```
### 请求体解析
请求体会根据 `Content-Type` 自动解析:
- `application/json` - 解析为 JSON 对象
- `application/x-www-form-urlencoded` - 解析为键值对对象
- 其他 - 保持原始字符串
```typescript
export default defineHttp<{ name: string; age: number }>({
method: 'POST',
handler(req, res) {
// body 已自动解析
const { name, age } = req.body as { name: string; age: number }
res.json({ received: { name, age } })
}
})
```
## HttpResponse 对象
HTTP 响应对象提供链式 API
```typescript
interface HttpResponse {
/** 原始 Node.js ServerResponse */
raw: ServerResponse
/** 设置状态码 */
status(code: number): HttpResponse
/** 设置响应头 */
header(name: string, value: string): HttpResponse
/** 发送 JSON 响应 */
json(data: unknown): void
/** 发送文本响应 */
text(data: string): void
/** 发送错误响应 */
error(code: number, message: string): void
}
```
### 使用示例
```typescript
export default defineHttp({
method: 'POST',
handler(req, res) {
// 设置状态码和自定义头
res
.status(201)
.header('X-Custom-Header', 'value')
.json({ created: true })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 发送错误响应
res.error(404, '资源不存在')
// 等价于: res.status(404).json({ error: '资源不存在' })
}
})
```
```typescript
export default defineHttp({
method: 'GET',
handler(req, res) {
// 发送纯文本
res.text('Hello, World!')
}
})
```
## 文件路由规范
### 命名转换
文件名会自动转换为路由路径:
| 文件路径 | 路由路径prefix=/api |
|---------|----------------------|
| `login.ts` | `/api/login` |
| `users/profile.ts` | `/api/users/profile` |
| `users/[id].ts` | `/api/users/:id` |
| `game/room/[roomId].ts` | `/api/game/room/:roomId` |
### 动态路由参数
使用 `[param]` 语法定义动态参数:
```typescript
// src/http/users/[id].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
// 直接从 params 获取路由参数
const { id } = req.params
res.json({ userId: id })
}
})
```
多个参数的情况:
```typescript
// src/http/users/[userId]/posts/[postId].ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
handler(req, res) {
const { userId, postId } = req.params
res.json({ userId, postId })
}
})
```
### 跳过规则
以下文件会被自动跳过:
-`_` 开头的文件(如 `_helper.ts`
- `index.ts` / `index.js` 文件
-`.ts` / `.js` / `.mts` / `.mjs` 文件
### 目录结构示例
```
src/
└── http/
├── _utils.ts # 跳过(下划线开头)
├── index.ts # 跳过index 文件)
├── health.ts # GET /api/health
├── login.ts # POST /api/login
├── register.ts # POST /api/register
└── users/
├── index.ts # 跳过
├── list.ts # GET /api/users/list
└── [id].ts # GET /api/users/:id
```
## CORS 配置
### 快速启用
```typescript
const server = await createServer({
port: 3000,
cors: true // 使用默认配置
})
```
### 自定义配置
```typescript
const server = await createServer({
port: 3000,
cors: {
// 允许的来源
origin: ['http://localhost:5173', 'https://myapp.com'],
// 或使用通配符
// origin: '*',
// origin: true, // 反射请求来源
// 允许的 HTTP 方法
methods: ['GET', 'POST', 'PUT', 'DELETE'],
// 允许的请求头
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
// 是否允许携带凭证cookies
credentials: true,
// 预检请求缓存时间(秒)
maxAge: 86400
}
})
```
### CorsOptions 类型
```typescript
interface CorsOptions {
/** 允许的来源字符串、字符串数组、true反射或 '*' */
origin?: string | string[] | boolean
/** 允许的 HTTP 方法 */
methods?: string[]
/** 允许的请求头 */
allowedHeaders?: string[]
/** 是否允许携带凭证 */
credentials?: boolean
/** 预检请求缓存时间(秒) */
maxAge?: number
}
```
## 路由合并
文件路由和内联路由可以同时使用,内联路由优先级更高:
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http',
httpPrefix: '/api',
// 内联路由会与文件路由合并
http: {
'/health': (req, res) => res.json({ status: 'ok' }),
'/api/special': (req, res) => res.json({ special: true })
}
})
```
## 与 WebSocket 共用端口
HTTP 路由与 WebSocket 服务自动共用同一端口:
```typescript
const server = await createServer({
port: 3000,
// WebSocket 相关配置
apiDir: './src/api',
msgDir: './src/msg',
// HTTP 相关配置
httpDir: './src/http',
httpPrefix: '/api',
cors: true
})
await server.start()
// 同一端口 3000
// - WebSocket: ws://localhost:3000
// - HTTP API: http://localhost:3000/api/*
```
## 完整示例
### 游戏服务器登录 API
```typescript
// src/http/auth/login.ts
import { defineHttp } from '@esengine/server'
import { createJwtAuthProvider } from '@esengine/server/auth'
interface LoginRequest {
username: string
password: string
}
interface LoginResponse {
token: string
userId: string
expiresAt: number
}
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600
})
export default defineHttp<LoginRequest>({
method: 'POST',
async handler(req, res) {
const { username, password } = req.body as LoginRequest
// 验证用户
const user = await db.users.findByUsername(username)
if (!user || !await verifyPassword(password, user.passwordHash)) {
res.error(401, '用户名或密码错误')
return
}
// 生成 JWT
const token = jwtProvider.sign({
sub: user.id,
name: user.username,
roles: user.roles
})
const response: LoginResponse = {
token,
userId: user.id,
expiresAt: Date.now() + 3600 * 1000
}
res.json(response)
}
})
```
### 游戏数据查询 API
```typescript
// src/http/game/leaderboard.ts
import { defineHttp } from '@esengine/server'
export default defineHttp({
method: 'GET',
async handler(req, res) {
const limit = parseInt(req.query.limit ?? '10')
const offset = parseInt(req.query.offset ?? '0')
const players = await db.players.findMany({
sort: { score: 'desc' },
limit,
offset
})
res.json({
data: players,
pagination: { limit, offset }
})
}
})
```
## 中间件
### 中间件类型
中间件是在路由处理前后执行的函数:
```typescript
type HttpMiddleware = (
req: HttpRequest,
res: HttpResponse,
next: () => Promise<void>
) => void | Promise<void>
```
### 内置中间件
```typescript
import {
requestLogger,
bodyLimit,
responseTime,
requestId,
securityHeaders
} from '@esengine/server'
const server = await createServer({
port: 3000,
http: { /* ... */ },
// 全局中间件通过 createHttpRouter 配置
})
```
#### requestLogger - 请求日志
```typescript
import { requestLogger } from '@esengine/server'
// 记录请求和响应时间
requestLogger()
// 同时记录请求体
requestLogger({ logBody: true })
```
#### bodyLimit - 请求体大小限制
```typescript
import { bodyLimit } from '@esengine/server'
// 限制请求体为 1MB
bodyLimit(1024 * 1024)
```
#### responseTime - 响应时间头
```typescript
import { responseTime } from '@esengine/server'
// 自动添加 X-Response-Time 响应头
responseTime()
```
#### requestId - 请求 ID
```typescript
import { requestId } from '@esengine/server'
// 自动生成并添加 X-Request-ID 响应头
requestId()
// 自定义头名称
requestId('X-Trace-ID')
```
#### securityHeaders - 安全头
```typescript
import { securityHeaders } from '@esengine/server'
// 添加常用安全响应头
securityHeaders()
// 自定义配置
securityHeaders({
hidePoweredBy: true,
frameOptions: 'DENY',
noSniff: true
})
```
### 自定义中间件
```typescript
import type { HttpMiddleware } from '@esengine/server'
// 认证中间件
const authMiddleware: HttpMiddleware = async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
res.error(401, 'Unauthorized')
return // 不调用 next(),终止请求
}
// 验证 token...
(req as any).userId = 'decoded-user-id'
await next() // 继续执行后续中间件和处理器
}
```
### 使用中间件
#### 使用 createHttpRouter
```typescript
import { createHttpRouter, requestLogger, bodyLimit } from '@esengine/server'
const router = createHttpRouter({
'/api/users': (req, res) => res.json([]),
'/api/admin': {
GET: {
handler: (req, res) => res.json({ admin: true }),
middlewares: [adminAuthMiddleware] // 路由级中间件
}
}
}, {
middlewares: [requestLogger(), bodyLimit(1024 * 1024)], // 全局中间件
timeout: 30000 // 全局超时 30 秒
})
```
## 请求超时
### 全局超时
```typescript
import { createHttpRouter } from '@esengine/server'
const router = createHttpRouter({
'/api/data': async (req, res) => {
// 如果处理超过 30 秒,自动返回 408 Request Timeout
await someSlowOperation()
res.json({ data: 'result' })
}
}, {
timeout: 30000 // 30 秒
})
```
### 路由级超时
```typescript
const router = createHttpRouter({
'/api/quick': (req, res) => res.json({ fast: true }),
'/api/slow': {
POST: {
handler: async (req, res) => {
await verySlowOperation()
res.json({ done: true })
},
timeout: 120000 // 这个路由允许 2 分钟
}
}
}, {
timeout: 10000 // 全局 10 秒(被路由级覆盖)
})
```
## 最佳实践
1. **使用 defineHttp** - 获得更好的类型提示和代码组织
2. **统一错误处理** - 使用 `res.error()` 返回一致的错误格式
3. **启用 CORS** - 前后端分离时必须配置
4. **目录组织** - 按功能模块组织 HTTP 路由文件
5. **验证输入** - 始终验证 `req.body``req.query` 的内容
6. **状态码规范** - 遵循 HTTP 状态码规范200、201、400、401、404、500 等)
7. **使用中间件** - 通过中间件实现认证、日志、限流等横切关注点
8. **设置超时** - 避免慢请求阻塞服务器

View File

@@ -147,6 +147,7 @@ service.on('chat', (data) => {
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
- [分布式房间](/modules/network/distributed/) - 多服务器房间管理和玩家路由
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化

View File

@@ -90,128 +90,35 @@ await server.start()
支持 HTTP API 与 WebSocket 共用端口,适用于登录、注册等场景。
### 文件路由
`httpDir` 目录下创建路由文件,自动映射为 HTTP 端点:
```typescript
const server = await createServer({
port: 3000,
httpDir: './src/http', // HTTP 路由目录
httpPrefix: '/api', // 路由前缀
cors: true,
// 或内联定义
http: {
'/health': (req, res) => res.json({ status: 'ok' })
}
})
```
src/http/
├── login.ts → POST /api/login
├── register.ts → POST /api/register
├── health.ts → GET /api/health (需设置 method: 'GET')
└── users/
└── [id].ts → POST /api/users/:id (动态路由)
```
### 定义路由
使用 `defineHttp` 定义类型安全的路由处理器:
```typescript
// src/http/login.ts
import { defineHttp } from '@esengine/server'
interface LoginBody {
username: string
password: string
}
export default defineHttp<LoginBody>({
method: 'POST', // 默认 POST可选 GET/PUT/DELETE/PATCH
export default defineHttp<{ username: string; password: string }>({
method: 'POST',
handler(req, res) {
const { username, password } = req.body
// 验证凭证...
if (!isValid(username, password)) {
res.error(401, 'Invalid credentials')
return
}
// 生成 token...
res.json({ token: '...', userId: '...' })
// 验证并返回 token...
res.json({ token: '...' })
}
})
```
### 请求对象 (HttpRequest)
```typescript
interface HttpRequest {
raw: IncomingMessage // Node.js 原始请求
method: string // 请求方法
path: string // 请求路径
query: Record<string, string> // 查询参数
headers: Record<string, string | string[] | undefined>
body: unknown // 解析后的 JSON 请求体
ip: string // 客户端 IP
}
```
### 响应对象 (HttpResponse)
```typescript
interface HttpResponse {
raw: ServerResponse // Node.js 原始响应
status(code: number): HttpResponse // 设置状态码(链式)
header(name: string, value: string): HttpResponse // 设置头(链式)
json(data: unknown): void // 发送 JSON
text(data: string): void // 发送文本
error(code: number, message: string): void // 发送错误
}
```
### 使用示例
```typescript
// 完整的登录服务器示例
import { createServer, defineHttp } from '@esengine/server'
import { createJwtAuthProvider, withAuth } from '@esengine/server/auth'
const jwtProvider = createJwtAuthProvider({
secret: process.env.JWT_SECRET!,
expiresIn: 3600 * 24,
})
const server = await createServer({
port: 8080,
httpDir: 'src/http',
httpPrefix: '/api',
cors: true,
})
// 包装认证WebSocket 连接验证 token
const authServer = withAuth(server, {
provider: jwtProvider,
extractCredentials: (req) => {
const url = new URL(req.url, 'http://localhost')
return url.searchParams.get('token')
},
})
await authServer.start()
// HTTP: http://localhost:8080/api/*
// WebSocket: ws://localhost:8080?token=xxx
```
### 内联路由
也可以直接在配置中定义路由(与文件路由合并,内联优先):
```typescript
const server = await createServer({
port: 8080,
http: {
'/health': {
GET: (req, res) => res.json({ status: 'ok' }),
},
'/webhook': async (req, res) => {
// 接受所有方法
await handleWebhook(req.body)
res.json({ received: true })
},
},
})
```
> 详细文档请参考 [HTTP 路由](/modules/network/http)
## Room 系统
@@ -373,6 +280,122 @@ class GameRoom extends Room {
}
```
## Schema 验证
使用内置的 Schema 验证系统进行运行时类型验证:
### 基础用法
```typescript
import { s, defineApiWithSchema } from '@esengine/server'
// 定义 Schema
const MoveSchema = s.object({
x: s.number(),
y: s.number(),
speed: s.number().optional()
})
// 类型自动推断
type Move = s.infer<typeof MoveSchema> // { x: number; y: number; speed?: number }
// 使用 Schema 定义 API自动验证
export default defineApiWithSchema(MoveSchema, {
handler(req, ctx) {
// req 已验证,类型安全
console.log(req.x, req.y)
}
})
```
### 验证器类型
| 类型 | 示例 | 描述 |
|------|------|------|
| `s.string()` | `s.string().min(1).max(50)` | 字符串,支持长度限制 |
| `s.number()` | `s.number().min(0).int()` | 数字,支持范围和整数限制 |
| `s.boolean()` | `s.boolean()` | 布尔值 |
| `s.literal()` | `s.literal('admin')` | 字面量类型 |
| `s.object()` | `s.object({ name: s.string() })` | 对象 |
| `s.array()` | `s.array(s.number())` | 数组 |
| `s.enum()` | `s.enum(['a', 'b'] as const)` | 枚举 |
| `s.union()` | `s.union([s.string(), s.number()])` | 联合类型 |
| `s.record()` | `s.record(s.any())` | 记录类型 |
### 修饰符
```typescript
// 可选字段
s.string().optional()
// 默认值
s.number().default(0)
// 可为 null
s.string().nullable()
// 字符串验证
s.string().min(1).max(100).email().url().regex(/^[a-z]+$/)
// 数字验证
s.number().min(0).max(100).int().positive()
// 数组验证
s.array(s.string()).min(1).max(10).nonempty()
// 对象验证
s.object({ ... }).strict() // 不允许额外字段
s.object({ ... }).partial() // 所有字段可选
s.object({ ... }).pick('name', 'age') // 选择字段
s.object({ ... }).omit('password') // 排除字段
```
### 消息验证
```typescript
import { s, defineMsgWithSchema } from '@esengine/server'
const InputSchema = s.object({
keys: s.array(s.string()),
timestamp: s.number()
})
export default defineMsgWithSchema(InputSchema, {
handler(msg, ctx) {
// msg 已验证
console.log(msg.keys, msg.timestamp)
}
})
```
### 手动验证
```typescript
import { s, parse, safeParse, createGuard } from '@esengine/server'
const UserSchema = s.object({
name: s.string(),
age: s.number().int().min(0)
})
// 抛出错误
const user = parse(UserSchema, data)
// 返回结果对象
const result = safeParse(UserSchema, data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
// 类型守卫
const isUser = createGuard(UserSchema)
if (isUser(data)) {
// data 是 User 类型
}
```
## 协议定义
`src/shared/protocol.ts` 中定义客户端和服务端共享的类型:

View File

@@ -13,6 +13,7 @@
"packages/network-ext/*",
"packages/editor/*",
"packages/editor/plugins/*",
"packages/devtools/*",
"packages/rust/*",
"packages/tools/*"
],

View File

@@ -0,0 +1,10 @@
# @esengine/node-editor
## 1.1.0
### Minor Changes
- [#426](https://github.com/esengine/esengine/pull/426) [`6970394`](https://github.com/esengine/esengine/commit/6970394717ab8f743b0a41e248e3404a3b6fc7dc) Thanks [@esengine](https://github.com/esengine)! - feat: 独立发布节点编辑器 | Standalone node editor release
- 移动到 packages/devtools 目录 | Move to packages/devtools directory
- 清理依赖,使包可独立使用 | Clean dependencies for standalone use
- 可用于 Cocos Creator / LayaAir 插件开发 | Available for Cocos/Laya plugin development

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/node-editor",
"version": "1.0.0",
"version": "1.1.0",
"description": "Universal node-based visual editor for blueprint, shader graph, and state machine",
"main": "dist/index.js",
"module": "dist/index.js",
@@ -30,17 +30,18 @@
"blueprint",
"shader-graph",
"state-machine",
"ecs",
"game-engine"
"react"
],
"author": "yhh",
"author": "ESEngine Team",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0"
},
"devDependencies": {
"react": "^18.3.1",
"zustand": "^5.0.8",
"@types/node": "^20.19.17",
"@types/react": "^18.3.12",
"@vitejs/plugin-react": "^4.7.0",
"react": "^18.3.1",
"rimraf": "^5.0.0",
"typescript": "^5.8.3",
"vite": "^6.0.7",
@@ -56,7 +57,6 @@
"repository": {
"type": "git",
"url": "https://github.com/esengine/esengine.git",
"directory": "packages/node-editor"
},
"private": true
"directory": "packages/devtools/node-editor"
}
}

View File

@@ -742,6 +742,7 @@ export class Core {
if (!this._instance) return;
this._instance._debugManager?.stop();
this._instance._sceneManager.destroy();
this._instance._serviceContainer.clear();
Core._logger.info('Core destroyed');
this._instance = null;

View File

@@ -1,5 +1,13 @@
# @esengine/database-drivers
## 1.1.1
### Patch Changes
- [#412](https://github.com/esengine/esengine/pull/412) [`85171a0`](https://github.com/esengine/esengine/commit/85171a0a5c073ef7883705ee4daaca8bb0218f20) Thanks [@esengine](https://github.com/esengine)! - fix: include dist directory in npm package
Previous 1.1.0 release was missing the compiled dist directory.
## 1.1.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/database-drivers",
"version": "1.1.0",
"version": "1.1.1",
"description": "Database connection drivers for ESEngine | ESEngine 数据库连接驱动",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1,5 +1,16 @@
# @esengine/database
## 1.1.1
### Patch Changes
- [#412](https://github.com/esengine/esengine/pull/412) [`85171a0`](https://github.com/esengine/esengine/commit/85171a0a5c073ef7883705ee4daaca8bb0218f20) Thanks [@esengine](https://github.com/esengine)! - fix: include dist directory in npm package
Previous 1.1.0 release was missing the compiled dist directory.
- Updated dependencies [[`85171a0`](https://github.com/esengine/esengine/commit/85171a0a5c073ef7883705ee4daaca8bb0218f20)]:
- @esengine/database-drivers@1.1.1
## 1.1.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/database",
"version": "1.1.0",
"version": "1.1.1",
"description": "Database CRUD operations and repositories for ESEngine | ESEngine 数据库 CRUD 操作和仓库",
"type": "module",
"main": "./dist/index.js",

View File

@@ -32,6 +32,8 @@
"build": "tsup && tsc --emitDeclarationOnly",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"lint": "eslint src --max-warnings 0",
"lint:fix": "eslint src --fix",
"clean": "rimraf dist"
},
"dependencies": {},

View File

@@ -11,11 +11,11 @@ import type {
ApiOutput,
MsgData,
Packet,
ConnectionStatus,
} from '../types'
import { RpcError, ErrorCode } from '../types'
import { json } from '../codec/json'
import type { Codec } from '../codec/types'
ConnectionStatus
} from '../types';
import { RpcError, ErrorCode } from '../types';
import { json } from '../codec/json';
import type { Codec } from '../codec/types';
// ============================================================================
// Re-exports | 类型重导出
@@ -29,9 +29,9 @@ export type {
ApiOutput,
MsgData,
ConnectionStatus,
Codec,
}
export { RpcError, ErrorCode }
Codec
};
export { RpcError, ErrorCode };
// ============================================================================
// Types | 类型定义
@@ -133,11 +133,11 @@ const PacketType = {
ApiResponse: 1,
ApiError: 2,
Message: 3,
Heartbeat: 9,
} as const
Heartbeat: 9
} as const;
const defaultWebSocketFactory: WebSocketFactory = (url) =>
new WebSocket(url) as unknown as WebSocketAdapter
new WebSocket(url) as unknown as WebSocketAdapter;
// ============================================================================
// RpcClient Class | RPC 客户端类
@@ -164,34 +164,34 @@ interface PendingCall {
* ```
*/
export class RpcClient<P extends ProtocolDef> {
private readonly _url: string
private readonly _codec: Codec
private readonly _timeout: number
private readonly _reconnectInterval: number
private readonly _wsFactory: WebSocketFactory
private readonly _options: RpcClientOptions
private readonly _url: string;
private readonly _codec: Codec;
private readonly _timeout: number;
private readonly _reconnectInterval: number;
private readonly _wsFactory: WebSocketFactory;
private readonly _options: RpcClientOptions;
private _ws: WebSocketAdapter | null = null
private _status: ConnectionStatus = 'closed'
private _callIdCounter = 0
private _shouldReconnect: boolean
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
private _ws: WebSocketAdapter | null = null;
private _status: ConnectionStatus = 'closed';
private _callIdCounter = 0;
private _shouldReconnect: boolean;
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly _pendingCalls = new Map<number, PendingCall>()
private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>()
private readonly _pendingCalls = new Map<number, PendingCall>();
private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>();
constructor(
_protocol: P,
url: string,
options: RpcClientOptions = {}
) {
this._url = url
this._options = options
this._codec = options.codec ?? json()
this._timeout = options.timeout ?? 30000
this._shouldReconnect = options.autoReconnect ?? true
this._reconnectInterval = options.reconnectInterval ?? 3000
this._wsFactory = options.webSocketFactory ?? defaultWebSocketFactory
this._url = url;
this._options = options;
this._codec = options.codec ?? json();
this._timeout = options.timeout ?? 30000;
this._shouldReconnect = options.autoReconnect ?? true;
this._reconnectInterval = options.reconnectInterval ?? 3000;
this._wsFactory = options.webSocketFactory ?? defaultWebSocketFactory;
}
/**
@@ -199,7 +199,7 @@ export class RpcClient<P extends ProtocolDef> {
* @en Connection status
*/
get status(): ConnectionStatus {
return this._status
return this._status;
}
/**
@@ -207,7 +207,7 @@ export class RpcClient<P extends ProtocolDef> {
* @en Whether connected
*/
get isConnected(): boolean {
return this._status === 'open'
return this._status === 'open';
}
/**
@@ -217,38 +217,38 @@ export class RpcClient<P extends ProtocolDef> {
connect(): Promise<this> {
return new Promise((resolve, reject) => {
if (this._status === 'open' || this._status === 'connecting') {
resolve(this)
return
resolve(this);
return;
}
this._status = 'connecting'
this._ws = this._wsFactory(this._url)
this._status = 'connecting';
this._ws = this._wsFactory(this._url);
this._ws.onopen = () => {
this._status = 'open'
this._options.onConnect?.()
resolve(this)
}
this._status = 'open';
this._options.onConnect?.();
resolve(this);
};
this._ws.onclose = (e) => {
this._status = 'closed'
this._rejectAllPending()
this._options.onDisconnect?.(e.reason)
this._scheduleReconnect()
}
this._status = 'closed';
this._rejectAllPending();
this._options.onDisconnect?.(e.reason);
this._scheduleReconnect();
};
this._ws.onerror = () => {
const err = new Error('WebSocket error')
this._options.onError?.(err)
const err = new Error('WebSocket error');
this._options.onError?.(err);
if (this._status === 'connecting') {
reject(err)
reject(err);
}
}
};
this._ws.onmessage = (e) => {
this._handleMessage(e.data as string | ArrayBuffer)
}
})
this._handleMessage(e.data as string | ArrayBuffer);
};
});
}
/**
@@ -256,12 +256,12 @@ export class RpcClient<P extends ProtocolDef> {
* @en Disconnect
*/
disconnect(): void {
this._shouldReconnect = false
this._clearReconnectTimer()
this._shouldReconnect = false;
this._clearReconnectTimer();
if (this._ws) {
this._status = 'closing'
this._ws.close()
this._ws = null
this._status = 'closing';
this._ws.close();
this._ws = null;
}
}
@@ -275,25 +275,25 @@ export class RpcClient<P extends ProtocolDef> {
): Promise<ApiOutput<P['api'][K]>> {
return new Promise((resolve, reject) => {
if (this._status !== 'open') {
reject(new RpcError(ErrorCode.CONNECTION_CLOSED, 'Not connected'))
return
reject(new RpcError(ErrorCode.CONNECTION_CLOSED, 'Not connected'));
return;
}
const id = ++this._callIdCounter
const id = ++this._callIdCounter;
const timer = setTimeout(() => {
this._pendingCalls.delete(id)
reject(new RpcError(ErrorCode.TIMEOUT, 'Request timeout'))
}, this._timeout)
this._pendingCalls.delete(id);
reject(new RpcError(ErrorCode.TIMEOUT, 'Request timeout'));
}, this._timeout);
this._pendingCalls.set(id, {
resolve: resolve as (v: unknown) => void,
reject,
timer,
})
timer
});
const packet: Packet = [PacketType.ApiRequest, id, name as string, input]
this._ws!.send(this._codec.encode(packet) as string | ArrayBuffer)
})
const packet: Packet = [PacketType.ApiRequest, id, name as string, input];
this._ws!.send(this._codec.encode(packet) as string | ArrayBuffer);
});
}
/**
@@ -301,9 +301,9 @@ export class RpcClient<P extends ProtocolDef> {
* @en Send message
*/
send<K extends MsgNames<P>>(name: K, data: MsgData<P['msg'][K]>): void {
if (this._status !== 'open') return
const packet: Packet = [PacketType.Message, name as string, data]
this._ws!.send(this._codec.encode(packet) as string | ArrayBuffer)
if (this._status !== 'open') return;
const packet: Packet = [PacketType.Message, name as string, data];
this._ws!.send(this._codec.encode(packet) as string | ArrayBuffer);
}
/**
@@ -314,14 +314,14 @@ export class RpcClient<P extends ProtocolDef> {
name: K,
handler: (data: MsgData<P['msg'][K]>) => void
): this {
const key = name as string
let handlers = this._msgHandlers.get(key)
const key = name as string;
let handlers = this._msgHandlers.get(key);
if (!handlers) {
handlers = new Set()
this._msgHandlers.set(key, handlers)
handlers = new Set();
this._msgHandlers.set(key, handlers);
}
handlers.add(handler as (data: unknown) => void)
return this
handlers.add(handler as (data: unknown) => void);
return this;
}
/**
@@ -332,13 +332,13 @@ export class RpcClient<P extends ProtocolDef> {
name: K,
handler?: (data: MsgData<P['msg'][K]>) => void
): this {
const key = name as string
const key = name as string;
if (handler) {
this._msgHandlers.get(key)?.delete(handler as (data: unknown) => void)
this._msgHandlers.get(key)?.delete(handler as (data: unknown) => void);
} else {
this._msgHandlers.delete(key)
this._msgHandlers.delete(key);
}
return this
return this;
}
/**
@@ -350,10 +350,10 @@ export class RpcClient<P extends ProtocolDef> {
handler: (data: MsgData<P['msg'][K]>) => void
): this {
const wrapper = (data: MsgData<P['msg'][K]>) => {
this.off(name, wrapper)
handler(data)
}
return this.on(name, wrapper)
this.off(name, wrapper);
handler(data);
};
return this.on(name, wrapper);
}
// ========================================================================
@@ -362,52 +362,52 @@ export class RpcClient<P extends ProtocolDef> {
private _handleMessage(raw: string | ArrayBuffer): void {
try {
const data = typeof raw === 'string' ? raw : new Uint8Array(raw)
const packet = this._codec.decode(data)
const type = packet[0]
const data = typeof raw === 'string' ? raw : new Uint8Array(raw);
const packet = this._codec.decode(data);
const type = packet[0];
switch (type) {
case PacketType.ApiResponse:
this._handleApiResponse(packet as [number, number, unknown])
break
this._handleApiResponse(packet as [number, number, unknown]);
break;
case PacketType.ApiError:
this._handleApiError(packet as [number, number, string, string])
break
this._handleApiError(packet as [number, number, string, string]);
break;
case PacketType.Message:
this._handleMsg(packet as [number, string, unknown])
break
this._handleMsg(packet as [number, string, unknown]);
break;
}
} catch (err) {
this._options.onError?.(err as Error)
this._options.onError?.(err as Error);
}
}
private _handleApiResponse([, id, result]: [number, number, unknown]): void {
const pending = this._pendingCalls.get(id)
const pending = this._pendingCalls.get(id);
if (pending) {
clearTimeout(pending.timer)
this._pendingCalls.delete(id)
pending.resolve(result)
clearTimeout(pending.timer);
this._pendingCalls.delete(id);
pending.resolve(result);
}
}
private _handleApiError([, id, code, message]: [number, number, string, string]): void {
const pending = this._pendingCalls.get(id)
const pending = this._pendingCalls.get(id);
if (pending) {
clearTimeout(pending.timer)
this._pendingCalls.delete(id)
pending.reject(new RpcError(code, message))
clearTimeout(pending.timer);
this._pendingCalls.delete(id);
pending.reject(new RpcError(code, message));
}
}
private _handleMsg([, path, data]: [number, string, unknown]): void {
const handlers = this._msgHandlers.get(path)
const handlers = this._msgHandlers.get(path);
if (handlers) {
for (const handler of handlers) {
try {
handler(data)
handler(data);
} catch (err) {
this._options.onError?.(err as Error)
this._options.onError?.(err as Error);
}
}
}
@@ -415,25 +415,25 @@ export class RpcClient<P extends ProtocolDef> {
private _rejectAllPending(): void {
for (const [, pending] of this._pendingCalls) {
clearTimeout(pending.timer)
pending.reject(new RpcError(ErrorCode.CONNECTION_CLOSED, 'Connection closed'))
clearTimeout(pending.timer);
pending.reject(new RpcError(ErrorCode.CONNECTION_CLOSED, 'Connection closed'));
}
this._pendingCalls.clear()
this._pendingCalls.clear();
}
private _scheduleReconnect(): void {
if (this._shouldReconnect && !this._reconnectTimer) {
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null
this.connect().catch(() => {})
}, this._reconnectInterval)
this._reconnectTimer = null;
this.connect().catch(() => {});
}, this._reconnectInterval);
}
}
private _clearReconnectTimer(): void {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer)
this._reconnectTimer = null
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
}
}
@@ -457,5 +457,5 @@ export function connect<P extends ProtocolDef>(
url: string,
options: RpcClientOptions = {}
): Promise<RpcClient<P>> {
return new RpcClient(protocol, url, options).connect()
return new RpcClient(protocol, url, options).connect();
}

View File

@@ -3,7 +3,7 @@
* @en Codec Module
*/
export type { Codec } from './types'
export { json } from './json'
export { msgpack } from './msgpack'
export { textEncode, textDecode } from './polyfill'
export type { Codec } from './types';
export { json } from './json';
export { msgpack } from './msgpack';
export { textEncode, textDecode } from './polyfill';

View File

@@ -3,9 +3,9 @@
* @en JSON Codec
*/
import type { Packet } from '../types'
import type { Codec } from './types'
import { textDecode } from './polyfill'
import type { Packet } from '../types';
import type { Codec } from './types';
import { textDecode } from './polyfill';
/**
* @zh 创建 JSON 编解码器
@@ -17,14 +17,14 @@ import { textDecode } from './polyfill'
export function json(): Codec {
return {
encode(packet: Packet): string {
return JSON.stringify(packet)
return JSON.stringify(packet);
},
decode(data: string | Uint8Array): Packet {
const str = typeof data === 'string'
? data
: textDecode(data)
return JSON.parse(str) as Packet
},
}
: textDecode(data);
return JSON.parse(str) as Packet;
}
};
}

View File

@@ -3,10 +3,10 @@
* @en MessagePack Codec
*/
import { Packr, Unpackr } from 'msgpackr'
import type { Packet } from '../types'
import type { Codec } from './types'
import { textEncode } from './polyfill'
import { Packr, Unpackr } from 'msgpackr';
import type { Packet } from '../types';
import type { Codec } from './types';
import { textEncode } from './polyfill';
/**
* @zh 创建 MessagePack 编解码器
@@ -16,19 +16,19 @@ import { textEncode } from './polyfill'
* @en Suitable for production, smaller size and faster speed
*/
export function msgpack(): Codec {
const encoder = new Packr({ structuredClone: true })
const decoder = new Unpackr({ structuredClone: true })
const encoder = new Packr({ structuredClone: true });
const decoder = new Unpackr({ structuredClone: true });
return {
encode(packet: Packet): Uint8Array {
return encoder.pack(packet)
return encoder.pack(packet);
},
decode(data: string | Uint8Array): Packet {
const buf = typeof data === 'string'
? textEncode(data)
: data
return decoder.unpack(buf) as Packet
},
}
: data;
return decoder.unpack(buf) as Packet;
}
};
}

View File

@@ -12,38 +12,38 @@
*/
function getTextEncoder(): { encode(str: string): Uint8Array } {
if (typeof TextEncoder !== 'undefined') {
return new TextEncoder()
return new TextEncoder();
}
return {
encode(str: string): Uint8Array {
const utf8: number[] = []
const utf8: number[] = [];
for (let i = 0; i < str.length; i++) {
let charCode = str.charCodeAt(i)
let charCode = str.charCodeAt(i);
if (charCode < 0x80) {
utf8.push(charCode)
utf8.push(charCode);
} else if (charCode < 0x800) {
utf8.push(0xc0 | (charCode >> 6), 0x80 | (charCode & 0x3f))
utf8.push(0xc0 | (charCode >> 6), 0x80 | (charCode & 0x3f));
} else if (charCode >= 0xd800 && charCode <= 0xdbff) {
i++
const low = str.charCodeAt(i)
charCode = 0x10000 + ((charCode - 0xd800) << 10) + (low - 0xdc00)
i++;
const low = str.charCodeAt(i);
charCode = 0x10000 + ((charCode - 0xd800) << 10) + (low - 0xdc00);
utf8.push(
0xf0 | (charCode >> 18),
0x80 | ((charCode >> 12) & 0x3f),
0x80 | ((charCode >> 6) & 0x3f),
0x80 | (charCode & 0x3f)
)
);
} else {
utf8.push(
0xe0 | (charCode >> 12),
0x80 | ((charCode >> 6) & 0x3f),
0x80 | (charCode & 0x3f)
)
);
}
}
return new Uint8Array(utf8)
},
}
return new Uint8Array(utf8);
}
};
}
/**
@@ -52,55 +52,55 @@ function getTextEncoder(): { encode(str: string): Uint8Array } {
*/
function getTextDecoder(): { decode(data: Uint8Array): string } {
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder()
return new TextDecoder();
}
return {
decode(data: Uint8Array): string {
let str = ''
let i = 0
let str = '';
let i = 0;
while (i < data.length) {
const byte1 = data[i++]
const byte1 = data[i++];
if (byte1 < 0x80) {
str += String.fromCharCode(byte1)
str += String.fromCharCode(byte1);
} else if ((byte1 & 0xe0) === 0xc0) {
const byte2 = data[i++]
str += String.fromCharCode(((byte1 & 0x1f) << 6) | (byte2 & 0x3f))
const byte2 = data[i++];
str += String.fromCharCode(((byte1 & 0x1f) << 6) | (byte2 & 0x3f));
} else if ((byte1 & 0xf0) === 0xe0) {
const byte2 = data[i++]
const byte3 = data[i++]
const byte2 = data[i++];
const byte3 = data[i++];
str += String.fromCharCode(
((byte1 & 0x0f) << 12) | ((byte2 & 0x3f) << 6) | (byte3 & 0x3f)
)
);
} else if ((byte1 & 0xf8) === 0xf0) {
const byte2 = data[i++]
const byte3 = data[i++]
const byte4 = data[i++]
const byte2 = data[i++];
const byte3 = data[i++];
const byte4 = data[i++];
const codePoint =
((byte1 & 0x07) << 18) |
((byte2 & 0x3f) << 12) |
((byte3 & 0x3f) << 6) |
(byte4 & 0x3f)
const offset = codePoint - 0x10000
(byte4 & 0x3f);
const offset = codePoint - 0x10000;
str += String.fromCharCode(
0xd800 + (offset >> 10),
0xdc00 + (offset & 0x3ff)
)
);
}
}
return str
},
}
return str;
}
};
}
const encoder = getTextEncoder()
const decoder = getTextDecoder()
const encoder = getTextEncoder();
const decoder = getTextDecoder();
/**
* @zh 将字符串编码为 UTF-8 字节数组
* @en Encode string to UTF-8 byte array
*/
export function textEncode(str: string): Uint8Array {
return encoder.encode(str)
return encoder.encode(str);
}
/**
@@ -108,5 +108,5 @@ export function textEncode(str: string): Uint8Array {
* @en Decode UTF-8 byte array to string
*/
export function textDecode(data: Uint8Array): string {
return decoder.decode(data)
return decoder.decode(data);
}

View File

@@ -3,7 +3,7 @@
* @en Codec Type Definitions
*/
import type { Packet } from '../types'
import type { Packet } from '../types';
/**
* @zh 编解码器接口

View File

@@ -3,7 +3,7 @@
* @en Protocol Definition Module
*/
import type { ApiDef, MsgDef, ProtocolDef } from './types'
import type { ApiDef, MsgDef, ProtocolDef } from './types';
/**
* @zh 创建 API 定义
@@ -15,7 +15,7 @@ import type { ApiDef, MsgDef, ProtocolDef } from './types'
* ```
*/
function api<TInput = void, TOutput = void>(): ApiDef<TInput, TOutput> {
return { _type: 'api' } as ApiDef<TInput, TOutput>
return { _type: 'api' } as ApiDef<TInput, TOutput>;
}
/**
@@ -28,7 +28,7 @@ function api<TInput = void, TOutput = void>(): ApiDef<TInput, TOutput> {
* ```
*/
function msg<TData = void>(): MsgDef<TData> {
return { _type: 'msg' } as MsgDef<TData>
return { _type: 'msg' } as MsgDef<TData>;
}
/**
@@ -49,7 +49,7 @@ function msg<TData = void>(): MsgDef<TData> {
* ```
*/
function define<T extends ProtocolDef>(protocol: T): T {
return protocol
return protocol;
}
/**
@@ -59,5 +59,5 @@ function define<T extends ProtocolDef>(protocol: T): T {
export const rpc = {
define,
api,
msg,
} as const
msg
} as const;

View File

@@ -38,9 +38,9 @@
* ```
*/
export { rpc } from './define'
export * from './types'
export { rpc } from './define';
export * from './types';
// Re-export client for browser/bundler compatibility
export { RpcClient, connect } from './client/index'
export type { RpcClientOptions, WebSocketAdapter, WebSocketFactory } from './client/index'
export { RpcClient, connect } from './client/index';
export type { RpcClientOptions, WebSocketAdapter, WebSocketFactory } from './client/index';

View File

@@ -3,37 +3,38 @@
* @en Server Connection Module
*/
import type { Connection, ConnectionStatus } from '../types'
import type { WebSocket } from 'ws';
import type { Connection, ConnectionStatus } from '../types';
/**
* @zh 服务端连接实现
* @en Server connection implementation
*/
export class ServerConnection<TData = unknown> implements Connection<TData> {
readonly id: string
readonly ip: string
data: TData
readonly id: string;
readonly ip: string;
data: TData;
private _status: ConnectionStatus = 'open'
private _socket: any
private _onClose?: () => void
private _status: ConnectionStatus = 'open';
private _socket: WebSocket;
private _onClose?: () => void;
constructor(options: {
id: string
ip: string
socket: any
socket: WebSocket
initialData: TData
onClose?: () => void
}) {
this.id = options.id
this.ip = options.ip
this.data = options.initialData
this._socket = options.socket
this._onClose = options.onClose
this.id = options.id;
this.ip = options.ip;
this.data = options.initialData;
this._socket = options.socket;
this._onClose = options.onClose;
}
get status(): ConnectionStatus {
return this._status
return this._status;
}
/**
@@ -41,8 +42,20 @@ export class ServerConnection<TData = unknown> implements Connection<TData> {
* @en Send raw data
*/
send(data: string | Uint8Array): void {
if (this._status !== 'open') return
this._socket.send(data)
if (this._status !== 'open') return;
this._socket.send(data);
}
/**
* @zh 发送二进制数据(原生 WebSocket 二进制帧)
* @en Send binary data (native WebSocket binary frame)
*
* @zh 直接发送 Uint8Array不经过 JSON 编码,效率更高
* @en Directly sends Uint8Array without JSON encoding, more efficient
*/
sendBinary(data: Uint8Array): void {
if (this._status !== 'open') return;
this._socket.send(data, { binary: true });
}
/**
@@ -50,12 +63,12 @@ export class ServerConnection<TData = unknown> implements Connection<TData> {
* @en Close connection
*/
close(reason?: string): void {
if (this._status !== 'open') return
if (this._status !== 'open') return;
this._status = 'closing'
this._socket.close(1000, reason)
this._status = 'closed'
this._onClose?.()
this._status = 'closing';
this._socket.close(1000, reason);
this._status = 'closed';
this._onClose?.();
}
/**
@@ -63,6 +76,6 @@ export class ServerConnection<TData = unknown> implements Connection<TData> {
* @en Mark connection as closed (internal use)
*/
_markClosed(): void {
this._status = 'closed'
this._status = 'closed';
}
}

View File

@@ -3,8 +3,8 @@
* @en RPC Server Module
*/
import { WebSocketServer, WebSocket } from 'ws'
import type { Server as HttpServer } from 'node:http'
import { WebSocketServer, WebSocket } from 'ws';
import type { Server as HttpServer } from 'node:http';
import type {
ProtocolDef,
ApiNames,
@@ -13,13 +13,13 @@ import type {
ApiOutput,
MsgData,
Packet,
PacketType,
Connection,
} from '../types'
import { RpcError, ErrorCode } from '../types'
import { json } from '../codec/json'
import type { Codec } from '../codec/types'
import { ServerConnection } from './connection'
Connection
} from '../types';
import type { IncomingMessage } from 'node:http';
import { RpcError, ErrorCode } from '../types';
import { json } from '../codec/json';
import type { Codec } from '../codec/types';
import { ServerConnection } from './connection';
// ============ Types ============
@@ -182,8 +182,8 @@ const PT = {
ApiResponse: 1,
ApiError: 2,
Message: 3,
Heartbeat: 9,
} as const
Heartbeat: 9
} as const;
/**
* @zh 创建 RPC 服务器
@@ -206,16 +206,22 @@ export function serve<P extends ProtocolDef, TConnData = unknown>(
_protocol: P,
options: ServeOptions<P, TConnData>
): RpcServer<P, TConnData> {
const codec = options.codec ?? json()
const connections: ServerConnection<TConnData>[] = []
let wss: WebSocketServer | null = null
let connIdCounter = 0
const codec = options.codec ?? json();
const connections: ServerConnection<TConnData>[] = [];
let wss: WebSocketServer | null = null;
let connIdCounter = 0;
const getClientIp = (ws: WebSocket, req: any): string => {
return req?.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
const getClientIp = (_ws: WebSocket, req: IncomingMessage | undefined): string => {
const forwarded = req?.headers?.['x-forwarded-for'];
const forwardedIp = typeof forwarded === 'string'
? forwarded.split(',')[0]?.trim()
: Array.isArray(forwarded)
? forwarded[0]?.split(',')[0]?.trim()
: undefined;
return forwardedIp
|| req?.socket?.remoteAddress
|| 'unknown'
}
|| 'unknown';
};
const handleMessage = async (
conn: ServerConnection<TConnData>,
@@ -224,23 +230,23 @@ export function serve<P extends ProtocolDef, TConnData = unknown>(
try {
const packet = codec.decode(
typeof data === 'string' ? data : new Uint8Array(data)
)
);
const type = packet[0]
const type = packet[0];
if (type === PT.ApiRequest) {
const [, id, path, input] = packet as [number, number, string, unknown]
await handleApiRequest(conn, id, path, input)
const [, id, path, input] = packet as [number, number, string, unknown];
await handleApiRequest(conn, id, path, input);
} else if (type === PT.Message) {
const [, path, msgData] = packet as [number, string, unknown]
await handleMsg(conn, path, msgData)
const [, path, msgData] = packet as [number, string, unknown];
await handleMsg(conn, path, msgData);
} else if (type === PT.Heartbeat) {
conn.send(codec.encode([PT.Heartbeat]))
conn.send(codec.encode([PT.Heartbeat]));
}
} catch (err) {
options.onError?.(err as Error, conn)
options.onError?.(err as Error, conn);
}
}
};
const handleApiRequest = async (
conn: ServerConnection<TConnData>,
@@ -248,44 +254,46 @@ export function serve<P extends ProtocolDef, TConnData = unknown>(
path: string,
input: unknown
): Promise<void> => {
const handler = (options.api as any)[path]
const apiHandlers = options.api as Record<string, ApiHandler<unknown, unknown, TConnData> | undefined>;
const handler = apiHandlers[path];
if (!handler) {
const errPacket: Packet = [PT.ApiError, id, ErrorCode.NOT_FOUND, `API not found: ${path}`]
conn.send(codec.encode(errPacket))
return
const errPacket: Packet = [PT.ApiError, id, ErrorCode.NOT_FOUND, `API not found: ${path}`];
conn.send(codec.encode(errPacket));
return;
}
try {
const result = await handler(input, conn)
const resPacket: Packet = [PT.ApiResponse, id, result]
conn.send(codec.encode(resPacket))
const result = await handler(input, conn);
const resPacket: Packet = [PT.ApiResponse, id, result];
conn.send(codec.encode(resPacket));
} catch (err) {
if (err instanceof RpcError) {
const errPacket: Packet = [PT.ApiError, id, err.code, err.message]
conn.send(codec.encode(errPacket))
const errPacket: Packet = [PT.ApiError, id, err.code, err.message];
conn.send(codec.encode(errPacket));
} else {
const errPacket: Packet = [PT.ApiError, id, ErrorCode.INTERNAL_ERROR, 'Internal server error']
conn.send(codec.encode(errPacket))
options.onError?.(err as Error, conn)
const errPacket: Packet = [PT.ApiError, id, ErrorCode.INTERNAL_ERROR, 'Internal server error'];
conn.send(codec.encode(errPacket));
options.onError?.(err as Error, conn);
}
}
}
};
const handleMsg = async (
conn: ServerConnection<TConnData>,
path: string,
data: unknown
): Promise<void> => {
const handler = options.msg?.[path as MsgNames<P>]
const msgHandlers = options.msg as Record<string, MsgHandler<unknown, TConnData> | undefined> | undefined;
const handler = msgHandlers?.[path];
if (handler) {
await (handler as any)(data, conn)
await handler(data, conn);
}
}
};
const server: RpcServer<P, TConnData> = {
get connections() {
return connections as ReadonlyArray<Connection<TConnData>>
return connections as ReadonlyArray<Connection<TConnData>>;
},
async start() {
@@ -293,18 +301,18 @@ export function serve<P extends ProtocolDef, TConnData = unknown>(
// 根据配置创建 WebSocketServer
if (options.server) {
// 附加到已有的 HTTP 服务器
wss = new WebSocketServer({ server: options.server })
wss = new WebSocketServer({ server: options.server });
} else if (options.port) {
// 独立创建
wss = new WebSocketServer({ port: options.port })
wss = new WebSocketServer({ port: options.port });
} else {
throw new Error('Either port or server must be provided')
throw new Error('Either port or server must be provided');
}
wss.on('connection', async (ws, req) => {
const id = String(++connIdCounter)
const ip = getClientIp(ws, req)
const initialData = options.createConnData?.() ?? ({} as TConnData)
const id = String(++connIdCounter);
const ip = getClientIp(ws, req);
const initialData = options.createConnData?.() ?? ({} as TConnData);
const conn = new ServerConnection<TConnData>({
id,
@@ -312,70 +320,70 @@ export function serve<P extends ProtocolDef, TConnData = unknown>(
socket: ws,
initialData,
onClose: () => {
const idx = connections.indexOf(conn)
if (idx !== -1) connections.splice(idx, 1)
},
})
const idx = connections.indexOf(conn);
if (idx !== -1) connections.splice(idx, 1);
}
});
connections.push(conn)
connections.push(conn);
ws.on('message', (data) => {
handleMessage(conn, data as string | Buffer)
})
handleMessage(conn, data as string | Buffer);
});
ws.on('close', async (code, reason) => {
conn._markClosed()
const idx = connections.indexOf(conn)
if (idx !== -1) connections.splice(idx, 1)
await options.onDisconnect?.(conn, reason?.toString())
})
conn._markClosed();
const idx = connections.indexOf(conn);
if (idx !== -1) connections.splice(idx, 1);
await options.onDisconnect?.(conn, reason?.toString());
});
ws.on('error', (err) => {
options.onError?.(err, conn)
})
options.onError?.(err, conn);
});
await options.onConnect?.(conn)
})
await options.onConnect?.(conn);
});
// 如果使用已有的 HTTP 服务器WebSocketServer 不会触发 listening 事件
if (options.server) {
options.onStart?.(0) // 端口由 HTTP 服务器管理
resolve()
options.onStart?.(0); // 端口由 HTTP 服务器管理
resolve();
} else {
wss.on('listening', () => {
options.onStart?.(options.port!)
resolve()
})
options.onStart?.(options.port!);
resolve();
});
}
})
});
},
async stop() {
return new Promise((resolve, reject) => {
if (!wss) {
resolve()
return
resolve();
return;
}
for (const conn of connections) {
conn.close('Server shutting down')
conn.close('Server shutting down');
}
wss.close((err) => {
if (err) reject(err)
else resolve()
})
})
if (err) reject(err);
else resolve();
});
});
},
send(conn, name, data) {
const packet: Packet = [PT.Message, name as string, data]
;(conn as ServerConnection<TConnData>).send(codec.encode(packet))
;(conn as ServerConnection<TConnData>).send(codec.encode(packet));
},
broadcast(name, data, opts) {
const packet: Packet = [PT.Message, name as string, data]
const encoded = codec.encode(packet)
const packet: Packet = [PT.Message, name as string, data];
const encoded = codec.encode(packet);
const excludeSet = new Set(
Array.isArray(opts?.exclude)
@@ -383,15 +391,15 @@ export function serve<P extends ProtocolDef, TConnData = unknown>(
: opts?.exclude
? [opts.exclude]
: []
)
);
for (const conn of connections) {
if (!excludeSet.has(conn)) {
conn.send(encoded)
conn.send(encoded);
}
}
},
}
}
};
return server
return server;
}

View File

@@ -29,8 +29,8 @@ export interface MsgDef<TData = unknown> {
* @en Protocol definition
*/
export interface ProtocolDef {
readonly api: Record<string, ApiDef<any, any>>
readonly msg: Record<string, MsgDef<any>>
readonly api: Record<string, ApiDef<unknown, unknown>>
readonly msg: Record<string, MsgDef<unknown>>
}
// ============ Type Inference ============
@@ -39,13 +39,13 @@ export interface ProtocolDef {
* @zh 提取 API 输入类型
* @en Extract API input type
*/
export type ApiInput<T> = T extends ApiDef<infer I, any> ? I : never
export type ApiInput<T> = T extends ApiDef<infer I, unknown> ? I : never
/**
* @zh 提取 API 输出类型
* @en Extract API output type
*/
export type ApiOutput<T> = T extends ApiDef<any, infer O> ? O : never
export type ApiOutput<T> = T extends ApiDef<unknown, infer O> ? O : never
/**
* @zh 提取消息数据类型
@@ -120,8 +120,9 @@ export const PacketType = {
ApiResponse: 1,
ApiError: 2,
Message: 3,
Heartbeat: 9,
} as const
Binary: 4,
Heartbeat: 9
} as const;
export type PacketType = typeof PacketType[keyof typeof PacketType]
@@ -173,6 +174,19 @@ export type MessagePacket = [
*/
export type HeartbeatPacket = [type: typeof PacketType.Heartbeat]
/**
* @zh 二进制数据包
* @en Binary data packet
*
* @zh 用于传输原始二进制数据,如 ECS 状态同步
* @en Used for raw binary data transmission, such as ECS state sync
*/
export type BinaryPacket = [
type: typeof PacketType.Binary,
channel: number,
data: Uint8Array
]
/**
* @zh 所有数据包类型
* @en All packet types
@@ -182,6 +196,7 @@ export type Packet =
| ApiResponsePacket
| ApiErrorPacket
| MessagePacket
| BinaryPacket
| HeartbeatPacket
// ============ Error Types ============
@@ -196,8 +211,8 @@ export class RpcError extends Error {
message: string,
public readonly details?: unknown
) {
super(message)
this.name = 'RpcError'
super(message);
this.name = 'RpcError';
}
}
@@ -211,7 +226,7 @@ export const ErrorCode = {
UNAUTHORIZED: 'UNAUTHORIZED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
TIMEOUT: 'TIMEOUT',
CONNECTION_CLOSED: 'CONNECTION_CLOSED',
} as const
CONNECTION_CLOSED: 'CONNECTION_CLOSED'
} as const;
export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode]

View File

@@ -1,5 +1,62 @@
# @esengine/server
## 4.5.0
### Minor Changes
- [#421](https://github.com/esengine/esengine/pull/421) [`f333b81`](https://github.com/esengine/esengine/commit/f333b81298a386a812b2428d3dcdce03d257fef8) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加分布式房间支持 | Add distributed room support
**@esengine/server** - 新增分布式房间管理功能 | Added distributed room management features
- 新增 `DistributedRoomManager` 支持多服务器房间管理 | Added `DistributedRoomManager` for multi-server room management
- 新增 `MemoryAdapter` 用于测试和单机模式 | Added `MemoryAdapter` for testing and standalone mode
- 新增 `RedisAdapter` 用于生产环境多服务器部署 | Added `RedisAdapter` for production multi-server deployments
- 新增 `LoadBalancedRouter` 支持 5 种负载均衡策略 | Added `LoadBalancedRouter` with 5 load balancing strategies
- round-robin: 轮询 | Round robin
- least-rooms: 最少房间数 | Fewest rooms
- least-players: 最少玩家数 | Fewest players
- random: 随机选择 | Random selection
- weighted: 权重(基于容量使用率)| Weighted by capacity usage
- `createServer` 新增 `distributed` 配置选项 | Added `distributed` config option to `createServer`
- 新增 `$redirect` 消息用于跨服务器玩家重定向 | Added `$redirect` message for cross-server player redirection
- 新增故障转移机制,服务器离线时自动恢复房间 | Added failover mechanism for automatic room recovery on server offline
- 新增 `room:migrated``server:draining` 事件类型 | Added `room:migrated` and `server:draining` event types
## 4.4.0
### Minor Changes
- [#419](https://github.com/esengine/esengine/pull/419) [`3b6fc82`](https://github.com/esengine/esengine/commit/3b6fc8266fa8e4d43058a44b48bf9169f78de068) Thanks [@esengine](https://github.com/esengine)! - feat(server): HTTP 路由增强 | HTTP router enhancement
**新功能 | New Features**
- 路由参数支持:`/users/:id``req.params.id` | Route parameters: `/users/:id``req.params.id`
- 中间件支持:全局和路由级中间件 | Middleware support: global and route-level
- 请求超时控制:全局和路由级超时 | Request timeout: global and route-level
**内置中间件 | Built-in Middleware**
- `requestLogger()` - 请求日志 | Request logging
- `bodyLimit()` - 请求体大小限制 | Body size limit
- `responseTime()` - 响应时间头 | Response time header
- `requestId()` - 请求 ID | Request ID
- `securityHeaders()` - 安全头 | Security headers
## 4.3.0
### Minor Changes
- [#417](https://github.com/esengine/esengine/pull/417) [`b80e967`](https://github.com/esengine/esengine/commit/b80e96782991b0f5dea65949e5c55325d2775132) Thanks [@esengine](https://github.com/esengine)! - feat(server): HTTP 路由增强 | HTTP router enhancement
**新功能 | New Features**
- 路由参数支持:`/users/:id``req.params.id` | Route parameters: `/users/:id``req.params.id`
- 中间件支持:全局和路由级中间件 | Middleware support: global and route-level
- 请求超时控制:全局和路由级超时 | Request timeout: global and route-level
**内置中间件 | Built-in Middleware**
- `requestLogger()` - 请求日志 | Request logging
- `bodyLimit()` - 请求体大小限制 | Body size limit
- `responseTime()` - 响应时间头 | Response time header
- `requestId()` - 请求 ID | Request ID
- `securityHeaders()` - 安全头 | Security headers
## 4.2.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/server",
"version": "4.2.0",
"version": "4.5.0",
"description": "Game server framework for ESEngine with file-based routing",
"type": "module",
"main": "./dist/index.js",
@@ -46,23 +46,19 @@
"test:watch": "vitest"
},
"dependencies": {
"@esengine/rpc": "workspace:*"
"@esengine/rpc": "workspace:*",
"@esengine/ecs-framework": "workspace:*"
},
"peerDependencies": {
"ws": ">=8.0.0",
"jsonwebtoken": ">=9.0.0",
"@esengine/ecs-framework": ">=2.7.1"
"jsonwebtoken": ">=9.0.0"
},
"peerDependenciesMeta": {
"jsonwebtoken": {
"optional": true
},
"@esengine/ecs-framework": {
"optional": true
}
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^20.0.0",
"@types/ws": "^8.5.13",

View File

@@ -168,9 +168,9 @@ describe('MockAuthProvider', () => {
it('should get all users', () => {
const users = provider.getUsers();
expect(users).toHaveLength(3);
expect(users.map(u => u.id)).toContain('1');
expect(users.map(u => u.id)).toContain('2');
expect(users.map(u => u.id)).toContain('3');
expect(users.map((u) => u.id)).toContain('1');
expect(users.map((u) => u.id)).toContain('2');
expect(users.map((u) => u.id)).toContain('3');
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { JwtAuthProvider, createJwtAuthProvider } from '../providers/JwtAuthProvider';
import { SessionAuthProvider, createSessionAuthProvider, type ISessionStorage } from '../providers/SessionAuthProvider';
@@ -125,7 +125,7 @@ describe('JwtAuthProvider', () => {
const token = provider.sign({ sub: '123', name: 'Alice' });
// Wait a bit so iat changes
await new Promise(resolve => setTimeout(resolve, 1100));
await new Promise((resolve) => setTimeout(resolve, 1100));
const result = await provider.refresh(token);
@@ -239,7 +239,7 @@ describe('SessionAuthProvider', () => {
it('should validate user on verify', async () => {
const validatingProvider = createSessionAuthProvider({
storage,
validateUser: (user) => user.id !== 'banned'
validateUser: (user: { id: string; name?: string }) => user.id !== 'banned'
});
const sessionId = await validatingProvider.createSession({ id: 'banned', name: 'Bad User' });
@@ -252,7 +252,7 @@ describe('SessionAuthProvider', () => {
it('should pass validation for valid user', async () => {
const validatingProvider = createSessionAuthProvider({
storage,
validateUser: (user) => user.id !== 'banned'
validateUser: (user: { id: string; name?: string }) => user.id !== 'banned'
});
const sessionId = await validatingProvider.createSession({ id: '123', name: 'Good User' });
@@ -269,7 +269,7 @@ describe('SessionAuthProvider', () => {
const session1 = await provider.getSession(sessionId);
const lastActive1 = session1?.lastActiveAt;
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
const result = await provider.refresh(sessionId);
expect(result.success).toBe(true);

View File

@@ -138,7 +138,7 @@ export class AuthContext<TUser = unknown> implements IAuthContext<TUser> {
* @en Check if has any of specified roles
*/
hasAnyRole(roles: string[]): boolean {
return roles.some(role => this._roles.includes(role));
return roles.some((role) => this._roles.includes(role));
}
/**
@@ -146,7 +146,7 @@ export class AuthContext<TUser = unknown> implements IAuthContext<TUser> {
* @en Check if has all specified roles
*/
hasAllRoles(roles: string[]): boolean {
return roles.every(role => this._roles.includes(role));
return roles.every((role) => this._roles.includes(role));
}
/**

View File

@@ -4,6 +4,7 @@
*/
import type { ServerConnection, GameServer } from '../../types/index.js';
import { createLogger } from '../../logger.js';
import type {
IAuthProvider,
AuthResult,
@@ -14,6 +15,8 @@ import type {
} from '../types.js';
import { AuthContext } from '../context.js';
const logger = createLogger('Auth');
/**
* @zh 认证数据键
* @en Auth data key
@@ -155,7 +158,7 @@ export function withAuth<TUser = unknown>(
}
}
} catch (error) {
console.error('[Auth] Error during auto-authentication:', error);
logger.error('Error during auto-authentication:', error);
}
}

View File

@@ -5,9 +5,12 @@
import type { Room, Player } from '../../room/index.js';
import type { IAuthContext, AuthRoomConfig } from '../types.js';
import { createLogger } from '../../logger.js';
import { getAuthContext } from './withAuth.js';
import { createGuestContext } from '../context.js';
const logger = createLogger('AuthRoom');
/**
* @zh 带认证的玩家
* @en Player with authentication
@@ -181,7 +184,7 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
: createGuestContext<TUser>();
if (requireAuth && !authContext.isAuthenticated) {
console.warn(`[AuthRoom] Rejected unauthenticated player: ${player.id}`);
logger.warn(`Rejected unauthenticated player: ${player.id}`);
this.kick(player as any, 'Authentication required');
return;
}
@@ -192,7 +195,7 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
: authContext.hasAllRoles(allowedRoles);
if (!hasRole) {
console.warn(`[AuthRoom] Rejected player ${player.id}: insufficient roles`);
logger.warn(`Rejected player ${player.id}: insufficient roles`);
this.kick(player as any, 'Insufficient permissions');
return;
}
@@ -204,12 +207,12 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
try {
const allowed = await this.onAuth(authPlayer);
if (!allowed) {
console.warn(`[AuthRoom] Rejected player ${player.id}: onAuth returned false`);
logger.warn(`Rejected player ${player.id}: onAuth returned false`);
this.kick(player as any, 'Authentication rejected');
return;
}
} catch (error) {
console.error(`[AuthRoom] Error in onAuth for player ${player.id}:`, error);
logger.error(`Error in onAuth for player ${player.id}:`, error);
this.kick(player as any, 'Authentication error');
return;
}
@@ -242,7 +245,7 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
* @en Get players by role
*/
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
return this.getAuthPlayers().filter(p => p.auth?.hasRole(role));
return this.getAuthPlayers().filter((p) => p.auth?.hasRole(role));
}
/**
@@ -250,7 +253,7 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
* @en Get player by user ID
*/
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
return this.getAuthPlayers().find(p => p.auth?.userId === userId);
return this.getAuthPlayers().find((p) => p.auth?.userId === userId);
}
}
@@ -281,7 +284,7 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
* ```
*/
export abstract class AuthRoomBase<TUser = unknown, TState = any, TPlayerData = Record<string, unknown>>
implements IAuthRoom<TUser> {
implements IAuthRoom<TUser> {
/**
* @zh 认证配置(子类可覆盖)

View File

@@ -77,7 +77,7 @@ export interface MockAuthConfig {
* ```
*/
export class MockAuthProvider<TUser extends MockUser = MockUser>
implements IAuthProvider<TUser, string> {
implements IAuthProvider<TUser, string> {
readonly name = 'mock';
@@ -102,7 +102,7 @@ export class MockAuthProvider<TUser extends MockUser = MockUser>
*/
private async _delay(): Promise<void> {
if (this._config.delay && this._config.delay > 0) {
await new Promise(resolve => setTimeout(resolve, this._config.delay));
await new Promise((resolve) => setTimeout(resolve, this._config.delay));
}
}

View File

@@ -3,10 +3,11 @@
* @en Game server core
*/
import * as path from 'node:path'
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
import { serve, type RpcServer } from '@esengine/rpc/server'
import { rpc } from '@esengine/rpc'
import * as path from 'node:path';
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
import { serve, type RpcServer } from '@esengine/rpc/server';
import { rpc } from '@esengine/rpc';
import { createLogger } from '../logger.js';
import type {
ServerConfig,
ServerConnection,
@@ -15,25 +16,28 @@ import type {
MsgContext,
LoadedApiHandler,
LoadedMsgHandler,
LoadedHttpHandler,
} from '../types/index.js'
import type { HttpRoutes, HttpHandler } from '../http/types.js'
import { loadApiHandlers, loadMsgHandlers, loadHttpHandlers } from '../router/loader.js'
import { RoomManager, type RoomClass, type Room } from '../room/index.js'
import { createHttpRouter } from '../http/router.js'
LoadedHttpHandler
} from '../types/index.js';
import type { HttpRoutes, HttpHandler } from '../http/types.js';
import type { Validator } from '../schema/index.js';
import { loadApiHandlers, loadMsgHandlers, loadHttpHandlers } from '../router/loader.js';
import { RoomManager, type RoomClass, type Room } from '../room/index.js';
import { createHttpRouter } from '../http/router.js';
import { DistributedRoomManager } from '../distributed/DistributedRoomManager.js';
import { MemoryAdapter } from '../distributed/adapters/MemoryAdapter.js';
/**
* @zh 默认配置
* @en Default configuration
*/
const DEFAULT_CONFIG: Required<Omit<ServerConfig, 'onStart' | 'onConnect' | 'onDisconnect' | 'http' | 'cors' | 'httpDir' | 'httpPrefix'>> & { httpDir: string; httpPrefix: string } = {
const DEFAULT_CONFIG: Required<Omit<ServerConfig, 'onStart' | 'onConnect' | 'onDisconnect' | 'http' | 'cors' | 'httpDir' | 'httpPrefix' | 'distributed'>> & { httpDir: string; httpPrefix: string } = {
port: 3000,
apiDir: 'src/api',
msgDir: 'src/msg',
httpDir: 'src/http',
httpPrefix: '/api',
tickRate: 20,
}
tickRate: 20
};
/**
* @zh 创建游戏服务器
@@ -55,40 +59,41 @@ const DEFAULT_CONFIG: Required<Omit<ServerConfig, 'onStart' | 'onConnect' | 'onD
* ```
*/
export async function createServer(config: ServerConfig = {}): Promise<GameServer> {
const opts = { ...DEFAULT_CONFIG, ...config }
const cwd = process.cwd()
const opts = { ...DEFAULT_CONFIG, ...config };
const cwd = process.cwd();
const logger = createLogger('Server');
// 加载文件路由处理器
const apiHandlers = await loadApiHandlers(path.resolve(cwd, opts.apiDir))
const msgHandlers = await loadMsgHandlers(path.resolve(cwd, opts.msgDir))
const apiHandlers = await loadApiHandlers(path.resolve(cwd, opts.apiDir));
const msgHandlers = await loadMsgHandlers(path.resolve(cwd, opts.msgDir));
// 加载 HTTP 文件路由
const httpDir = config.httpDir ?? opts.httpDir
const httpPrefix = config.httpPrefix ?? opts.httpPrefix
const httpHandlers = await loadHttpHandlers(path.resolve(cwd, httpDir), httpPrefix)
const httpDir = config.httpDir ?? opts.httpDir;
const httpPrefix = config.httpPrefix ?? opts.httpPrefix;
const httpHandlers = await loadHttpHandlers(path.resolve(cwd, httpDir), httpPrefix);
if (apiHandlers.length > 0) {
console.log(`[Server] Loaded ${apiHandlers.length} API handlers`)
logger.info(`Loaded ${apiHandlers.length} API handlers`);
}
if (msgHandlers.length > 0) {
console.log(`[Server] Loaded ${msgHandlers.length} message handlers`)
logger.info(`Loaded ${msgHandlers.length} message handlers`);
}
if (httpHandlers.length > 0) {
console.log(`[Server] Loaded ${httpHandlers.length} HTTP handlers`)
logger.info(`Loaded ${httpHandlers.length} HTTP handlers`);
}
// 合并 HTTP 路由(文件路由 + 内联路由)
const mergedHttpRoutes: HttpRoutes = {}
const mergedHttpRoutes: HttpRoutes = {};
// 先添加文件路由
for (const handler of httpHandlers) {
const existingRoute = mergedHttpRoutes[handler.route]
const existingRoute = mergedHttpRoutes[handler.route];
if (existingRoute && typeof existingRoute !== 'function') {
(existingRoute as Record<string, HttpHandler>)[handler.method] = handler.definition.handler
(existingRoute as Record<string, HttpHandler>)[handler.method] = handler.definition.handler;
} else {
mergedHttpRoutes[handler.route] = {
[handler.method]: handler.definition.handler,
}
[handler.method]: handler.definition.handler
};
}
}
@@ -96,64 +101,105 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
if (config.http) {
for (const [route, handlerOrMethods] of Object.entries(config.http)) {
if (typeof handlerOrMethods === 'function') {
mergedHttpRoutes[route] = handlerOrMethods
mergedHttpRoutes[route] = handlerOrMethods;
} else {
const existing = mergedHttpRoutes[route]
const existing = mergedHttpRoutes[route];
if (existing && typeof existing !== 'function') {
Object.assign(existing, handlerOrMethods)
Object.assign(existing, handlerOrMethods);
} else {
mergedHttpRoutes[route] = handlerOrMethods
mergedHttpRoutes[route] = handlerOrMethods;
}
}
}
}
const hasHttpRoutes = Object.keys(mergedHttpRoutes).length > 0
const hasHttpRoutes = Object.keys(mergedHttpRoutes).length > 0;
// 分布式模式配置
const distributedConfig = config.distributed;
const isDistributed = distributedConfig?.enabled ?? false;
// 动态构建协议
const apiDefs: Record<string, ReturnType<typeof rpc.api>> = {
// 内置 API
JoinRoom: rpc.api(),
LeaveRoom: rpc.api(),
}
LeaveRoom: rpc.api()
};
const msgDefs: Record<string, ReturnType<typeof rpc.msg>> = {
// 内置消息(房间消息透传)
RoomMessage: rpc.msg(),
}
// 分布式重定向消息
$redirect: rpc.msg()
};
for (const handler of apiHandlers) {
apiDefs[handler.name] = rpc.api()
apiDefs[handler.name] = rpc.api();
}
for (const handler of msgHandlers) {
msgDefs[handler.name] = rpc.msg()
msgDefs[handler.name] = rpc.msg();
}
const protocol = rpc.define({
api: apiDefs,
msg: msgDefs,
})
msg: msgDefs
});
// 服务器状态
let currentTick = 0
let tickInterval: ReturnType<typeof setInterval> | null = null
let rpcServer: RpcServer<typeof protocol, Record<string, unknown>> | null = null
let httpServer: HttpServer | null = null
let currentTick = 0;
let tickInterval: ReturnType<typeof setInterval> | null = null;
let rpcServer: RpcServer<typeof protocol, Record<string, unknown>> | null = null;
let httpServer: HttpServer | null = null;
// 发送函数(延迟绑定,因为 rpcServer 在 start() 后才创建)
const sendFn = (conn: any, type: string, data: unknown) => {
rpcServer?.send(conn, 'RoomMessage' as any, { type, data } as any);
};
// 二进制发送函数(使用原生 WebSocket 二进制帧,效率更高)
const sendBinaryFn = (conn: any, data: Uint8Array) => {
if (conn && typeof conn.sendBinary === 'function') {
conn.sendBinary(data);
}
};
// 房间管理器(立即初始化,以便 define() 可在 start() 前调用)
const roomManager = new RoomManager((conn, type, data) => {
rpcServer?.send(conn, 'RoomMessage' as any, { type, data } as any)
})
let roomManager: RoomManager | DistributedRoomManager;
let distributedManager: DistributedRoomManager | null = null;
if (isDistributed && distributedConfig) {
// 分布式模式
const adapter = distributedConfig.adapter ?? new MemoryAdapter();
distributedManager = new DistributedRoomManager(
adapter,
{
serverId: distributedConfig.serverId,
serverAddress: distributedConfig.serverAddress,
serverPort: distributedConfig.serverPort ?? opts.port,
heartbeatInterval: distributedConfig.heartbeatInterval,
snapshotInterval: distributedConfig.snapshotInterval,
enableFailover: distributedConfig.enableFailover,
capacity: distributedConfig.capacity
},
sendFn,
sendBinaryFn
);
roomManager = distributedManager;
logger.info(`Distributed mode enabled (serverId: ${distributedConfig.serverId})`);
} else {
// 单机模式
roomManager = new RoomManager(sendFn, sendBinaryFn);
}
// 构建 API 处理器映射
const apiMap: Record<string, LoadedApiHandler> = {}
const apiMap: Record<string, LoadedApiHandler> = {};
for (const handler of apiHandlers) {
apiMap[handler.name] = handler
apiMap[handler.name] = handler;
}
// 构建消息处理器映射
const msgMap: Record<string, LoadedMsgHandler> = {}
const msgMap: Record<string, LoadedMsgHandler> = {};
for (const handler of msgHandlers) {
msgMap[handler.name] = handler
msgMap[handler.name] = handler;
}
// 游戏服务器实例
@@ -161,15 +207,15 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
rooms: RoomManager
} = {
get connections() {
return (rpcServer?.connections ?? []) as ReadonlyArray<ServerConnection>
return (rpcServer?.connections ?? []) as ReadonlyArray<ServerConnection>;
},
get tick() {
return currentTick
return currentTick;
},
get rooms() {
return roomManager
return roomManager;
},
/**
@@ -177,12 +223,12 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
* @en Define room type
*/
define(name: string, roomClass: new () => unknown): void {
roomManager.define(name, roomClass as RoomClass)
roomManager.define(name, roomClass as RoomClass);
},
async start() {
// 构建 API handlers
const apiHandlersObj: Record<string, (input: unknown, conn: any) => Promise<unknown>> = {}
const apiHandlersObj: Record<string, (input: unknown, conn: any) => Promise<unknown>> = {};
// 内置 JoinRoom API
apiHandlersObj['JoinRoom'] = async (input: any, conn) => {
@@ -190,163 +236,227 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
roomType?: string
roomId?: string
options?: Record<string, unknown>
}
};
if (roomId) {
const result = await roomManager.joinById(roomId, conn.id, conn)
const result = await roomManager.joinById(roomId, conn.id, conn);
if (!result) {
throw new Error('Failed to join room')
throw new Error('Failed to join room');
}
return { roomId: result.room.id, playerId: result.player.id }
return { roomId: result.room.id, playerId: result.player.id };
}
if (roomType) {
const result = await roomManager.joinOrCreate(roomType, conn.id, conn, options)
if (!result) {
throw new Error('Failed to join or create room')
// 分布式模式:使用 joinOrCreateDistributed
if (distributedManager) {
const result = await distributedManager.joinOrCreateDistributed(
roomType,
conn.id,
conn,
options
);
if (!result) {
throw new Error('Failed to join or create room');
}
if ('redirect' in result) {
// 发送重定向消息给客户端
rpcServer?.send(conn, '$redirect' as any, {
address: result.redirect,
roomType
} as any);
return { redirect: result.redirect };
}
return { roomId: result.room.id, playerId: result.player.id };
}
return { roomId: result.room.id, playerId: result.player.id }
// 单机模式
const result = await roomManager.joinOrCreate(roomType, conn.id, conn, options);
if (!result) {
throw new Error('Failed to join or create room');
}
return { roomId: result.room.id, playerId: result.player.id };
}
throw new Error('roomType or roomId required')
}
throw new Error('roomType or roomId required');
};
// 内置 LeaveRoom API
apiHandlersObj['LeaveRoom'] = async (_input, conn) => {
await roomManager.leave(conn.id)
return { success: true }
}
await roomManager.leave(conn.id);
return { success: true };
};
// 文件路由 API
for (const [name, handler] of Object.entries(apiMap)) {
apiHandlersObj[name] = async (input, conn) => {
const ctx: ApiContext = {
conn: conn as ServerConnection,
server: gameServer,
server: gameServer
};
const definition = handler.definition as { schema?: Validator<unknown> };
if (definition.schema) {
const result = definition.schema.validate(input);
if (!result.success) {
const pathStr = result.error.path.length > 0
? ` at "${result.error.path.join('.')}"`
: '';
throw new Error(`Validation failed${pathStr}: ${result.error.message}`);
}
return handler.definition.handler(result.data, ctx);
}
return handler.definition.handler(input, ctx)
}
return handler.definition.handler(input, ctx);
};
}
// 构建消息 handlers
const msgHandlersObj: Record<string, (data: unknown, conn: any) => void | Promise<void>> = {}
const msgHandlersObj: Record<string, (data: unknown, conn: any) => void | Promise<void>> = {};
// 内置 RoomMessage 处理
msgHandlersObj['RoomMessage'] = async (data: any, conn) => {
const { type, data: payload } = data as { type: string; data: unknown }
roomManager.handleMessage(conn.id, type, payload)
}
const { type, data: payload } = data as { type: string; data: unknown };
roomManager.handleMessage(conn.id, type, payload);
};
// 文件路由消息
for (const [name, handler] of Object.entries(msgMap)) {
msgHandlersObj[name] = async (data, conn) => {
const ctx: MsgContext = {
conn: conn as ServerConnection,
server: gameServer,
server: gameServer
};
const definition = handler.definition as { schema?: Validator<unknown> };
if (definition.schema) {
const result = definition.schema.validate(data);
if (!result.success) {
const pathStr = result.error.path.length > 0
? ` at "${result.error.path.join('.')}"`
: '';
logger.warn(`Message validation failed for ${name}${pathStr}: ${result.error.message}`);
return;
}
await handler.definition.handler(result.data, ctx);
return;
}
await handler.definition.handler(data, ctx)
}
await handler.definition.handler(data, ctx);
};
}
// 如果有 HTTP 路由,创建 HTTP 服务器
if (hasHttpRoutes) {
const httpRouter = createHttpRouter(mergedHttpRoutes, config.cors ?? true)
const httpRouter = createHttpRouter(mergedHttpRoutes, {
cors: config.cors ?? true
});
httpServer = createHttpServer(async (req, res) => {
// 先尝试 HTTP 路由
const handled = await httpRouter(req, res)
const handled = await httpRouter(req, res);
if (!handled) {
// 未匹配的请求返回 404
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'Not Found' }))
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Not Found' }));
}
})
});
// 使用 HTTP 服务器创建 RPC
rpcServer = serve(protocol, {
server: httpServer,
createConnData: () => ({}),
onStart: () => {
console.log(`[Server] Started on http://localhost:${opts.port}`)
opts.onStart?.(opts.port)
logger.info(`Started on http://localhost:${opts.port}`);
opts.onStart?.(opts.port);
},
onConnect: async (conn) => {
await config.onConnect?.(conn as ServerConnection)
await config.onConnect?.(conn as ServerConnection);
},
onDisconnect: async (conn) => {
await roomManager?.leave(conn.id, 'disconnected')
await config.onDisconnect?.(conn as ServerConnection)
await roomManager?.leave(conn.id, 'disconnected');
await config.onDisconnect?.(conn as ServerConnection);
},
api: apiHandlersObj as any,
msg: msgHandlersObj as any,
})
msg: msgHandlersObj as any
});
await rpcServer.start()
await rpcServer.start();
// 启动 HTTP 服务器
await new Promise<void>((resolve) => {
httpServer!.listen(opts.port, () => resolve())
})
httpServer!.listen(opts.port, () => resolve());
});
} else {
// 仅 WebSocket 模式
rpcServer = serve(protocol, {
port: opts.port,
createConnData: () => ({}),
onStart: (p) => {
console.log(`[Server] Started on ws://localhost:${p}`)
opts.onStart?.(p)
logger.info(`Started on ws://localhost:${p}`);
opts.onStart?.(p);
},
onConnect: async (conn) => {
await config.onConnect?.(conn as ServerConnection)
await config.onConnect?.(conn as ServerConnection);
},
onDisconnect: async (conn) => {
await roomManager?.leave(conn.id, 'disconnected')
await config.onDisconnect?.(conn as ServerConnection)
await roomManager?.leave(conn.id, 'disconnected');
await config.onDisconnect?.(conn as ServerConnection);
},
api: apiHandlersObj as any,
msg: msgHandlersObj as any,
})
msg: msgHandlersObj as any
});
await rpcServer.start()
await rpcServer.start();
}
// 启动分布式管理器
if (distributedManager) {
await distributedManager.start();
}
// 启动 tick 循环
if (opts.tickRate > 0) {
tickInterval = setInterval(() => {
currentTick++
}, 1000 / opts.tickRate)
currentTick++;
}, 1000 / opts.tickRate);
}
},
async stop() {
if (tickInterval) {
clearInterval(tickInterval)
tickInterval = null
clearInterval(tickInterval);
tickInterval = null;
}
// 停止分布式管理器(优雅关闭)
if (distributedManager) {
await distributedManager.stop(true);
}
if (rpcServer) {
await rpcServer.stop()
rpcServer = null
await rpcServer.stop();
rpcServer = null;
}
if (httpServer) {
await new Promise<void>((resolve, reject) => {
httpServer!.close((err) => {
if (err) reject(err)
else resolve()
})
})
httpServer = null
if (err) reject(err);
else resolve();
});
});
httpServer = null;
}
},
broadcast(name, data) {
rpcServer?.broadcast(name as any, data as any)
rpcServer?.broadcast(name as any, data as any);
},
send(conn, name, data) {
rpcServer?.send(conn as any, name as any, data as any)
},
}
rpcServer?.send(conn as any, name as any, data as any);
}
};
return gameServer as GameServer
return gameServer as GameServer;
}

View File

@@ -0,0 +1,707 @@
/**
* @zh 分布式房间管理器
* @en Distributed room manager
*
* @zh 继承 RoomManager添加分布式功能支持。包括跨服务器房间注册、
* 玩家路由、状态同步和故障转移。
* @en Extends RoomManager with distributed features. Includes cross-server room
* registration, player routing, state synchronization, and failover.
*/
import { RoomManager } from '../room/RoomManager.js';
import { Room, type RoomOptions } from '../room/Room.js';
import type { Player } from '../room/Player.js';
import type { IDistributedAdapter } from './adapters/IDistributedAdapter.js';
import type {
DistributedRoomManagerConfig,
RoomRegistration,
RoutingResult,
RoutingRequest,
ServerRegistration,
DistributedEvent,
Unsubscribe
} from './types.js';
import { createLogger } from '../logger.js';
const logger = createLogger('DistributedRoom');
/**
* @zh 分布式房间管理器配置(内部使用)
* @en Distributed room manager configuration (internal use)
*/
interface InternalConfig extends Required<Omit<DistributedRoomManagerConfig, 'metadata'>> {
metadata: Record<string, unknown>;
}
/**
* @zh 分布式房间管理器
* @en Distributed room manager
*
* @zh 扩展基础 RoomManager添加以下功能
* - 服务器注册和心跳
* - 跨服务器房间注册
* - 玩家路由和重定向
* - 状态快照和恢复
* - 分布式锁防止竞态
* @en Extends base RoomManager with:
* - Server registration and heartbeat
* - Cross-server room registration
* - Player routing and redirection
* - State snapshots and recovery
* - Distributed locks to prevent race conditions
*/
export class DistributedRoomManager extends RoomManager {
private readonly _adapter: IDistributedAdapter;
private readonly _config: InternalConfig;
private readonly _serverId: string;
private _heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private _snapshotTimer: ReturnType<typeof setInterval> | null = null;
private _subscriptions: Unsubscribe[] = [];
private _isShuttingDown = false;
/**
* @zh 创建分布式房间管理器
* @en Create distributed room manager
*
* @param adapter - 分布式适配器 | Distributed adapter
* @param config - 配置 | Configuration
* @param sendFn - 消息发送函数 | Message send function
* @param sendBinaryFn - 二进制发送函数 | Binary send function
*/
constructor(
adapter: IDistributedAdapter,
config: DistributedRoomManagerConfig,
sendFn: (conn: any, type: string, data: unknown) => void,
sendBinaryFn?: (conn: any, data: Uint8Array) => void
) {
super(sendFn, sendBinaryFn);
this._adapter = adapter;
this._serverId = config.serverId;
this._config = {
serverId: config.serverId,
serverAddress: config.serverAddress,
serverPort: config.serverPort,
heartbeatInterval: config.heartbeatInterval ?? 5000,
snapshotInterval: config.snapshotInterval ?? 30000,
migrationTimeout: config.migrationTimeout ?? 10000,
enableFailover: config.enableFailover ?? true,
capacity: config.capacity ?? 100,
metadata: config.metadata ?? {}
};
}
/**
* @zh 获取服务器 ID
* @en Get server ID
*/
get serverId(): string {
return this._serverId;
}
/**
* @zh 获取分布式适配器
* @en Get distributed adapter
*/
get adapter(): IDistributedAdapter {
return this._adapter;
}
/**
* @zh 获取配置
* @en Get configuration
*/
get config(): Readonly<InternalConfig> {
return this._config;
}
// =========================================================================
// 生命周期 | Lifecycle
// =========================================================================
/**
* @zh 启动分布式房间管理器
* @en Start distributed room manager
*/
async start(): Promise<void> {
if (!this._adapter.isConnected()) {
await this._adapter.connect();
}
// 注册服务器 | Register server
await this._registerServer();
// 订阅事件 | Subscribe to events
await this._subscribeToEvents();
// 启动心跳 | Start heartbeat
this._startHeartbeat();
// 启动快照(如果启用)| Start snapshots (if enabled)
if (this._config.snapshotInterval > 0) {
this._startSnapshotTimer();
}
logger.info(`Distributed room manager started: ${this._serverId}`);
}
/**
* @zh 停止分布式房间管理器
* @en Stop distributed room manager
*
* @param graceful - 是否优雅关闭(等待玩家退出)| Whether to gracefully shutdown (wait for players)
*/
async stop(graceful = true): Promise<void> {
this._isShuttingDown = true;
// 停止定时器 | Stop timers
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
if (this._snapshotTimer) {
clearInterval(this._snapshotTimer);
this._snapshotTimer = null;
}
// 取消订阅 | Unsubscribe
for (const unsub of this._subscriptions) {
unsub();
}
this._subscriptions = [];
if (graceful) {
// 标记为 draining停止接收新玩家 | Mark as draining, stop accepting new players
await this._adapter.updateServer(this._serverId, { status: 'draining' });
// 保存所有房间状态快照 | Save all room state snapshots
await this._saveAllSnapshots();
}
// 注销服务器 | Unregister server
await this._adapter.unregisterServer(this._serverId);
logger.info(`Distributed room manager stopped: ${this._serverId}`);
}
// =========================================================================
// 房间操作覆盖 | Room Operation Overrides
// =========================================================================
/**
* @zh 房间创建后注册到分布式系统
* @en Register room to distributed system after creation
*/
protected override async _onRoomCreated(name: string, room: Room): Promise<void> {
const registration: RoomRegistration = {
roomId: room.id,
roomType: name,
serverId: this._serverId,
serverAddress: `${this._config.serverAddress}:${this._config.serverPort}`,
playerCount: room.players.length,
maxPlayers: room.maxPlayers,
isLocked: room.isLocked,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
};
await this._adapter.registerRoom(registration);
logger.debug(`Registered room: ${room.id}`);
}
/**
* @zh 房间销毁时从分布式系统注销
* @en Unregister room from distributed system when disposed
*/
protected override _onRoomDisposed(roomId: string): void {
super._onRoomDisposed(roomId);
// 异步注销房间 | Async unregister room
this._adapter.unregisterRoom(roomId).catch(err => {
logger.error(`Failed to unregister room ${roomId}:`, err);
});
// 删除快照 | Delete snapshot
this._adapter.deleteSnapshot(roomId).catch(err => {
logger.error(`Failed to delete snapshot for ${roomId}:`, err);
});
}
/**
* @zh 玩家加入后更新分布式房间信息
* @en Update distributed room info after player joins
*/
protected override _onPlayerJoined(playerId: string, roomId: string, player: Player): void {
super._onPlayerJoined(playerId, roomId, player);
const room = this._rooms.get(roomId);
if (room) {
this._adapter.updateRoom(roomId, {
playerCount: room.players.length,
updatedAt: Date.now()
}).catch(err => {
logger.error(`Failed to update room ${roomId}:`, err);
});
}
}
/**
* @zh 玩家离开后更新分布式房间信息
* @en Update distributed room info after player leaves
*/
protected override _onPlayerLeft(playerId: string, roomId: string): void {
super._onPlayerLeft(playerId, roomId);
const room = this._rooms.get(roomId);
if (room) {
this._adapter.updateRoom(roomId, {
playerCount: room.players.length,
updatedAt: Date.now()
}).catch(err => {
logger.error(`Failed to update room ${roomId}:`, err);
});
}
}
// =========================================================================
// 分布式路由 | Distributed Routing
// =========================================================================
/**
* @zh 路由玩家到合适的房间/服务器
* @en Route player to appropriate room/server
*
* @param request - 路由请求 | Routing request
* @returns 路由结果 | Routing result
*/
async route(request: RoutingRequest): Promise<RoutingResult> {
// 如果指定了房间 ID直接查找 | If room ID specified, look it up directly
if (request.roomId) {
return this._routeToRoom(request.roomId);
}
// 按类型查找可用房间 | Find available room by type
if (request.roomType) {
return this._routeByType(request.roomType, request.query);
}
return { type: 'unavailable', reason: 'No room type or room ID specified' };
}
/**
* @zh 加入或创建房间(分布式版本)
* @en Join or create room (distributed version)
*
* @zh 此方法会:
* 1. 先在分布式注册表中查找可用房间
* 2. 如果找到其他服务器的房间,返回重定向
* 3. 如果找到本地房间或需要创建,在本地处理
* @en This method will:
* 1. First search for available room in distributed registry
* 2. If room found on another server, return redirect
* 3. If local room found or creation needed, handle locally
*/
async joinOrCreateDistributed(
name: string,
playerId: string,
conn: any,
options?: RoomOptions
): Promise<{ room: Room; player: Player } | { redirect: string } | null> {
// 使用分布式锁防止竞态条件 | Use distributed lock to prevent race conditions
const lockKey = `joinOrCreate:${name}`;
const locked = await this._adapter.acquireLock(lockKey, 5000);
if (!locked) {
// 等待一小段时间后重试 | Wait and retry
await this._sleep(100);
return this.joinOrCreateDistributed(name, playerId, conn, options);
}
try {
// 先在分布式注册表中查找 | First search in distributed registry
const availableRoom = await this._adapter.findAvailableRoom(name);
if (availableRoom) {
// 检查是否在本地服务器 | Check if on local server
if (availableRoom.serverId === this._serverId) {
// 本地房间 | Local room
return super.joinOrCreate(name, playerId, conn, options);
} else {
// 其他服务器,返回重定向 | Other server, return redirect
return { redirect: availableRoom.serverAddress };
}
}
// 没有可用房间,在本地创建 | No available room, create locally
return super.joinOrCreate(name, playerId, conn, options);
} finally {
await this._adapter.releaseLock(lockKey);
}
}
// =========================================================================
// 状态管理 | State Management
// =========================================================================
/**
* @zh 保存房间状态快照
* @en Save room state snapshot
*
* @param roomId - 房间 ID | Room ID
*/
async saveSnapshot(roomId: string): Promise<void> {
const room = this._rooms.get(roomId);
if (!room) return;
const def = this._getDefinitionByRoom(room);
if (!def) return;
const snapshot = {
roomId: room.id,
roomType: def.name,
state: room.state ?? {},
players: room.players.map(p => ({
id: p.id,
data: p.data ?? {}
})),
version: Date.now(),
timestamp: Date.now()
};
await this._adapter.saveSnapshot(snapshot);
logger.debug(`Saved snapshot for room: ${roomId}`);
}
/**
* @zh 从快照恢复房间
* @en Restore room from snapshot
*
* @param roomId - 房间 ID | Room ID
* @returns 是否成功恢复 | Whether restore was successful
*/
async restoreFromSnapshot(roomId: string): Promise<boolean> {
const snapshot = await this._adapter.loadSnapshot(roomId);
if (!snapshot) return false;
// 创建房间实例 | Create room instance
const room = await this._createRoomInstance(
snapshot.roomType,
{ state: snapshot.state },
snapshot.roomId
);
if (!room) return false;
// 注册到分布式系统 | Register to distributed system
await this._onRoomCreated(snapshot.roomType, room);
logger.info(`Restored room from snapshot: ${roomId}`);
return true;
}
// =========================================================================
// 私有方法 | Private Methods
// =========================================================================
/**
* @zh 注册服务器到分布式系统
* @en Register server to distributed system
*/
private async _registerServer(): Promise<void> {
const registration: ServerRegistration = {
serverId: this._serverId,
address: this._config.serverAddress,
port: this._config.serverPort,
roomCount: this._rooms.size,
playerCount: this._countTotalPlayers(),
capacity: this._config.capacity,
status: 'online',
lastHeartbeat: Date.now(),
metadata: this._config.metadata
};
await this._adapter.registerServer(registration);
}
/**
* @zh 订阅分布式事件
* @en Subscribe to distributed events
*/
private async _subscribeToEvents(): Promise<void> {
// 订阅服务器离线事件以触发故障转移 | Subscribe to server offline for failover
if (this._config.enableFailover) {
const unsub = await this._adapter.subscribe('server:offline', (event) => {
this._handleServerOffline(event);
});
this._subscriptions.push(unsub);
}
// 订阅房间消息事件 | Subscribe to room message events
const roomMsgUnsub = await this._adapter.subscribe('room:message', (event) => {
this._handleRoomMessage(event);
});
this._subscriptions.push(roomMsgUnsub);
}
/**
* @zh 启动心跳定时器
* @en Start heartbeat timer
*/
private _startHeartbeat(): void {
this._heartbeatTimer = setInterval(async () => {
try {
await this._adapter.heartbeat(this._serverId);
await this._adapter.updateServer(this._serverId, {
roomCount: this._rooms.size,
playerCount: this._countTotalPlayers()
});
} catch (err) {
logger.error('Heartbeat failed:', err);
}
}, this._config.heartbeatInterval);
}
/**
* @zh 启动快照定时器
* @en Start snapshot timer
*/
private _startSnapshotTimer(): void {
this._snapshotTimer = setInterval(async () => {
await this._saveAllSnapshots();
}, this._config.snapshotInterval);
}
/**
* @zh 保存所有房间快照
* @en Save all room snapshots
*/
private async _saveAllSnapshots(): Promise<void> {
const promises: Promise<void>[] = [];
for (const roomId of this._rooms.keys()) {
promises.push(this.saveSnapshot(roomId));
}
await Promise.allSettled(promises);
}
/**
* @zh 路由到指定房间
* @en Route to specific room
*/
private async _routeToRoom(roomId: string): Promise<RoutingResult> {
// 先检查本地 | Check local first
if (this._rooms.has(roomId)) {
return { type: 'local', roomId };
}
// 从分布式注册表查询 | Query from distributed registry
const registration = await this._adapter.getRoom(roomId);
if (!registration) {
return { type: 'unavailable', reason: 'Room not found' };
}
if (registration.serverId === this._serverId) {
return { type: 'local', roomId };
}
return {
type: 'redirect',
serverAddress: registration.serverAddress,
roomId
};
}
/**
* @zh 按类型路由
* @en Route by type
*/
private async _routeByType(
roomType: string,
_query?: RoutingRequest['query']
): Promise<RoutingResult> {
const availableRoom = await this._adapter.findAvailableRoom(roomType);
if (!availableRoom) {
// 没有可用房间,需要创建 | No available room, need to create
return { type: 'create', roomId: undefined };
}
if (availableRoom.serverId === this._serverId) {
return { type: 'local', roomId: availableRoom.roomId };
}
return {
type: 'redirect',
serverAddress: availableRoom.serverAddress,
roomId: availableRoom.roomId
};
}
/**
* @zh 处理服务器离线事件
* @en Handle server offline event
*/
private _handleServerOffline(event: DistributedEvent): void {
if (this._isShuttingDown) return;
if (!this._config.enableFailover) return;
const offlineServerId = event.serverId;
if (offlineServerId === this._serverId) return;
logger.info(`Server offline detected: ${offlineServerId}`);
this._tryRecoverRoomsFromServer(offlineServerId).catch(err => {
logger.error(`Failed to recover rooms from ${offlineServerId}:`, err);
});
}
/**
* @zh 尝试从离线服务器恢复房间
* @en Try to recover rooms from offline server
*/
private async _tryRecoverRoomsFromServer(offlineServerId: string): Promise<void> {
// 检查是否有容量接收更多房间
if (this._rooms.size >= this._config.capacity) {
logger.warn(`Cannot recover rooms: server at capacity (${this._rooms.size}/${this._config.capacity})`);
return;
}
// 查询该服务器上的所有房间
const rooms = await this._adapter.queryRooms({ serverId: offlineServerId });
if (rooms.length === 0) {
logger.info(`No rooms to recover from ${offlineServerId}`);
return;
}
logger.info(`Attempting to recover ${rooms.length} rooms from ${offlineServerId}`);
for (const roomReg of rooms) {
// 检查容量
if (this._rooms.size >= this._config.capacity) {
logger.warn('Reached capacity during recovery, stopping');
break;
}
// 尝试获取恢复锁,防止多个服务器同时恢复同一房间
const lockKey = `failover:${roomReg.roomId}`;
const acquired = await this._adapter.acquireLock(lockKey, this._config.migrationTimeout);
if (!acquired) {
continue;
}
try {
// 从快照恢复房间
const success = await this.restoreFromSnapshot(roomReg.roomId);
if (success) {
logger.info(`Successfully recovered room ${roomReg.roomId}`);
// 发布恢复事件
await this._adapter.publish({
type: 'room:migrated',
serverId: this._serverId,
roomId: roomReg.roomId,
payload: {
fromServer: offlineServerId,
toServer: this._serverId
},
timestamp: Date.now()
});
}
} finally {
await this._adapter.releaseLock(lockKey);
}
}
}
/**
* @zh 处理跨服务器房间消息
* @en Handle cross-server room message
*/
private _handleRoomMessage(event: DistributedEvent): void {
if (!event.roomId) return;
const room = this._rooms.get(event.roomId);
if (!room) return;
const payload = event.payload as { messageType: string; data: unknown; playerId?: string };
if (payload.playerId) {
room._handleMessage(payload.messageType, payload.data, payload.playerId);
}
}
/**
* @zh 统计总玩家数
* @en Count total players
*/
private _countTotalPlayers(): number {
let count = 0;
for (const room of this._rooms.values()) {
count += room.players.length;
}
return count;
}
/**
* @zh 根据房间实例获取定义
* @en Get definition by room instance
*/
private _getDefinitionByRoom(room: Room): { name: string } | null {
for (const [name, def] of this._definitions) {
if (room instanceof def.roomClass) {
return { name };
}
}
return null;
}
/**
* @zh 休眠指定时间
* @en Sleep for specified time
*/
private _sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* @zh 向其他服务器的房间发送消息
* @en Send message to room on another server
*
* @param roomId - 房间 ID | Room ID
* @param messageType - 消息类型 | Message type
* @param data - 消息数据 | Message data
* @param playerId - 发送者玩家 ID可选| Sender player ID (optional)
*/
async sendToRemoteRoom(
roomId: string,
messageType: string,
data: unknown,
playerId?: string
): Promise<void> {
await this._adapter.sendToRoom(roomId, messageType, data, playerId);
}
/**
* @zh 获取所有在线服务器
* @en Get all online servers
*/
async getServers(): Promise<ServerRegistration[]> {
return this._adapter.getServers();
}
/**
* @zh 查询分布式房间
* @en Query distributed rooms
*/
async queryDistributedRooms(query: {
roomType?: string;
hasSpace?: boolean;
notLocked?: boolean;
metadata?: Record<string, unknown>;
limit?: number;
}): Promise<RoomRegistration[]> {
return this._adapter.queryRooms(query);
}
}

View File

@@ -0,0 +1,453 @@
/**
* @zh DistributedRoomManager 单元测试
* @en DistributedRoomManager unit tests
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { DistributedRoomManager } from '../DistributedRoomManager.js';
import { MemoryAdapter } from '../adapters/MemoryAdapter.js';
import { Room } from '../../room/Room.js';
class TestRoom extends Room {
maxPlayers = 4;
}
describe('DistributedRoomManager', () => {
let adapter: MemoryAdapter;
let manager: DistributedRoomManager;
const mockSendFn = vi.fn();
beforeEach(async () => {
vi.clearAllMocks();
adapter = new MemoryAdapter({ enableTtlCheck: false });
manager = new DistributedRoomManager(adapter, {
serverId: 'server-1',
serverAddress: 'localhost',
serverPort: 3000,
heartbeatInterval: 60000, // 长间隔避免测试中触发
snapshotInterval: 0 // 禁用自动快照
}, mockSendFn);
manager.define('test', TestRoom);
await manager.start();
});
afterEach(async () => {
await manager.stop(false);
});
// =========================================================================
// 生命周期 | Lifecycle
// =========================================================================
describe('lifecycle', () => {
it('should start and register server', async () => {
const servers = await adapter.getServers();
expect(servers).toHaveLength(1);
expect(servers[0].serverId).toBe('server-1');
expect(servers[0].status).toBe('online');
});
it('should stop and unregister server', async () => {
await manager.stop(false);
const servers = await adapter.getServers();
expect(servers).toHaveLength(0);
});
it('should expose serverId and config', () => {
expect(manager.serverId).toBe('server-1');
expect(manager.config.serverAddress).toBe('localhost');
expect(manager.config.serverPort).toBe(3000);
});
});
// =========================================================================
// 房间操作 | Room Operations
// =========================================================================
describe('room operations', () => {
it('should create room and register to distributed system', async () => {
const room = await manager.create('test');
expect(room).toBeDefined();
expect(room?.id).toBeDefined();
const registration = await adapter.getRoom(room!.id);
expect(registration).toBeDefined();
expect(registration?.roomType).toBe('test');
expect(registration?.serverId).toBe('server-1');
});
it('should update room count on server after creating room', async () => {
await manager.create('test');
await manager.create('test');
const server = await adapter.getServer('server-1');
expect(server?.roomCount).toBe(2);
});
it('should unregister room from distributed system on dispose', async () => {
const room = await manager.create('test');
const roomId = room!.id;
room!.dispose();
// 等待异步注销完成 | Wait for async unregister
await new Promise(r => setTimeout(r, 50));
const registration = await adapter.getRoom(roomId);
expect(registration).toBeNull();
});
it('should update player count in distributed registration', async () => {
const mockConn = { send: vi.fn() };
const result = await manager.joinOrCreate('test', 'player-1', mockConn);
expect(result).toBeDefined();
const registration = await adapter.getRoom(result!.room.id);
expect(registration?.playerCount).toBe(1);
});
});
// =========================================================================
// 分布式路由 | Distributed Routing
// =========================================================================
describe('distributed routing', () => {
it('should route to local room', async () => {
const room = await manager.create('test');
const result = await manager.route({ roomId: room!.id, playerId: 'p1' });
expect(result.type).toBe('local');
expect(result.roomId).toBe(room!.id);
});
it('should return unavailable for non-existent room', async () => {
const result = await manager.route({ roomId: 'non-existent', playerId: 'p1' });
expect(result.type).toBe('unavailable');
});
it('should return create when no available room exists', async () => {
const result = await manager.route({ roomType: 'test', playerId: 'p1' });
expect(result.type).toBe('create');
});
it('should return local for available local room', async () => {
const room = await manager.create('test');
const result = await manager.route({ roomType: 'test', playerId: 'p1' });
expect(result.type).toBe('local');
expect(result.roomId).toBe(room!.id);
});
it('should return redirect for room on another server', async () => {
// 直接在适配器中注册另一个服务器的房间 | Register room from another server directly
await adapter.registerServer({
serverId: 'server-2',
address: 'other-host',
port: 3001,
roomCount: 1,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
await adapter.registerRoom({
roomId: 'remote-room-1',
roomType: 'test',
serverId: 'server-2',
serverAddress: 'other-host:3001',
playerCount: 0,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
});
const result = await manager.route({ roomType: 'test', playerId: 'p1' });
expect(result.type).toBe('redirect');
expect(result.serverAddress).toBe('other-host:3001');
expect(result.roomId).toBe('remote-room-1');
});
});
// =========================================================================
// 分布式加入创建 | Distributed Join/Create
// =========================================================================
describe('joinOrCreateDistributed', () => {
it('should create room locally when none exists', async () => {
const mockConn = { send: vi.fn() };
const result = await manager.joinOrCreateDistributed('test', 'player-1', mockConn);
expect(result).not.toBeNull();
expect('room' in result!).toBe(true);
if ('room' in result!) {
expect(result.room).toBeDefined();
expect(result.player.id).toBe('player-1');
}
});
it('should join existing local room', async () => {
const room = await manager.create('test');
const mockConn = { send: vi.fn() };
const result = await manager.joinOrCreateDistributed('test', 'player-1', mockConn);
expect(result).not.toBeNull();
expect('room' in result!).toBe(true);
if ('room' in result!) {
expect(result.room.id).toBe(room!.id);
}
});
it('should return redirect for remote room', async () => {
await adapter.registerServer({
serverId: 'server-2',
address: 'remote',
port: 3001,
roomCount: 1,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
await adapter.registerRoom({
roomId: 'remote-room',
roomType: 'test',
serverId: 'server-2',
serverAddress: 'remote:3001',
playerCount: 0,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
});
const mockConn = { send: vi.fn() };
const result = await manager.joinOrCreateDistributed('test', 'player-1', mockConn);
expect(result).not.toBeNull();
expect('redirect' in result!).toBe(true);
if ('redirect' in result!) {
expect(result.redirect).toBe('remote:3001');
}
});
});
// =========================================================================
// 状态快照 | State Snapshots
// =========================================================================
describe('snapshots', () => {
it('should save room snapshot', async () => {
const room = await manager.create('test', { state: { score: 100 } });
await manager.saveSnapshot(room!.id);
const snapshot = await adapter.loadSnapshot(room!.id);
expect(snapshot).toBeDefined();
expect(snapshot?.roomId).toBe(room!.id);
expect(snapshot?.roomType).toBe('test');
});
it('should restore room from snapshot', async () => {
// 手动创建快照 | Manually create snapshot
await adapter.saveSnapshot({
roomId: 'restored-room',
roomType: 'test',
state: { score: 500 },
players: [],
version: 1,
timestamp: Date.now()
});
const restored = await manager.restoreFromSnapshot('restored-room');
expect(restored).toBe(true);
const room = manager.getRoom('restored-room');
expect(room).toBeDefined();
});
it('should return false when snapshot not found', async () => {
const restored = await manager.restoreFromSnapshot('non-existent');
expect(restored).toBe(false);
});
});
// =========================================================================
// 跨服务器通信 | Cross-Server Communication
// =========================================================================
describe('cross-server communication', () => {
it('should send message to remote room', async () => {
const handler = vi.fn();
await adapter.subscribe('room:message', handler);
await adapter.registerRoom({
roomId: 'remote-room',
roomType: 'test',
serverId: 'server-2',
serverAddress: 'remote:3001',
playerCount: 1,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
});
await manager.sendToRemoteRoom('remote-room', 'chat', { text: 'hello' }, 'player-1');
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'room:message',
roomId: 'remote-room',
payload: {
messageType: 'chat',
data: { text: 'hello' },
playerId: 'player-1'
}
})
);
});
});
// =========================================================================
// 查询方法 | Query Methods
// =========================================================================
describe('query methods', () => {
it('should get all servers', async () => {
await adapter.registerServer({
serverId: 'server-2',
address: 'other',
port: 3001,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
const servers = await manager.getServers();
expect(servers).toHaveLength(2);
});
it('should query distributed rooms', async () => {
await manager.create('test');
await adapter.registerRoom({
roomId: 'remote-room',
roomType: 'test',
serverId: 'server-2',
serverAddress: 'remote:3001',
playerCount: 0,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
});
const rooms = await manager.queryDistributedRooms({ roomType: 'test' });
expect(rooms).toHaveLength(2);
});
});
// =========================================================================
// 事件订阅 | Event Subscription
// =========================================================================
describe('event subscription', () => {
it('should handle room messages for local rooms', async () => {
const room = await manager.create('test');
const handleSpy = vi.spyOn(room!, '_handleMessage');
await adapter.publish({
type: 'room:message',
serverId: 'server-2',
roomId: room!.id,
payload: {
messageType: 'test',
data: { foo: 'bar' },
playerId: 'player-1'
},
timestamp: Date.now()
});
expect(handleSpy).toHaveBeenCalledWith('test', { foo: 'bar' }, 'player-1');
});
});
// =========================================================================
// 优雅关闭 | Graceful Shutdown
// =========================================================================
describe('graceful shutdown', () => {
it('should mark server as draining on graceful stop', async () => {
const statusHandler = vi.fn();
// 创建新的管理器用于此测试 | Create new manager for this test
const newAdapter = new MemoryAdapter({ enableTtlCheck: false });
const newManager = new DistributedRoomManager(newAdapter, {
serverId: 'graceful-server',
serverAddress: 'localhost',
serverPort: 3002,
heartbeatInterval: 60000,
snapshotInterval: 0
}, mockSendFn);
newManager.define('test', TestRoom);
await newManager.start();
// 监听状态变化 | Watch for status changes
// 由于我们在 stop(true) 中调用 updateServer可以检查最终状态
await newManager.stop(true);
// 验证服务器已注销 | Verify server is unregistered
const server = await newAdapter.getServer('graceful-server');
expect(server).toBeNull();
});
it('should save all snapshots on graceful stop', async () => {
const newAdapter = new MemoryAdapter({ enableTtlCheck: false });
const newManager = new DistributedRoomManager(newAdapter, {
serverId: 'snapshot-server',
serverAddress: 'localhost',
serverPort: 3003,
heartbeatInterval: 60000,
snapshotInterval: 0
}, mockSendFn);
newManager.define('test', TestRoom);
await newManager.start();
// 创建房间 | Create rooms
const room1 = await newManager.create('test');
const room2 = await newManager.create('test');
await newManager.stop(true);
// 验证快照已保存 | Verify snapshots are saved
const snapshot1 = await newAdapter.loadSnapshot(room1!.id);
const snapshot2 = await newAdapter.loadSnapshot(room2!.id);
expect(snapshot1).toBeDefined();
expect(snapshot2).toBeDefined();
});
});
});

View File

@@ -0,0 +1,582 @@
/**
* @zh MemoryAdapter 单元测试
* @en MemoryAdapter unit tests
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MemoryAdapter } from '../adapters/MemoryAdapter.js';
import type { ServerRegistration, RoomRegistration, DistributedEvent } from '../types.js';
describe('MemoryAdapter', () => {
let adapter: MemoryAdapter;
beforeEach(async () => {
adapter = new MemoryAdapter({ enableTtlCheck: false });
await adapter.connect();
});
afterEach(async () => {
await adapter.disconnect();
});
// =========================================================================
// 生命周期 | Lifecycle
// =========================================================================
describe('lifecycle', () => {
it('should connect and disconnect', async () => {
const newAdapter = new MemoryAdapter();
expect(newAdapter.isConnected()).toBe(false);
await newAdapter.connect();
expect(newAdapter.isConnected()).toBe(true);
await newAdapter.disconnect();
expect(newAdapter.isConnected()).toBe(false);
});
it('should not throw on double connect', async () => {
await adapter.connect();
expect(adapter.isConnected()).toBe(true);
});
it('should not throw on double disconnect', async () => {
await adapter.disconnect();
await adapter.disconnect();
expect(adapter.isConnected()).toBe(false);
});
});
// =========================================================================
// 服务器注册 | Server Registry
// =========================================================================
describe('server registry', () => {
const createServer = (id: string): ServerRegistration => ({
serverId: id,
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
it('should register and get server', async () => {
const server = createServer('server-1');
await adapter.registerServer(server);
const result = await adapter.getServer('server-1');
expect(result).toBeDefined();
expect(result?.serverId).toBe('server-1');
});
it('should get all online servers', async () => {
await adapter.registerServer(createServer('server-1'));
await adapter.registerServer(createServer('server-2'));
const servers = await adapter.getServers();
expect(servers).toHaveLength(2);
});
it('should filter out offline servers', async () => {
const server1 = createServer('server-1');
const server2 = { ...createServer('server-2'), status: 'offline' as const };
await adapter.registerServer(server1);
await adapter.registerServer(server2);
const servers = await adapter.getServers();
expect(servers).toHaveLength(1);
expect(servers[0].serverId).toBe('server-1');
});
it('should unregister server and cleanup rooms', async () => {
const server = createServer('server-1');
await adapter.registerServer(server);
const room: RoomRegistration = {
roomId: 'room-1',
roomType: 'game',
serverId: 'server-1',
serverAddress: 'localhost:3000',
playerCount: 0,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
};
await adapter.registerRoom(room);
await adapter.unregisterServer('server-1');
const serverResult = await adapter.getServer('server-1');
expect(serverResult).toBeNull();
const roomResult = await adapter.getRoom('room-1');
expect(roomResult).toBeNull();
});
it('should update server heartbeat', async () => {
const server = createServer('server-1');
await adapter.registerServer(server);
const before = (await adapter.getServer('server-1'))?.lastHeartbeat;
await new Promise(r => setTimeout(r, 10));
await adapter.heartbeat('server-1');
const after = (await adapter.getServer('server-1'))?.lastHeartbeat;
expect(after).toBeGreaterThan(before!);
});
it('should update server info', async () => {
const server = createServer('server-1');
await adapter.registerServer(server);
await adapter.updateServer('server-1', { roomCount: 5, playerCount: 10 });
const result = await adapter.getServer('server-1');
expect(result?.roomCount).toBe(5);
expect(result?.playerCount).toBe(10);
});
});
// =========================================================================
// 房间注册 | Room Registry
// =========================================================================
describe('room registry', () => {
const createRoom = (id: string, serverId = 'server-1'): RoomRegistration => ({
roomId: id,
roomType: 'game',
serverId,
serverAddress: 'localhost:3000',
playerCount: 0,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
});
beforeEach(async () => {
await adapter.registerServer({
serverId: 'server-1',
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
});
it('should register and get room', async () => {
const room = createRoom('room-1');
await adapter.registerRoom(room);
const result = await adapter.getRoom('room-1');
expect(result).toBeDefined();
expect(result?.roomId).toBe('room-1');
});
it('should update server room count on register', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom(createRoom('room-2'));
const server = await adapter.getServer('server-1');
expect(server?.roomCount).toBe(2);
});
it('should unregister room', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.unregisterRoom('room-1');
const result = await adapter.getRoom('room-1');
expect(result).toBeNull();
});
it('should update room info', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.updateRoom('room-1', { playerCount: 2, isLocked: true });
const result = await adapter.getRoom('room-1');
expect(result?.playerCount).toBe(2);
expect(result?.isLocked).toBe(true);
});
it('should query rooms by type', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom({ ...createRoom('room-2'), roomType: 'lobby' });
const games = await adapter.queryRooms({ roomType: 'game' });
expect(games).toHaveLength(1);
expect(games[0].roomId).toBe('room-1');
});
it('should query rooms with space', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom({ ...createRoom('room-2'), playerCount: 4 });
const available = await adapter.queryRooms({ hasSpace: true });
expect(available).toHaveLength(1);
expect(available[0].roomId).toBe('room-1');
});
it('should query unlocked rooms', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom({ ...createRoom('room-2'), isLocked: true });
const unlocked = await adapter.queryRooms({ notLocked: true });
expect(unlocked).toHaveLength(1);
expect(unlocked[0].roomId).toBe('room-1');
});
it('should query rooms by metadata', async () => {
await adapter.registerRoom({ ...createRoom('room-1'), metadata: { map: 'forest' } });
await adapter.registerRoom({ ...createRoom('room-2'), metadata: { map: 'desert' } });
const forest = await adapter.queryRooms({ metadata: { map: 'forest' } });
expect(forest).toHaveLength(1);
expect(forest[0].roomId).toBe('room-1');
});
it('should support pagination', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom(createRoom('room-2'));
await adapter.registerRoom(createRoom('room-3'));
const page1 = await adapter.queryRooms({ limit: 2 });
expect(page1).toHaveLength(2);
const page2 = await adapter.queryRooms({ offset: 2, limit: 2 });
expect(page2).toHaveLength(1);
});
it('should find available room', async () => {
await adapter.registerRoom({ ...createRoom('room-1'), playerCount: 4 }); // full
await adapter.registerRoom({ ...createRoom('room-2'), isLocked: true }); // locked
await adapter.registerRoom(createRoom('room-3')); // available
const available = await adapter.findAvailableRoom('game');
expect(available?.roomId).toBe('room-3');
});
it('should return null when no available room', async () => {
await adapter.registerRoom({ ...createRoom('room-1'), playerCount: 4 });
const available = await adapter.findAvailableRoom('game');
expect(available).toBeNull();
});
it('should get rooms by server', async () => {
await adapter.registerServer({
serverId: 'server-2',
address: 'localhost',
port: 3001,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
await adapter.registerRoom(createRoom('room-1', 'server-1'));
await adapter.registerRoom(createRoom('room-2', 'server-2'));
const server1Rooms = await adapter.getRoomsByServer('server-1');
expect(server1Rooms).toHaveLength(1);
expect(server1Rooms[0].roomId).toBe('room-1');
});
});
// =========================================================================
// 快照 | Snapshots
// =========================================================================
describe('snapshots', () => {
it('should save and load snapshot', async () => {
const snapshot = {
roomId: 'room-1',
roomType: 'game',
state: { score: 100 },
players: [{ id: 'player-1', data: { name: 'Alice' } }],
version: 1,
timestamp: Date.now()
};
await adapter.saveSnapshot(snapshot);
const result = await adapter.loadSnapshot('room-1');
expect(result).toEqual(snapshot);
});
it('should return null for non-existent snapshot', async () => {
const result = await adapter.loadSnapshot('non-existent');
expect(result).toBeNull();
});
it('should delete snapshot', async () => {
await adapter.saveSnapshot({
roomId: 'room-1',
roomType: 'game',
state: {},
players: [],
version: 1,
timestamp: Date.now()
});
await adapter.deleteSnapshot('room-1');
const result = await adapter.loadSnapshot('room-1');
expect(result).toBeNull();
});
});
// =========================================================================
// 发布/订阅 | Pub/Sub
// =========================================================================
describe('pub/sub', () => {
it('should publish and subscribe to events', async () => {
const handler = vi.fn();
await adapter.subscribe('room:created', handler);
const event: DistributedEvent = {
type: 'room:created',
serverId: 'server-1',
roomId: 'room-1',
payload: { roomType: 'game' },
timestamp: Date.now()
};
await adapter.publish(event);
expect(handler).toHaveBeenCalledWith(event);
});
it('should support wildcard subscription', async () => {
const handler = vi.fn();
await adapter.subscribe('*', handler);
await adapter.publish({
type: 'room:created',
serverId: 'server-1',
payload: {},
timestamp: Date.now()
});
await adapter.publish({
type: 'server:online',
serverId: 'server-1',
payload: {},
timestamp: Date.now()
});
expect(handler).toHaveBeenCalledTimes(2);
});
it('should unsubscribe correctly', async () => {
const handler = vi.fn();
const unsub = await adapter.subscribe('room:created', handler);
unsub();
await adapter.publish({
type: 'room:created',
serverId: 'server-1',
payload: {},
timestamp: Date.now()
});
expect(handler).not.toHaveBeenCalled();
});
it('should handle errors in handlers gracefully', async () => {
const errorHandler = vi.fn(() => { throw new Error('Test error'); });
const normalHandler = vi.fn();
await adapter.subscribe('room:created', errorHandler);
await adapter.subscribe('room:created', normalHandler);
await adapter.publish({
type: 'room:created',
serverId: 'server-1',
payload: {},
timestamp: Date.now()
});
expect(errorHandler).toHaveBeenCalled();
expect(normalHandler).toHaveBeenCalled();
});
it('should send to room', async () => {
await adapter.registerServer({
serverId: 'server-1',
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
await adapter.registerRoom({
roomId: 'room-1',
roomType: 'game',
serverId: 'server-1',
serverAddress: 'localhost:3000',
playerCount: 0,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
});
const handler = vi.fn();
await adapter.subscribe('room:message', handler);
await adapter.sendToRoom('room-1', 'chat', { text: 'hello' }, 'player-1');
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'room:message',
roomId: 'room-1',
payload: {
messageType: 'chat',
data: { text: 'hello' },
playerId: 'player-1'
}
})
);
});
});
// =========================================================================
// 分布式锁 | Distributed Locks
// =========================================================================
describe('distributed locks', () => {
it('should acquire and release lock', async () => {
const acquired = await adapter.acquireLock('test-lock', 5000);
expect(acquired).toBe(true);
await adapter.releaseLock('test-lock');
const acquiredAgain = await adapter.acquireLock('test-lock', 5000);
expect(acquiredAgain).toBe(true);
});
it('should fail to acquire held lock', async () => {
await adapter.acquireLock('test-lock', 5000);
const acquiredAgain = await adapter.acquireLock('test-lock', 5000);
expect(acquiredAgain).toBe(false);
});
it('should acquire expired lock', async () => {
await adapter.acquireLock('test-lock', 1);
await new Promise(r => setTimeout(r, 10));
const acquired = await adapter.acquireLock('test-lock', 5000);
expect(acquired).toBe(true);
});
it('should extend lock', async () => {
await adapter.acquireLock('test-lock', 100);
const extended = await adapter.extendLock('test-lock', 5000);
expect(extended).toBe(true);
});
it('should fail to extend non-existent lock', async () => {
const extended = await adapter.extendLock('non-existent', 5000);
expect(extended).toBe(false);
});
});
// =========================================================================
// TTL 检查 | TTL Check
// =========================================================================
describe('TTL check', () => {
it('should mark server offline after TTL expires', async () => {
const ttlAdapter = new MemoryAdapter({
serverTtl: 50,
ttlCheckInterval: 20,
enableTtlCheck: true
});
await ttlAdapter.connect();
const handler = vi.fn();
await ttlAdapter.subscribe('server:offline', handler);
await ttlAdapter.registerServer({
serverId: 'server-1',
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
await new Promise(r => setTimeout(r, 100));
expect(handler).toHaveBeenCalled();
const server = await ttlAdapter.getServer('server-1');
expect(server?.status).toBe('offline');
await ttlAdapter.disconnect();
});
});
// =========================================================================
// 测试辅助方法 | Test Helper Methods
// =========================================================================
describe('test helpers', () => {
it('should clear all data', async () => {
await adapter.registerServer({
serverId: 'server-1',
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
adapter._clear();
const servers = await adapter.getServers();
expect(servers).toHaveLength(0);
});
it('should expose internal state for testing', () => {
const state = adapter._getState();
expect(state.servers).toBeDefined();
expect(state.rooms).toBeDefined();
expect(state.snapshots).toBeDefined();
});
});
// =========================================================================
// 错误处理 | Error Handling
// =========================================================================
describe('error handling', () => {
it('should throw when not connected', async () => {
const disconnected = new MemoryAdapter();
await expect(disconnected.registerServer({} as ServerRegistration))
.rejects.toThrow('MemoryAdapter is not connected');
});
});
});

View File

@@ -0,0 +1,750 @@
/**
* @zh RedisAdapter 单元测试
* @en RedisAdapter unit tests
*
* @zh 使用 Mock Redis 客户端进行测试
* @en Uses Mock Redis client for testing
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { RedisAdapter } from '../adapters/RedisAdapter.js';
import type { RedisClient } from '../adapters/RedisAdapter.js';
import type { ServerRegistration, RoomRegistration, DistributedEvent } from '../types.js';
// 共享状态,用于模拟 Redis Pub/Sub
const sharedStore = new Map<string, string>();
const sharedSets = new Map<string, Set<string>>();
const sharedHashes = new Map<string, Map<string, string>>();
const sharedExpireTimes = new Map<string, number>();
const sharedPubSubHandlers = new Map<string, Set<(channel: string, message: string) => void>>();
function clearSharedState(): void {
sharedStore.clear();
sharedSets.clear();
sharedHashes.clear();
sharedExpireTimes.clear();
sharedPubSubHandlers.clear();
}
/**
* @zh 创建 Mock Redis 客户端
* @en Create Mock Redis client
*/
function createMockRedisClient(): RedisClient {
const eventHandlers = new Map<string, Set<(...args: unknown[]) => void>>();
const mockClient: RedisClient = {
// 基础操作
get: vi.fn(async (key: string) => sharedStore.get(key) ?? null),
set: vi.fn(async (key: string, value: string, ...args: (string | number)[]) => {
// 处理 NX 选项
if (args.includes('NX') && sharedStore.has(key)) {
return null;
}
sharedStore.set(key, value);
// 处理 EX 选项
const exIndex = args.indexOf('EX');
if (exIndex !== -1 && typeof args[exIndex + 1] === 'number') {
sharedExpireTimes.set(key, Date.now() + (args[exIndex + 1] as number) * 1000);
}
return 'OK';
}),
del: vi.fn(async (...keys: string[]) => {
let count = 0;
for (const key of keys) {
if (sharedStore.delete(key) || sharedHashes.delete(key) || sharedSets.delete(key)) {
count++;
}
}
return count;
}),
expire: vi.fn(async (key: string, seconds: number) => {
if (sharedStore.has(key) || sharedHashes.has(key)) {
sharedExpireTimes.set(key, Date.now() + seconds * 1000);
return 1;
}
return 0;
}),
ttl: vi.fn(async (key: string) => {
const expire = sharedExpireTimes.get(key);
if (!expire) return -1;
const remaining = Math.ceil((expire - Date.now()) / 1000);
return remaining > 0 ? remaining : -2;
}),
// Hash 操作
hget: vi.fn(async (key: string, field: string) => {
return sharedHashes.get(key)?.get(field) ?? null;
}),
hset: vi.fn(async (key: string, ...args: (string | number | Buffer)[]) => {
if (!sharedHashes.has(key)) {
sharedHashes.set(key, new Map());
}
const hash = sharedHashes.get(key)!;
let added = 0;
for (let i = 0; i < args.length; i += 2) {
const field = String(args[i]);
const value = String(args[i + 1]);
if (!hash.has(field)) added++;
hash.set(field, value);
}
return added;
}),
hdel: vi.fn(async (key: string, ...fields: string[]) => {
const hash = sharedHashes.get(key);
if (!hash) return 0;
let count = 0;
for (const field of fields) {
if (hash.delete(field)) count++;
}
return count;
}),
hgetall: vi.fn(async (key: string) => {
const hash = sharedHashes.get(key);
if (!hash) return {};
const result: Record<string, string> = {};
for (const [k, v] of hash) {
result[k] = v;
}
return result;
}),
hmset: vi.fn(async (key: string, ...args: (string | number | Buffer)[]) => {
if (!sharedHashes.has(key)) {
sharedHashes.set(key, new Map());
}
const hash = sharedHashes.get(key)!;
for (let i = 0; i < args.length; i += 2) {
hash.set(String(args[i]), String(args[i + 1]));
}
return 'OK';
}),
// Set 操作
sadd: vi.fn(async (key: string, ...members: string[]) => {
if (!sharedSets.has(key)) {
sharedSets.set(key, new Set());
}
const set = sharedSets.get(key)!;
let added = 0;
for (const member of members) {
if (!set.has(member)) {
set.add(member);
added++;
}
}
return added;
}),
srem: vi.fn(async (key: string, ...members: string[]) => {
const set = sharedSets.get(key);
if (!set) return 0;
let removed = 0;
for (const member of members) {
if (set.delete(member)) removed++;
}
return removed;
}),
smembers: vi.fn(async (key: string) => {
return Array.from(sharedSets.get(key) ?? []);
}),
// Pub/Sub - 使用共享的处理器集合
publish: vi.fn(async (channel: string, message: string) => {
const handlers = sharedPubSubHandlers.get(channel);
if (handlers) {
for (const handler of handlers) {
handler(channel, message);
}
}
return handlers?.size ?? 0;
}),
subscribe: vi.fn(async (channel: string) => {
// 注册 message 事件处理器到共享的 pub/sub 处理器
const messageHandlers = eventHandlers.get('message');
if (messageHandlers) {
if (!sharedPubSubHandlers.has(channel)) {
sharedPubSubHandlers.set(channel, new Set());
}
for (const handler of messageHandlers) {
sharedPubSubHandlers.get(channel)!.add(handler as (channel: string, message: string) => void);
}
}
return 1;
}),
psubscribe: vi.fn(async () => 1),
unsubscribe: vi.fn(async (channel: string) => {
sharedPubSubHandlers.delete(channel);
return 1;
}),
punsubscribe: vi.fn(async () => 1),
// 事件
on: vi.fn((event: string, callback: (...args: unknown[]) => void) => {
if (!eventHandlers.has(event)) {
eventHandlers.set(event, new Set());
}
eventHandlers.get(event)!.add(callback);
}),
off: vi.fn((event: string, callback: (...args: unknown[]) => void) => {
eventHandlers.get(event)?.delete(callback);
}),
// Lua 脚本
eval: vi.fn(async (script: string, numkeys: number, ...args: (string | number)[]) => {
const key = String(args[0]);
const token = String(args[1]);
// 释放锁脚本
if (script.includes('redis.call("del"')) {
if (sharedStore.get(key) === token) {
sharedStore.delete(key);
return 1;
}
return 0;
}
// 扩展锁脚本
if (script.includes('redis.call("pexpire"')) {
if (sharedStore.get(key) === token) {
const ttlMs = Number(args[2]);
sharedExpireTimes.set(key, Date.now() + ttlMs);
return 1;
}
return 0;
}
return 0;
}),
// 连接
duplicate: vi.fn(() => createMockRedisClient()),
quit: vi.fn(async () => 'OK'),
disconnect: vi.fn()
};
return mockClient;
}
describe('RedisAdapter', () => {
let adapter: RedisAdapter;
let mockClient: RedisClient;
beforeEach(async () => {
clearSharedState();
mockClient = createMockRedisClient();
adapter = new RedisAdapter({
factory: () => mockClient,
prefix: 'test:'
});
await adapter.connect();
});
afterEach(async () => {
await adapter.disconnect();
});
// =========================================================================
// 生命周期 | Lifecycle
// =========================================================================
describe('lifecycle', () => {
it('should connect and disconnect', async () => {
const newAdapter = new RedisAdapter({
factory: () => createMockRedisClient()
});
expect(newAdapter.isConnected()).toBe(false);
await newAdapter.connect();
expect(newAdapter.isConnected()).toBe(true);
await newAdapter.disconnect();
expect(newAdapter.isConnected()).toBe(false);
});
it('should not throw on double connect', async () => {
await adapter.connect();
expect(adapter.isConnected()).toBe(true);
});
it('should not throw on double disconnect', async () => {
await adapter.disconnect();
await adapter.disconnect();
expect(adapter.isConnected()).toBe(false);
});
});
// =========================================================================
// 服务器注册 | Server Registry
// =========================================================================
describe('server registry', () => {
const createServer = (id: string): ServerRegistration => ({
serverId: id,
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
it('should register and get server', async () => {
const server = createServer('server-1');
await adapter.registerServer(server);
const result = await adapter.getServer('server-1');
expect(result).toBeDefined();
expect(result?.serverId).toBe('server-1');
expect(result?.address).toBe('localhost');
expect(result?.port).toBe(3000);
});
it('should get all online servers', async () => {
await adapter.registerServer(createServer('server-1'));
await adapter.registerServer(createServer('server-2'));
const servers = await adapter.getServers();
expect(servers).toHaveLength(2);
});
it('should unregister server', async () => {
await adapter.registerServer(createServer('server-1'));
await adapter.unregisterServer('server-1');
const result = await adapter.getServer('server-1');
expect(result).toBeNull();
});
it('should update server heartbeat', async () => {
const server = createServer('server-1');
await adapter.registerServer(server);
await new Promise(r => setTimeout(r, 10));
await adapter.heartbeat('server-1');
const result = await adapter.getServer('server-1');
expect(result?.lastHeartbeat).toBeGreaterThan(server.lastHeartbeat);
});
it('should update server info', async () => {
await adapter.registerServer(createServer('server-1'));
await adapter.updateServer('server-1', { roomCount: 5, playerCount: 10 });
const result = await adapter.getServer('server-1');
expect(result?.roomCount).toBe(5);
expect(result?.playerCount).toBe(10);
});
it('should publish draining event when status changes', async () => {
await adapter.registerServer(createServer('server-1'));
const handler = vi.fn();
await adapter.subscribe('server:draining', handler);
await adapter.updateServer('server-1', { status: 'draining' });
expect(handler).toHaveBeenCalled();
});
});
// =========================================================================
// 房间注册 | Room Registry
// =========================================================================
describe('room registry', () => {
const createRoom = (id: string, serverId = 'server-1'): RoomRegistration => ({
roomId: id,
roomType: 'game',
serverId,
serverAddress: 'localhost:3000',
playerCount: 0,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
});
beforeEach(async () => {
await adapter.registerServer({
serverId: 'server-1',
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
});
it('should register and get room', async () => {
const room = createRoom('room-1');
await adapter.registerRoom(room);
const result = await adapter.getRoom('room-1');
expect(result).toBeDefined();
expect(result?.roomId).toBe('room-1');
expect(result?.roomType).toBe('game');
});
it('should unregister room', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.unregisterRoom('room-1');
const result = await adapter.getRoom('room-1');
expect(result).toBeNull();
});
it('should update room info', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.updateRoom('room-1', { playerCount: 2, isLocked: true });
const result = await adapter.getRoom('room-1');
expect(result?.playerCount).toBe(2);
expect(result?.isLocked).toBe(true);
});
it('should query rooms by type', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom({ ...createRoom('room-2'), roomType: 'lobby' });
const games = await adapter.queryRooms({ roomType: 'game' });
expect(games).toHaveLength(1);
expect(games[0].roomId).toBe('room-1');
});
it('should query rooms with space', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom({ ...createRoom('room-2'), playerCount: 4 });
const available = await adapter.queryRooms({ hasSpace: true });
expect(available).toHaveLength(1);
expect(available[0].roomId).toBe('room-1');
});
it('should query unlocked rooms', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom({ ...createRoom('room-2'), isLocked: true });
const unlocked = await adapter.queryRooms({ notLocked: true });
expect(unlocked).toHaveLength(1);
expect(unlocked[0].roomId).toBe('room-1');
});
it('should support pagination', async () => {
await adapter.registerRoom(createRoom('room-1'));
await adapter.registerRoom(createRoom('room-2'));
await adapter.registerRoom(createRoom('room-3'));
const page1 = await adapter.queryRooms({ limit: 2 });
expect(page1).toHaveLength(2);
const page2 = await adapter.queryRooms({ offset: 2, limit: 2 });
expect(page2).toHaveLength(1);
});
it('should find available room', async () => {
await adapter.registerRoom({ ...createRoom('room-1'), playerCount: 4 }); // full
await adapter.registerRoom({ ...createRoom('room-2'), isLocked: true }); // locked
await adapter.registerRoom(createRoom('room-3')); // available
const available = await adapter.findAvailableRoom('game');
expect(available?.roomId).toBe('room-3');
});
it('should get rooms by server', async () => {
await adapter.registerServer({
serverId: 'server-2',
address: 'localhost',
port: 3001,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
await adapter.registerRoom(createRoom('room-1', 'server-1'));
await adapter.registerRoom(createRoom('room-2', 'server-2'));
const server1Rooms = await adapter.getRoomsByServer('server-1');
expect(server1Rooms).toHaveLength(1);
expect(server1Rooms[0].roomId).toBe('room-1');
});
it('should publish lock/unlock events', async () => {
await adapter.registerRoom(createRoom('room-1'));
const lockHandler = vi.fn();
const unlockHandler = vi.fn();
await adapter.subscribe('room:locked', lockHandler);
await adapter.subscribe('room:unlocked', unlockHandler);
await adapter.updateRoom('room-1', { isLocked: true });
expect(lockHandler).toHaveBeenCalled();
await adapter.updateRoom('room-1', { isLocked: false });
expect(unlockHandler).toHaveBeenCalled();
});
});
// =========================================================================
// 快照 | Snapshots
// =========================================================================
describe('snapshots', () => {
it('should save and load snapshot', async () => {
const snapshot = {
roomId: 'room-1',
roomType: 'game',
state: { score: 100 },
players: [{ id: 'player-1', data: { name: 'Alice' } }],
version: 1,
timestamp: Date.now()
};
await adapter.saveSnapshot(snapshot);
const result = await adapter.loadSnapshot('room-1');
expect(result).toEqual(snapshot);
});
it('should return null for non-existent snapshot', async () => {
const result = await adapter.loadSnapshot('non-existent');
expect(result).toBeNull();
});
it('should delete snapshot', async () => {
await adapter.saveSnapshot({
roomId: 'room-1',
roomType: 'game',
state: {},
players: [],
version: 1,
timestamp: Date.now()
});
await adapter.deleteSnapshot('room-1');
const result = await adapter.loadSnapshot('room-1');
expect(result).toBeNull();
});
});
// =========================================================================
// 发布/订阅 | Pub/Sub
// =========================================================================
describe('pub/sub', () => {
it('should publish and subscribe to events', async () => {
const handler = vi.fn();
await adapter.subscribe('room:created', handler);
const event: DistributedEvent = {
type: 'room:created',
serverId: 'server-1',
roomId: 'room-1',
payload: { roomType: 'game' },
timestamp: Date.now()
};
await adapter.publish(event);
expect(handler).toHaveBeenCalledWith(event);
});
it('should support wildcard subscription', async () => {
const handler = vi.fn();
await adapter.subscribe('*', handler);
await adapter.publish({
type: 'room:created',
serverId: 'server-1',
payload: {},
timestamp: Date.now()
});
await adapter.publish({
type: 'server:online',
serverId: 'server-1',
payload: {},
timestamp: Date.now()
});
expect(handler).toHaveBeenCalledTimes(2);
});
it('should unsubscribe correctly', async () => {
const handler = vi.fn();
const unsub = await adapter.subscribe('room:created', handler);
unsub();
await adapter.publish({
type: 'room:created',
serverId: 'server-1',
payload: {},
timestamp: Date.now()
});
expect(handler).not.toHaveBeenCalled();
});
it('should send to room', async () => {
await adapter.registerServer({
serverId: 'server-1',
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
await adapter.registerRoom({
roomId: 'room-1',
roomType: 'game',
serverId: 'server-1',
serverAddress: 'localhost:3000',
playerCount: 0,
maxPlayers: 4,
isLocked: false,
metadata: {},
createdAt: Date.now(),
updatedAt: Date.now()
});
const handler = vi.fn();
await adapter.subscribe('room:message', handler);
await adapter.sendToRoom('room-1', 'chat', { text: 'hello' }, 'player-1');
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
type: 'room:message',
roomId: 'room-1',
payload: {
messageType: 'chat',
data: { text: 'hello' },
playerId: 'player-1'
}
})
);
});
});
// =========================================================================
// 分布式锁 | Distributed Locks
// =========================================================================
describe('distributed locks', () => {
it('should acquire and release lock', async () => {
const acquired = await adapter.acquireLock('test-lock', 5000);
expect(acquired).toBe(true);
await adapter.releaseLock('test-lock');
const acquiredAgain = await adapter.acquireLock('test-lock', 5000);
expect(acquiredAgain).toBe(true);
});
it('should fail to acquire held lock', async () => {
await adapter.acquireLock('test-lock', 5000);
const acquiredAgain = await adapter.acquireLock('test-lock', 5000);
expect(acquiredAgain).toBe(false);
});
it('should extend lock', async () => {
await adapter.acquireLock('test-lock', 100);
const extended = await adapter.extendLock('test-lock', 5000);
expect(extended).toBe(true);
});
it('should fail to extend non-existent lock', async () => {
const extended = await adapter.extendLock('non-existent', 5000);
expect(extended).toBe(false);
});
it('should fail to release lock without token', async () => {
// 没有获取锁就释放,应该什么都不做
await adapter.releaseLock('test-lock');
// 仍然可以获取锁
const acquired = await adapter.acquireLock('test-lock', 5000);
expect(acquired).toBe(true);
});
});
// =========================================================================
// 错误处理 | Error Handling
// =========================================================================
describe('error handling', () => {
it('should throw when not connected', async () => {
const disconnected = new RedisAdapter({
factory: () => createMockRedisClient()
});
await expect(disconnected.registerServer({} as ServerRegistration))
.rejects.toThrow('RedisAdapter is not connected');
});
});
// =========================================================================
// 配置 | Configuration
// =========================================================================
describe('configuration', () => {
it('should use default prefix', async () => {
const testMockClient = createMockRedisClient();
const defaultAdapter = new RedisAdapter({
factory: () => testMockClient
});
await defaultAdapter.connect();
await defaultAdapter.registerServer({
serverId: 'server-1',
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
// hmset 应该被调用key 应该包含默认前缀 'dist:'
expect(testMockClient.hmset).toHaveBeenCalled();
await defaultAdapter.disconnect();
});
it('should use custom prefix', async () => {
const testMockClient = createMockRedisClient();
const customAdapter = new RedisAdapter({
factory: () => testMockClient,
prefix: 'game:'
});
await customAdapter.connect();
await customAdapter.registerServer({
serverId: 'server-1',
address: 'localhost',
port: 3000,
roomCount: 0,
playerCount: 0,
capacity: 100,
status: 'online',
lastHeartbeat: Date.now()
});
// hmset 应该被调用
expect(testMockClient.hmset).toHaveBeenCalled();
await customAdapter.disconnect();
});
});
});

View File

@@ -0,0 +1,257 @@
/**
* @zh 分布式适配器接口
* @en Distributed adapter interface
*
* @zh 定义分布式房间系统的存储和通信层抽象
* @en Defines the storage and communication layer abstraction for distributed room system
*/
import type {
ServerRegistration,
RoomRegistration,
RoomQuery,
RoomSnapshot,
DistributedEvent,
DistributedEventType,
DistributedEventHandler,
Unsubscribe
} from '../types.js';
/**
* @zh 分布式适配器接口
* @en Distributed adapter interface
*
* @zh 所有分布式后端Redis、消息队列等都需要实现此接口
* @en All distributed backends (Redis, message queue, etc.) must implement this interface
*/
export interface IDistributedAdapter {
// =========================================================================
// 生命周期 | Lifecycle
// =========================================================================
/**
* @zh 连接到分布式后端
* @en Connect to distributed backend
*/
connect(): Promise<void>;
/**
* @zh 断开连接
* @en Disconnect from backend
*/
disconnect(): Promise<void>;
/**
* @zh 检查是否已连接
* @en Check if connected
*/
isConnected(): boolean;
// =========================================================================
// 服务器注册 | Server Registry
// =========================================================================
/**
* @zh 注册服务器
* @en Register server
*
* @param server - 服务器注册信息 | Server registration info
*/
registerServer(server: ServerRegistration): Promise<void>;
/**
* @zh 注销服务器
* @en Unregister server
*
* @param serverId - 服务器 ID | Server ID
*/
unregisterServer(serverId: string): Promise<void>;
/**
* @zh 更新服务器心跳
* @en Update server heartbeat
*
* @param serverId - 服务器 ID | Server ID
*/
heartbeat(serverId: string): Promise<void>;
/**
* @zh 获取所有在线服务器
* @en Get all online servers
*/
getServers(): Promise<ServerRegistration[]>;
/**
* @zh 获取指定服务器
* @en Get specific server
*
* @param serverId - 服务器 ID | Server ID
*/
getServer(serverId: string): Promise<ServerRegistration | null>;
/**
* @zh 更新服务器信息
* @en Update server info
*
* @param serverId - 服务器 ID | Server ID
* @param updates - 更新内容 | Updates
*/
updateServer(serverId: string, updates: Partial<ServerRegistration>): Promise<void>;
// =========================================================================
// 房间注册 | Room Registry
// =========================================================================
/**
* @zh 注册房间
* @en Register room
*
* @param room - 房间注册信息 | Room registration info
*/
registerRoom(room: RoomRegistration): Promise<void>;
/**
* @zh 注销房间
* @en Unregister room
*
* @param roomId - 房间 ID | Room ID
*/
unregisterRoom(roomId: string): Promise<void>;
/**
* @zh 更新房间信息
* @en Update room info
*
* @param roomId - 房间 ID | Room ID
* @param updates - 更新内容 | Updates
*/
updateRoom(roomId: string, updates: Partial<RoomRegistration>): Promise<void>;
/**
* @zh 获取房间信息
* @en Get room info
*
* @param roomId - 房间 ID | Room ID
*/
getRoom(roomId: string): Promise<RoomRegistration | null>;
/**
* @zh 查询房间列表
* @en Query room list
*
* @param query - 查询条件 | Query criteria
*/
queryRooms(query: RoomQuery): Promise<RoomRegistration[]>;
/**
* @zh 获取指定类型的可用房间(用于 joinOrCreate
* @en Get available room of type (for joinOrCreate)
*
* @param roomType - 房间类型 | Room type
*/
findAvailableRoom(roomType: string): Promise<RoomRegistration | null>;
/**
* @zh 获取服务器的所有房间
* @en Get all rooms of a server
*
* @param serverId - 服务器 ID | Server ID
*/
getRoomsByServer(serverId: string): Promise<RoomRegistration[]>;
// =========================================================================
// 房间状态 | Room State
// =========================================================================
/**
* @zh 保存房间状态快照
* @en Save room state snapshot
*
* @param snapshot - 状态快照 | State snapshot
*/
saveSnapshot(snapshot: RoomSnapshot): Promise<void>;
/**
* @zh 加载房间状态快照
* @en Load room state snapshot
*
* @param roomId - 房间 ID | Room ID
*/
loadSnapshot(roomId: string): Promise<RoomSnapshot | null>;
/**
* @zh 删除房间状态
* @en Delete room state
*
* @param roomId - 房间 ID | Room ID
*/
deleteSnapshot(roomId: string): Promise<void>;
// =========================================================================
// 发布/订阅 | Pub/Sub
// =========================================================================
/**
* @zh 发布事件
* @en Publish event
*
* @param event - 分布式事件 | Distributed event
*/
publish(event: DistributedEvent): Promise<void>;
/**
* @zh 订阅事件
* @en Subscribe to events
*
* @param pattern - 事件类型模式(支持 '*' 通配符) | Event type pattern (supports '*' wildcard)
* @param handler - 事件处理器 | Event handler
* @returns 取消订阅函数 | Unsubscribe function
*/
subscribe(
pattern: DistributedEventType | '*',
handler: DistributedEventHandler
): Promise<Unsubscribe>;
/**
* @zh 向特定房间发送消息(跨服务器)
* @en Send message to specific room (cross-server)
*
* @param roomId - 房间 ID | Room ID
* @param messageType - 消息类型 | Message type
* @param data - 消息数据 | Message data
* @param playerId - 发送者玩家 ID可选 | Sender player ID (optional)
*/
sendToRoom(roomId: string, messageType: string, data: unknown, playerId?: string): Promise<void>;
// =========================================================================
// 分布式锁 | Distributed Lock
// =========================================================================
/**
* @zh 获取分布式锁
* @en Acquire distributed lock
*
* @param key - 锁的键名 | Lock key
* @param ttlMs - 锁的生存时间(毫秒) | Lock TTL (ms)
* @returns 是否成功获取锁 | Whether lock was acquired
*/
acquireLock(key: string, ttlMs: number): Promise<boolean>;
/**
* @zh 释放分布式锁
* @en Release distributed lock
*
* @param key - 锁的键名 | Lock key
*/
releaseLock(key: string): Promise<void>;
/**
* @zh 扩展锁的生存时间
* @en Extend lock TTL
*
* @param key - 锁的键名 | Lock key
* @param ttlMs - 新的生存时间(毫秒) | New TTL (ms)
* @returns 是否成功扩展 | Whether extension was successful
*/
extendLock(key: string, ttlMs: number): Promise<boolean>;
}

View File

@@ -0,0 +1,503 @@
/**
* @zh 内存分布式适配器
* @en Memory distributed adapter
*
* @zh 用于单机模式和测试的内存实现。所有数据存储在进程内存中。
* @en In-memory implementation for single-server mode and testing. All data stored in process memory.
*/
import type { IDistributedAdapter } from './IDistributedAdapter.js';
import type {
ServerRegistration,
RoomRegistration,
RoomQuery,
RoomSnapshot,
DistributedEvent,
DistributedEventType,
DistributedEventHandler,
Unsubscribe
} from '../types.js';
/**
* @zh 内存适配器配置
* @en Memory adapter configuration
*/
export interface MemoryAdapterConfig {
/**
* @zh 服务器 TTL毫秒超时后视为离线
* @en Server TTL (ms), considered offline after timeout
* @default 15000
*/
serverTtl?: number;
/**
* @zh 是否启用 TTL 检查
* @en Whether to enable TTL check
* @default true
*/
enableTtlCheck?: boolean;
/**
* @zh TTL 检查间隔(毫秒)
* @en TTL check interval (ms)
* @default 5000
*/
ttlCheckInterval?: number;
}
/**
* @zh 内存分布式适配器
* @en Memory distributed adapter
*/
export class MemoryAdapter implements IDistributedAdapter {
private readonly _config: Required<MemoryAdapterConfig>;
private _connected = false;
// 存储
private readonly _servers = new Map<string, ServerRegistration>();
private readonly _rooms = new Map<string, RoomRegistration>();
private readonly _snapshots = new Map<string, RoomSnapshot>();
private readonly _locks = new Map<string, { owner: string; expireAt: number }>();
// 事件订阅
private readonly _subscribers = new Map<string, Set<DistributedEventHandler>>();
private _subscriberId = 0;
// TTL 检查定时器
private _ttlCheckTimer: ReturnType<typeof setInterval> | null = null;
constructor(config: MemoryAdapterConfig = {}) {
this._config = {
serverTtl: 15000,
enableTtlCheck: true,
ttlCheckInterval: 5000,
...config
};
}
// =========================================================================
// 生命周期 | Lifecycle
// =========================================================================
async connect(): Promise<void> {
if (this._connected) return;
this._connected = true;
if (this._config.enableTtlCheck) {
this._ttlCheckTimer = setInterval(
() => this._checkServerTtl(),
this._config.ttlCheckInterval
);
}
}
async disconnect(): Promise<void> {
if (!this._connected) return;
if (this._ttlCheckTimer) {
clearInterval(this._ttlCheckTimer);
this._ttlCheckTimer = null;
}
this._connected = false;
this._servers.clear();
this._rooms.clear();
this._snapshots.clear();
this._locks.clear();
this._subscribers.clear();
}
isConnected(): boolean {
return this._connected;
}
// =========================================================================
// 服务器注册 | Server Registry
// =========================================================================
async registerServer(server: ServerRegistration): Promise<void> {
this._ensureConnected();
this._servers.set(server.serverId, { ...server, lastHeartbeat: Date.now() });
await this.publish({
type: 'server:online',
serverId: server.serverId,
payload: server,
timestamp: Date.now()
});
}
async unregisterServer(serverId: string): Promise<void> {
this._ensureConnected();
const server = this._servers.get(serverId);
if (!server) return;
this._servers.delete(serverId);
// 清理该服务器的所有房间
for (const [roomId, room] of this._rooms) {
if (room.serverId === serverId) {
this._rooms.delete(roomId);
}
}
await this.publish({
type: 'server:offline',
serverId,
payload: { serverId },
timestamp: Date.now()
});
}
async heartbeat(serverId: string): Promise<void> {
this._ensureConnected();
const server = this._servers.get(serverId);
if (server) {
server.lastHeartbeat = Date.now();
}
}
async getServers(): Promise<ServerRegistration[]> {
this._ensureConnected();
return Array.from(this._servers.values()).filter(s => s.status === 'online');
}
async getServer(serverId: string): Promise<ServerRegistration | null> {
this._ensureConnected();
return this._servers.get(serverId) ?? null;
}
async updateServer(serverId: string, updates: Partial<ServerRegistration>): Promise<void> {
this._ensureConnected();
const server = this._servers.get(serverId);
if (server) {
Object.assign(server, updates);
}
}
// =========================================================================
// 房间注册 | Room Registry
// =========================================================================
async registerRoom(room: RoomRegistration): Promise<void> {
this._ensureConnected();
this._rooms.set(room.roomId, { ...room });
// 更新服务器的房间计数
const server = this._servers.get(room.serverId);
if (server) {
server.roomCount = this._countRoomsByServer(room.serverId);
}
await this.publish({
type: 'room:created',
serverId: room.serverId,
roomId: room.roomId,
payload: { roomType: room.roomType },
timestamp: Date.now()
});
}
async unregisterRoom(roomId: string): Promise<void> {
this._ensureConnected();
const room = this._rooms.get(roomId);
if (!room) return;
this._rooms.delete(roomId);
this._snapshots.delete(roomId);
// 更新服务器的房间计数
const server = this._servers.get(room.serverId);
if (server) {
server.roomCount = this._countRoomsByServer(room.serverId);
}
await this.publish({
type: 'room:disposed',
serverId: room.serverId,
roomId,
payload: {},
timestamp: Date.now()
});
}
async updateRoom(roomId: string, updates: Partial<RoomRegistration>): Promise<void> {
this._ensureConnected();
const room = this._rooms.get(roomId);
if (!room) return;
Object.assign(room, updates, { updatedAt: Date.now() });
await this.publish({
type: 'room:updated',
serverId: room.serverId,
roomId,
payload: updates,
timestamp: Date.now()
});
}
async getRoom(roomId: string): Promise<RoomRegistration | null> {
this._ensureConnected();
return this._rooms.get(roomId) ?? null;
}
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> {
this._ensureConnected();
let results = Array.from(this._rooms.values());
// 按类型过滤
if (query.roomType) {
results = results.filter(r => r.roomType === query.roomType);
}
// 按空位过滤
if (query.hasSpace) {
results = results.filter(r => r.playerCount < r.maxPlayers);
}
// 按锁定状态过滤
if (query.notLocked) {
results = results.filter(r => !r.isLocked);
}
// 按元数据过滤
if (query.metadata) {
results = results.filter(r => {
for (const [key, value] of Object.entries(query.metadata!)) {
if (r.metadata[key] !== value) {
return false;
}
}
return true;
});
}
// 分页
if (query.offset) {
results = results.slice(query.offset);
}
if (query.limit) {
results = results.slice(0, query.limit);
}
return results;
}
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> {
const rooms = await this.queryRooms({
roomType,
hasSpace: true,
notLocked: true,
limit: 1
});
return rooms[0] ?? null;
}
async getRoomsByServer(serverId: string): Promise<RoomRegistration[]> {
this._ensureConnected();
return Array.from(this._rooms.values()).filter(r => r.serverId === serverId);
}
// =========================================================================
// 房间状态 | Room State
// =========================================================================
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> {
this._ensureConnected();
this._snapshots.set(snapshot.roomId, { ...snapshot });
}
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> {
this._ensureConnected();
return this._snapshots.get(roomId) ?? null;
}
async deleteSnapshot(roomId: string): Promise<void> {
this._ensureConnected();
this._snapshots.delete(roomId);
}
// =========================================================================
// 发布/订阅 | Pub/Sub
// =========================================================================
async publish(event: DistributedEvent): Promise<void> {
this._ensureConnected();
// 通知所有匹配的订阅者
const wildcardHandlers = this._subscribers.get('*') ?? new Set();
const typeHandlers = this._subscribers.get(event.type) ?? new Set();
for (const handler of wildcardHandlers) {
try {
handler(event);
} catch (error) {
console.error('Event handler error:', error);
}
}
for (const handler of typeHandlers) {
try {
handler(event);
} catch (error) {
console.error('Event handler error:', error);
}
}
}
async subscribe(
pattern: DistributedEventType | '*',
handler: DistributedEventHandler
): Promise<Unsubscribe> {
this._ensureConnected();
if (!this._subscribers.has(pattern)) {
this._subscribers.set(pattern, new Set());
}
this._subscribers.get(pattern)!.add(handler);
return () => {
const handlers = this._subscribers.get(pattern);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this._subscribers.delete(pattern);
}
}
};
}
async sendToRoom(
roomId: string,
messageType: string,
data: unknown,
playerId?: string
): Promise<void> {
this._ensureConnected();
const room = this._rooms.get(roomId);
if (!room) return;
await this.publish({
type: 'room:message',
serverId: room.serverId,
roomId,
payload: { messageType, data, playerId },
timestamp: Date.now()
});
}
// =========================================================================
// 分布式锁 | Distributed Lock
// =========================================================================
async acquireLock(key: string, ttlMs: number): Promise<boolean> {
this._ensureConnected();
const now = Date.now();
const existing = this._locks.get(key);
// 检查锁是否已过期
if (existing && existing.expireAt > now) {
return false;
}
// 获取锁
const owner = `lock_${++this._subscriberId}`;
this._locks.set(key, { owner, expireAt: now + ttlMs });
return true;
}
async releaseLock(key: string): Promise<void> {
this._ensureConnected();
this._locks.delete(key);
}
async extendLock(key: string, ttlMs: number): Promise<boolean> {
this._ensureConnected();
const lock = this._locks.get(key);
if (!lock) return false;
lock.expireAt = Date.now() + ttlMs;
return true;
}
// =========================================================================
// 私有方法 | Private Methods
// =========================================================================
private _ensureConnected(): void {
if (!this._connected) {
throw new Error('MemoryAdapter is not connected');
}
}
private _countRoomsByServer(serverId: string): number {
let count = 0;
for (const room of this._rooms.values()) {
if (room.serverId === serverId) {
count++;
}
}
return count;
}
private async _checkServerTtl(): Promise<void> {
const now = Date.now();
const expiredServers: string[] = [];
for (const [serverId, server] of this._servers) {
if (server.status === 'online' && now - server.lastHeartbeat > this._config.serverTtl) {
server.status = 'offline';
expiredServers.push(serverId);
}
}
// 发布服务器离线事件
for (const serverId of expiredServers) {
await this.publish({
type: 'server:offline',
serverId,
payload: { serverId, reason: 'heartbeat_timeout' },
timestamp: now
});
}
}
// =========================================================================
// 测试辅助方法 | Test Helper Methods
// =========================================================================
/**
* @zh 清除所有数据(仅用于测试)
* @en Clear all data (for testing only)
*/
_clear(): void {
this._servers.clear();
this._rooms.clear();
this._snapshots.clear();
this._locks.clear();
}
/**
* @zh 获取内部状态(仅用于测试)
* @en Get internal state (for testing only)
*/
_getState(): {
servers: Map<string, ServerRegistration>;
rooms: Map<string, RoomRegistration>;
snapshots: Map<string, RoomSnapshot>;
} {
return {
servers: this._servers,
rooms: this._rooms,
snapshots: this._snapshots
};
}
}

View File

@@ -0,0 +1,789 @@
/**
* @zh Redis 分布式适配器
* @en Redis distributed adapter
*
* @zh 基于 Redis 的分布式房间适配器,支持 Pub/Sub、分布式锁和状态持久化
* @en Redis-based distributed room adapter with Pub/Sub, distributed lock and state persistence
*/
import type { IDistributedAdapter } from './IDistributedAdapter.js';
import type {
ServerRegistration,
RoomRegistration,
RoomQuery,
RoomSnapshot,
DistributedEvent,
DistributedEventType,
DistributedEventHandler,
Unsubscribe
} from '../types.js';
/**
* @zh Redis 客户端接口(兼容 ioredis
* @en Redis client interface (compatible with ioredis)
*/
export interface RedisClient {
// 基础操作
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: (string | number)[]): Promise<string | null>;
del(...keys: string[]): Promise<number>;
expire(key: string, seconds: number): Promise<number>;
ttl(key: string): Promise<number>;
// Hash 操作
hget(key: string, field: string): Promise<string | null>;
hset(key: string, ...args: (string | number | Buffer)[]): Promise<number>;
hdel(key: string, ...fields: string[]): Promise<number>;
hgetall(key: string): Promise<Record<string, string>>;
hmset(key: string, ...args: (string | number | Buffer)[]): Promise<'OK'>;
// Set 操作
sadd(key: string, ...members: string[]): Promise<number>;
srem(key: string, ...members: string[]): Promise<number>;
smembers(key: string): Promise<string[]>;
// Pub/Sub
publish(channel: string, message: string): Promise<number>;
subscribe(channel: string): Promise<number>;
psubscribe(pattern: string): Promise<number>;
unsubscribe(...channels: string[]): Promise<number>;
punsubscribe(...patterns: string[]): Promise<number>;
// 事件(重载支持 message 事件的类型安全)
on(event: 'message', callback: (channel: string, message: string) => void): void;
on(event: 'pmessage', callback: (pattern: string, channel: string, message: string) => void): void;
on(event: string, callback: (...args: unknown[]) => void): void;
off(event: 'message', callback: (channel: string, message: string) => void): void;
off(event: 'pmessage', callback: (pattern: string, channel: string, message: string) => void): void;
off(event: string, callback: (...args: unknown[]) => void): void;
// Lua 脚本
eval(script: string, numkeys: number, ...args: (string | number)[]): Promise<unknown>;
// 连接
duplicate(): RedisClient;
quit(): Promise<'OK'>;
disconnect(): void;
}
/**
* @zh Redis 连接工厂
* @en Redis connection factory
*/
export type RedisClientFactory = () => RedisClient | Promise<RedisClient>;
/**
* @zh Redis 适配器配置
* @en Redis adapter configuration
*/
export interface RedisAdapterConfig {
/**
* @zh Redis 客户端工厂(惰性连接)
* @en Redis client factory (lazy connection)
*
* @example
* ```typescript
* import Redis from 'ioredis'
* const adapter = new RedisAdapter({
* factory: () => new Redis('redis://localhost:6379')
* })
* ```
*/
factory: RedisClientFactory;
/**
* @zh 键前缀
* @en Key prefix
* @default 'dist:'
*/
prefix?: string;
/**
* @zh 服务器 TTL
* @en Server TTL (seconds)
* @default 30
*/
serverTtl?: number;
/**
* @zh 房间 TTL0 = 永不过期
* @en Room TTL (seconds), 0 = never expire
* @default 0
*/
roomTtl?: number;
/**
* @zh 快照 TTL
* @en Snapshot TTL (seconds)
* @default 86400 (24 hours)
*/
snapshotTtl?: number;
/**
* @zh Pub/Sub 频道名
* @en Pub/Sub channel name
* @default 'distributed:events'
*/
channel?: string;
}
// Lua 脚本:安全释放锁
const RELEASE_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
// Lua 脚本:扩展锁 TTL
const EXTEND_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`;
/**
* @zh Redis 分布式适配器
* @en Redis distributed adapter
*
* @example
* ```typescript
* import Redis from 'ioredis'
* import { RedisAdapter, DistributedRoomManager } from '@esengine/server'
*
* const adapter = new RedisAdapter({
* factory: () => new Redis('redis://localhost:6379'),
* prefix: 'game:'
* })
*
* const manager = new DistributedRoomManager(adapter, {
* serverId: 'server-1',
* serverAddress: 'localhost',
* serverPort: 3000
* }, sendFn)
*
* await manager.start()
* ```
*/
export class RedisAdapter implements IDistributedAdapter {
private readonly _config: Required<RedisAdapterConfig>;
private _client: RedisClient | null = null;
private _subscriber: RedisClient | null = null;
private _connected = false;
// 锁的 owner token用于安全释放
private readonly _lockTokens = new Map<string, string>();
// 事件处理器
private readonly _handlers = new Map<string, Set<DistributedEventHandler>>();
private _messageHandler: ((channel: string, message: string) => void) | null = null;
constructor(config: RedisAdapterConfig) {
this._config = {
prefix: 'dist:',
serverTtl: 30,
roomTtl: 0,
snapshotTtl: 86400,
channel: 'distributed:events',
...config,
factory: config.factory
};
}
// =========================================================================
// Key 生成器 | Key Generators
// =========================================================================
private _key(type: string, id?: string): string {
return id
? `${this._config.prefix}${type}:${id}`
: `${this._config.prefix}${type}`;
}
private _serverKey(serverId: string): string {
return this._key('server', serverId);
}
private _roomKey(roomId: string): string {
return this._key('room', roomId);
}
private _snapshotKey(roomId: string): string {
return this._key('snapshot', roomId);
}
private _lockKey(key: string): string {
return this._key('lock', key);
}
private _serversSetKey(): string {
return this._key('servers');
}
private _roomsSetKey(): string {
return this._key('rooms');
}
private _serverRoomsKey(serverId: string): string {
return this._key('server-rooms', serverId);
}
// =========================================================================
// 生命周期 | Lifecycle
// =========================================================================
async connect(): Promise<void> {
if (this._connected) return;
// 创建主客户端
this._client = await this._config.factory();
// 创建订阅专用客户端
this._subscriber = this._client.duplicate();
// 设置消息处理器
this._messageHandler = (channel: string, message: string) => {
if (channel !== this._config.channel) return;
try {
const event: DistributedEvent = JSON.parse(message);
this._dispatchEvent(event);
} catch (error) {
console.error('[RedisAdapter] Failed to parse event:', error);
}
};
this._subscriber.on('message', this._messageHandler);
await this._subscriber.subscribe(this._config.channel);
this._connected = true;
}
async disconnect(): Promise<void> {
if (!this._connected) return;
// 清理订阅
if (this._subscriber) {
if (this._messageHandler) {
this._subscriber.off('message', this._messageHandler);
}
await this._subscriber.unsubscribe(this._config.channel);
this._subscriber.disconnect();
this._subscriber = null;
}
// 关闭主客户端
if (this._client) {
await this._client.quit();
this._client = null;
}
this._handlers.clear();
this._lockTokens.clear();
this._connected = false;
}
isConnected(): boolean {
return this._connected;
}
private _ensureConnected(): RedisClient {
if (!this._connected || !this._client) {
throw new Error('RedisAdapter is not connected');
}
return this._client;
}
// =========================================================================
// 服务器注册 | Server Registry
// =========================================================================
async registerServer(server: ServerRegistration): Promise<void> {
const client = this._ensureConnected();
const key = this._serverKey(server.serverId);
// 存储服务器信息
await client.hmset(
key,
'serverId', server.serverId,
'address', server.address,
'port', String(server.port),
'roomCount', String(server.roomCount),
'playerCount', String(server.playerCount),
'capacity', String(server.capacity),
'status', server.status,
'lastHeartbeat', String(Date.now()),
'metadata', JSON.stringify(server.metadata ?? {})
);
// 设置 TTL
await client.expire(key, this._config.serverTtl);
// 添加到服务器集合
await client.sadd(this._serversSetKey(), server.serverId);
// 发布事件
await this.publish({
type: 'server:online',
serverId: server.serverId,
payload: server,
timestamp: Date.now()
});
}
async unregisterServer(serverId: string): Promise<void> {
const client = this._ensureConnected();
const key = this._serverKey(serverId);
// 删除服务器信息
await client.del(key);
// 从服务器集合移除
await client.srem(this._serversSetKey(), serverId);
// 删除该服务器的所有房间
const roomIds = await client.smembers(this._serverRoomsKey(serverId));
for (const roomId of roomIds) {
await this.unregisterRoom(roomId);
}
await client.del(this._serverRoomsKey(serverId));
// 发布事件
await this.publish({
type: 'server:offline',
serverId,
payload: { serverId },
timestamp: Date.now()
});
}
async heartbeat(serverId: string): Promise<void> {
const client = this._ensureConnected();
const key = this._serverKey(serverId);
// 更新心跳时间并刷新 TTL
await client.hset(key, 'lastHeartbeat', String(Date.now()));
await client.expire(key, this._config.serverTtl);
}
async getServers(): Promise<ServerRegistration[]> {
const client = this._ensureConnected();
const serverIds = await client.smembers(this._serversSetKey());
const servers: ServerRegistration[] = [];
for (const serverId of serverIds) {
const server = await this.getServer(serverId);
if (server && server.status === 'online') {
servers.push(server);
}
}
return servers;
}
async getServer(serverId: string): Promise<ServerRegistration | null> {
const client = this._ensureConnected();
const key = this._serverKey(serverId);
const data = await client.hgetall(key);
if (!data || !data.serverId) return null;
return {
serverId: data.serverId,
address: data.address,
port: parseInt(data.port, 10),
roomCount: parseInt(data.roomCount, 10),
playerCount: parseInt(data.playerCount, 10),
capacity: parseInt(data.capacity, 10),
status: data.status as ServerRegistration['status'],
lastHeartbeat: parseInt(data.lastHeartbeat, 10),
metadata: data.metadata ? JSON.parse(data.metadata) : {}
};
}
async updateServer(serverId: string, updates: Partial<ServerRegistration>): Promise<void> {
const client = this._ensureConnected();
const key = this._serverKey(serverId);
const args: (string | number)[] = [];
if (updates.address !== undefined) args.push('address', updates.address);
if (updates.port !== undefined) args.push('port', String(updates.port));
if (updates.roomCount !== undefined) args.push('roomCount', String(updates.roomCount));
if (updates.playerCount !== undefined) args.push('playerCount', String(updates.playerCount));
if (updates.capacity !== undefined) args.push('capacity', String(updates.capacity));
if (updates.status !== undefined) args.push('status', updates.status);
if (updates.metadata !== undefined) args.push('metadata', JSON.stringify(updates.metadata));
if (args.length > 0) {
await client.hmset(key, ...args);
}
// 如果是 draining 状态,发布事件
if (updates.status === 'draining') {
await this.publish({
type: 'server:draining',
serverId,
payload: { serverId },
timestamp: Date.now()
});
}
}
// =========================================================================
// 房间注册 | Room Registry
// =========================================================================
async registerRoom(room: RoomRegistration): Promise<void> {
const client = this._ensureConnected();
const key = this._roomKey(room.roomId);
// 存储房间信息
await client.hmset(
key,
'roomId', room.roomId,
'roomType', room.roomType,
'serverId', room.serverId,
'serverAddress', room.serverAddress,
'playerCount', String(room.playerCount),
'maxPlayers', String(room.maxPlayers),
'isLocked', room.isLocked ? '1' : '0',
'metadata', JSON.stringify(room.metadata),
'createdAt', String(room.createdAt),
'updatedAt', String(room.updatedAt)
);
// 设置 TTL如果配置了
if (this._config.roomTtl > 0) {
await client.expire(key, this._config.roomTtl);
}
// 添加到房间集合
await client.sadd(this._roomsSetKey(), room.roomId);
// 添加到服务器的房间列表
await client.sadd(this._serverRoomsKey(room.serverId), room.roomId);
// 更新服务器房间计数
const roomCount = (await client.smembers(this._serverRoomsKey(room.serverId))).length;
await client.hset(this._serverKey(room.serverId), 'roomCount', String(roomCount));
// 发布事件
await this.publish({
type: 'room:created',
serverId: room.serverId,
roomId: room.roomId,
payload: { roomType: room.roomType },
timestamp: Date.now()
});
}
async unregisterRoom(roomId: string): Promise<void> {
const client = this._ensureConnected();
const room = await this.getRoom(roomId);
if (!room) return;
const key = this._roomKey(roomId);
// 删除房间信息
await client.del(key);
// 从房间集合移除
await client.srem(this._roomsSetKey(), roomId);
// 从服务器的房间列表移除
await client.srem(this._serverRoomsKey(room.serverId), roomId);
// 更新服务器房间计数
const roomCount = (await client.smembers(this._serverRoomsKey(room.serverId))).length;
await client.hset(this._serverKey(room.serverId), 'roomCount', String(roomCount));
// 删除快照
await this.deleteSnapshot(roomId);
// 发布事件
await this.publish({
type: 'room:disposed',
serverId: room.serverId,
roomId,
payload: {},
timestamp: Date.now()
});
}
async updateRoom(roomId: string, updates: Partial<RoomRegistration>): Promise<void> {
const client = this._ensureConnected();
const room = await this.getRoom(roomId);
if (!room) return;
const key = this._roomKey(roomId);
const args: (string | number)[] = [];
if (updates.playerCount !== undefined) args.push('playerCount', String(updates.playerCount));
if (updates.maxPlayers !== undefined) args.push('maxPlayers', String(updates.maxPlayers));
if (updates.isLocked !== undefined) args.push('isLocked', updates.isLocked ? '1' : '0');
if (updates.metadata !== undefined) args.push('metadata', JSON.stringify(updates.metadata));
args.push('updatedAt', String(Date.now()));
if (args.length > 0) {
await client.hmset(key, ...args);
}
// 发布更新事件
await this.publish({
type: 'room:updated',
serverId: room.serverId,
roomId,
payload: updates,
timestamp: Date.now()
});
// 如果锁定状态变化,发布专门事件
if (updates.isLocked !== undefined) {
await this.publish({
type: updates.isLocked ? 'room:locked' : 'room:unlocked',
serverId: room.serverId,
roomId,
payload: {},
timestamp: Date.now()
});
}
}
async getRoom(roomId: string): Promise<RoomRegistration | null> {
const client = this._ensureConnected();
const key = this._roomKey(roomId);
const data = await client.hgetall(key);
if (!data || !data.roomId) return null;
return {
roomId: data.roomId,
roomType: data.roomType,
serverId: data.serverId,
serverAddress: data.serverAddress,
playerCount: parseInt(data.playerCount, 10),
maxPlayers: parseInt(data.maxPlayers, 10),
isLocked: data.isLocked === '1',
metadata: data.metadata ? JSON.parse(data.metadata) : {},
createdAt: parseInt(data.createdAt, 10),
updatedAt: parseInt(data.updatedAt, 10)
};
}
async queryRooms(query: RoomQuery): Promise<RoomRegistration[]> {
const client = this._ensureConnected();
const roomIds = await client.smembers(this._roomsSetKey());
let results: RoomRegistration[] = [];
// 获取所有房间
for (const roomId of roomIds) {
const room = await this.getRoom(roomId);
if (room) results.push(room);
}
// 过滤
if (query.roomType) {
results = results.filter(r => r.roomType === query.roomType);
}
if (query.hasSpace) {
results = results.filter(r => r.playerCount < r.maxPlayers);
}
if (query.notLocked) {
results = results.filter(r => !r.isLocked);
}
if (query.metadata) {
results = results.filter(r => {
for (const [key, value] of Object.entries(query.metadata!)) {
if (r.metadata[key] !== value) return false;
}
return true;
});
}
// 分页
if (query.offset) {
results = results.slice(query.offset);
}
if (query.limit) {
results = results.slice(0, query.limit);
}
return results;
}
async findAvailableRoom(roomType: string): Promise<RoomRegistration | null> {
const rooms = await this.queryRooms({
roomType,
hasSpace: true,
notLocked: true,
limit: 1
});
return rooms[0] ?? null;
}
async getRoomsByServer(serverId: string): Promise<RoomRegistration[]> {
const client = this._ensureConnected();
const roomIds = await client.smembers(this._serverRoomsKey(serverId));
const rooms: RoomRegistration[] = [];
for (const roomId of roomIds) {
const room = await this.getRoom(roomId);
if (room) rooms.push(room);
}
return rooms;
}
// =========================================================================
// 房间状态 | Room State
// =========================================================================
async saveSnapshot(snapshot: RoomSnapshot): Promise<void> {
const client = this._ensureConnected();
const key = this._snapshotKey(snapshot.roomId);
await client.set(key, JSON.stringify(snapshot));
await client.expire(key, this._config.snapshotTtl);
}
async loadSnapshot(roomId: string): Promise<RoomSnapshot | null> {
const client = this._ensureConnected();
const key = this._snapshotKey(roomId);
const data = await client.get(key);
return data ? JSON.parse(data) : null;
}
async deleteSnapshot(roomId: string): Promise<void> {
const client = this._ensureConnected();
const key = this._snapshotKey(roomId);
await client.del(key);
}
// =========================================================================
// 发布/订阅 | Pub/Sub
// =========================================================================
async publish(event: DistributedEvent): Promise<void> {
const client = this._ensureConnected();
await client.publish(this._config.channel, JSON.stringify(event));
}
async subscribe(
pattern: DistributedEventType | '*',
handler: DistributedEventHandler
): Promise<Unsubscribe> {
if (!this._handlers.has(pattern)) {
this._handlers.set(pattern, new Set());
}
this._handlers.get(pattern)!.add(handler);
return () => {
const handlers = this._handlers.get(pattern);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this._handlers.delete(pattern);
}
}
};
}
async sendToRoom(
roomId: string,
messageType: string,
data: unknown,
playerId?: string
): Promise<void> {
const room = await this.getRoom(roomId);
if (!room) return;
await this.publish({
type: 'room:message',
serverId: room.serverId,
roomId,
payload: { messageType, data, playerId },
timestamp: Date.now()
});
}
private _dispatchEvent(event: DistributedEvent): void {
// 通知通配符订阅者
const wildcardHandlers = this._handlers.get('*');
if (wildcardHandlers) {
for (const handler of wildcardHandlers) {
try {
handler(event);
} catch (error) {
console.error('[RedisAdapter] Event handler error:', error);
}
}
}
// 通知类型匹配的订阅者
const typeHandlers = this._handlers.get(event.type);
if (typeHandlers) {
for (const handler of typeHandlers) {
try {
handler(event);
} catch (error) {
console.error('[RedisAdapter] Event handler error:', error);
}
}
}
}
// =========================================================================
// 分布式锁 | Distributed Lock
// =========================================================================
async acquireLock(key: string, ttlMs: number): Promise<boolean> {
const client = this._ensureConnected();
const lockKey = this._lockKey(key);
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`;
const ttlSeconds = Math.ceil(ttlMs / 1000);
const result = await client.set(lockKey, token, 'NX', 'EX', ttlSeconds);
if (result === 'OK') {
this._lockTokens.set(key, token);
return true;
}
return false;
}
async releaseLock(key: string): Promise<void> {
const client = this._ensureConnected();
const lockKey = this._lockKey(key);
const token = this._lockTokens.get(key);
if (!token) return;
await client.eval(RELEASE_LOCK_SCRIPT, 1, lockKey, token);
this._lockTokens.delete(key);
}
async extendLock(key: string, ttlMs: number): Promise<boolean> {
const client = this._ensureConnected();
const lockKey = this._lockKey(key);
const token = this._lockTokens.get(key);
if (!token) return false;
const result = await client.eval(EXTEND_LOCK_SCRIPT, 1, lockKey, token, String(ttlMs));
return result === 1;
}
}
/**
* @zh 创建 Redis 适配器
* @en Create Redis adapter
*/
export function createRedisAdapter(config: RedisAdapterConfig): RedisAdapter {
return new RedisAdapter(config);
}

View File

@@ -0,0 +1,14 @@
/**
* @zh 分布式适配器模块导出
* @en Distributed adapters module exports
*/
export type { IDistributedAdapter } from './IDistributedAdapter.js';
export { MemoryAdapter, type MemoryAdapterConfig } from './MemoryAdapter.js';
export {
RedisAdapter,
createRedisAdapter,
type RedisAdapterConfig,
type RedisClient,
type RedisClientFactory
} from './RedisAdapter.js';

View File

@@ -0,0 +1,67 @@
/**
* @zh 分布式房间支持模块
* @en Distributed room support module
*
* @zh 提供多服务器房间管理、跨服务器路由和故障转移功能。
* @en Provides multi-server room management, cross-server routing, and failover features.
*
* @example
* ```typescript
* import {
* DistributedRoomManager,
* MemoryAdapter,
* type IDistributedAdapter
* } from '@esengine/server/distributed';
*
* // 单机模式(使用内存适配器)
* const adapter = new MemoryAdapter();
* const manager = new DistributedRoomManager(adapter, {
* serverId: 'server-1',
* serverAddress: 'localhost',
* serverPort: 3000
* }, sendFn);
*
* await manager.start();
* ```
*/
// 类型导出 | Type exports
export type {
ServerStatus,
ServerRegistration,
RoomRegistration,
RoomQuery,
PlayerSnapshot,
RoomSnapshot,
DistributedEventType,
DistributedEvent,
DistributedEventHandler,
Unsubscribe,
DistributedRoomManagerConfig,
DistributedConfig,
RoutingResultType,
RoutingResult,
RoutingRequest
} from './types.js';
// 适配器导出 | Adapter exports
export type { IDistributedAdapter } from './adapters/index.js';
export { MemoryAdapter, type MemoryAdapterConfig } from './adapters/index.js';
export {
RedisAdapter,
createRedisAdapter,
type RedisAdapterConfig,
type RedisClient,
type RedisClientFactory
} from './adapters/index.js';
// 路由模块 | Routing module
export {
LoadBalancedRouter,
createLoadBalancedRouter,
type LoadBalanceStrategy,
type LoadBalancedRouterConfig
} from './routing/index.js';
// 分布式房间管理器 | Distributed room manager
export { DistributedRoomManager } from './DistributedRoomManager.js';

View File

@@ -0,0 +1,198 @@
/**
* @zh 负载均衡路由器
* @en Load-balanced router for server selection
*/
import type { ServerRegistration } from '../types.js';
/**
* @zh 负载均衡策略
* @en Load balancing strategy
*/
export type LoadBalanceStrategy =
| 'round-robin' // 轮询
| 'least-rooms' // 最少房间
| 'least-players' // 最少玩家
| 'random' // 随机
| 'weighted'; // 加权(基于剩余容量)
/**
* @zh 负载均衡路由器配置
* @en Load-balanced router configuration
*/
export interface LoadBalancedRouterConfig {
/**
* @zh 负载均衡策略
* @en Load balancing strategy
* @default 'least-rooms'
*/
strategy?: LoadBalanceStrategy;
/**
* @zh 本地服务器优先
* @en Prefer local server
* @default true
*/
preferLocal?: boolean;
/**
* @zh 本地服务器优先阈值0-1之间表示本地服务器负载低于此比例时优先使用本地
* @en Local server preference threshold (0-1, prefer local if load is below this ratio)
* @default 0.8
*/
localPreferenceThreshold?: number;
}
/**
* @zh 负载均衡路由器
* @en Load-balanced router for selecting optimal server
*
* @example
* ```typescript
* const router = new LoadBalancedRouter({
* strategy: 'least-rooms',
* preferLocal: true
* });
*
* const bestServer = router.selectServer(servers, 'server-1');
* ```
*/
export class LoadBalancedRouter {
private readonly _config: Required<LoadBalancedRouterConfig>;
private _roundRobinIndex = 0;
constructor(config: LoadBalancedRouterConfig = {}) {
this._config = {
strategy: config.strategy ?? 'least-rooms',
preferLocal: config.preferLocal ?? true,
localPreferenceThreshold: config.localPreferenceThreshold ?? 0.8
};
}
/**
* @zh 选择最优服务器
* @en Select optimal server
*
* @param servers - 可用服务器列表 | Available servers
* @param localServerId - 本地服务器 ID | Local server ID
* @returns 最优服务器,如果没有可用服务器返回 null | Optimal server, or null if none available
*/
selectServer(
servers: ServerRegistration[],
localServerId?: string
): ServerRegistration | null {
// 过滤掉不可用的服务器
const availableServers = servers.filter(s =>
s.status === 'online' && s.roomCount < s.capacity
);
if (availableServers.length === 0) {
return null;
}
// 本地服务器优先检查
if (this._config.preferLocal && localServerId) {
const localServer = availableServers.find(s => s.serverId === localServerId);
if (localServer) {
const loadRatio = localServer.roomCount / localServer.capacity;
if (loadRatio < this._config.localPreferenceThreshold) {
return localServer;
}
}
}
// 应用负载均衡策略
switch (this._config.strategy) {
case 'round-robin':
return this._selectRoundRobin(availableServers);
case 'least-rooms':
return this._selectLeastRooms(availableServers);
case 'least-players':
return this._selectLeastPlayers(availableServers);
case 'random':
return this._selectRandom(availableServers);
case 'weighted':
return this._selectWeighted(availableServers);
default:
return this._selectLeastRooms(availableServers);
}
}
/**
* @zh 选择创建房间的最优服务器
* @en Select optimal server for room creation
*/
selectServerForCreation(
servers: ServerRegistration[],
localServerId?: string
): ServerRegistration | null {
return this.selectServer(servers, localServerId);
}
/**
* @zh 重置轮询索引
* @en Reset round-robin index
*/
resetRoundRobin(): void {
this._roundRobinIndex = 0;
}
// =========================================================================
// 私有方法 | Private Methods
// =========================================================================
private _selectRoundRobin(servers: ServerRegistration[]): ServerRegistration {
const server = servers[this._roundRobinIndex % servers.length];
this._roundRobinIndex++;
return server;
}
private _selectLeastRooms(servers: ServerRegistration[]): ServerRegistration {
return servers.reduce((best, current) =>
current.roomCount < best.roomCount ? current : best
);
}
private _selectLeastPlayers(servers: ServerRegistration[]): ServerRegistration {
return servers.reduce((best, current) =>
current.playerCount < best.playerCount ? current : best
);
}
private _selectRandom(servers: ServerRegistration[]): ServerRegistration {
return servers[Math.floor(Math.random() * servers.length)];
}
private _selectWeighted(servers: ServerRegistration[]): ServerRegistration {
// 计算每个服务器的权重(剩余容量占比)
const weights = servers.map(s => ({
server: s,
weight: (s.capacity - s.roomCount) / s.capacity
}));
// 计算总权重
const totalWeight = weights.reduce((sum, w) => sum + w.weight, 0);
// 随机选择(加权)
let random = Math.random() * totalWeight;
for (const { server, weight } of weights) {
random -= weight;
if (random <= 0) {
return server;
}
}
// 兜底返回第一个
return servers[0];
}
}
/**
* @zh 创建负载均衡路由器
* @en Create load-balanced router
*/
export function createLoadBalancedRouter(
config?: LoadBalancedRouterConfig
): LoadBalancedRouter {
return new LoadBalancedRouter(config);
}

View File

@@ -0,0 +1,11 @@
/**
* @zh 路由模块导出
* @en Routing module exports
*/
export {
LoadBalancedRouter,
createLoadBalancedRouter,
type LoadBalanceStrategy,
type LoadBalancedRouterConfig
} from './LoadBalancedRouter.js';

View File

@@ -0,0 +1,496 @@
/**
* @zh 分布式房间支持类型定义
* @en Distributed room support type definitions
*/
// =============================================================================
// 服务器注册 | Server Registration
// =============================================================================
/**
* @zh 服务器状态
* @en Server status
*/
export type ServerStatus = 'online' | 'draining' | 'offline';
/**
* @zh 服务器注册信息
* @en Server registration info
*/
export interface ServerRegistration {
/**
* @zh 服务器唯一标识
* @en Server unique identifier
*/
serverId: string;
/**
* @zh 服务器地址(供客户端连接)
* @en Server address (for client connection)
*/
address: string;
/**
* @zh 服务器端口
* @en Server port
*/
port: number;
/**
* @zh 当前房间数量
* @en Current room count
*/
roomCount: number;
/**
* @zh 当前玩家数量
* @en Current player count
*/
playerCount: number;
/**
* @zh 服务器容量(最大房间数)
* @en Server capacity (max rooms)
*/
capacity: number;
/**
* @zh 服务器状态
* @en Server status
*/
status: ServerStatus;
/**
* @zh 最后心跳时间戳
* @en Last heartbeat timestamp
*/
lastHeartbeat: number;
/**
* @zh 服务器元数据
* @en Server metadata
*/
metadata?: Record<string, unknown>;
}
// =============================================================================
// 房间注册 | Room Registration
// =============================================================================
/**
* @zh 房间注册信息
* @en Room registration info
*/
export interface RoomRegistration {
/**
* @zh 房间唯一标识
* @en Room unique identifier
*/
roomId: string;
/**
* @zh 房间类型名称
* @en Room type name
*/
roomType: string;
/**
* @zh 所在服务器 ID
* @en Host server ID
*/
serverId: string;
/**
* @zh 服务器地址
* @en Server address
*/
serverAddress: string;
/**
* @zh 当前玩家数量
* @en Current player count
*/
playerCount: number;
/**
* @zh 最大玩家数量
* @en Max player count
*/
maxPlayers: number;
/**
* @zh 房间是否已锁定
* @en Whether room is locked
*/
isLocked: boolean;
/**
* @zh 房间元数据(标签、自定义属性等)
* @en Room metadata (tags, custom properties, etc.)
*/
metadata: Record<string, unknown>;
/**
* @zh 创建时间戳
* @en Creation timestamp
*/
createdAt: number;
/**
* @zh 更新时间戳
* @en Update timestamp
*/
updatedAt: number;
}
/**
* @zh 房间查询条件
* @en Room query criteria
*/
export interface RoomQuery {
/**
* @zh 房间类型
* @en Room type
*/
roomType?: string;
/**
* @zh 服务器 ID查询特定服务器上的房间
* @en Server ID (query rooms on specific server)
*/
serverId?: string;
/**
* @zh 是否有空位
* @en Whether has available space
*/
hasSpace?: boolean;
/**
* @zh 是否未锁定
* @en Whether not locked
*/
notLocked?: boolean;
/**
* @zh 元数据过滤
* @en Metadata filter
*/
metadata?: Record<string, unknown>;
/**
* @zh 返回数量限制
* @en Result limit
*/
limit?: number;
/**
* @zh 偏移量(分页)
* @en Offset (pagination)
*/
offset?: number;
}
// =============================================================================
// 房间状态快照 | Room State Snapshot
// =============================================================================
/**
* @zh 玩家快照
* @en Player snapshot
*/
export interface PlayerSnapshot {
/**
* @zh 玩家 ID
* @en Player ID
*/
id: string;
/**
* @zh 玩家数据
* @en Player data
*/
data: Record<string, unknown>;
}
/**
* @zh 房间状态快照
* @en Room state snapshot
*/
export interface RoomSnapshot<TState = unknown> {
/**
* @zh 房间 ID
* @en Room ID
*/
roomId: string;
/**
* @zh 房间类型
* @en Room type
*/
roomType: string;
/**
* @zh 房间状态
* @en Room state
*/
state: TState;
/**
* @zh 玩家列表
* @en Player list
*/
players: PlayerSnapshot[];
/**
* @zh 快照版本号
* @en Snapshot version
*/
version: number;
/**
* @zh 快照时间戳
* @en Snapshot timestamp
*/
timestamp: number;
}
// =============================================================================
// 分布式事件 | Distributed Events
// =============================================================================
/**
* @zh 分布式事件类型
* @en Distributed event types
*/
export type DistributedEventType =
| 'room:created'
| 'room:disposed'
| 'room:updated'
| 'room:locked'
| 'room:unlocked'
| 'room:message'
| 'room:migrated'
| 'player:joined'
| 'player:left'
| 'server:online'
| 'server:offline'
| 'server:draining';
/**
* @zh 分布式事件
* @en Distributed event
*/
export interface DistributedEvent<T = unknown> {
/**
* @zh 事件类型
* @en Event type
*/
type: DistributedEventType;
/**
* @zh 发送方服务器 ID
* @en Sender server ID
*/
serverId: string;
/**
* @zh 相关房间 ID可选
* @en Related room ID (optional)
*/
roomId?: string;
/**
* @zh 事件载荷
* @en Event payload
*/
payload: T;
/**
* @zh 事件时间戳
* @en Event timestamp
*/
timestamp: number;
}
/**
* @zh 事件处理器
* @en Event handler
*/
export type DistributedEventHandler<T = unknown> = (event: DistributedEvent<T>) => void;
/**
* @zh 取消订阅函数
* @en Unsubscribe function
*/
export type Unsubscribe = () => void;
// =============================================================================
// 分布式配置 | Distributed Configuration
// =============================================================================
/**
* @zh 分布式房间管理器配置
* @en Distributed room manager configuration
*/
export interface DistributedRoomManagerConfig {
/**
* @zh 服务器 ID唯一标识
* @en Server ID (unique identifier)
*/
serverId: string;
/**
* @zh 服务器公开地址(供客户端连接)
* @en Server public address (for client connection)
*/
serverAddress: string;
/**
* @zh 服务器端口
* @en Server port
*/
serverPort: number;
/**
* @zh 心跳间隔(毫秒)
* @en Heartbeat interval (ms)
* @default 5000
*/
heartbeatInterval?: number;
/**
* @zh 状态快照间隔毫秒0 = 禁用
* @en State snapshot interval (ms), 0 = disabled
* @default 30000
*/
snapshotInterval?: number;
/**
* @zh 房间迁移超时(毫秒)
* @en Room migration timeout (ms)
* @default 10000
*/
migrationTimeout?: number;
/**
* @zh 是否启用自动故障转移
* @en Whether to enable automatic failover
* @default true
*/
enableFailover?: boolean;
/**
* @zh 服务器容量(最大房间数)
* @en Server capacity (max rooms)
* @default 100
*/
capacity?: number;
/**
* @zh 服务器元数据
* @en Server metadata
*/
metadata?: Record<string, unknown>;
}
/**
* @zh 服务器分布式配置(用于 createServer
* @en Server distributed configuration (for createServer)
*/
export interface DistributedConfig extends Omit<DistributedRoomManagerConfig, 'serverPort'> {
/**
* @zh 是否启用分布式模式
* @en Whether to enable distributed mode
* @default false
*/
enabled: boolean;
/**
* @zh 分布式适配器(可选,默认使用 MemoryAdapter
* @en Distributed adapter (optional, defaults to MemoryAdapter)
*/
adapter?: import('./adapters/IDistributedAdapter.js').IDistributedAdapter;
/**
* @zh 服务器端口(可选,默认使用服务器配置的端口)
* @en Server port (optional, defaults to server config port)
*/
serverPort?: number;
}
// =============================================================================
// 路由 | Routing
// =============================================================================
/**
* @zh 路由结果类型
* @en Routing result type
*/
export type RoutingResultType = 'local' | 'redirect' | 'create' | 'unavailable';
/**
* @zh 路由结果
* @en Routing result
*/
export interface RoutingResult {
/**
* @zh 路由类型
* @en Routing type
*/
type: RoutingResultType;
/**
* @zh 目标服务器地址redirect 时)
* @en Target server address (for redirect)
*/
serverAddress?: string;
/**
* @zh 目标房间 ID
* @en Target room ID
*/
roomId?: string;
/**
* @zh 错误信息unavailable 时)
* @en Error message (for unavailable)
*/
reason?: string;
}
/**
* @zh 路由请求
* @en Routing request
*/
export interface RoutingRequest {
/**
* @zh 玩家 ID
* @en Player ID
*/
playerId: string;
/**
* @zh 房间类型joinOrCreate 时)
* @en Room type (for joinOrCreate)
*/
roomType?: string;
/**
* @zh 房间 IDjoinById 时)
* @en Room ID (for joinById)
*/
roomId?: string;
/**
* @zh 首选服务器 ID
* @en Preferred server ID
*/
preferredServerId?: string;
/**
* @zh 房间查询条件
* @en Room query criteria
*/
query?: RoomQuery;
}

View File

@@ -3,20 +3,17 @@
* @en ECSRoom integration tests
*/
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import {
Core,
Component,
ECSComponent,
sync,
initChangeTracker,
getSyncMetadata,
registerSyncComponent,
} from '@esengine/ecs-framework'
import { createTestEnv, type TestEnvironment, wait } from '../testing/TestServer.js'
import { ECSRoom } from './ECSRoom.js'
import type { Player } from '../room/Player.js'
import { onMessage } from '../room/decorators.js'
sync
} from '@esengine/ecs-framework';
import { createTestEnv, type TestEnvironment, wait } from '../testing/TestServer.js';
import { ECSRoom } from './ECSRoom.js';
import type { Player } from '../room/Player.js';
import { onMessage } from '../room/decorators.js';
// ============================================================================
// Test Components | 测试组件
@@ -24,16 +21,10 @@ import { onMessage } from '../room/decorators.js'
@ECSComponent('ECSRoomTest_PlayerComponent')
class PlayerComponent extends Component {
@sync('string') name: string = ''
@sync('uint16') score: number = 0
@sync('float32') x: number = 0
@sync('float32') y: number = 0
}
@ECSComponent('ECSRoomTest_HealthComponent')
class HealthComponent extends Component {
@sync('int32') current: number = 100
@sync('int32') max: number = 100
@sync('string') name: string = '';
@sync('uint16') score: number = 0;
@sync('float32') x: number = 0;
@sync('float32') y: number = 0;
}
// ============================================================================
@@ -50,69 +41,69 @@ interface TestPlayerData {
class TestECSRoom extends ECSRoom<TestRoomState, TestPlayerData> {
state: TestRoomState = {
gameStarted: false,
}
gameStarted: false
};
onCreate(): void {
// 可以在这里添加系统
}
onJoin(player: Player<TestPlayerData>): void {
const entity = this.createPlayerEntity(player.id)
const comp = entity.addComponent(new PlayerComponent())
comp.name = player.data.nickname || `Player_${player.id.slice(-4)}`
comp.x = Math.random() * 100
comp.y = Math.random() * 100
const entity = this.createPlayerEntity(player.id);
const comp = entity.addComponent(new PlayerComponent());
comp.name = player.data.nickname || `Player_${player.id.slice(-4)}`;
comp.x = Math.random() * 100;
comp.y = Math.random() * 100;
this.broadcast('PlayerJoined', {
playerId: player.id,
name: comp.name,
})
name: comp.name
});
}
async onLeave(player: Player<TestPlayerData>, reason?: string): Promise<void> {
await super.onLeave(player, reason)
this.broadcast('PlayerLeft', { playerId: player.id })
await super.onLeave(player, reason);
this.broadcast('PlayerLeft', { playerId: player.id });
}
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player<TestPlayerData>): void {
const entity = this.getPlayerEntity(player.id)
const entity = this.getPlayerEntity(player.id);
if (entity) {
const comp = entity.getComponent(PlayerComponent)
const comp = entity.getComponent(PlayerComponent);
if (comp) {
comp.x = data.x
comp.y = data.y
comp.x = data.x;
comp.y = data.y;
}
}
}
@onMessage('AddScore')
handleAddScore(data: { amount: number }, player: Player<TestPlayerData>): void {
const entity = this.getPlayerEntity(player.id)
const entity = this.getPlayerEntity(player.id);
if (entity) {
const comp = entity.getComponent(PlayerComponent)
const comp = entity.getComponent(PlayerComponent);
if (comp) {
comp.score += data.amount
comp.score += data.amount;
}
}
}
@onMessage('Ping')
handlePing(_data: unknown, player: Player<TestPlayerData>): void {
player.send('Pong', { timestamp: Date.now() })
player.send('Pong', { timestamp: Date.now() });
}
getWorld() {
return this.world
return this.world;
}
getScene() {
return this.scene
return this.scene;
}
getPlayerEntityCount(): number {
return this.scene.entities.buffer.length
return this.scene.entities.buffer.length;
}
}
@@ -121,25 +112,24 @@ class TestECSRoom extends ECSRoom<TestRoomState, TestPlayerData> {
// ============================================================================
describe('ECSRoom Integration Tests', () => {
let env: TestEnvironment
let env: TestEnvironment;
beforeAll(() => {
Core.create()
registerSyncComponent('ECSRoomTest_PlayerComponent', PlayerComponent)
registerSyncComponent('ECSRoomTest_HealthComponent', HealthComponent)
})
Core.create();
// @ECSComponent 装饰器已自动注册组件
});
afterAll(() => {
Core.destroy()
})
Core.destroy();
});
beforeEach(async () => {
env = await createTestEnv({ tickRate: 20 })
})
env = await createTestEnv({ tickRate: 20 });
});
afterEach(async () => {
await env.cleanup()
})
await env.cleanup();
});
// ========================================================================
// Room Creation | 房间创建
@@ -147,28 +137,28 @@ describe('ECSRoom Integration Tests', () => {
describe('Room Creation', () => {
it('should create ECSRoom with World and Scene', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client = await env.createClient()
await client.joinRoom('ecs-test')
const client = await env.createClient();
await client.joinRoom('ecs-test');
expect(client.roomId).toBeDefined()
})
expect(client.roomId).toBeDefined();
});
it('should have World managed by Core.worldManager', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client = await env.createClient()
await client.joinRoom('ecs-test')
const client = await env.createClient();
await client.joinRoom('ecs-test');
// 验证 World 正常创建(通过消息通信验证)
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
client.sendToRoom('Ping', {})
const pong = await pongPromise
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong');
client.sendToRoom('Ping', {});
const pong = await pongPromise;
expect(pong.timestamp).toBeGreaterThan(0)
})
})
expect(pong.timestamp).toBeGreaterThan(0);
});
});
// ========================================================================
// Player Entity Management | 玩家实体管理
@@ -176,41 +166,41 @@ describe('ECSRoom Integration Tests', () => {
describe('Player Entity Management', () => {
it('should create player entity on join', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('ecs-test')
const client1 = await env.createClient();
const { roomId } = await client1.joinRoom('ecs-test');
// 等待第二个玩家加入时收到广播
const joinPromise = client1.waitForRoomMessage<{ playerId: string; name: string }>(
'PlayerJoined'
)
);
const client2 = await env.createClient()
await client2.joinRoomById(roomId)
const client2 = await env.createClient();
await client2.joinRoomById(roomId);
const joinMsg = await joinPromise
expect(joinMsg.playerId).toBe(client2.playerId)
expect(joinMsg.name).toContain('Player_')
})
const joinMsg = await joinPromise;
expect(joinMsg.playerId).toBe(client2.playerId);
expect(joinMsg.name).toContain('Player_');
});
it('should destroy player entity on leave', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('ecs-test')
const client1 = await env.createClient();
const { roomId } = await client1.joinRoom('ecs-test');
const client2 = await env.createClient()
await client2.joinRoomById(roomId)
const client2 = await env.createClient();
await client2.joinRoomById(roomId);
const leavePromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerLeft')
const leavePromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerLeft');
await client2.leaveRoom()
await client2.leaveRoom();
const leaveMsg = await leavePromise
expect(leaveMsg.playerId).toBeDefined()
})
})
const leaveMsg = await leavePromise;
expect(leaveMsg.playerId).toBeDefined();
});
});
// ========================================================================
// Component Sync | 组件同步
@@ -218,41 +208,41 @@ describe('ECSRoom Integration Tests', () => {
describe('Component State Updates', () => {
it('should update component via message handler', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client = await env.createClient()
await client.joinRoom('ecs-test')
const client = await env.createClient();
await client.joinRoom('ecs-test');
client.sendToRoom('Move', { x: 100, y: 200 })
client.sendToRoom('Move', { x: 100, y: 200 });
// 等待处理
await wait(50)
await wait(50);
// 验证 Ping/Pong 仍能工作(房间仍活跃)
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
client.sendToRoom('Ping', {})
const pong = await pongPromise
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong');
client.sendToRoom('Ping', {});
const pong = await pongPromise;
expect(pong.timestamp).toBeGreaterThan(0)
})
expect(pong.timestamp).toBeGreaterThan(0);
});
it('should handle AddScore message', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client = await env.createClient()
await client.joinRoom('ecs-test')
const client = await env.createClient();
await client.joinRoom('ecs-test');
client.sendToRoom('AddScore', { amount: 50 })
client.sendToRoom('AddScore', { amount: 25 })
client.sendToRoom('AddScore', { amount: 50 });
client.sendToRoom('AddScore', { amount: 25 });
await wait(50)
await wait(50);
// 确认房间仍然活跃
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
client.sendToRoom('Ping', {})
await pongPromise
})
})
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong');
client.sendToRoom('Ping', {});
await pongPromise;
});
});
// ========================================================================
// Sync Broadcast | 同步广播
@@ -260,22 +250,22 @@ describe('ECSRoom Integration Tests', () => {
describe('State Sync Broadcast', () => {
it('should receive $sync messages when enabled', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client = await env.createClient()
await client.joinRoom('ecs-test')
const client = await env.createClient();
await client.joinRoom('ecs-test');
// 触发状态变更
client.sendToRoom('Move', { x: 50, y: 75 })
client.sendToRoom('Move', { x: 50, y: 75 });
// 等待 tick 处理
await wait(200)
await wait(200);
// 检查是否收到 $sync 消息
const hasSync = client.hasReceivedMessage('RoomMessage')
expect(hasSync).toBe(true)
})
})
const hasSync = client.hasReceivedMessage('RoomMessage');
expect(hasSync).toBe(true);
});
});
// ========================================================================
// Multi-player Sync | 多玩家同步
@@ -283,47 +273,47 @@ describe('ECSRoom Integration Tests', () => {
describe('Multi-player Scenarios', () => {
it('should handle multiple players in same room', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('ecs-test')
const client1 = await env.createClient();
const { roomId } = await client1.joinRoom('ecs-test');
const client2 = await env.createClient()
const joinPromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerJoined')
await client2.joinRoomById(roomId)
const client2 = await env.createClient();
const joinPromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerJoined');
await client2.joinRoomById(roomId);
const joinMsg = await joinPromise
expect(joinMsg.playerId).toBe(client2.playerId)
})
const joinMsg = await joinPromise;
expect(joinMsg.playerId).toBe(client2.playerId);
});
it('should broadcast to all players on state change', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('ecs-test')
const client1 = await env.createClient();
const { roomId } = await client1.joinRoom('ecs-test');
const client2 = await env.createClient()
const client2 = await env.createClient();
// client1 等待收到 client2 加入的广播
const joinPromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerJoined')
const joinPromise = client1.waitForRoomMessage<{ playerId: string }>('PlayerJoined');
await client2.joinRoomById(roomId)
await client2.joinRoomById(roomId);
const joinMsg = await joinPromise
expect(joinMsg.playerId).toBe(client2.playerId)
const joinMsg = await joinPromise;
expect(joinMsg.playerId).toBe(client2.playerId);
// 验证每个客户端都能独立通信
const pong1Promise = client1.waitForRoomMessage<{ timestamp: number }>('Pong')
client1.sendToRoom('Ping', {})
const pong1 = await pong1Promise
expect(pong1.timestamp).toBeGreaterThan(0)
const pong1Promise = client1.waitForRoomMessage<{ timestamp: number }>('Pong');
client1.sendToRoom('Ping', {});
const pong1 = await pong1Promise;
expect(pong1.timestamp).toBeGreaterThan(0);
const pong2Promise = client2.waitForRoomMessage<{ timestamp: number }>('Pong')
client2.sendToRoom('Ping', {})
const pong2 = await pong2Promise
expect(pong2.timestamp).toBeGreaterThan(0)
})
})
const pong2Promise = client2.waitForRoomMessage<{ timestamp: number }>('Pong');
client2.sendToRoom('Ping', {});
const pong2 = await pong2Promise;
expect(pong2.timestamp).toBeGreaterThan(0);
});
});
// ========================================================================
// Cleanup | 清理
@@ -331,18 +321,18 @@ describe('ECSRoom Integration Tests', () => {
describe('Room Cleanup', () => {
it('should cleanup World on dispose', async () => {
env.server.define('ecs-test', TestECSRoom)
env.server.define('ecs-test', TestECSRoom);
const client = await env.createClient()
await client.joinRoom('ecs-test')
const client = await env.createClient();
await client.joinRoom('ecs-test');
await client.leaveRoom()
await client.leaveRoom();
// 等待自动销毁
await wait(100)
await wait(100);
// 房间应该已销毁
expect(client.roomId).toBeNull()
})
})
})
expect(client.roomId).toBeNull();
});
});
});

View File

@@ -24,7 +24,7 @@ import {
NETWORK_ENTITY_METADATA,
type NetworkEntityMetadata,
// Events
ECSEventType,
ECSEventType
} from '@esengine/ecs-framework';
import { Room, type RoomOptions } from '../room/Room.js';
@@ -62,7 +62,7 @@ export interface ECSRoomConfig {
const DEFAULT_ECS_CONFIG: ECSRoomConfig = {
syncInterval: 50, // 20 Hz
enableDeltaSync: true,
enableAutoNetworkEntity: true,
enableAutoNetworkEntity: true
};
/**
@@ -143,6 +143,15 @@ export abstract class ECSRoom<TState = any, TPlayerData = Record<string, unknown
constructor(ecsConfig?: Partial<ECSRoomConfig>) {
super();
// Check Core initialization
if (!Core.worldManager) {
throw new Error(
'ECSRoom requires Core.create() to be called first. ' +
'Ensure Core is initialized before creating ECSRoom instances.'
);
}
this.ecsConfig = { ...DEFAULT_ECS_CONFIG, ...ecsConfig };
this.worldId = `room_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
@@ -268,9 +277,12 @@ export abstract class ECSRoom<TState = any, TPlayerData = Record<string, unknown
/**
* @zh 发送二进制数据给指定玩家
* @en Send binary data to specific player
*
* @zh 使用原生 WebSocket 二进制帧发送,效率更高
* @en Uses native WebSocket binary frames, more efficient
*/
protected sendBinary(player: Player<TPlayerData>, data: Uint8Array): void {
player.send('$sync', { data: Array.from(data) });
player.sendBinary(data);
}
/**
@@ -305,7 +317,7 @@ export abstract class ECSRoom<TState = any, TPlayerData = Record<string, unknown
*/
protected broadcastDelta(): void {
const entities = this._getSyncEntities();
const changedEntities = entities.filter(entity => this._hasChanges(entity));
const changedEntities = entities.filter((entity) => this._hasChanges(entity));
if (changedEntities.length === 0) return;

View File

@@ -41,7 +41,7 @@ export type {
Component,
EntitySystem,
Scene,
World,
World
} from '@esengine/ecs-framework';
// Re-export sync types
@@ -55,7 +55,7 @@ export {
SyncOperation,
type SyncType,
type SyncFieldMetadata,
type SyncMetadata,
type SyncMetadata
} from '@esengine/ecs-framework';
// Re-export room decorators

View File

@@ -3,7 +3,20 @@
* @en API, message, and HTTP definition helpers
*/
import type { ApiDefinition, MsgDefinition, HttpDefinition } from '../types/index.js'
import type { ApiDefinition, MsgDefinition, HttpDefinition } from '../types/index.js';
import type { Validator, Infer } from '../schema/index.js';
/**
* @zh 带 Schema 的 API 定义选项
* @en API definition options with schema
*/
export interface ApiDefinitionWithSchema<TReq, TRes, TData = Record<string, unknown>> extends ApiDefinition<TReq, TRes, TData> {
/**
* @zh 请求数据 Schema自动验证
* @en Request data schema (auto validation)
*/
schema?: Validator<TReq>;
}
/**
* @zh 定义 API 处理器
@@ -21,11 +34,79 @@ import type { ApiDefinition, MsgDefinition, HttpDefinition } from '../types/inde
* }
* })
* ```
*
* @example
* ```typescript
* // 使用 Schema 验证 | With schema validation
* import { defineApi, s } from '@esengine/server'
*
* const MoveSchema = s.object({
* x: s.number(),
* y: s.number()
* });
*
* export default defineApi({
* schema: MoveSchema,
* handler(req, ctx) {
* // req 已验证,类型安全 | req is validated, type-safe
* console.log(req.x, req.y);
* }
* })
* ```
*/
export function defineApi<TReq, TRes, TData = Record<string, unknown>>(
definition: ApiDefinition<TReq, TRes, TData>
): ApiDefinition<TReq, TRes, TData> {
return definition
definition: ApiDefinitionWithSchema<TReq, TRes, TData>
): ApiDefinitionWithSchema<TReq, TRes, TData> {
return definition;
}
/**
* @zh 使用 Schema 定义 API 处理器(类型自动推断)
* @en Define API handler with schema (auto type inference)
*
* @example
* ```typescript
* import { defineApiWithSchema, s } from '@esengine/server'
*
* const MoveSchema = s.object({
* x: s.number(),
* y: s.number()
* });
*
* export default defineApiWithSchema(MoveSchema, {
* handler(req, ctx) {
* // req 类型自动推断为 { x: number, y: number }
* // req type is auto-inferred as { x: number, y: number }
* console.log(req.x, req.y);
* return { success: true };
* }
* })
* ```
*/
export function defineApiWithSchema<
TReq,
TRes,
TData = Record<string, unknown>
>(
schema: Validator<TReq>,
definition: Omit<ApiDefinition<TReq, TRes, TData>, 'validate'>
): ApiDefinitionWithSchema<TReq, TRes, TData> {
return {
...definition,
schema
};
}
/**
* @zh 带 Schema 的消息定义选项
* @en Message definition options with schema
*/
export interface MsgDefinitionWithSchema<TMsg, TData = Record<string, unknown>> extends MsgDefinition<TMsg, TData> {
/**
* @zh 消息数据 Schema自动验证
* @en Message data schema (auto validation)
*/
schema?: Validator<TMsg>;
}
/**
@@ -43,11 +124,65 @@ export function defineApi<TReq, TRes, TData = Record<string, unknown>>(
* }
* })
* ```
*
* @example
* ```typescript
* // 使用 Schema 验证 | With schema validation
* import { defineMsg, s } from '@esengine/server'
*
* const InputSchema = s.object({
* keys: s.array(s.string()),
* timestamp: s.number()
* });
*
* export default defineMsg({
* schema: InputSchema,
* handler(msg, ctx) {
* // msg 已验证,类型安全 | msg is validated, type-safe
* console.log(msg.keys, msg.timestamp);
* }
* })
* ```
*/
export function defineMsg<TMsg, TData = Record<string, unknown>>(
definition: MsgDefinitionWithSchema<TMsg, TData>
): MsgDefinitionWithSchema<TMsg, TData> {
return definition;
}
/**
* @zh 使用 Schema 定义消息处理器(类型自动推断)
* @en Define message handler with schema (auto type inference)
*
* @example
* ```typescript
* import { defineMsgWithSchema, s } from '@esengine/server'
*
* const InputSchema = s.object({
* keys: s.array(s.string()),
* timestamp: s.number()
* });
*
* export default defineMsgWithSchema(InputSchema, {
* handler(msg, ctx) {
* // msg 类型自动推断
* // msg type is auto-inferred
* console.log(msg.keys, msg.timestamp);
* }
* })
* ```
*/
export function defineMsgWithSchema<
TMsg,
TData = Record<string, unknown>
>(
schema: Validator<TMsg>,
definition: MsgDefinition<TMsg, TData>
): MsgDefinition<TMsg, TData> {
return definition
): MsgDefinitionWithSchema<TMsg, TData> {
return {
...definition,
schema
};
}
/**
@@ -77,5 +212,5 @@ export function defineMsg<TMsg, TData = Record<string, unknown>>(
export function defineHttp<TBody = unknown>(
definition: HttpDefinition<TBody>
): HttpDefinition<TBody> {
return definition
return definition;
}

View File

@@ -0,0 +1,672 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { createHttpRouter } from '../router.js';
import type { HttpMiddleware, HttpRoutes } from '../types.js';
/**
* @zh 创建模拟请求对象
* @en Create mock request object
*/
function createMockRequest(options: {
method?: string;
url?: string;
headers?: Record<string, string>;
body?: string;
} = {}): IncomingMessage {
const { method = 'GET', url = '/', headers = {}, body } = options;
const req = {
method,
url,
headers: { host: 'localhost', ...headers },
socket: { remoteAddress: '127.0.0.1' },
on: vi.fn((event: string, handler: (data?: any) => void) => {
if (event === 'data' && body) {
handler(Buffer.from(body));
}
if (event === 'end') {
handler();
}
return req;
})
} as unknown as IncomingMessage;
return req;
}
/**
* @zh 创建模拟响应对象
* @en Create mock response object
*/
function createMockResponse(): ServerResponse & {
_statusCode: number;
_headers: Record<string, string>;
_body: string;
} {
const res = {
_statusCode: 200,
_headers: {} as Record<string, string>,
_body: '',
writableEnded: false,
get statusCode() {
return this._statusCode;
},
set statusCode(code: number) {
this._statusCode = code;
},
setHeader(name: string, value: string) {
this._headers[name.toLowerCase()] = value;
},
removeHeader(name: string) {
delete this._headers[name.toLowerCase()];
},
end(data?: string) {
this._body = data ?? '';
this.writableEnded = true;
}
} as any;
return res;
}
describe('HTTP Router', () => {
describe('Route Matching', () => {
it('should match exact paths', async () => {
const handler = vi.fn((req, res) => res.json({ ok: true }));
const router = createHttpRouter({
'/api/health': handler
});
const req = createMockRequest({ url: '/api/health' });
const res = createMockResponse();
const matched = await router(req, res);
expect(matched).toBe(true);
expect(handler).toHaveBeenCalled();
});
it('should return false for non-matching paths', async () => {
const router = createHttpRouter({
'/api/health': (req, res) => res.json({ ok: true })
});
const req = createMockRequest({ url: '/api/unknown' });
const res = createMockResponse();
const matched = await router(req, res);
expect(matched).toBe(false);
});
it('should match by HTTP method', async () => {
const getHandler = vi.fn((req, res) => res.json({ method: 'GET' }));
const postHandler = vi.fn((req, res) => res.json({ method: 'POST' }));
const router = createHttpRouter({
'/api/users': {
GET: getHandler,
POST: postHandler
}
});
const getReq = createMockRequest({ method: 'GET', url: '/api/users' });
const getRes = createMockResponse();
await router(getReq, getRes);
expect(getHandler).toHaveBeenCalled();
expect(postHandler).not.toHaveBeenCalled();
getHandler.mockClear();
postHandler.mockClear();
const postReq = createMockRequest({ method: 'POST', url: '/api/users' });
const postRes = createMockResponse();
await router(postReq, postRes);
expect(postHandler).toHaveBeenCalled();
expect(getHandler).not.toHaveBeenCalled();
});
});
describe('Route Parameters', () => {
it('should extract single route param', async () => {
let capturedParams: Record<string, string> = {};
const router = createHttpRouter({
'/users/:id': (req, res) => {
capturedParams = req.params;
res.json({ id: req.params.id });
}
});
const req = createMockRequest({ url: '/users/123' });
const res = createMockResponse();
await router(req, res);
expect(capturedParams).toEqual({ id: '123' });
});
it('should extract multiple route params', async () => {
let capturedParams: Record<string, string> = {};
const router = createHttpRouter({
'/users/:userId/posts/:postId': (req, res) => {
capturedParams = req.params;
res.json(req.params);
}
});
const req = createMockRequest({ url: '/users/42/posts/99' });
const res = createMockResponse();
await router(req, res);
expect(capturedParams).toEqual({ userId: '42', postId: '99' });
});
it('should decode URI components in params', async () => {
let capturedParams: Record<string, string> = {};
const router = createHttpRouter({
'/search/:query': (req, res) => {
capturedParams = req.params;
res.json({ query: req.params.query });
}
});
const req = createMockRequest({ url: '/search/hello%20world' });
const res = createMockResponse();
await router(req, res);
expect(capturedParams.query).toBe('hello world');
});
it('should prioritize static routes over param routes', async () => {
const staticHandler = vi.fn((req, res) => res.json({ type: 'static' }));
const paramHandler = vi.fn((req, res) => res.json({ type: 'param' }));
const router = createHttpRouter({
'/users/me': staticHandler,
'/users/:id': paramHandler
});
const req = createMockRequest({ url: '/users/me' });
const res = createMockResponse();
await router(req, res);
expect(staticHandler).toHaveBeenCalled();
expect(paramHandler).not.toHaveBeenCalled();
});
});
describe('Middleware', () => {
it('should execute global middlewares in order', async () => {
const order: number[] = [];
const middleware1: HttpMiddleware = async (req, res, next) => {
order.push(1);
await next();
order.push(4);
};
const middleware2: HttpMiddleware = async (req, res, next) => {
order.push(2);
await next();
order.push(3);
};
const router = createHttpRouter(
{
'/test': (req, res) => {
order.push(0);
res.json({ ok: true });
}
},
{ middlewares: [middleware1, middleware2] }
);
const req = createMockRequest({ url: '/test' });
const res = createMockResponse();
await router(req, res);
expect(order).toEqual([1, 2, 0, 3, 4]);
});
it('should allow middleware to short-circuit', async () => {
const handler = vi.fn((req, res) => res.json({ ok: true }));
const authMiddleware: HttpMiddleware = async (req, res, next) => {
res.error(401, 'Unauthorized');
// 不调用 next()
};
const router = createHttpRouter(
{ '/protected': handler },
{ middlewares: [authMiddleware] }
);
const req = createMockRequest({ url: '/protected' });
const res = createMockResponse();
await router(req, res);
expect(handler).not.toHaveBeenCalled();
expect(res._statusCode).toBe(401);
});
it('should execute route-level middlewares', async () => {
const globalMiddleware = vi.fn(async (req, res, next) => {
(req as any).global = true;
await next();
});
const routeMiddleware = vi.fn(async (req, res, next) => {
(req as any).route = true;
await next();
});
let receivedReq: any;
const router = createHttpRouter(
{
'/test': {
handler: (req, res) => {
receivedReq = req;
res.json({ ok: true });
},
middlewares: [routeMiddleware]
}
},
{ middlewares: [globalMiddleware] }
);
const req = createMockRequest({ url: '/test' });
const res = createMockResponse();
await router(req, res);
expect(globalMiddleware).toHaveBeenCalled();
expect(routeMiddleware).toHaveBeenCalled();
expect(receivedReq.global).toBe(true);
expect(receivedReq.route).toBe(true);
});
});
describe('Timeout', () => {
it('should timeout slow handlers', async () => {
const router = createHttpRouter(
{
'/slow': async (req, res) => {
await new Promise(resolve => setTimeout(resolve, 200));
res.json({ ok: true });
}
},
{ timeout: 50 }
);
const req = createMockRequest({ url: '/slow' });
const res = createMockResponse();
await router(req, res);
expect(res._statusCode).toBe(408);
expect(JSON.parse(res._body)).toEqual({ error: 'Request Timeout' });
});
it('should use route-specific timeout over global', async () => {
const router = createHttpRouter(
{
'/slow': {
handler: async (req, res) => {
await new Promise(resolve => setTimeout(resolve, 100));
res.json({ ok: true });
},
timeout: 200 // 路由级超时更长
}
},
{ timeout: 50 } // 全局超时较短
);
const req = createMockRequest({ url: '/slow' });
const res = createMockResponse();
await router(req, res);
// 应该成功,因为路由级超时是 200ms
expect(res._statusCode).toBe(200);
});
it('should not timeout fast handlers', async () => {
const router = createHttpRouter(
{
'/fast': (req, res) => {
res.json({ ok: true });
}
},
{ timeout: 1000 }
);
const req = createMockRequest({ url: '/fast' });
const res = createMockResponse();
await router(req, res);
expect(res._statusCode).toBe(200);
});
});
describe('Request Parsing', () => {
it('should parse query parameters', async () => {
let capturedQuery: Record<string, string> = {};
const router = createHttpRouter({
'/search': (req, res) => {
capturedQuery = req.query;
res.json({ query: req.query });
}
});
const req = createMockRequest({ url: '/search?q=hello&page=1' });
const res = createMockResponse();
await router(req, res);
expect(capturedQuery).toEqual({ q: 'hello', page: '1' });
});
it('should parse JSON body', async () => {
let capturedBody: unknown;
const router = createHttpRouter({
'/api/data': {
POST: (req, res) => {
capturedBody = req.body;
res.json({ received: true });
}
}
});
const req = createMockRequest({
method: 'POST',
url: '/api/data',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'test', value: 42 })
});
const res = createMockResponse();
await router(req, res);
expect(capturedBody).toEqual({ name: 'test', value: 42 });
});
it('should extract client IP', async () => {
let capturedIp: string = '';
const router = createHttpRouter({
'/ip': (req, res) => {
capturedIp = req.ip;
res.json({ ip: req.ip });
}
});
const req = createMockRequest({ url: '/ip' });
const res = createMockResponse();
await router(req, res);
expect(capturedIp).toBe('127.0.0.1');
});
it('should prefer X-Forwarded-For header for IP', async () => {
let capturedIp: string = '';
const router = createHttpRouter({
'/ip': (req, res) => {
capturedIp = req.ip;
res.json({ ip: req.ip });
}
});
const req = createMockRequest({
url: '/ip',
headers: { 'x-forwarded-for': '203.0.113.195, 70.41.3.18' }
});
const res = createMockResponse();
await router(req, res);
expect(capturedIp).toBe('203.0.113.195');
});
});
describe('CORS', () => {
it('should handle OPTIONS preflight', async () => {
const router = createHttpRouter(
{ '/api/data': (req, res) => res.json({}) },
{ cors: true }
);
const req = createMockRequest({
method: 'OPTIONS',
url: '/api/data',
headers: { origin: 'http://example.com' }
});
const res = createMockResponse();
await router(req, res);
expect(res._statusCode).toBe(204);
// cors: true 使用通配符 '*',安全默认不启用 credentials
expect(res._headers['access-control-allow-origin']).toBe('*');
});
it('should use wildcard when origin: true without credentials (for security)', async () => {
// 为了安全(避免 CodeQL 警告origin: true 现在等同于 origin: '*'
// For security (avoiding CodeQL warnings), origin: true now equals origin: '*'
const router = createHttpRouter(
{ '/api/data': (req, res) => res.json({}) },
{ cors: { origin: true } }
);
const req = createMockRequest({
method: 'OPTIONS',
url: '/api/data',
headers: { origin: 'http://example.com' }
});
const res = createMockResponse();
await router(req, res);
expect(res._statusCode).toBe(204);
expect(res._headers['access-control-allow-origin']).toBe('*');
});
it('should set CORS headers on regular requests', async () => {
const router = createHttpRouter(
{ '/api/data': (req, res) => res.json({}) },
{ cors: { origin: 'http://allowed.com', credentials: true } }
);
const req = createMockRequest({
url: '/api/data',
headers: { origin: 'http://allowed.com' }
});
const res = createMockResponse();
await router(req, res);
expect(res._headers['access-control-allow-origin']).toBe('http://allowed.com');
expect(res._headers['access-control-allow-credentials']).toBe('true');
});
it('should not set CORS headers when cors is false', async () => {
const router = createHttpRouter(
{ '/api/data': (req, res) => res.json({}) },
{ cors: false }
);
const req = createMockRequest({
url: '/api/data',
headers: { origin: 'http://example.com' }
});
const res = createMockResponse();
await router(req, res);
expect(res._headers['access-control-allow-origin']).toBeUndefined();
});
it('should not allow credentials with wildcard origin (security)', async () => {
const router = createHttpRouter(
{ '/api/data': (req, res) => res.json({}) },
{ cors: { origin: '*', credentials: true } }
);
const req = createMockRequest({
url: '/api/data',
headers: { origin: 'http://evil.com' }
});
const res = createMockResponse();
await router(req, res);
// 安全credentials + 通配符时不设置 origin 头
expect(res._headers['access-control-allow-origin']).toBeUndefined();
expect(res._headers['access-control-allow-credentials']).toBeUndefined();
});
it('should not allow credentials with origin: true (security)', async () => {
const router = createHttpRouter(
{ '/api/data': (req, res) => res.json({}) },
{ cors: { origin: true, credentials: true } }
);
const req = createMockRequest({
url: '/api/data',
headers: { origin: 'http://evil.com' }
});
const res = createMockResponse();
await router(req, res);
// 安全credentials + 反射时不设置 origin 头
expect(res._headers['access-control-allow-origin']).toBeUndefined();
expect(res._headers['access-control-allow-credentials']).toBeUndefined();
});
it('should allow credentials with whitelist origin', async () => {
const router = createHttpRouter(
{ '/api/data': (req, res) => res.json({}) },
{ cors: { origin: ['http://trusted.com', 'http://also-trusted.com'], credentials: true } }
);
const req = createMockRequest({
url: '/api/data',
headers: { origin: 'http://trusted.com' }
});
const res = createMockResponse();
await router(req, res);
expect(res._headers['access-control-allow-origin']).toBe('http://trusted.com');
expect(res._headers['access-control-allow-credentials']).toBe('true');
});
it('should reject non-whitelisted origin with credentials', async () => {
const router = createHttpRouter(
{ '/api/data': (req, res) => res.json({}) },
{ cors: { origin: ['http://trusted.com'], credentials: true } }
);
const req = createMockRequest({
url: '/api/data',
headers: { origin: 'http://evil.com' }
});
const res = createMockResponse();
await router(req, res);
expect(res._headers['access-control-allow-origin']).toBeUndefined();
expect(res._headers['access-control-allow-credentials']).toBeUndefined();
});
});
describe('Response Methods', () => {
it('should send JSON response', async () => {
const router = createHttpRouter({
'/json': (req, res) => res.json({ message: 'hello' })
});
const req = createMockRequest({ url: '/json' });
const res = createMockResponse();
await router(req, res);
expect(res._headers['content-type']).toBe('application/json; charset=utf-8');
expect(JSON.parse(res._body)).toEqual({ message: 'hello' });
});
it('should send text response', async () => {
const router = createHttpRouter({
'/text': (req, res) => res.text('Hello World')
});
const req = createMockRequest({ url: '/text' });
const res = createMockResponse();
await router(req, res);
expect(res._headers['content-type']).toBe('text/plain; charset=utf-8');
expect(res._body).toBe('Hello World');
});
it('should send error response', async () => {
const router = createHttpRouter({
'/error': (req, res) => res.error(404, 'Not Found')
});
const req = createMockRequest({ url: '/error' });
const res = createMockResponse();
await router(req, res);
expect(res._statusCode).toBe(404);
expect(JSON.parse(res._body)).toEqual({ error: 'Not Found' });
});
it('should support status chaining', async () => {
const router = createHttpRouter({
'/created': (req, res) => res.status(201).json({ id: 1 })
});
const req = createMockRequest({ url: '/created' });
const res = createMockResponse();
await router(req, res);
expect(res._statusCode).toBe(201);
});
});
describe('Error Handling', () => {
it('should catch handler errors and return 500', async () => {
const router = createHttpRouter({
'/error': () => {
throw new Error('Something went wrong');
}
});
const req = createMockRequest({ url: '/error' });
const res = createMockResponse();
await router(req, res);
expect(res._statusCode).toBe(500);
expect(JSON.parse(res._body)).toEqual({ error: 'Internal Server Error' });
});
});
});

View File

@@ -5,3 +5,4 @@
export * from './types.js';
export { createHttpRouter } from './router.js';
export * from './middleware.js';

Some files were not shown because too many files have changed in this diff Show More