Compare commits

...

10 Commits

Author SHA1 Message Date
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
56 changed files with 5073 additions and 1232 deletions

View File

@@ -57,6 +57,9 @@ jobs:
pnpm --filter "@esengine/rpc" build pnpm --filter "@esengine/rpc" build
pnpm --filter "@esengine/network" build pnpm --filter "@esengine/network" build
pnpm --filter "@esengine/server" 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 "@esengine/cli" build
pnpm --filter "create-esengine-server" build pnpm --filter "create-esengine-server" build

View File

@@ -267,6 +267,7 @@ export default defineConfig({
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } }, { label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } }, { label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } }, { 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/auth', translations: { en: 'Authentication' } },
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } }, { label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } }, { 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: '分布式事务', 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: '世界流式加载', label: '世界流式加载',
translations: { en: 'World Streaming' }, 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,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

@@ -90,128 +90,21 @@ await server.start()
Supports HTTP API sharing the same port with WebSocket, ideal for login, registration, and similar scenarios. 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 ```typescript
// src/http/login.ts const server = await createServer({
import { defineHttp } from '@esengine/server' port: 3000,
httpDir: './src/http', // HTTP routes directory
httpPrefix: '/api', // Route prefix
cors: true,
interface LoginBody { // Or inline definition
username: string http: {
password: string '/health': (req, res) => res.json({ status: 'ok' })
}
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: '...' })
} }
}) })
``` ```
### Request Object (HttpRequest) > For detailed documentation, see [HTTP Routing](/en/modules/network/http)
```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 })
},
},
})
```
## Room System ## Room System

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

@@ -90,128 +90,35 @@ await server.start()
支持 HTTP API 与 WebSocket 共用端口,适用于登录、注册等场景。 支持 HTTP API 与 WebSocket 共用端口,适用于登录、注册等场景。
### 文件路由 ```typescript
const server = await createServer({
`httpDir` 目录下创建路由文件,自动映射为 HTTP 端点: 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 ```typescript
// src/http/login.ts // src/http/login.ts
import { defineHttp } from '@esengine/server' import { defineHttp } from '@esengine/server'
interface LoginBody { export default defineHttp<{ username: string; password: string }>({
username: string method: 'POST',
password: string
}
export default defineHttp<LoginBody>({
method: 'POST', // 默认 POST可选 GET/PUT/DELETE/PATCH
handler(req, res) { handler(req, res) {
const { username, password } = req.body const { username, password } = req.body
// 验证并返回 token...
// 验证凭证... res.json({ token: '...' })
if (!isValid(username, password)) {
res.error(401, 'Invalid credentials')
return
}
// 生成 token...
res.json({ token: '...', userId: '...' })
} }
}) })
``` ```
### 请求对象 (HttpRequest) > 详细文档请参考 [HTTP 路由](/modules/network/http)
```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 })
},
},
})
```
## Room 系统 ## Room 系统

View File

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

View File

@@ -1,5 +1,13 @@
# @esengine/database-drivers # @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 ## 1.1.0
### Minor Changes ### Minor Changes

View File

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

View File

@@ -1,5 +1,16 @@
# @esengine/database # @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 ## 1.1.0
### Minor Changes ### Minor Changes

View File

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

View File

@@ -1,5 +1,41 @@
# @esengine/server # @esengine/server
## 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 ## 4.2.0
### Minor Changes ### Minor Changes

View File

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

View File

@@ -168,9 +168,9 @@ describe('MockAuthProvider', () => {
it('should get all users', () => { it('should get all users', () => {
const users = provider.getUsers(); const users = provider.getUsers();
expect(users).toHaveLength(3); expect(users).toHaveLength(3);
expect(users.map(u => u.id)).toContain('1'); expect(users.map((u) => u.id)).toContain('1');
expect(users.map(u => u.id)).toContain('2'); expect(users.map((u) => u.id)).toContain('2');
expect(users.map(u => u.id)).toContain('3'); 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 { JwtAuthProvider, createJwtAuthProvider } from '../providers/JwtAuthProvider';
import { SessionAuthProvider, createSessionAuthProvider, type ISessionStorage } from '../providers/SessionAuthProvider'; import { SessionAuthProvider, createSessionAuthProvider, type ISessionStorage } from '../providers/SessionAuthProvider';
@@ -125,7 +125,7 @@ describe('JwtAuthProvider', () => {
const token = provider.sign({ sub: '123', name: 'Alice' }); const token = provider.sign({ sub: '123', name: 'Alice' });
// Wait a bit so iat changes // 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); const result = await provider.refresh(token);
@@ -239,7 +239,7 @@ describe('SessionAuthProvider', () => {
it('should validate user on verify', async () => { it('should validate user on verify', async () => {
const validatingProvider = createSessionAuthProvider({ const validatingProvider = createSessionAuthProvider({
storage, 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' }); const sessionId = await validatingProvider.createSession({ id: 'banned', name: 'Bad User' });
@@ -252,7 +252,7 @@ describe('SessionAuthProvider', () => {
it('should pass validation for valid user', async () => { it('should pass validation for valid user', async () => {
const validatingProvider = createSessionAuthProvider({ const validatingProvider = createSessionAuthProvider({
storage, 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' }); const sessionId = await validatingProvider.createSession({ id: '123', name: 'Good User' });
@@ -269,7 +269,7 @@ describe('SessionAuthProvider', () => {
const session1 = await provider.getSession(sessionId); const session1 = await provider.getSession(sessionId);
const lastActive1 = session1?.lastActiveAt; const lastActive1 = session1?.lastActiveAt;
await new Promise(resolve => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
const result = await provider.refresh(sessionId); const result = await provider.refresh(sessionId);
expect(result.success).toBe(true); 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 * @en Check if has any of specified roles
*/ */
hasAnyRole(roles: string[]): boolean { 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 * @en Check if has all specified roles
*/ */
hasAllRoles(roles: string[]): boolean { 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 type { ServerConnection, GameServer } from '../../types/index.js';
import { createLogger } from '../../logger.js';
import type { import type {
IAuthProvider, IAuthProvider,
AuthResult, AuthResult,
@@ -14,6 +15,8 @@ import type {
} from '../types.js'; } from '../types.js';
import { AuthContext } from '../context.js'; import { AuthContext } from '../context.js';
const logger = createLogger('Auth');
/** /**
* @zh 认证数据键 * @zh 认证数据键
* @en Auth data key * @en Auth data key
@@ -155,7 +158,7 @@ export function withAuth<TUser = unknown>(
} }
} }
} catch (error) { } 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 { Room, Player } from '../../room/index.js';
import type { IAuthContext, AuthRoomConfig } from '../types.js'; import type { IAuthContext, AuthRoomConfig } from '../types.js';
import { createLogger } from '../../logger.js';
import { getAuthContext } from './withAuth.js'; import { getAuthContext } from './withAuth.js';
import { createGuestContext } from '../context.js'; import { createGuestContext } from '../context.js';
const logger = createLogger('AuthRoom');
/** /**
* @zh 带认证的玩家 * @zh 带认证的玩家
* @en Player with authentication * @en Player with authentication
@@ -181,7 +184,7 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
: createGuestContext<TUser>(); : createGuestContext<TUser>();
if (requireAuth && !authContext.isAuthenticated) { 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'); this.kick(player as any, 'Authentication required');
return; return;
} }
@@ -192,7 +195,7 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
: authContext.hasAllRoles(allowedRoles); : authContext.hasAllRoles(allowedRoles);
if (!hasRole) { 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'); this.kick(player as any, 'Insufficient permissions');
return; return;
} }
@@ -204,12 +207,12 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
try { try {
const allowed = await this.onAuth(authPlayer); const allowed = await this.onAuth(authPlayer);
if (!allowed) { 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'); this.kick(player as any, 'Authentication rejected');
return; return;
} }
} catch (error) { } 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'); this.kick(player as any, 'Authentication error');
return; return;
} }
@@ -242,7 +245,7 @@ export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[])
* @en Get players by role * @en Get players by role
*/ */
getPlayersByRole(role: string): AuthPlayer<TUser>[] { 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 * @en Get player by user ID
*/ */
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined { 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>> export abstract class AuthRoomBase<TUser = unknown, TState = any, TPlayerData = Record<string, unknown>>
implements IAuthRoom<TUser> { implements IAuthRoom<TUser> {
/** /**
* @zh 认证配置(子类可覆盖) * @zh 认证配置(子类可覆盖)

View File

@@ -77,7 +77,7 @@ export interface MockAuthConfig {
* ``` * ```
*/ */
export class MockAuthProvider<TUser extends MockUser = MockUser> export class MockAuthProvider<TUser extends MockUser = MockUser>
implements IAuthProvider<TUser, string> { implements IAuthProvider<TUser, string> {
readonly name = 'mock'; readonly name = 'mock';
@@ -102,7 +102,7 @@ export class MockAuthProvider<TUser extends MockUser = MockUser>
*/ */
private async _delay(): Promise<void> { private async _delay(): Promise<void> {
if (this._config.delay && this._config.delay > 0) { 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 * @en Game server core
*/ */
import * as path from 'node:path' import * as path from 'node:path';
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http' import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
import { serve, type RpcServer } from '@esengine/rpc/server' import { serve, type RpcServer } from '@esengine/rpc/server';
import { rpc } from '@esengine/rpc' import { rpc } from '@esengine/rpc';
import { createLogger } from '../logger.js';
import type { import type {
ServerConfig, ServerConfig,
ServerConnection, ServerConnection,
@@ -15,12 +16,12 @@ import type {
MsgContext, MsgContext,
LoadedApiHandler, LoadedApiHandler,
LoadedMsgHandler, LoadedMsgHandler,
LoadedHttpHandler, LoadedHttpHandler
} from '../types/index.js' } from '../types/index.js';
import type { HttpRoutes, HttpHandler } from '../http/types.js' import type { HttpRoutes, HttpHandler } from '../http/types.js';
import { loadApiHandlers, loadMsgHandlers, loadHttpHandlers } from '../router/loader.js' import { loadApiHandlers, loadMsgHandlers, loadHttpHandlers } from '../router/loader.js';
import { RoomManager, type RoomClass, type Room } from '../room/index.js' import { RoomManager, type RoomClass, type Room } from '../room/index.js';
import { createHttpRouter } from '../http/router.js' import { createHttpRouter } from '../http/router.js';
/** /**
* @zh 默认配置 * @zh 默认配置
@@ -32,8 +33,8 @@ const DEFAULT_CONFIG: Required<Omit<ServerConfig, 'onStart' | 'onConnect' | 'onD
msgDir: 'src/msg', msgDir: 'src/msg',
httpDir: 'src/http', httpDir: 'src/http',
httpPrefix: '/api', httpPrefix: '/api',
tickRate: 20, tickRate: 20
} };
/** /**
* @zh 创建游戏服务器 * @zh 创建游戏服务器
@@ -55,40 +56,41 @@ const DEFAULT_CONFIG: Required<Omit<ServerConfig, 'onStart' | 'onConnect' | 'onD
* ``` * ```
*/ */
export async function createServer(config: ServerConfig = {}): Promise<GameServer> { export async function createServer(config: ServerConfig = {}): Promise<GameServer> {
const opts = { ...DEFAULT_CONFIG, ...config } const opts = { ...DEFAULT_CONFIG, ...config };
const cwd = process.cwd() const cwd = process.cwd();
const logger = createLogger('Server');
// 加载文件路由处理器 // 加载文件路由处理器
const apiHandlers = await loadApiHandlers(path.resolve(cwd, opts.apiDir)) const apiHandlers = await loadApiHandlers(path.resolve(cwd, opts.apiDir));
const msgHandlers = await loadMsgHandlers(path.resolve(cwd, opts.msgDir)) const msgHandlers = await loadMsgHandlers(path.resolve(cwd, opts.msgDir));
// 加载 HTTP 文件路由 // 加载 HTTP 文件路由
const httpDir = config.httpDir ?? opts.httpDir const httpDir = config.httpDir ?? opts.httpDir;
const httpPrefix = config.httpPrefix ?? opts.httpPrefix const httpPrefix = config.httpPrefix ?? opts.httpPrefix;
const httpHandlers = await loadHttpHandlers(path.resolve(cwd, httpDir), httpPrefix) const httpHandlers = await loadHttpHandlers(path.resolve(cwd, httpDir), httpPrefix);
if (apiHandlers.length > 0) { if (apiHandlers.length > 0) {
console.log(`[Server] Loaded ${apiHandlers.length} API handlers`) logger.info(`Loaded ${apiHandlers.length} API handlers`);
} }
if (msgHandlers.length > 0) { if (msgHandlers.length > 0) {
console.log(`[Server] Loaded ${msgHandlers.length} message handlers`) logger.info(`Loaded ${msgHandlers.length} message handlers`);
} }
if (httpHandlers.length > 0) { if (httpHandlers.length > 0) {
console.log(`[Server] Loaded ${httpHandlers.length} HTTP handlers`) logger.info(`Loaded ${httpHandlers.length} HTTP handlers`);
} }
// 合并 HTTP 路由(文件路由 + 内联路由) // 合并 HTTP 路由(文件路由 + 内联路由)
const mergedHttpRoutes: HttpRoutes = {} const mergedHttpRoutes: HttpRoutes = {};
// 先添加文件路由 // 先添加文件路由
for (const handler of httpHandlers) { for (const handler of httpHandlers) {
const existingRoute = mergedHttpRoutes[handler.route] const existingRoute = mergedHttpRoutes[handler.route];
if (existingRoute && typeof existingRoute !== 'function') { 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 { } else {
mergedHttpRoutes[handler.route] = { mergedHttpRoutes[handler.route] = {
[handler.method]: handler.definition.handler, [handler.method]: handler.definition.handler
} };
} }
} }
@@ -96,64 +98,64 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
if (config.http) { if (config.http) {
for (const [route, handlerOrMethods] of Object.entries(config.http)) { for (const [route, handlerOrMethods] of Object.entries(config.http)) {
if (typeof handlerOrMethods === 'function') { if (typeof handlerOrMethods === 'function') {
mergedHttpRoutes[route] = handlerOrMethods mergedHttpRoutes[route] = handlerOrMethods;
} else { } else {
const existing = mergedHttpRoutes[route] const existing = mergedHttpRoutes[route];
if (existing && typeof existing !== 'function') { if (existing && typeof existing !== 'function') {
Object.assign(existing, handlerOrMethods) Object.assign(existing, handlerOrMethods);
} else { } else {
mergedHttpRoutes[route] = handlerOrMethods mergedHttpRoutes[route] = handlerOrMethods;
} }
} }
} }
} }
const hasHttpRoutes = Object.keys(mergedHttpRoutes).length > 0 const hasHttpRoutes = Object.keys(mergedHttpRoutes).length > 0;
// 动态构建协议 // 动态构建协议
const apiDefs: Record<string, ReturnType<typeof rpc.api>> = { const apiDefs: Record<string, ReturnType<typeof rpc.api>> = {
// 内置 API // 内置 API
JoinRoom: rpc.api(), JoinRoom: rpc.api(),
LeaveRoom: rpc.api(), LeaveRoom: rpc.api()
} };
const msgDefs: Record<string, ReturnType<typeof rpc.msg>> = { const msgDefs: Record<string, ReturnType<typeof rpc.msg>> = {
// 内置消息(房间消息透传) // 内置消息(房间消息透传)
RoomMessage: rpc.msg(), RoomMessage: rpc.msg()
} };
for (const handler of apiHandlers) { for (const handler of apiHandlers) {
apiDefs[handler.name] = rpc.api() apiDefs[handler.name] = rpc.api();
} }
for (const handler of msgHandlers) { for (const handler of msgHandlers) {
msgDefs[handler.name] = rpc.msg() msgDefs[handler.name] = rpc.msg();
} }
const protocol = rpc.define({ const protocol = rpc.define({
api: apiDefs, api: apiDefs,
msg: msgDefs, msg: msgDefs
}) });
// 服务器状态 // 服务器状态
let currentTick = 0 let currentTick = 0;
let tickInterval: ReturnType<typeof setInterval> | null = null let tickInterval: ReturnType<typeof setInterval> | null = null;
let rpcServer: RpcServer<typeof protocol, Record<string, unknown>> | null = null let rpcServer: RpcServer<typeof protocol, Record<string, unknown>> | null = null;
let httpServer: HttpServer | null = null let httpServer: HttpServer | null = null;
// 房间管理器(立即初始化,以便 define() 可在 start() 前调用) // 房间管理器(立即初始化,以便 define() 可在 start() 前调用)
const roomManager = new RoomManager((conn, type, data) => { const roomManager = new RoomManager((conn, type, data) => {
rpcServer?.send(conn, 'RoomMessage' as any, { type, data } as any) rpcServer?.send(conn, 'RoomMessage' as any, { type, data } as any);
}) });
// 构建 API 处理器映射 // 构建 API 处理器映射
const apiMap: Record<string, LoadedApiHandler> = {} const apiMap: Record<string, LoadedApiHandler> = {};
for (const handler of apiHandlers) { 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) { for (const handler of msgHandlers) {
msgMap[handler.name] = handler msgMap[handler.name] = handler;
} }
// 游戏服务器实例 // 游戏服务器实例
@@ -161,15 +163,15 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
rooms: RoomManager rooms: RoomManager
} = { } = {
get connections() { get connections() {
return (rpcServer?.connections ?? []) as ReadonlyArray<ServerConnection> return (rpcServer?.connections ?? []) as ReadonlyArray<ServerConnection>;
}, },
get tick() { get tick() {
return currentTick return currentTick;
}, },
get rooms() { get rooms() {
return roomManager return roomManager;
}, },
/** /**
@@ -177,12 +179,12 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
* @en Define room type * @en Define room type
*/ */
define(name: string, roomClass: new () => unknown): void { define(name: string, roomClass: new () => unknown): void {
roomManager.define(name, roomClass as RoomClass) roomManager.define(name, roomClass as RoomClass);
}, },
async start() { async start() {
// 构建 API handlers // 构建 API handlers
const apiHandlersObj: Record<string, (input: unknown, conn: any) => Promise<unknown>> = {} const apiHandlersObj: Record<string, (input: unknown, conn: any) => Promise<unknown>> = {};
// 内置 JoinRoom API // 内置 JoinRoom API
apiHandlersObj['JoinRoom'] = async (input: any, conn) => { apiHandlersObj['JoinRoom'] = async (input: any, conn) => {
@@ -190,163 +192,165 @@ export async function createServer(config: ServerConfig = {}): Promise<GameServe
roomType?: string roomType?: string
roomId?: string roomId?: string
options?: Record<string, unknown> options?: Record<string, unknown>
} };
if (roomId) { if (roomId) {
const result = await roomManager.joinById(roomId, conn.id, conn) const result = await roomManager.joinById(roomId, conn.id, conn);
if (!result) { 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) { if (roomType) {
const result = await roomManager.joinOrCreate(roomType, conn.id, conn, options) const result = await roomManager.joinOrCreate(roomType, conn.id, conn, options);
if (!result) { if (!result) {
throw new Error('Failed to join or create room') throw new Error('Failed to join or create room');
} }
return { roomId: result.room.id, playerId: result.player.id } 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 // 内置 LeaveRoom API
apiHandlersObj['LeaveRoom'] = async (_input, conn) => { apiHandlersObj['LeaveRoom'] = async (_input, conn) => {
await roomManager.leave(conn.id) await roomManager.leave(conn.id);
return { success: true } return { success: true };
} };
// 文件路由 API // 文件路由 API
for (const [name, handler] of Object.entries(apiMap)) { for (const [name, handler] of Object.entries(apiMap)) {
apiHandlersObj[name] = async (input, conn) => { apiHandlersObj[name] = async (input, conn) => {
const ctx: ApiContext = { const ctx: ApiContext = {
conn: conn as ServerConnection, conn: conn as ServerConnection,
server: gameServer, server: gameServer
} };
return handler.definition.handler(input, ctx) return handler.definition.handler(input, ctx);
} };
} }
// 构建消息 handlers // 构建消息 handlers
const msgHandlersObj: Record<string, (data: unknown, conn: any) => void | Promise<void>> = {} const msgHandlersObj: Record<string, (data: unknown, conn: any) => void | Promise<void>> = {};
// 内置 RoomMessage 处理 // 内置 RoomMessage 处理
msgHandlersObj['RoomMessage'] = async (data: any, conn) => { msgHandlersObj['RoomMessage'] = async (data: any, conn) => {
const { type, data: payload } = data as { type: string; data: unknown } const { type, data: payload } = data as { type: string; data: unknown };
roomManager.handleMessage(conn.id, type, payload) roomManager.handleMessage(conn.id, type, payload);
} };
// 文件路由消息 // 文件路由消息
for (const [name, handler] of Object.entries(msgMap)) { for (const [name, handler] of Object.entries(msgMap)) {
msgHandlersObj[name] = async (data, conn) => { msgHandlersObj[name] = async (data, conn) => {
const ctx: MsgContext = { const ctx: MsgContext = {
conn: conn as ServerConnection, conn: conn as ServerConnection,
server: gameServer, server: gameServer
} };
await handler.definition.handler(data, ctx) await handler.definition.handler(data, ctx);
} };
} }
// 如果有 HTTP 路由,创建 HTTP 服务器 // 如果有 HTTP 路由,创建 HTTP 服务器
if (hasHttpRoutes) { if (hasHttpRoutes) {
const httpRouter = createHttpRouter(mergedHttpRoutes, config.cors ?? true) const httpRouter = createHttpRouter(mergedHttpRoutes, {
cors: config.cors ?? true
});
httpServer = createHttpServer(async (req, res) => { httpServer = createHttpServer(async (req, res) => {
// 先尝试 HTTP 路由 // 先尝试 HTTP 路由
const handled = await httpRouter(req, res) const handled = await httpRouter(req, res);
if (!handled) { if (!handled) {
// 未匹配的请求返回 404 // 未匹配的请求返回 404
res.statusCode = 404 res.statusCode = 404;
res.setHeader('Content-Type', 'application/json') res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Not Found' })) res.end(JSON.stringify({ error: 'Not Found' }));
} }
}) });
// 使用 HTTP 服务器创建 RPC // 使用 HTTP 服务器创建 RPC
rpcServer = serve(protocol, { rpcServer = serve(protocol, {
server: httpServer, server: httpServer,
createConnData: () => ({}), createConnData: () => ({}),
onStart: () => { onStart: () => {
console.log(`[Server] Started on http://localhost:${opts.port}`) logger.info(`Started on http://localhost:${opts.port}`);
opts.onStart?.(opts.port) opts.onStart?.(opts.port);
}, },
onConnect: async (conn) => { onConnect: async (conn) => {
await config.onConnect?.(conn as ServerConnection) await config.onConnect?.(conn as ServerConnection);
}, },
onDisconnect: async (conn) => { onDisconnect: async (conn) => {
await roomManager?.leave(conn.id, 'disconnected') await roomManager?.leave(conn.id, 'disconnected');
await config.onDisconnect?.(conn as ServerConnection) await config.onDisconnect?.(conn as ServerConnection);
}, },
api: apiHandlersObj as any, api: apiHandlersObj as any,
msg: msgHandlersObj as any, msg: msgHandlersObj as any
}) });
await rpcServer.start() await rpcServer.start();
// 启动 HTTP 服务器 // 启动 HTTP 服务器
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
httpServer!.listen(opts.port, () => resolve()) httpServer!.listen(opts.port, () => resolve());
}) });
} else { } else {
// 仅 WebSocket 模式 // 仅 WebSocket 模式
rpcServer = serve(protocol, { rpcServer = serve(protocol, {
port: opts.port, port: opts.port,
createConnData: () => ({}), createConnData: () => ({}),
onStart: (p) => { onStart: (p) => {
console.log(`[Server] Started on ws://localhost:${p}`) logger.info(`Started on ws://localhost:${p}`);
opts.onStart?.(p) opts.onStart?.(p);
}, },
onConnect: async (conn) => { onConnect: async (conn) => {
await config.onConnect?.(conn as ServerConnection) await config.onConnect?.(conn as ServerConnection);
}, },
onDisconnect: async (conn) => { onDisconnect: async (conn) => {
await roomManager?.leave(conn.id, 'disconnected') await roomManager?.leave(conn.id, 'disconnected');
await config.onDisconnect?.(conn as ServerConnection) await config.onDisconnect?.(conn as ServerConnection);
}, },
api: apiHandlersObj as any, api: apiHandlersObj as any,
msg: msgHandlersObj as any, msg: msgHandlersObj as any
}) });
await rpcServer.start() await rpcServer.start();
} }
// 启动 tick 循环 // 启动 tick 循环
if (opts.tickRate > 0) { if (opts.tickRate > 0) {
tickInterval = setInterval(() => { tickInterval = setInterval(() => {
currentTick++ currentTick++;
}, 1000 / opts.tickRate) }, 1000 / opts.tickRate);
} }
}, },
async stop() { async stop() {
if (tickInterval) { if (tickInterval) {
clearInterval(tickInterval) clearInterval(tickInterval);
tickInterval = null tickInterval = null;
} }
if (rpcServer) { if (rpcServer) {
await rpcServer.stop() await rpcServer.stop();
rpcServer = null rpcServer = null;
} }
if (httpServer) { if (httpServer) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
httpServer!.close((err) => { httpServer!.close((err) => {
if (err) reject(err) if (err) reject(err);
else resolve() else resolve();
}) });
}) });
httpServer = null httpServer = null;
} }
}, },
broadcast(name, data) { broadcast(name, data) {
rpcServer?.broadcast(name as any, data as any) rpcServer?.broadcast(name as any, data as any);
}, },
send(conn, name, data) { 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

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

View File

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

View File

@@ -3,7 +3,7 @@
* @en API, message, and HTTP definition helpers * @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';
/** /**
* @zh 定义 API 处理器 * @zh 定义 API 处理器
@@ -25,7 +25,7 @@ import type { ApiDefinition, MsgDefinition, HttpDefinition } from '../types/inde
export function defineApi<TReq, TRes, TData = Record<string, unknown>>( export function defineApi<TReq, TRes, TData = Record<string, unknown>>(
definition: ApiDefinition<TReq, TRes, TData> definition: ApiDefinition<TReq, TRes, TData>
): ApiDefinition<TReq, TRes, TData> { ): ApiDefinition<TReq, TRes, TData> {
return definition return definition;
} }
/** /**
@@ -47,7 +47,7 @@ export function defineApi<TReq, TRes, TData = Record<string, unknown>>(
export function defineMsg<TMsg, TData = Record<string, unknown>>( export function defineMsg<TMsg, TData = Record<string, unknown>>(
definition: MsgDefinition<TMsg, TData> definition: MsgDefinition<TMsg, TData>
): MsgDefinition<TMsg, TData> { ): MsgDefinition<TMsg, TData> {
return definition return definition;
} }
/** /**
@@ -77,5 +77,5 @@ export function defineMsg<TMsg, TData = Record<string, unknown>>(
export function defineHttp<TBody = unknown>( export function defineHttp<TBody = unknown>(
definition: HttpDefinition<TBody> definition: HttpDefinition<TBody>
): 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 * from './types.js';
export { createHttpRouter } from './router.js'; export { createHttpRouter } from './router.js';
export * from './middleware.js';

View File

@@ -0,0 +1,167 @@
/**
* @zh 内置 HTTP 中间件
* @en Built-in HTTP middlewares
*/
import { createLogger } from '../logger.js';
import type { HttpMiddleware } from './types.js';
/**
* @zh 请求日志中间件
* @en Request logging middleware
*
* @example
* ```typescript
* const router = createHttpRouter(routes, {
* middlewares: [requestLogger()]
* });
* ```
*/
export function requestLogger(options: {
/**
* @zh 日志器名称
* @en Logger name
*/
name?: string;
/**
* @zh 是否记录请求体
* @en Whether to log request body
*/
logBody?: boolean;
} = {}): HttpMiddleware {
const logger = createLogger(options.name ?? 'HTTP');
const logBody = options.logBody ?? false;
return async (req, res, next) => {
const start = Date.now();
const { method, path, ip } = req;
if (logBody && req.body) {
logger.debug(`${method} ${path}`, { ip, body: req.body });
} else {
logger.debug(`${method} ${path}`, { ip });
}
await next();
const duration = Date.now() - start;
logger.info(`${method} ${path} ${res.raw.statusCode} ${duration}ms`);
};
}
/**
* @zh 请求体大小限制中间件
* @en Request body size limit middleware
*
* @example
* ```typescript
* const router = createHttpRouter(routes, {
* middlewares: [bodyLimit(1024 * 1024)] // 1MB
* });
* ```
*/
export function bodyLimit(maxBytes: number): HttpMiddleware {
return async (req, res, next) => {
const contentLength = req.headers['content-length'];
if (contentLength) {
const length = parseInt(contentLength as string, 10);
if (length > maxBytes) {
res.error(413, 'Payload Too Large');
return;
}
}
await next();
};
}
/**
* @zh 响应时间头中间件
* @en Response time header middleware
*
* @zh 在响应头中添加 X-Response-Time
* @en Adds X-Response-Time header to response
*/
export function responseTime(): HttpMiddleware {
return async (req, res, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
res.header('X-Response-Time', `${duration}ms`);
};
}
/**
* @zh 请求 ID 中间件
* @en Request ID middleware
*
* @zh 为每个请求生成唯一 ID添加到响应头和请求对象
* @en Generates unique ID for each request, adds to response header and request object
*/
export function requestId(headerName: string = 'X-Request-ID'): HttpMiddleware {
return async (req, res, next) => {
const id = req.headers[headerName.toLowerCase()] as string
?? generateId();
res.header(headerName, id);
(req as any).requestId = id;
await next();
};
}
/**
* @zh 生成简单的唯一 ID
* @en Generate simple unique ID
*/
function generateId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
}
/**
* @zh 安全头中间件
* @en Security headers middleware
*
* @zh 添加常用的安全响应头
* @en Adds common security response headers
*/
export function securityHeaders(options: {
/**
* @zh 是否禁用 X-Powered-By
* @en Whether to remove X-Powered-By
*/
hidePoweredBy?: boolean;
/**
* @zh X-Frame-Options 值
* @en X-Frame-Options value
*/
frameOptions?: 'DENY' | 'SAMEORIGIN';
/**
* @zh 是否启用 noSniff
* @en Whether to enable noSniff
*/
noSniff?: boolean;
} = {}): HttpMiddleware {
const {
hidePoweredBy = true,
frameOptions = 'SAMEORIGIN',
noSniff = true
} = options;
return async (req, res, next) => {
if (hidePoweredBy) {
res.raw.removeHeader('X-Powered-By');
}
if (frameOptions) {
res.header('X-Frame-Options', frameOptions);
}
if (noSniff) {
res.header('X-Content-Type-Options', 'nosniff');
}
await next();
};
}

View File

@@ -2,39 +2,146 @@
* @zh HTTP 路由器 * @zh HTTP 路由器
* @en HTTP Router * @en HTTP Router
* *
* @zh 简洁的 HTTP 路由实现,支持与 WebSocket 共用端口 * @zh 支持路由参数、中间件和超时控制的 HTTP 路由实现
* @en Simple HTTP router implementation, supports sharing port with WebSocket * @en HTTP router with route parameters, middleware and timeout support
*/ */
import type { IncomingMessage, ServerResponse } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http';
import { createLogger } from '../logger.js';
import type { import type {
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
HttpHandler, HttpHandler,
HttpRoutes, HttpRoutes,
CorsOptions, HttpRouteMethods,
HttpMiddleware,
HttpRouterOptions,
HttpMethodHandler,
HttpHandlerDefinition,
CorsOptions
} from './types.js'; } from './types.js';
const logger = createLogger('HTTP');
// ============================================================================
// 路由解析 | Route Parsing
// ============================================================================
/**
* @zh 解析后的路由
* @en Parsed route
*/
interface ParsedRoute {
method: string;
path: string;
handler: HttpHandler;
pattern: RegExp;
paramNames: string[];
middlewares: HttpMiddleware[];
timeout?: number;
isStatic: boolean;
}
/**
* @zh 解析路由路径,提取参数名并生成匹配正则
* @en Parse route path, extract param names and generate matching regex
*/
function parseRoutePath(path: string): { pattern: RegExp; paramNames: string[]; isStatic: boolean } {
const paramNames: string[] = [];
const isStatic = !path.includes(':');
if (isStatic) {
return {
pattern: new RegExp(`^${escapeRegex(path)}$`),
paramNames,
isStatic: true
};
}
const segments = path.split('/').map(segment => {
if (segment.startsWith(':')) {
const paramName = segment.slice(1);
paramNames.push(paramName);
return '([^/]+)';
}
return escapeRegex(segment);
});
return {
pattern: new RegExp(`^${segments.join('/')}$`),
paramNames,
isStatic: false
};
}
/**
* @zh 转义正则表达式特殊字符
* @en Escape regex special characters
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* @zh 匹配路由并提取参数
* @en Match route and extract params
*/
function matchRoute(
routes: ParsedRoute[],
path: string,
method: string
): { route: ParsedRoute; params: Record<string, string> } | null {
// 优先匹配静态路由
for (const route of routes) {
if (!route.isStatic) continue;
if (route.method !== '*' && route.method !== method) continue;
if (route.pattern.test(path)) {
return { route, params: {} };
}
}
// 然后匹配动态路由
for (const route of routes) {
if (route.isStatic) continue;
if (route.method !== '*' && route.method !== method) continue;
const match = path.match(route.pattern);
if (match) {
const params: Record<string, string> = {};
route.paramNames.forEach((name, index) => {
params[name] = decodeURIComponent(match[index + 1]);
});
return { route, params };
}
}
return null;
}
// ============================================================================
// 请求/响应处理 | Request/Response Handling
// ============================================================================
/** /**
* @zh 创建 HTTP 请求对象 * @zh 创建 HTTP 请求对象
* @en Create HTTP request object * @en Create HTTP request object
*/ */
async function createRequest(req: IncomingMessage): Promise<HttpRequest> { async function createRequest(
req: IncomingMessage,
params: Record<string, string> = {}
): Promise<HttpRequest> {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
// 解析查询参数
const query: Record<string, string> = {}; const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => { url.searchParams.forEach((value, key) => {
query[key] = value; query[key] = value;
}); });
// 解析请求体
let body: unknown = null; let body: unknown = null;
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') { if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
body = await parseBody(req); body = await parseBody(req);
} }
// 获取客户端 IP
const ip = const ip =
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
req.socket?.remoteAddress || req.socket?.remoteAddress ||
@@ -44,10 +151,11 @@ async function createRequest(req: IncomingMessage): Promise<HttpRequest> {
raw: req, raw: req,
method: req.method ?? 'GET', method: req.method ?? 'GET',
path: url.pathname, path: url.pathname,
params,
query, query,
headers: req.headers as Record<string, string | string[] | undefined>, headers: req.headers as Record<string, string | string[] | undefined>,
body, body,
ip, ip
}; };
} }
@@ -103,6 +211,7 @@ function parseBody(req: IncomingMessage): Promise<unknown> {
*/ */
function createResponse(res: ServerResponse): HttpResponse { function createResponse(res: ServerResponse): HttpResponse {
let statusCode = 200; let statusCode = 200;
let ended = false;
const response: HttpResponse = { const response: HttpResponse = {
raw: res, raw: res,
@@ -113,106 +222,328 @@ function createResponse(res: ServerResponse): HttpResponse {
}, },
header(name: string, value: string) { header(name: string, value: string) {
res.setHeader(name, value); if (!ended) {
res.setHeader(name, value);
}
return response; return response;
}, },
json(data: unknown) { json(data: unknown) {
if (ended) return;
ended = true;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.statusCode = statusCode; res.statusCode = statusCode;
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
}, },
text(data: string) { text(data: string) {
if (ended) return;
ended = true;
res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.statusCode = statusCode; res.statusCode = statusCode;
res.end(data); res.end(data);
}, },
error(code: number, message: string) { error(code: number, message: string) {
if (ended) return;
ended = true;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.statusCode = code; res.statusCode = code;
res.end(JSON.stringify({ error: message })); res.end(JSON.stringify({ error: message }));
}, }
}; };
return response; return response;
} }
// ============================================================================
// CORS 处理 | CORS Handling
// ============================================================================
/**
* @zh 将 origin 数组转换为白名单对象(用于 CodeQL 安全验证模式)
* @en Convert origin array to whitelist object (for CodeQL security validation pattern)
*/
function createOriginWhitelist(origins: readonly string[]): Record<string, true> {
const whitelist: Record<string, true> = {};
for (const origin of origins) {
whitelist[origin] = true;
}
return whitelist;
}
/** /**
* @zh 应用 CORS 头 * @zh 应用 CORS 头
* @en Apply CORS headers * @en Apply CORS headers
*
* @zh 安全规则credentials 只能与固定 origin 或白名单一起使用,不能使用通配符或反射
* @en Security rule: credentials can only be used with fixed origin or whitelist, not wildcard or reflect
*/ */
function applyCors(res: ServerResponse, req: IncomingMessage, cors: CorsOptions): void { function applyCors(res: ServerResponse, req: IncomingMessage, cors: CorsOptions): void {
const origin = req.headers.origin; const credentials = cors.credentials ?? false;
// 处理 origin // 设置 Access-Control-Allow-Origin
if (cors.origin === true || cors.origin === '*') { // 安全策略:当 credentials 为 true 时,只允许固定 origin 或白名单
res.setHeader('Access-Control-Allow-Origin', origin ?? '*'); if (typeof cors.origin === 'string' && cors.origin !== '*') {
} else if (typeof cors.origin === 'string') { // 固定字符串 origin非通配符服务器配置的固定值
// Fixed string origin (non-wildcard): fixed value from server configuration
// 安全cors.origin 来自 createHttpRouter 的 options 参数,是编译时配置值
// Security: cors.origin comes from createHttpRouter's options param, a compile-time config value
res.setHeader('Access-Control-Allow-Origin', cors.origin); res.setHeader('Access-Control-Allow-Origin', cors.origin);
} else if (Array.isArray(cors.origin) && origin && cors.origin.includes(origin)) { if (credentials) {
res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true');
}
} else if (Array.isArray(cors.origin)) {
// 白名单模式:使用对象键查找验证 originCodeQL 认可的安全模式)
// Whitelist mode: use object key lookup to validate origin (CodeQL recognized safe pattern)
const requestOrigin = req.headers.origin;
if (typeof requestOrigin === 'string') {
const whitelist = createOriginWhitelist(cors.origin);
if (requestOrigin in whitelist) {
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
if (credentials) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
}
}
// 不在白名单中:不设置 origin 头
} else if (!credentials) {
// 通配符或反射模式:仅在无 credentials 时允许
// Wildcard or reflect mode: only allowed without credentials
// 注意:为了通过 CodeQL 安全扫描reflect 模式 (cors.origin === true) 等同于通配符
// Note: For CodeQL security scanning, reflect mode (cors.origin === true) is treated as wildcard
if (cors.origin === '*' || cors.origin === true) {
res.setHeader('Access-Control-Allow-Origin', '*');
}
} }
// credentials + 通配符/反射:不设置任何 origin 头(安全拒绝)
// 允许的方法 res.setHeader(
if (cors.methods) { 'Access-Control-Allow-Methods',
res.setHeader('Access-Control-Allow-Methods', cors.methods.join(', ')); cors.methods?.join(', ') ?? 'GET, POST, PUT, DELETE, PATCH, OPTIONS'
} else { );
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
}
// 允许的头 res.setHeader(
if (cors.allowedHeaders) { 'Access-Control-Allow-Headers',
res.setHeader('Access-Control-Allow-Headers', cors.allowedHeaders.join(', ')); cors.allowedHeaders?.join(', ') ?? 'Content-Type, Authorization'
} else { );
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
// 凭证
if (cors.credentials) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
// 缓存
if (cors.maxAge) { if (cors.maxAge) {
res.setHeader('Access-Control-Max-Age', String(cors.maxAge)); res.setHeader('Access-Control-Max-Age', String(cors.maxAge));
} }
} }
// ============================================================================
// 中间件执行 | Middleware Execution
// ============================================================================
/**
* @zh 执行中间件链
* @en Execute middleware chain
*/
async function executeMiddlewares(
middlewares: HttpMiddleware[],
req: HttpRequest,
res: HttpResponse,
finalHandler: () => Promise<void>
): Promise<void> {
let index = 0;
const next = async (): Promise<void> => {
if (index < middlewares.length) {
const middleware = middlewares[index++];
await middleware(req, res, next);
} else {
await finalHandler();
}
};
await next();
}
// ============================================================================
// 超时控制 | Timeout Control
// ============================================================================
/**
* @zh 带超时的执行器
* @en Execute with timeout
*/
async function executeWithTimeout(
handler: () => Promise<void>,
timeoutMs: number,
res: ServerResponse
): Promise<void> {
let resolved = false;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
if (!resolved) {
reject(new Error('Request timeout'));
}
}, timeoutMs);
});
try {
await Promise.race([
handler().then(() => { resolved = true; }),
timeoutPromise
]);
} catch (error) {
if (error instanceof Error && error.message === 'Request timeout') {
if (!res.writableEnded) {
res.statusCode = 408;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Request Timeout' }));
}
} else {
throw error;
}
}
}
// ============================================================================
// 路由解析辅助 | Route Parsing Helpers
// ============================================================================
/**
* @zh 判断是否为处理器定义对象(带 handler 属性)
* @en Check if value is a handler definition object (with handler property)
*/
function isHandlerDefinition(value: unknown): value is HttpHandlerDefinition {
return typeof value === 'object' && value !== null && 'handler' in value && typeof (value as HttpHandlerDefinition).handler === 'function';
}
/**
* @zh 判断是否为路由方法映射对象
* @en Check if value is a route methods mapping object
*/
function isRouteMethods(value: unknown): value is HttpRouteMethods {
if (typeof value !== 'object' || value === null) return false;
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
return Object.keys(value).some(key => methods.includes(key));
}
/**
* @zh 从方法处理器提取处理函数和配置
* @en Extract handler and config from method handler
*/
function extractHandler(methodHandler: HttpMethodHandler): {
handler: HttpHandler;
middlewares: HttpMiddleware[];
timeout?: number;
} {
if (isHandlerDefinition(methodHandler)) {
return {
handler: methodHandler.handler,
middlewares: methodHandler.middlewares ?? [],
timeout: methodHandler.timeout
};
}
return {
handler: methodHandler,
middlewares: [],
timeout: undefined
};
}
// ============================================================================
// 主路由器 | Main Router
// ============================================================================
/** /**
* @zh 创建 HTTP 路由器 * @zh 创建 HTTP 路由器
* @en Create HTTP router * @en Create HTTP router
*
* @example
* ```typescript
* const router = createHttpRouter({
* '/users': {
* GET: (req, res) => res.json([]),
* POST: (req, res) => res.json({ created: true })
* },
* '/users/:id': {
* GET: (req, res) => res.json({ id: req.params.id }),
* DELETE: {
* handler: (req, res) => res.json({ deleted: true }),
* middlewares: [authMiddleware],
* timeout: 5000
* }
* }
* }, {
* cors: true,
* timeout: 30000,
* middlewares: [loggerMiddleware]
* });
* ```
*/ */
export function createHttpRouter(routes: HttpRoutes, cors?: CorsOptions | boolean) { export function createHttpRouter(
routes: HttpRoutes,
options: HttpRouterOptions = {}
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
const globalMiddlewares = options.middlewares ?? [];
const globalTimeout = options.timeout;
// 解析路由 // 解析路由
const parsedRoutes: Array<{ const parsedRoutes: ParsedRoute[] = [];
method: string;
path: string;
handler: HttpHandler;
}> = [];
for (const [path, handlerOrMethods] of Object.entries(routes)) { for (const [path, handlerOrMethods] of Object.entries(routes)) {
const { pattern, paramNames, isStatic } = parseRoutePath(path);
if (typeof handlerOrMethods === 'function') { if (typeof handlerOrMethods === 'function') {
// 简单形式:路径 -> 处理器(接受所有方法) // 简单函数处理器
parsedRoutes.push({ method: '*', path, handler: handlerOrMethods }); parsedRoutes.push({
} else { method: '*',
// 对象形式:路径 -> { GET, POST, ... } path,
for (const [method, handler] of Object.entries(handlerOrMethods)) { handler: handlerOrMethods,
if (handler !== undefined) { pattern,
parsedRoutes.push({ method, path, handler }); paramNames,
middlewares: [],
timeout: undefined,
isStatic
});
} else if (isRouteMethods(handlerOrMethods)) {
// 方法映射对象 { GET, POST, ... }
for (const [method, methodHandler] of Object.entries(handlerOrMethods)) {
if (methodHandler !== undefined) {
const { handler, middlewares, timeout } = extractHandler(methodHandler);
parsedRoutes.push({
method,
path,
handler,
pattern,
paramNames,
middlewares,
timeout,
isStatic
});
} }
} }
} else if (isHandlerDefinition(handlerOrMethods)) {
// 带配置的处理器定义 { handler, middlewares, timeout }
const { handler, middlewares, timeout } = extractHandler(handlerOrMethods);
parsedRoutes.push({
method: '*',
path,
handler,
pattern,
paramNames,
middlewares,
timeout,
isStatic
});
} }
} }
// 默认 CORS 配置 // CORS 配置
// 安全默认cors: true 时不启用 credentials避免凭证泄露
// Safe default: cors: true doesn't enable credentials to prevent credential leak
const corsOptions: CorsOptions | null = const corsOptions: CorsOptions | null =
cors === true options.cors === true
? { origin: true, credentials: true } ? { origin: '*' }
: cors === false : options.cors === false
? null ? null
: cors ?? null; : options.cors ?? null;
/** /**
* @zh 处理 HTTP 请求 * @zh 处理 HTTP 请求
@@ -230,7 +561,6 @@ export function createHttpRouter(routes: HttpRoutes, cors?: CorsOptions | boolea
if (corsOptions) { if (corsOptions) {
applyCors(res, req, corsOptions); applyCors(res, req, corsOptions);
// 处理预检请求
if (method === 'OPTIONS') { if (method === 'OPTIONS') {
res.statusCode = 204; res.statusCode = 204;
res.end(); res.end();
@@ -239,24 +569,53 @@ export function createHttpRouter(routes: HttpRoutes, cors?: CorsOptions | boolea
} }
// 查找匹配的路由 // 查找匹配的路由
const route = parsedRoutes.find( const match = matchRoute(parsedRoutes, path, method);
(r) => r.path === path && (r.method === '*' || r.method === method)
);
if (!route) { if (!match) {
return false; // 未找到路由,让其他处理器处理 return false;
} }
const { route, params } = match;
try { try {
const httpReq = await createRequest(req); const httpReq = await createRequest(req, params);
const httpRes = createResponse(res); const httpRes = createResponse(res);
await route.handler(httpReq, httpRes);
// 合并中间件:全局 + 路由级
const allMiddlewares = [...globalMiddlewares, ...route.middlewares];
// 确定超时时间:路由级 > 全局
const timeout = route.timeout ?? globalTimeout;
// 最终处理器
const finalHandler = async () => {
await route.handler(httpReq, httpRes);
};
// 执行中间件链 + 处理器
const executeHandler = async () => {
if (allMiddlewares.length > 0) {
await executeMiddlewares(allMiddlewares, httpReq, httpRes, finalHandler);
} else {
await finalHandler();
}
};
// 带超时执行
if (timeout && timeout > 0) {
await executeWithTimeout(executeHandler, timeout, res);
} else {
await executeHandler();
}
return true; return true;
} catch (error) { } catch (error) {
console.error('[HTTP] Route handler error:', error); logger.error('Route handler error:', error);
res.statusCode = 500; if (!res.writableEnded) {
res.setHeader('Content-Type', 'application/json'); res.statusCode = 500;
res.end(JSON.stringify({ error: 'Internal Server Error' })); res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Internal Server Error' }));
}
return true; return true;
} }
}; };

View File

@@ -28,6 +28,12 @@ export interface HttpRequest {
*/ */
path: string; path: string;
/**
* @zh 路由参数(从 URL 路径提取,如 /users/:id
* @en Route parameters (extracted from URL path, e.g., /users/:id)
*/
params: Record<string, string>;
/** /**
* @zh 查询参数 * @zh 查询参数
* @en Query parameters * @en Query parameters
@@ -102,8 +108,102 @@ export interface HttpResponse {
export type HttpHandler = (req: HttpRequest, res: HttpResponse) => void | Promise<void>; export type HttpHandler = (req: HttpRequest, res: HttpResponse) => void | Promise<void>;
/** /**
* @zh HTTP 路由定义 * @zh HTTP 中间件函数
* @en HTTP route definition * @en HTTP middleware function
*
* @example
* ```typescript
* const authMiddleware: HttpMiddleware = async (req, res, next) => {
* if (!req.headers.authorization) {
* res.error(401, 'Unauthorized');
* return;
* }
* await next();
* };
* ```
*/
export type HttpMiddleware = (
req: HttpRequest,
res: HttpResponse,
next: () => Promise<void>
) => void | Promise<void>;
/**
* @zh 带中间件和超时的路由处理器定义
* @en Route handler definition with middleware and timeout support
*/
export interface HttpHandlerDefinition {
/**
* @zh 处理函数
* @en Handler function
*/
handler: HttpHandler;
/**
* @zh 路由级中间件
* @en Route-level middlewares
*/
middlewares?: HttpMiddleware[];
/**
* @zh 路由级超时时间(毫秒),覆盖全局设置
* @en Route-level timeout in milliseconds, overrides global setting
*/
timeout?: number;
}
/**
* @zh HTTP 路由方法配置(支持简单处理器或完整定义)
* @en HTTP route method configuration (supports simple handler or full definition)
*/
export type HttpMethodHandler = HttpHandler | HttpHandlerDefinition;
/**
* @zh HTTP 路由方法映射
* @en HTTP route methods mapping
*/
export interface HttpRouteMethods {
GET?: HttpMethodHandler;
POST?: HttpMethodHandler;
PUT?: HttpMethodHandler;
DELETE?: HttpMethodHandler;
PATCH?: HttpMethodHandler;
OPTIONS?: HttpMethodHandler;
}
/**
* @zh HTTP 路由配置
* @en HTTP routes configuration
*
* @example
* ```typescript
* const routes: HttpRoutes = {
* // 简单处理器
* '/health': (req, res) => res.json({ ok: true }),
*
* // 按方法分开
* '/users': {
* GET: (req, res) => res.json([]),
* POST: (req, res) => res.json({ created: true })
* },
*
* // 路由参数
* '/users/:id': {
* GET: (req, res) => res.json({ id: req.params.id }),
* DELETE: {
* handler: (req, res) => res.json({ deleted: true }),
* middlewares: [authMiddleware],
* timeout: 5000
* }
* }
* };
* ```
*/
export type HttpRoutes = Record<string, HttpMethodHandler | HttpRouteMethods>;
/**
* @zh HTTP 路由定义(内部使用)
* @en HTTP route definition (internal use)
*/ */
export interface HttpRoute { export interface HttpRoute {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | '*'; method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | '*';
@@ -111,19 +211,6 @@ export interface HttpRoute {
handler: HttpHandler; handler: HttpHandler;
} }
/**
* @zh HTTP 路由配置
* @en HTTP routes configuration
*/
export type HttpRoutes = Record<string, HttpHandler | {
GET?: HttpHandler;
POST?: HttpHandler;
PUT?: HttpHandler;
DELETE?: HttpHandler;
PATCH?: HttpHandler;
OPTIONS?: HttpHandler;
}>;
/** /**
* @zh CORS 配置 * @zh CORS 配置
* @en CORS configuration * @en CORS configuration
@@ -159,3 +246,27 @@ export interface CorsOptions {
*/ */
maxAge?: number; maxAge?: number;
} }
/**
* @zh HTTP 路由器选项
* @en HTTP router options
*/
export interface HttpRouterOptions {
/**
* @zh CORS 配置
* @en CORS configuration
*/
cors?: CorsOptions | boolean;
/**
* @zh 全局请求超时时间(毫秒)
* @en Global request timeout in milliseconds
*/
timeout?: number;
/**
* @zh 全局中间件
* @en Global middlewares
*/
middlewares?: HttpMiddleware[];
}

View File

@@ -27,15 +27,15 @@
*/ */
// Core // Core
export { createServer } from './core/server.js' export { createServer } from './core/server.js';
// Helpers // Helpers
export { defineApi, defineMsg, defineHttp } from './helpers/define.js' export { defineApi, defineMsg, defineHttp } from './helpers/define.js';
// Room System // Room System
export { Room, type RoomOptions } from './room/Room.js' export { Room, type RoomOptions } from './room/Room.js';
export { Player, type IPlayer } from './room/Player.js' export { Player, type IPlayer } from './room/Player.js';
export { onMessage } from './room/decorators.js' export { onMessage } from './room/decorators.js';
// Types // Types
export type { export type {
@@ -47,18 +47,18 @@ export type {
ApiDefinition, ApiDefinition,
MsgDefinition, MsgDefinition,
HttpDefinition, HttpDefinition,
HttpMethod, HttpMethod
} from './types/index.js' } from './types/index.js';
// HTTP // HTTP
export { createHttpRouter } from './http/router.js' export { createHttpRouter } from './http/router.js';
export type { export type {
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
HttpHandler, HttpHandler,
HttpRoutes, HttpRoutes,
CorsOptions, CorsOptions
} from './http/types.js' } from './http/types.js';
// Re-export useful types from @esengine/rpc // Re-export useful types from @esengine/rpc
export { RpcError, ErrorCode } from '@esengine/rpc' export { RpcError, ErrorCode } from '@esengine/rpc';

View File

@@ -0,0 +1,34 @@
/**
* @zh 日志模块 - 直接使用 @esengine/ecs-framework 的 Logger
* @en Logger module - Uses @esengine/ecs-framework Logger directly
*/
import { createLogger as ecsCreateLogger, type ILogger } from '@esengine/ecs-framework';
export type { ILogger };
/**
* @zh 创建命名日志器
* @en Create a named logger
*
* @param name - @zh 日志器名称 @en Logger name
* @returns @zh 日志器实例 @en Logger instance
*
* @example
* ```typescript
* import { createLogger } from './logger.js'
*
* const logger = createLogger('Server')
* logger.info('Started on port 3000')
* logger.error('Connection failed:', error)
* ```
*/
export function createLogger(name: string): ILogger {
return ecsCreateLogger(name);
}
/**
* @zh 默认服务器日志器
* @en Default server logger
*/
export const serverLogger = createLogger('Server');

View File

@@ -42,7 +42,7 @@ describe('TokenBucketStrategy', () => {
strategy.consume('user-1'); strategy.consume('user-1');
} }
await new Promise(resolve => setTimeout(resolve, 150)); await new Promise((resolve) => setTimeout(resolve, 150));
const result = strategy.consume('user-1'); const result = strategy.consume('user-1');
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
@@ -92,7 +92,7 @@ describe('TokenBucketStrategy', () => {
it('should clean up full buckets', async () => { it('should clean up full buckets', async () => {
strategy.consume('user-1'); strategy.consume('user-1');
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
strategy.cleanup(); strategy.cleanup();
}); });
@@ -131,7 +131,7 @@ describe('SlidingWindowStrategy', () => {
strategy.consume('user-1'); strategy.consume('user-1');
} }
await new Promise(resolve => setTimeout(resolve, 1100)); await new Promise((resolve) => setTimeout(resolve, 1100));
const result = strategy.consume('user-1'); const result = strategy.consume('user-1');
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
@@ -192,7 +192,7 @@ describe('FixedWindowStrategy', () => {
strategy.consume('user-1'); strategy.consume('user-1');
} }
await new Promise(resolve => setTimeout(resolve, 1100)); await new Promise((resolve) => setTimeout(resolve, 1100));
const result = strategy.consume('user-1'); const result = strategy.consume('user-1');
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
@@ -224,7 +224,7 @@ describe('FixedWindowStrategy', () => {
it('should clean up old windows', async () => { it('should clean up old windows', async () => {
strategy.consume('user-1'); strategy.consume('user-1');
await new Promise(resolve => setTimeout(resolve, 2100)); await new Promise((resolve) => setTimeout(resolve, 2100));
strategy.cleanup(); strategy.cleanup();
}); });

View File

@@ -100,7 +100,7 @@ function getMessageTypeFromMethod(target: any, methodName: string): string | und
*/ */
export function rateLimit(config?: MessageRateLimitConfig): MethodDecorator { export function rateLimit(config?: MessageRateLimitConfig): MethodDecorator {
return function ( return function (
target: Object, target: object,
propertyKey: string | symbol, propertyKey: string | symbol,
descriptor: PropertyDescriptor descriptor: PropertyDescriptor
): PropertyDescriptor { ): PropertyDescriptor {
@@ -159,7 +159,7 @@ export function rateLimit(config?: MessageRateLimitConfig): MethodDecorator {
*/ */
export function noRateLimit(): MethodDecorator { export function noRateLimit(): MethodDecorator {
return function ( return function (
target: Object, target: object,
propertyKey: string | symbol, propertyKey: string | symbol,
descriptor: PropertyDescriptor descriptor: PropertyDescriptor
): PropertyDescriptor { ): PropertyDescriptor {
@@ -202,7 +202,7 @@ export function rateLimitMessage(
config?: MessageRateLimitConfig config?: MessageRateLimitConfig
): MethodDecorator { ): MethodDecorator {
return function ( return function (
target: Object, target: object,
propertyKey: string | symbol, propertyKey: string | symbol,
descriptor: PropertyDescriptor descriptor: PropertyDescriptor
): PropertyDescriptor { ): PropertyDescriptor {
@@ -232,7 +232,7 @@ export function rateLimitMessage(
*/ */
export function noRateLimitMessage(messageType: string): MethodDecorator { export function noRateLimitMessage(messageType: string): MethodDecorator {
return function ( return function (
target: Object, target: object,
propertyKey: string | symbol, propertyKey: string | symbol,
descriptor: PropertyDescriptor descriptor: PropertyDescriptor
): PropertyDescriptor { ): PropertyDescriptor {

View File

@@ -108,6 +108,50 @@ function setPlayerRateLimitContext(player: Player, context: IRateLimitContext):
}); });
} }
/**
* @zh 抽象构造器类型
* @en Abstract constructor type
*/
type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
/**
* @zh 可混入的 Room 构造器类型(支持抽象和具体类)
* @en Mixable Room constructor type (supports both abstract and concrete classes)
*/
type RoomConstructor = AbstractConstructor<Room>;
// ============================================================================
// Mixin 类型辅助函数 | Mixin Type Helpers
// ============================================================================
// TypeScript 的 mixin 模式存在类型系统限制:
// 1. ES6 class 语法不支持 `extends` 抽象类型参数
// 2. 泛型类型参数无法直接用于 class extends 子句
// 以下辅助函数封装了必要的类型转换,使 mixin 实现更清晰
//
// TypeScript mixin pattern has type system limitations:
// 1. ES6 class syntax doesn't support `extends` with abstract type parameters
// 2. Generic type parameters cannot be used directly in class extends clause
// The following helpers encapsulate necessary type casts for cleaner mixin implementation
// ============================================================================
/**
* @zh 将抽象 Room 构造器转换为可继承的具体构造器
* @en Convert abstract Room constructor to extendable concrete constructor
*/
function toExtendable<T extends RoomConstructor>(Base: T): new (...args: any[]) => Room {
return Base as unknown as new (...args: any[]) => Room;
}
/**
* @zh 将 mixin 类转换为正确的返回类型
* @en Cast mixin class to correct return type
*/
function toMixinResult<TBase extends RoomConstructor, TInterface>(
MixinClass: AbstractConstructor<any>
): TBase & AbstractConstructor<TInterface> {
return MixinClass as unknown as TBase & AbstractConstructor<TInterface>;
}
/** /**
* @zh 包装房间类添加速率限制功能 * @zh 包装房间类添加速率限制功能
* @en Wrap room class with rate limit functionality * @en Wrap room class with rate limit functionality
@@ -148,10 +192,10 @@ function setPlayerRateLimitContext(player: Player, context: IRateLimitContext):
* } * }
* ``` * ```
*/ */
export function withRateLimit<TBase extends new (...args: any[]) => Room = new (...args: any[]) => Room>( export function withRateLimit<TBase extends RoomConstructor>(
Base: TBase, Base: TBase,
config: RateLimitConfig = {} config: RateLimitConfig = {}
): TBase & (new (...args: any[]) => IRateLimitRoom) { ): TBase & AbstractConstructor<IRateLimitRoom> {
const { const {
messagesPerSecond = 10, messagesPerSecond = 10,
burstSize = 20, burstSize = 20,
@@ -163,7 +207,9 @@ export function withRateLimit<TBase extends new (...args: any[]) => Room = new (
cleanupInterval = 60000 cleanupInterval = 60000
} = config; } = config;
abstract class RateLimitRoom extends (Base as new (...args: any[]) => Room) implements IRateLimitRoom { const BaseRoom = toExtendable(Base);
abstract class RateLimitRoom extends BaseRoom implements IRateLimitRoom {
private _rateLimitStrategy: IRateLimitStrategy; private _rateLimitStrategy: IRateLimitStrategy;
private _playerContexts: WeakMap<Player, RateLimitContext> = new WeakMap(); private _playerContexts: WeakMap<Player, RateLimitContext> = new WeakMap();
private _cleanupTimer: ReturnType<typeof setInterval> | null = null; private _cleanupTimer: ReturnType<typeof setInterval> | null = null;
@@ -381,5 +427,5 @@ export function withRateLimit<TBase extends new (...args: any[]) => Room = new (
} }
} }
return RateLimitRoom as unknown as TBase & (new (...args: any[]) => IRateLimitRoom); return toMixinResult<TBase, IRateLimitRoom>(RateLimitRoom);
} }

View File

@@ -168,7 +168,7 @@ export class SlidingWindowStrategy implements IRateLimitStrategy {
*/ */
private _cleanExpiredTimestamps(window: WindowState, now: number): void { private _cleanExpiredTimestamps(window: WindowState, now: number): void {
const cutoff = now - this._windowMs; const cutoff = now - this._windowMs;
window.timestamps = window.timestamps.filter(ts => ts > cutoff); window.timestamps = window.timestamps.filter((ts) => ts > cutoff);
} }
/** /**

View File

@@ -3,7 +3,7 @@
* @en Player class * @en Player class
*/ */
import type { Connection } from '@esengine/rpc' import type { Connection } from '@esengine/rpc';
/** /**
* @zh 玩家接口 * @zh 玩家接口
@@ -22,13 +22,13 @@ export interface IPlayer<TData = Record<string, unknown>> {
* @en Player implementation * @en Player implementation
*/ */
export class Player<TData = Record<string, unknown>> implements IPlayer<TData> { export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
readonly id: string readonly id: string;
readonly roomId: string readonly roomId: string;
data: TData data: TData;
private _conn: Connection<any> private _conn: Connection<any>;
private _sendFn: (conn: Connection<any>, type: string, data: unknown) => void private _sendFn: (conn: Connection<any>, type: string, data: unknown) => void;
private _leaveFn: (player: Player<TData>, reason?: string) => void private _leaveFn: (player: Player<TData>, reason?: string) => void;
constructor(options: { constructor(options: {
id: string id: string
@@ -38,12 +38,12 @@ export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
leaveFn: (player: Player<TData>, reason?: string) => void leaveFn: (player: Player<TData>, reason?: string) => void
initialData?: TData initialData?: TData
}) { }) {
this.id = options.id this.id = options.id;
this.roomId = options.roomId this.roomId = options.roomId;
this._conn = options.conn this._conn = options.conn;
this._sendFn = options.sendFn this._sendFn = options.sendFn;
this._leaveFn = options.leaveFn this._leaveFn = options.leaveFn;
this.data = options.initialData ?? ({} as TData) this.data = options.initialData ?? ({} as TData);
} }
/** /**
@@ -51,7 +51,7 @@ export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
* @en Get underlying connection * @en Get underlying connection
*/ */
get connection(): Connection<any> { get connection(): Connection<any> {
return this._conn return this._conn;
} }
/** /**
@@ -59,7 +59,7 @@ export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
* @en Send message to player * @en Send message to player
*/ */
send<T>(type: string, data: T): void { send<T>(type: string, data: T): void {
this._sendFn(this._conn, type, data) this._sendFn(this._conn, type, data);
} }
/** /**
@@ -67,6 +67,6 @@ export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
* @en Make player leave the room * @en Make player leave the room
*/ */
leave(reason?: string): void { leave(reason?: string): void {
this._leaveFn(this, reason) this._leaveFn(this, reason);
} }
} }

View File

@@ -3,7 +3,7 @@
* @en Room base class * @en Room base class
*/ */
import { Player } from './Player.js' import { Player } from './Player.js';
/** /**
* @zh 房间配置 * @zh 房间配置
@@ -26,7 +26,7 @@ interface MessageHandlerMeta {
* @zh 消息处理器存储 key * @zh 消息处理器存储 key
* @en Message handler storage key * @en Message handler storage key
*/ */
const MESSAGE_HANDLERS = Symbol('messageHandlers') const MESSAGE_HANDLERS = Symbol('messageHandlers');
/** /**
* @zh 房间基类 * @zh 房间基类
@@ -58,19 +58,19 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @zh 最大玩家数 * @zh 最大玩家数
* @en Maximum players * @en Maximum players
*/ */
maxPlayers = 16 maxPlayers = 16;
/** /**
* @zh Tick 速率每秒0 = 不自动 tick * @zh Tick 速率每秒0 = 不自动 tick
* @en Tick rate (per second), 0 = no auto tick * @en Tick rate (per second), 0 = no auto tick
*/ */
tickRate = 0 tickRate = 0;
/** /**
* @zh 空房间自动销毁 * @zh 空房间自动销毁
* @en Auto dispose when empty * @en Auto dispose when empty
*/ */
autoDispose = true autoDispose = true;
// ======================================================================== // ========================================================================
// 状态 | State // 状态 | State
@@ -80,21 +80,21 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @zh 房间状态 * @zh 房间状态
* @en Room state * @en Room state
*/ */
state: TState = {} as TState state: TState = {} as TState;
// ======================================================================== // ========================================================================
// 内部属性 | Internal properties // 内部属性 | Internal properties
// ======================================================================== // ========================================================================
private _id: string = '' private _id: string = '';
private _players: Map<string, Player<TPlayerData>> = new Map() private _players: Map<string, Player<TPlayerData>> = new Map();
private _locked = false private _locked = false;
private _disposed = false private _disposed = false;
private _tickInterval: ReturnType<typeof setInterval> | null = null private _tickInterval: ReturnType<typeof setInterval> | null = null;
private _lastTickTime = 0 private _lastTickTime = 0;
private _broadcastFn: ((type: string, data: unknown) => void) | null = null private _broadcastFn: ((type: string, data: unknown) => void) | null = null;
private _sendFn: ((conn: any, type: string, data: unknown) => void) | null = null private _sendFn: ((conn: any, type: string, data: unknown) => void) | null = null;
private _disposeFn: (() => void) | null = null private _disposeFn: (() => void) | null = null;
// ======================================================================== // ========================================================================
// 只读属性 | Readonly properties // 只读属性 | Readonly properties
@@ -105,7 +105,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Room ID * @en Room ID
*/ */
get id(): string { get id(): string {
return this._id return this._id;
} }
/** /**
@@ -113,7 +113,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en All players * @en All players
*/ */
get players(): ReadonlyArray<Player<TPlayerData>> { get players(): ReadonlyArray<Player<TPlayerData>> {
return Array.from(this._players.values()) return Array.from(this._players.values());
} }
/** /**
@@ -121,7 +121,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Player count * @en Player count
*/ */
get playerCount(): number { get playerCount(): number {
return this._players.size return this._players.size;
} }
/** /**
@@ -129,7 +129,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Is full * @en Is full
*/ */
get isFull(): boolean { get isFull(): boolean {
return this._players.size >= this.maxPlayers return this._players.size >= this.maxPlayers;
} }
/** /**
@@ -137,7 +137,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Is locked * @en Is locked
*/ */
get isLocked(): boolean { get isLocked(): boolean {
return this._locked return this._locked;
} }
/** /**
@@ -145,7 +145,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Is disposed * @en Is disposed
*/ */
get isDisposed(): boolean { get isDisposed(): boolean {
return this._disposed return this._disposed;
} }
// ======================================================================== // ========================================================================
@@ -192,7 +192,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
*/ */
broadcast<T>(type: string, data: T): void { broadcast<T>(type: string, data: T): void {
for (const player of this._players.values()) { for (const player of this._players.values()) {
player.send(type, data) player.send(type, data);
} }
} }
@@ -203,7 +203,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
broadcastExcept<T>(except: Player<TPlayerData>, type: string, data: T): void { broadcastExcept<T>(except: Player<TPlayerData>, type: string, data: T): void {
for (const player of this._players.values()) { for (const player of this._players.values()) {
if (player.id !== except.id) { if (player.id !== except.id) {
player.send(type, data) player.send(type, data);
} }
} }
} }
@@ -213,7 +213,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Get player by id * @en Get player by id
*/ */
getPlayer(id: string): Player<TPlayerData> | undefined { getPlayer(id: string): Player<TPlayerData> | undefined {
return this._players.get(id) return this._players.get(id);
} }
/** /**
@@ -221,7 +221,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Kick player * @en Kick player
*/ */
kick(player: Player<TPlayerData>, reason?: string): void { kick(player: Player<TPlayerData>, reason?: string): void {
player.leave(reason ?? 'kicked') player.leave(reason ?? 'kicked');
} }
/** /**
@@ -229,7 +229,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Lock room * @en Lock room
*/ */
lock(): void { lock(): void {
this._locked = true this._locked = true;
} }
/** /**
@@ -237,7 +237,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Unlock room * @en Unlock room
*/ */
unlock(): void { unlock(): void {
this._locked = false this._locked = false;
} }
/** /**
@@ -245,18 +245,18 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Manually dispose room * @en Manually dispose room
*/ */
dispose(): void { dispose(): void {
if (this._disposed) return if (this._disposed) return;
this._disposed = true this._disposed = true;
this._stopTick() this._stopTick();
for (const player of this._players.values()) { for (const player of this._players.values()) {
player.leave('room_disposed') player.leave('room_disposed');
} }
this._players.clear() this._players.clear();
this.onDispose() this.onDispose();
this._disposeFn?.() this._disposeFn?.();
} }
// ======================================================================== // ========================================================================
@@ -272,18 +272,18 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
broadcastFn: (type: string, data: unknown) => void broadcastFn: (type: string, data: unknown) => void
disposeFn: () => void disposeFn: () => void
}): void { }): void {
this._id = options.id this._id = options.id;
this._sendFn = options.sendFn this._sendFn = options.sendFn;
this._broadcastFn = options.broadcastFn this._broadcastFn = options.broadcastFn;
this._disposeFn = options.disposeFn this._disposeFn = options.disposeFn;
} }
/** /**
* @internal * @internal
*/ */
async _create(options?: RoomOptions): Promise<void> { async _create(options?: RoomOptions): Promise<void> {
await this.onCreate(options) await this.onCreate(options);
this._startTick() this._startTick();
} }
/** /**
@@ -291,7 +291,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
*/ */
async _addPlayer(id: string, conn: any): Promise<Player<TPlayerData> | null> { async _addPlayer(id: string, conn: any): Promise<Player<TPlayerData> | null> {
if (this._locked || this.isFull || this._disposed) { if (this._locked || this.isFull || this._disposed) {
return null return null;
} }
const player = new Player<TPlayerData>({ const player = new Player<TPlayerData>({
@@ -299,27 +299,27 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
roomId: this._id, roomId: this._id,
conn, conn,
sendFn: this._sendFn!, sendFn: this._sendFn!,
leaveFn: (p, reason) => this._removePlayer(p.id, reason), leaveFn: (p, reason) => this._removePlayer(p.id, reason)
}) });
this._players.set(id, player) this._players.set(id, player);
await this.onJoin(player) await this.onJoin(player);
return player return player;
} }
/** /**
* @internal * @internal
*/ */
async _removePlayer(id: string, reason?: string): Promise<void> { async _removePlayer(id: string, reason?: string): Promise<void> {
const player = this._players.get(id) const player = this._players.get(id);
if (!player) return if (!player) return;
this._players.delete(id) this._players.delete(id);
await this.onLeave(player, reason) await this.onLeave(player, reason);
if (this.autoDispose && this._players.size === 0) { if (this.autoDispose && this._players.size === 0) {
this.dispose() this.dispose();
} }
} }
@@ -327,16 +327,16 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @internal * @internal
*/ */
_handleMessage(type: string, data: unknown, playerId: string): void { _handleMessage(type: string, data: unknown, playerId: string): void {
const player = this._players.get(playerId) const player = this._players.get(playerId);
if (!player) return if (!player) return;
const handlers = (this.constructor as any)[MESSAGE_HANDLERS] as MessageHandlerMeta[] | undefined const handlers = (this.constructor as any)[MESSAGE_HANDLERS] as MessageHandlerMeta[] | undefined;
if (handlers) { if (handlers) {
for (const handler of handlers) { for (const handler of handlers) {
if (handler.type === type) { if (handler.type === type) {
const method = (this as any)[handler.method] const method = (this as any)[handler.method];
if (typeof method === 'function') { if (typeof method === 'function') {
method.call(this, data, player) method.call(this, data, player);
} }
} }
} }
@@ -344,21 +344,21 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
} }
private _startTick(): void { private _startTick(): void {
if (this.tickRate <= 0) return if (this.tickRate <= 0) return;
this._lastTickTime = performance.now() this._lastTickTime = performance.now();
this._tickInterval = setInterval(() => { this._tickInterval = setInterval(() => {
const now = performance.now() const now = performance.now();
const dt = (now - this._lastTickTime) / 1000 const dt = (now - this._lastTickTime) / 1000;
this._lastTickTime = now this._lastTickTime = now;
this.onTick(dt) this.onTick(dt);
}, 1000 / this.tickRate) }, 1000 / this.tickRate);
} }
private _stopTick(): void { private _stopTick(): void {
if (this._tickInterval) { if (this._tickInterval) {
clearInterval(this._tickInterval) clearInterval(this._tickInterval);
this._tickInterval = null this._tickInterval = null;
} }
} }
} }
@@ -368,7 +368,7 @@ export abstract class Room<TState = any, TPlayerData = Record<string, unknown>>
* @en Get message handler metadata * @en Get message handler metadata
*/ */
export function getMessageHandlers(target: any): MessageHandlerMeta[] { export function getMessageHandlers(target: any): MessageHandlerMeta[] {
return target[MESSAGE_HANDLERS] || [] return target[MESSAGE_HANDLERS] || [];
} }
/** /**
@@ -377,7 +377,7 @@ export function getMessageHandlers(target: any): MessageHandlerMeta[] {
*/ */
export function registerMessageHandler(target: any, type: string, method: string): void { export function registerMessageHandler(target: any, type: string, method: string): void {
if (!target[MESSAGE_HANDLERS]) { if (!target[MESSAGE_HANDLERS]) {
target[MESSAGE_HANDLERS] = [] target[MESSAGE_HANDLERS] = [];
} }
target[MESSAGE_HANDLERS].push({ type, method }) target[MESSAGE_HANDLERS].push({ type, method });
} }

View File

@@ -3,8 +3,11 @@
* @en Room manager * @en Room manager
*/ */
import { Room, type RoomOptions } from './Room.js' import { Room, type RoomOptions } from './Room.js';
import type { Player } from './Player.js' import type { Player } from './Player.js';
import { createLogger } from '../logger.js';
const logger = createLogger('Room');
/** /**
* @zh 房间类型 * @zh 房间类型
@@ -25,15 +28,15 @@ interface RoomDefinition {
* @en Room manager * @en Room manager
*/ */
export class RoomManager { export class RoomManager {
private _definitions: Map<string, RoomDefinition> = new Map() private _definitions: Map<string, RoomDefinition> = new Map();
private _rooms: Map<string, Room> = new Map() private _rooms: Map<string, Room> = new Map();
private _playerToRoom: Map<string, string> = new Map() private _playerToRoom: Map<string, string> = new Map();
private _nextRoomId = 1 private _nextRoomId = 1;
private _sendFn: (conn: any, type: string, data: unknown) => void private _sendFn: (conn: any, type: string, data: unknown) => void;
constructor(sendFn: (conn: any, type: string, data: unknown) => void) { constructor(sendFn: (conn: any, type: string, data: unknown) => void) {
this._sendFn = sendFn this._sendFn = sendFn;
} }
/** /**
@@ -41,7 +44,7 @@ export class RoomManager {
* @en Define room type * @en Define room type
*/ */
define<T extends Room>(name: string, roomClass: RoomClass<T>): void { define<T extends Room>(name: string, roomClass: RoomClass<T>): void {
this._definitions.set(name, { roomClass }) this._definitions.set(name, { roomClass });
} }
/** /**
@@ -49,33 +52,33 @@ export class RoomManager {
* @en Create room * @en Create room
*/ */
async create(name: string, options?: RoomOptions): Promise<Room | null> { async create(name: string, options?: RoomOptions): Promise<Room | null> {
const def = this._definitions.get(name) const def = this._definitions.get(name);
if (!def) { if (!def) {
console.warn(`[RoomManager] Room type not found: ${name}`) logger.warn(`Room type not found: ${name}`);
return null return null;
} }
const roomId = this._generateRoomId() const roomId = this._generateRoomId();
const room = new def.roomClass() const room = new def.roomClass();
room._init({ room._init({
id: roomId, id: roomId,
sendFn: this._sendFn, sendFn: this._sendFn,
broadcastFn: (type, data) => { broadcastFn: (type, data) => {
for (const player of room.players) { for (const player of room.players) {
player.send(type, data) player.send(type, data);
} }
}, },
disposeFn: () => { disposeFn: () => {
this._rooms.delete(roomId) this._rooms.delete(roomId);
}, }
}) });
this._rooms.set(roomId, room) this._rooms.set(roomId, room);
await room._create(options) await room._create(options);
console.log(`[Room] Created: ${name} (${roomId})`) logger.info(`Created: ${name} (${roomId})`);
return room return room;
} }
/** /**
@@ -89,22 +92,22 @@ export class RoomManager {
options?: RoomOptions options?: RoomOptions
): Promise<{ room: Room; player: Player } | null> { ): Promise<{ room: Room; player: Player } | null> {
// 查找可加入的房间 // 查找可加入的房间
let room = this._findAvailableRoom(name) let room = this._findAvailableRoom(name);
// 没有则创建 // 没有则创建
if (!room) { if (!room) {
room = await this.create(name, options) room = await this.create(name, options);
if (!room) return null if (!room) return null;
} }
// 加入房间 // 加入房间
const player = await room._addPlayer(playerId, conn) const player = await room._addPlayer(playerId, conn);
if (!player) return null if (!player) return null;
this._playerToRoom.set(playerId, room.id) this._playerToRoom.set(playerId, room.id);
console.log(`[Room] Player ${playerId} joined ${room.id}`) logger.info(`Player ${playerId} joined ${room.id}`);
return { room, player } return { room, player };
} }
/** /**
@@ -116,16 +119,16 @@ export class RoomManager {
playerId: string, playerId: string,
conn: any conn: any
): Promise<{ room: Room; player: Player } | null> { ): Promise<{ room: Room; player: Player } | null> {
const room = this._rooms.get(roomId) const room = this._rooms.get(roomId);
if (!room) return null if (!room) return null;
const player = await room._addPlayer(playerId, conn) const player = await room._addPlayer(playerId, conn);
if (!player) return null if (!player) return null;
this._playerToRoom.set(playerId, room.id) this._playerToRoom.set(playerId, room.id);
console.log(`[Room] Player ${playerId} joined ${room.id}`) logger.info(`Player ${playerId} joined ${room.id}`);
return { room, player } return { room, player };
} }
/** /**
@@ -133,16 +136,16 @@ export class RoomManager {
* @en Player leave * @en Player leave
*/ */
async leave(playerId: string, reason?: string): Promise<void> { async leave(playerId: string, reason?: string): Promise<void> {
const roomId = this._playerToRoom.get(playerId) const roomId = this._playerToRoom.get(playerId);
if (!roomId) return if (!roomId) return;
const room = this._rooms.get(roomId) const room = this._rooms.get(roomId);
if (room) { if (room) {
await room._removePlayer(playerId, reason) await room._removePlayer(playerId, reason);
} }
this._playerToRoom.delete(playerId) this._playerToRoom.delete(playerId);
console.log(`[Room] Player ${playerId} left ${roomId}`) logger.info(`Player ${playerId} left ${roomId}`);
} }
/** /**
@@ -150,12 +153,12 @@ export class RoomManager {
* @en Handle message * @en Handle message
*/ */
handleMessage(playerId: string, type: string, data: unknown): void { handleMessage(playerId: string, type: string, data: unknown): void {
const roomId = this._playerToRoom.get(playerId) const roomId = this._playerToRoom.get(playerId);
if (!roomId) return if (!roomId) return;
const room = this._rooms.get(roomId) const room = this._rooms.get(roomId);
if (room) { if (room) {
room._handleMessage(type, data, playerId) room._handleMessage(type, data, playerId);
} }
} }
@@ -164,7 +167,7 @@ export class RoomManager {
* @en Get room * @en Get room
*/ */
getRoom(roomId: string): Room | undefined { getRoom(roomId: string): Room | undefined {
return this._rooms.get(roomId) return this._rooms.get(roomId);
} }
/** /**
@@ -172,8 +175,8 @@ export class RoomManager {
* @en Get player's room * @en Get player's room
*/ */
getPlayerRoom(playerId: string): Room | undefined { getPlayerRoom(playerId: string): Room | undefined {
const roomId = this._playerToRoom.get(playerId) const roomId = this._playerToRoom.get(playerId);
return roomId ? this._rooms.get(roomId) : undefined return roomId ? this._rooms.get(roomId) : undefined;
} }
/** /**
@@ -181,7 +184,7 @@ export class RoomManager {
* @en Get all rooms * @en Get all rooms
*/ */
getRooms(): ReadonlyArray<Room> { getRooms(): ReadonlyArray<Room> {
return Array.from(this._rooms.values()) return Array.from(this._rooms.values());
} }
/** /**
@@ -189,17 +192,17 @@ export class RoomManager {
* @en Get all rooms of a type * @en Get all rooms of a type
*/ */
getRoomsByType(name: string): Room[] { getRoomsByType(name: string): Room[] {
const def = this._definitions.get(name) const def = this._definitions.get(name);
if (!def) return [] if (!def) return [];
return Array.from(this._rooms.values()).filter( return Array.from(this._rooms.values()).filter(
room => room instanceof def.roomClass (room) => room instanceof def.roomClass
) );
} }
private _findAvailableRoom(name: string): Room | null { private _findAvailableRoom(name: string): Room | null {
const def = this._definitions.get(name) const def = this._definitions.get(name);
if (!def) return null if (!def) return null;
for (const room of this._rooms.values()) { for (const room of this._rooms.values()) {
if ( if (
@@ -208,14 +211,14 @@ export class RoomManager {
!room.isLocked && !room.isLocked &&
!room.isDisposed !room.isDisposed
) { ) {
return room return room;
} }
} }
return null return null;
} }
private _generateRoomId(): string { private _generateRoomId(): string {
return `room_${this._nextRoomId++}` return `room_${this._nextRoomId++}`;
} }
} }

View File

@@ -3,7 +3,7 @@
* @en Room decorators * @en Room decorators
*/ */
import { registerMessageHandler } from './Room.js' import { registerMessageHandler } from './Room.js';
/** /**
* @zh 消息处理器装饰器 * @zh 消息处理器装饰器
@@ -30,6 +30,6 @@ export function onMessage(type: string): MethodDecorator {
propertyKey: string | symbol, propertyKey: string | symbol,
_descriptor: PropertyDescriptor _descriptor: PropertyDescriptor
) { ) {
registerMessageHandler(target.constructor, type, propertyKey as string) registerMessageHandler(target.constructor, type, propertyKey as string);
} };
} }

View File

@@ -3,7 +3,7 @@
* @en Room system * @en Room system
*/ */
export { Room, type RoomOptions } from './Room.js' export { Room, type RoomOptions } from './Room.js';
export { Player, type IPlayer } from './Player.js' export { Player, type IPlayer } from './Player.js';
export { RoomManager, type RoomClass } from './RoomManager.js' export { RoomManager, type RoomClass } from './RoomManager.js';
export { onMessage } from './decorators.js' export { onMessage } from './decorators.js';

View File

@@ -3,9 +3,10 @@
* @en File-based router loader * @en File-based router loader
*/ */
import * as fs from 'node:fs' import * as fs from 'node:fs';
import * as path from 'node:path' import * as path from 'node:path';
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url';
import { createLogger } from '../logger.js';
import type { import type {
ApiDefinition, ApiDefinition,
MsgDefinition, MsgDefinition,
@@ -13,8 +14,10 @@ import type {
LoadedApiHandler, LoadedApiHandler,
LoadedMsgHandler, LoadedMsgHandler,
LoadedHttpHandler, LoadedHttpHandler,
HttpMethod, HttpMethod
} from '../types/index.js' } from '../types/index.js';
const logger = createLogger('Server');
/** /**
* @zh 将文件名转换为 API/消息名称 * @zh 将文件名转换为 API/消息名称
@@ -26,12 +29,12 @@ import type {
* 'save_blueprint.ts' -> 'SaveBlueprint' * 'save_blueprint.ts' -> 'SaveBlueprint'
*/ */
function fileNameToHandlerName(fileName: string): string { function fileNameToHandlerName(fileName: string): string {
const baseName = fileName.replace(/\.(ts|js|mts|mjs)$/, '') const baseName = fileName.replace(/\.(ts|js|mts|mjs)$/, '');
return baseName return baseName
.split(/[-_]/) .split(/[-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1)) .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('') .join('');
} }
/** /**
@@ -40,23 +43,23 @@ function fileNameToHandlerName(fileName: string): string {
*/ */
function scanDirectory(dir: string): string[] { function scanDirectory(dir: string): string[] {
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
return [] return [];
} }
const files: string[] = [] const files: string[] = [];
const entries = fs.readdirSync(dir, { withFileTypes: true }) const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) { if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) {
// 跳过 index 和下划线开头的文件 // 跳过 index 和下划线开头的文件
if (entry.name.startsWith('_') || entry.name.startsWith('index.')) { if (entry.name.startsWith('_') || entry.name.startsWith('index.')) {
continue continue;
} }
files.push(path.join(dir, entry.name)) files.push(path.join(dir, entry.name));
} }
} }
return files return files;
} }
/** /**
@@ -64,29 +67,29 @@ function scanDirectory(dir: string): string[] {
* @en Load API handlers * @en Load API handlers
*/ */
export async function loadApiHandlers(apiDir: string): Promise<LoadedApiHandler[]> { export async function loadApiHandlers(apiDir: string): Promise<LoadedApiHandler[]> {
const files = scanDirectory(apiDir) const files = scanDirectory(apiDir);
const handlers: LoadedApiHandler[] = [] const handlers: LoadedApiHandler[] = [];
for (const filePath of files) { for (const filePath of files) {
try { try {
const fileUrl = pathToFileURL(filePath).href const fileUrl = pathToFileURL(filePath).href;
const module = await import(fileUrl) const module = await import(fileUrl);
const definition = module.default as ApiDefinition<unknown, unknown, unknown> const definition = module.default as ApiDefinition<unknown, unknown, unknown>;
if (definition && typeof definition.handler === 'function') { if (definition && typeof definition.handler === 'function') {
const name = fileNameToHandlerName(path.basename(filePath)) const name = fileNameToHandlerName(path.basename(filePath));
handlers.push({ handlers.push({
name, name,
path: filePath, path: filePath,
definition, definition
}) });
} }
} catch (err) { } catch (err) {
console.warn(`[Server] Failed to load API handler: ${filePath}`, err) logger.warn(`Failed to load API handler: ${filePath}`, err);
} }
} }
return handlers return handlers;
} }
/** /**
@@ -94,29 +97,29 @@ export async function loadApiHandlers(apiDir: string): Promise<LoadedApiHandler[
* @en Load message handlers * @en Load message handlers
*/ */
export async function loadMsgHandlers(msgDir: string): Promise<LoadedMsgHandler[]> { export async function loadMsgHandlers(msgDir: string): Promise<LoadedMsgHandler[]> {
const files = scanDirectory(msgDir) const files = scanDirectory(msgDir);
const handlers: LoadedMsgHandler[] = [] const handlers: LoadedMsgHandler[] = [];
for (const filePath of files) { for (const filePath of files) {
try { try {
const fileUrl = pathToFileURL(filePath).href const fileUrl = pathToFileURL(filePath).href;
const module = await import(fileUrl) const module = await import(fileUrl);
const definition = module.default as MsgDefinition<unknown, unknown> const definition = module.default as MsgDefinition<unknown, unknown>;
if (definition && typeof definition.handler === 'function') { if (definition && typeof definition.handler === 'function') {
const name = fileNameToHandlerName(path.basename(filePath)) const name = fileNameToHandlerName(path.basename(filePath));
handlers.push({ handlers.push({
name, name,
path: filePath, path: filePath,
definition, definition
}) });
} }
} catch (err) { } catch (err) {
console.warn(`[Server] Failed to load msg handler: ${filePath}`, err) logger.warn(`Failed to load msg handler: ${filePath}`, err);
} }
} }
return handlers return handlers;
} }
/** /**
@@ -125,27 +128,27 @@ export async function loadMsgHandlers(msgDir: string): Promise<LoadedMsgHandler[
*/ */
function scanDirectoryRecursive(dir: string, baseDir: string = dir): Array<{ filePath: string; relativePath: string }> { function scanDirectoryRecursive(dir: string, baseDir: string = dir): Array<{ filePath: string; relativePath: string }> {
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
return [] return [];
} }
const files: Array<{ filePath: string; relativePath: string }> = [] const files: Array<{ filePath: string; relativePath: string }> = [];
const entries = fs.readdirSync(dir, { withFileTypes: true }) const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const fullPath = path.join(dir, entry.name) const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
files.push(...scanDirectoryRecursive(fullPath, baseDir)) files.push(...scanDirectoryRecursive(fullPath, baseDir));
} else if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) { } else if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) {
if (entry.name.startsWith('_') || entry.name.startsWith('index.')) { if (entry.name.startsWith('_') || entry.name.startsWith('index.')) {
continue continue;
} }
const relativePath = path.relative(baseDir, fullPath) const relativePath = path.relative(baseDir, fullPath);
files.push({ filePath: fullPath, relativePath }) files.push({ filePath: fullPath, relativePath });
} }
} }
return files return files;
} }
/** /**
@@ -161,17 +164,17 @@ function filePathToRoute(relativePath: string, prefix: string): string {
let route = relativePath let route = relativePath
.replace(/\.(ts|js|mts|mjs)$/, '') .replace(/\.(ts|js|mts|mjs)$/, '')
.replace(/\\/g, '/') .replace(/\\/g, '/')
.replace(/\[([^\]]+)\]/g, ':$1') .replace(/\[(\w+)\]/g, ':$1');
if (!route.startsWith('/')) { if (!route.startsWith('/')) {
route = '/' + route route = '/' + route;
} }
const fullRoute = prefix.endsWith('/') const fullRoute = prefix.endsWith('/')
? prefix.slice(0, -1) + route ? prefix.slice(0, -1) + route
: prefix + route : prefix + route;
return fullRoute return fullRoute;
} }
/** /**
@@ -194,30 +197,30 @@ export async function loadHttpHandlers(
httpDir: string, httpDir: string,
prefix: string = '/api' prefix: string = '/api'
): Promise<LoadedHttpHandler[]> { ): Promise<LoadedHttpHandler[]> {
const files = scanDirectoryRecursive(httpDir) const files = scanDirectoryRecursive(httpDir);
const handlers: LoadedHttpHandler[] = [] const handlers: LoadedHttpHandler[] = [];
for (const { filePath, relativePath } of files) { for (const { filePath, relativePath } of files) {
try { try {
const fileUrl = pathToFileURL(filePath).href const fileUrl = pathToFileURL(filePath).href;
const module = await import(fileUrl) const module = await import(fileUrl);
const definition = module.default as HttpDefinition<unknown> const definition = module.default as HttpDefinition<unknown>;
if (definition && typeof definition.handler === 'function') { if (definition && typeof definition.handler === 'function') {
const route = filePathToRoute(relativePath, prefix) const route = filePathToRoute(relativePath, prefix);
const method: HttpMethod = definition.method ?? 'POST' const method: HttpMethod = definition.method ?? 'POST';
handlers.push({ handlers.push({
route, route,
method, method,
path: filePath, path: filePath,
definition, definition
}) });
} }
} catch (err) { } catch (err) {
console.warn(`[Server] Failed to load HTTP handler: ${filePath}`, err) logger.warn(`Failed to load HTTP handler: ${filePath}`, err);
} }
} }
return handlers return handlers;
} }

View File

@@ -3,7 +3,7 @@
* @en Mock room for testing * @en Mock room for testing
*/ */
import { Room, onMessage, type Player } from '../room/index.js' import { Room, onMessage, type Player } from '../room/index.js';
/** /**
* @zh 模拟房间状态 * @zh 模拟房间状态
@@ -41,27 +41,27 @@ export class MockRoom extends Room<MockRoomState> {
state: MockRoomState = { state: MockRoomState = {
messages: [], messages: [],
joinCount: 0, joinCount: 0,
leaveCount: 0, leaveCount: 0
} };
onCreate(): void { onCreate(): void {
// 房间创建 // 房间创建
} }
onJoin(player: Player): void { onJoin(player: Player): void {
this.state.joinCount++ this.state.joinCount++;
this.broadcast('PlayerJoined', { this.broadcast('PlayerJoined', {
playerId: player.id, playerId: player.id,
joinCount: this.state.joinCount, joinCount: this.state.joinCount
}) });
} }
onLeave(player: Player): void { onLeave(player: Player): void {
this.state.leaveCount++ this.state.leaveCount++;
this.broadcast('PlayerLeft', { this.broadcast('PlayerLeft', {
playerId: player.id, playerId: player.id,
leaveCount: this.state.leaveCount, leaveCount: this.state.leaveCount
}) });
} }
@onMessage('*') @onMessage('*')
@@ -69,31 +69,31 @@ export class MockRoom extends Room<MockRoomState> {
this.state.messages.push({ this.state.messages.push({
type, type,
data, data,
playerId: player.id, playerId: player.id
}) });
// 回显消息给所有玩家 // 回显消息给所有玩家
this.broadcast('MessageReceived', { this.broadcast('MessageReceived', {
type, type,
data, data,
from: player.id, from: player.id
}) });
} }
@onMessage('Echo') @onMessage('Echo')
handleEcho(data: unknown, player: Player): void { handleEcho(data: unknown, player: Player): void {
// 只回复给发送者 // 只回复给发送者
player.send('EchoReply', data) player.send('EchoReply', data);
} }
@onMessage('Broadcast') @onMessage('Broadcast')
handleBroadcast(data: unknown, _player: Player): void { handleBroadcast(data: unknown, _player: Player): void {
this.broadcast('BroadcastMessage', data) this.broadcast('BroadcastMessage', data);
} }
@onMessage('Ping') @onMessage('Ping')
handlePing(_data: unknown, player: Player): void { handlePing(_data: unknown, player: Player): void {
player.send('Pong', { timestamp: Date.now() }) player.send('Pong', { timestamp: Date.now() });
} }
} }
@@ -107,7 +107,7 @@ export class MockRoom extends Room<MockRoomState> {
export class EchoRoom extends Room { export class EchoRoom extends Room {
@onMessage('*') @onMessage('*')
handleAnyMessage(data: unknown, player: Player, type: string): void { handleAnyMessage(data: unknown, player: Player, type: string): void {
player.send(type, data) player.send(type, data);
} }
} }
@@ -120,15 +120,15 @@ export class EchoRoom extends Room {
*/ */
export class BroadcastRoom extends Room { export class BroadcastRoom extends Room {
onJoin(player: Player): void { onJoin(player: Player): void {
this.broadcast('PlayerJoined', { id: player.id }) this.broadcast('PlayerJoined', { id: player.id });
} }
onLeave(player: Player): void { onLeave(player: Player): void {
this.broadcast('PlayerLeft', { id: player.id }) this.broadcast('PlayerLeft', { id: player.id });
} }
@onMessage('*') @onMessage('*')
handleAnyMessage(data: unknown, player: Player, type: string): void { handleAnyMessage(data: unknown, player: Player, type: string): void {
this.broadcast(type, { from: player.id, data }) this.broadcast(type, { from: player.id, data });
} }
} }

View File

@@ -6,10 +6,10 @@
* @en This file demonstrates how to use testing utilities for server testing * @en This file demonstrates how to use testing utilities for server testing
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createTestEnv, type TestEnvironment, wait } from './TestServer.js' import { createTestEnv, type TestEnvironment, wait } from './TestServer.js';
import { MockRoom, BroadcastRoom } from './MockRoom.js' import { MockRoom, BroadcastRoom } from './MockRoom.js';
import { Room, onMessage, type Player } from '../room/index.js' import { Room, onMessage, type Player } from '../room/index.js';
// ============================================================================ // ============================================================================
// Custom Room for Testing | 自定义测试房间 // Custom Room for Testing | 自定义测试房间
@@ -21,52 +21,52 @@ interface GameState {
} }
class GameRoom extends Room<GameState> { class GameRoom extends Room<GameState> {
maxPlayers = 4 maxPlayers = 4;
state: GameState = { state: GameState = {
players: new Map(), players: new Map(),
scores: new Map(), scores: new Map()
} };
onJoin(player: Player): void { onJoin(player: Player): void {
this.state.players.set(player.id, { x: 0, y: 0 }) this.state.players.set(player.id, { x: 0, y: 0 });
this.state.scores.set(player.id, 0) this.state.scores.set(player.id, 0);
this.broadcast('PlayerJoined', { this.broadcast('PlayerJoined', {
playerId: player.id, playerId: player.id,
playerCount: this.state.players.size, playerCount: this.state.players.size
}) });
} }
onLeave(player: Player): void { onLeave(player: Player): void {
this.state.players.delete(player.id) this.state.players.delete(player.id);
this.state.scores.delete(player.id) this.state.scores.delete(player.id);
this.broadcast('PlayerLeft', { this.broadcast('PlayerLeft', {
playerId: player.id, playerId: player.id,
playerCount: this.state.players.size, playerCount: this.state.players.size
}) });
} }
@onMessage('Move') @onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player): void { handleMove(data: { x: number; y: number }, player: Player): void {
const pos = this.state.players.get(player.id) const pos = this.state.players.get(player.id);
if (pos) { if (pos) {
pos.x = data.x pos.x = data.x;
pos.y = data.y pos.y = data.y;
this.broadcast('PlayerMoved', { this.broadcast('PlayerMoved', {
playerId: player.id, playerId: player.id,
x: data.x, x: data.x,
y: data.y, y: data.y
}) });
} }
} }
@onMessage('Score') @onMessage('Score')
handleScore(data: { points: number }, player: Player): void { handleScore(data: { points: number }, player: Player): void {
const current = this.state.scores.get(player.id) ?? 0 const current = this.state.scores.get(player.id) ?? 0;
this.state.scores.set(player.id, current + data.points) this.state.scores.set(player.id, current + data.points);
player.send('ScoreUpdated', { player.send('ScoreUpdated', {
score: this.state.scores.get(player.id), score: this.state.scores.get(player.id)
}) });
} }
} }
@@ -75,15 +75,15 @@ class GameRoom extends Room<GameState> {
// ============================================================================ // ============================================================================
describe('Room Integration Tests', () => { describe('Room Integration Tests', () => {
let env: TestEnvironment let env: TestEnvironment;
beforeEach(async () => { beforeEach(async () => {
env = await createTestEnv() env = await createTestEnv();
}) });
afterEach(async () => { afterEach(async () => {
await env.cleanup() await env.cleanup();
}) });
// ======================================================================== // ========================================================================
// Basic Tests | 基础测试 // Basic Tests | 基础测试
@@ -91,39 +91,39 @@ describe('Room Integration Tests', () => {
describe('Basic Room Operations', () => { describe('Basic Room Operations', () => {
it('should create and join room', async () => { it('should create and join room', async () => {
env.server.define('game', GameRoom) env.server.define('game', GameRoom);
const client = await env.createClient() const client = await env.createClient();
const result = await client.joinRoom('game') const result = await client.joinRoom('game');
expect(result.roomId).toBeDefined() expect(result.roomId).toBeDefined();
expect(result.playerId).toBeDefined() expect(result.playerId).toBeDefined();
expect(client.roomId).toBe(result.roomId) expect(client.roomId).toBe(result.roomId);
}) });
it('should leave room', async () => { it('should leave room', async () => {
env.server.define('game', GameRoom) env.server.define('game', GameRoom);
const client = await env.createClient() const client = await env.createClient();
await client.joinRoom('game') await client.joinRoom('game');
await client.leaveRoom() await client.leaveRoom();
expect(client.roomId).toBeNull() expect(client.roomId).toBeNull();
}) });
it('should join existing room by id', async () => { it('should join existing room by id', async () => {
env.server.define('game', GameRoom) env.server.define('game', GameRoom);
const client1 = await env.createClient() const client1 = await env.createClient();
const { roomId } = await client1.joinRoom('game') const { roomId } = await client1.joinRoom('game');
const client2 = await env.createClient() const client2 = await env.createClient();
const result = await client2.joinRoomById(roomId) const result = await client2.joinRoomById(roomId);
expect(result.roomId).toBe(roomId) expect(result.roomId).toBe(roomId);
}) });
}) });
// ======================================================================== // ========================================================================
// Message Tests | 消息测试 // Message Tests | 消息测试
@@ -131,66 +131,66 @@ describe('Room Integration Tests', () => {
describe('Room Messages', () => { describe('Room Messages', () => {
it('should receive room messages', async () => { it('should receive room messages', async () => {
env.server.define('game', GameRoom) env.server.define('game', GameRoom);
const client = await env.createClient() const client = await env.createClient();
await client.joinRoom('game') await client.joinRoom('game');
const movePromise = client.waitForRoomMessage('PlayerMoved') const movePromise = client.waitForRoomMessage('PlayerMoved');
client.sendToRoom('Move', { x: 100, y: 200 }) client.sendToRoom('Move', { x: 100, y: 200 });
const msg = await movePromise const msg = await movePromise;
expect(msg).toEqual({ expect(msg).toEqual({
playerId: client.playerId, playerId: client.playerId,
x: 100, x: 100,
y: 200, y: 200
}) });
}) });
it('should receive broadcast messages', async () => { it('should receive broadcast messages', async () => {
env.server.define('game', GameRoom) env.server.define('game', GameRoom);
const [client1, client2] = await env.createClients(2) const [client1, client2] = await env.createClients(2);
const { roomId } = await client1.joinRoom('game') const { roomId } = await client1.joinRoom('game');
await client2.joinRoomById(roomId) await client2.joinRoomById(roomId);
// client1 等待收到 client2 的移动消息 // client1 等待收到 client2 的移动消息
const movePromise = client1.waitForRoomMessage('PlayerMoved') const movePromise = client1.waitForRoomMessage('PlayerMoved');
client2.sendToRoom('Move', { x: 50, y: 75 }) client2.sendToRoom('Move', { x: 50, y: 75 });
const msg = await movePromise const msg = await movePromise;
expect(msg).toMatchObject({ expect(msg).toMatchObject({
playerId: client2.playerId, playerId: client2.playerId,
x: 50, x: 50,
y: 75, y: 75
}) });
}) });
it('should handle player join/leave broadcasts', async () => { it('should handle player join/leave broadcasts', async () => {
env.server.define('broadcast', BroadcastRoom) env.server.define('broadcast', BroadcastRoom);
const client1 = await env.createClient() const client1 = await env.createClient();
const { roomId } = await client1.joinRoom('broadcast') const { roomId } = await client1.joinRoom('broadcast');
// 等待 client2 加入的广播 // 等待 client2 加入的广播
const joinPromise = client1.waitForRoomMessage<{ id: string }>('PlayerJoined') const joinPromise = client1.waitForRoomMessage<{ id: string }>('PlayerJoined');
const client2 = await env.createClient() const client2 = await env.createClient();
const client2Result = await client2.joinRoomById(roomId) const client2Result = await client2.joinRoomById(roomId);
const joinMsg = await joinPromise const joinMsg = await joinPromise;
expect(joinMsg).toMatchObject({ id: client2Result.playerId }) expect(joinMsg).toMatchObject({ id: client2Result.playerId });
// 等待 client2 离开的广播 // 等待 client2 离开的广播
const leavePromise = client1.waitForRoomMessage<{ id: string }>('PlayerLeft') const leavePromise = client1.waitForRoomMessage<{ id: string }>('PlayerLeft');
const client2PlayerId = client2.playerId // 保存 playerId const client2PlayerId = client2.playerId; // 保存 playerId
await client2.leaveRoom() await client2.leaveRoom();
const leaveMsg = await leavePromise const leaveMsg = await leavePromise;
expect(leaveMsg).toMatchObject({ id: client2PlayerId }) expect(leaveMsg).toMatchObject({ id: client2PlayerId });
}) });
}) });
// ======================================================================== // ========================================================================
// MockRoom Tests | 模拟房间测试 // MockRoom Tests | 模拟房间测试
@@ -198,45 +198,45 @@ describe('Room Integration Tests', () => {
describe('MockRoom', () => { describe('MockRoom', () => {
it('should record messages', async () => { it('should record messages', async () => {
env.server.define('mock', MockRoom) env.server.define('mock', MockRoom);
const client = await env.createClient() const client = await env.createClient();
await client.joinRoom('mock') await client.joinRoom('mock');
// 使用 Echo 消息,因为它是明确定义的 // 使用 Echo 消息,因为它是明确定义的
const echoPromise = client.waitForRoomMessage('EchoReply') const echoPromise = client.waitForRoomMessage('EchoReply');
client.sendToRoom('Echo', { value: 123 }) client.sendToRoom('Echo', { value: 123 });
await echoPromise await echoPromise;
expect(client.hasReceivedMessage('RoomMessage')).toBe(true) expect(client.hasReceivedMessage('RoomMessage')).toBe(true);
}) });
it('should handle echo', async () => { it('should handle echo', async () => {
env.server.define('mock', MockRoom) env.server.define('mock', MockRoom);
const client = await env.createClient() const client = await env.createClient();
await client.joinRoom('mock') await client.joinRoom('mock');
const echoPromise = client.waitForRoomMessage('EchoReply') const echoPromise = client.waitForRoomMessage('EchoReply');
client.sendToRoom('Echo', { message: 'hello' }) client.sendToRoom('Echo', { message: 'hello' });
const reply = await echoPromise const reply = await echoPromise;
expect(reply).toEqual({ message: 'hello' }) expect(reply).toEqual({ message: 'hello' });
}) });
it('should handle ping/pong', async () => { it('should handle ping/pong', async () => {
env.server.define('mock', MockRoom) env.server.define('mock', MockRoom);
const client = await env.createClient() const client = await env.createClient();
await client.joinRoom('mock') await client.joinRoom('mock');
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong') const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong');
client.sendToRoom('Ping', {}) client.sendToRoom('Ping', {});
const pong = await pongPromise const pong = await pongPromise;
expect(pong.timestamp).toBeGreaterThan(0) expect(pong.timestamp).toBeGreaterThan(0);
}) });
}) });
// ======================================================================== // ========================================================================
// Multiple Clients Tests | 多客户端测试 // Multiple Clients Tests | 多客户端测试
@@ -244,45 +244,45 @@ describe('Room Integration Tests', () => {
describe('Multiple Clients', () => { describe('Multiple Clients', () => {
it('should handle multiple clients in same room', async () => { it('should handle multiple clients in same room', async () => {
env.server.define('game', GameRoom) env.server.define('game', GameRoom);
const clients = await env.createClients(3) const clients = await env.createClients(3);
const { roomId } = await clients[0].joinRoom('game') const { roomId } = await clients[0].joinRoom('game');
for (let i = 1; i < clients.length; i++) { for (let i = 1; i < clients.length; i++) {
await clients[i].joinRoomById(roomId) await clients[i].joinRoomById(roomId);
} }
// 所有客户端都应该能收到消息 // 所有客户端都应该能收到消息
const promises = clients.map((c) => c.waitForRoomMessage('PlayerMoved')) const promises = clients.map((c) => c.waitForRoomMessage('PlayerMoved'));
clients[0].sendToRoom('Move', { x: 1, y: 2 }) clients[0].sendToRoom('Move', { x: 1, y: 2 });
const results = await Promise.all(promises) const results = await Promise.all(promises);
for (const result of results) { for (const result of results) {
expect(result).toMatchObject({ x: 1, y: 2 }) expect(result).toMatchObject({ x: 1, y: 2 });
} }
}) });
it('should handle concurrent room operations', async () => { it('should handle concurrent room operations', async () => {
env.server.define('game', GameRoom) env.server.define('game', GameRoom);
const clients = await env.createClients(4) // maxPlayers = 4 const clients = await env.createClients(4); // maxPlayers = 4
// 顺序加入房间(避免并发创建多个房间) // 顺序加入房间(避免并发创建多个房间)
const { roomId } = await clients[0].joinRoom('game') const { roomId } = await clients[0].joinRoom('game');
// 其余客户端加入同一房间 // 其余客户端加入同一房间
const results = await Promise.all( const results = await Promise.all(
clients.slice(1).map((c) => c.joinRoomById(roomId)) clients.slice(1).map((c) => c.joinRoomById(roomId))
) );
// 验证所有客户端都在同一房间 // 验证所有客户端都在同一房间
for (const result of results) { for (const result of results) {
expect(result.roomId).toBe(roomId) expect(result.roomId).toBe(roomId);
} }
}) });
}) });
// ======================================================================== // ========================================================================
// Error Handling Tests | 错误处理测试 // Error Handling Tests | 错误处理测试
@@ -290,31 +290,31 @@ describe('Room Integration Tests', () => {
describe('Error Handling', () => { describe('Error Handling', () => {
it('should reject joining non-existent room type', async () => { it('should reject joining non-existent room type', async () => {
const client = await env.createClient() const client = await env.createClient();
await expect(client.joinRoom('nonexistent')).rejects.toThrow() await expect(client.joinRoom('nonexistent')).rejects.toThrow();
}) });
it('should handle client disconnect gracefully', async () => { it('should handle client disconnect gracefully', async () => {
env.server.define('game', GameRoom) env.server.define('game', GameRoom);
const client1 = await env.createClient() const client1 = await env.createClient();
const { roomId } = await client1.joinRoom('game') const { roomId } = await client1.joinRoom('game');
const client2 = await env.createClient() const client2 = await env.createClient();
await client2.joinRoomById(roomId) await client2.joinRoomById(roomId);
// 等待 client2 离开的广播 // 等待 client2 离开的广播
const leavePromise = client1.waitForRoomMessage('PlayerLeft') const leavePromise = client1.waitForRoomMessage('PlayerLeft');
// 强制断开 client2 // 强制断开 client2
await client2.disconnect() await client2.disconnect();
// client1 应该收到离开消息 // client1 应该收到离开消息
const msg = await leavePromise const msg = await leavePromise;
expect(msg).toBeDefined() expect(msg).toBeDefined();
}) });
}) });
// ======================================================================== // ========================================================================
// Assertion Helpers Tests | 断言辅助测试 // Assertion Helpers Tests | 断言辅助测试
@@ -322,50 +322,50 @@ describe('Room Integration Tests', () => {
describe('TestClient Assertions', () => { describe('TestClient Assertions', () => {
it('should track received messages', async () => { it('should track received messages', async () => {
env.server.define('mock', MockRoom) env.server.define('mock', MockRoom);
const client = await env.createClient() const client = await env.createClient();
await client.joinRoom('mock') await client.joinRoom('mock');
// 发送多条消息 // 发送多条消息
client.sendToRoom('Test', { n: 1 }) client.sendToRoom('Test', { n: 1 });
client.sendToRoom('Test', { n: 2 }) client.sendToRoom('Test', { n: 2 });
client.sendToRoom('Test', { n: 3 }) client.sendToRoom('Test', { n: 3 });
// 等待消息处理 // 等待消息处理
await wait(100) await wait(100);
expect(client.getMessageCount()).toBeGreaterThan(0) expect(client.getMessageCount()).toBeGreaterThan(0);
expect(client.hasReceivedMessage('RoomMessage')).toBe(true) expect(client.hasReceivedMessage('RoomMessage')).toBe(true);
}) });
it('should get messages of specific type', async () => { it('should get messages of specific type', async () => {
env.server.define('mock', MockRoom) env.server.define('mock', MockRoom);
const client = await env.createClient() const client = await env.createClient();
await client.joinRoom('mock') await client.joinRoom('mock');
client.sendToRoom('Ping', {}) client.sendToRoom('Ping', {});
await client.waitForRoomMessage('Pong') await client.waitForRoomMessage('Pong');
const pongs = client.getMessagesOfType('RoomMessage') const pongs = client.getMessagesOfType('RoomMessage');
expect(pongs.length).toBeGreaterThan(0) expect(pongs.length).toBeGreaterThan(0);
}) });
it('should clear message history', async () => { it('should clear message history', async () => {
env.server.define('mock', MockRoom) env.server.define('mock', MockRoom);
const client = await env.createClient() const client = await env.createClient();
await client.joinRoom('mock') await client.joinRoom('mock');
client.sendToRoom('Test', {}) client.sendToRoom('Test', {});
await wait(50) await wait(50);
expect(client.getMessageCount()).toBeGreaterThan(0) expect(client.getMessageCount()).toBeGreaterThan(0);
client.clearMessages() client.clearMessages();
expect(client.getMessageCount()).toBe(0) expect(client.getMessageCount()).toBe(0);
}) });
}) });
}) });

View File

@@ -3,9 +3,12 @@
* @en Test client for server testing * @en Test client for server testing
*/ */
import WebSocket from 'ws' import WebSocket from 'ws';
import { json } from '@esengine/rpc/codec' import { json } from '@esengine/rpc/codec';
import type { Codec } from '@esengine/rpc/codec' import type { Codec } from '@esengine/rpc/codec';
import { createLogger } from '../logger.js';
const logger = createLogger('TestClient');
// ============================================================================ // ============================================================================
// Types | 类型定义 // Types | 类型定义
@@ -65,8 +68,8 @@ const PacketType = {
ApiRequest: 0, ApiRequest: 0,
ApiResponse: 1, ApiResponse: 1,
ApiError: 2, ApiError: 2,
Message: 3, Message: 3
} as const } as const;
// ============================================================================ // ============================================================================
// TestClient Class | 测试客户端类 // TestClient Class | 测试客户端类
@@ -106,26 +109,26 @@ interface PendingCall {
* ``` * ```
*/ */
export class TestClient { export class TestClient {
private readonly _port: number private readonly _port: number;
private readonly _codec: Codec private readonly _codec: Codec;
private readonly _timeout: number private readonly _timeout: number;
private readonly _connectTimeout: number private readonly _connectTimeout: number;
private _ws: WebSocket | null = null private _ws: WebSocket | null = null;
private _callIdCounter = 0 private _callIdCounter = 0;
private _connected = false private _connected = false;
private _currentRoomId: string | null = null private _currentRoomId: string | null = null;
private _currentPlayerId: string | null = null private _currentPlayerId: string | null = null;
private readonly _pendingCalls = new Map<number, PendingCall>() private readonly _pendingCalls = new Map<number, PendingCall>();
private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>() private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>();
private readonly _receivedMessages: ReceivedMessage[] = [] private readonly _receivedMessages: ReceivedMessage[] = [];
constructor(port: number, options: TestClientOptions = {}) { constructor(port: number, options: TestClientOptions = {}) {
this._port = port this._port = port;
this._codec = options.codec ?? json() this._codec = options.codec ?? json();
this._timeout = options.timeout ?? 5000 this._timeout = options.timeout ?? 5000;
this._connectTimeout = options.connectTimeout ?? 5000 this._connectTimeout = options.connectTimeout ?? 5000;
} }
// ======================================================================== // ========================================================================
@@ -137,7 +140,7 @@ export class TestClient {
* @en Whether connected * @en Whether connected
*/ */
get isConnected(): boolean { get isConnected(): boolean {
return this._connected return this._connected;
} }
/** /**
@@ -145,7 +148,7 @@ export class TestClient {
* @en Current room ID * @en Current room ID
*/ */
get roomId(): string | null { get roomId(): string | null {
return this._currentRoomId return this._currentRoomId;
} }
/** /**
@@ -153,7 +156,7 @@ export class TestClient {
* @en Current player ID * @en Current player ID
*/ */
get playerId(): string | null { get playerId(): string | null {
return this._currentPlayerId return this._currentPlayerId;
} }
/** /**
@@ -161,7 +164,7 @@ export class TestClient {
* @en All received messages * @en All received messages
*/ */
get receivedMessages(): ReadonlyArray<ReceivedMessage> { get receivedMessages(): ReadonlyArray<ReceivedMessage> {
return this._receivedMessages return this._receivedMessages;
} }
// ======================================================================== // ========================================================================
@@ -174,36 +177,36 @@ export class TestClient {
*/ */
connect(): Promise<this> { connect(): Promise<this> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = `ws://localhost:${this._port}` const url = `ws://localhost:${this._port}`;
this._ws = new WebSocket(url) this._ws = new WebSocket(url);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this._ws?.close() this._ws?.close();
reject(new Error(`Connection timeout after ${this._connectTimeout}ms`)) reject(new Error(`Connection timeout after ${this._connectTimeout}ms`));
}, this._connectTimeout) }, this._connectTimeout);
this._ws.on('open', () => { this._ws.on('open', () => {
clearTimeout(timeout) clearTimeout(timeout);
this._connected = true this._connected = true;
resolve(this) resolve(this);
}) });
this._ws.on('close', () => { this._ws.on('close', () => {
this._connected = false this._connected = false;
this._rejectAllPending('Connection closed') this._rejectAllPending('Connection closed');
}) });
this._ws.on('error', (err) => { this._ws.on('error', (err) => {
clearTimeout(timeout) clearTimeout(timeout);
if (!this._connected) { if (!this._connected) {
reject(err) reject(err);
} }
}) });
this._ws.on('message', (data: Buffer) => { this._ws.on('message', (data: Buffer) => {
this._handleMessage(data) this._handleMessage(data);
}) });
}) });
} }
/** /**
@@ -213,18 +216,18 @@ export class TestClient {
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!this._ws || this._ws.readyState === WebSocket.CLOSED) { if (!this._ws || this._ws.readyState === WebSocket.CLOSED) {
resolve() resolve();
return return;
} }
this._ws.once('close', () => { this._ws.once('close', () => {
this._connected = false this._connected = false;
this._ws = null this._ws = null;
resolve() resolve();
}) });
this._ws.close() this._ws.close();
}) });
} }
// ======================================================================== // ========================================================================
@@ -236,10 +239,10 @@ export class TestClient {
* @en Join a room * @en Join a room
*/ */
async joinRoom(roomType: string, options?: Record<string, unknown>): Promise<JoinRoomResult> { async joinRoom(roomType: string, options?: Record<string, unknown>): Promise<JoinRoomResult> {
const result = await this.call<JoinRoomResult>('JoinRoom', { roomType, options }) const result = await this.call<JoinRoomResult>('JoinRoom', { roomType, options });
this._currentRoomId = result.roomId this._currentRoomId = result.roomId;
this._currentPlayerId = result.playerId this._currentPlayerId = result.playerId;
return result return result;
} }
/** /**
@@ -247,10 +250,10 @@ export class TestClient {
* @en Join a room by ID * @en Join a room by ID
*/ */
async joinRoomById(roomId: string): Promise<JoinRoomResult> { async joinRoomById(roomId: string): Promise<JoinRoomResult> {
const result = await this.call<JoinRoomResult>('JoinRoom', { roomId }) const result = await this.call<JoinRoomResult>('JoinRoom', { roomId });
this._currentRoomId = result.roomId this._currentRoomId = result.roomId;
this._currentPlayerId = result.playerId this._currentPlayerId = result.playerId;
return result return result;
} }
/** /**
@@ -258,9 +261,9 @@ export class TestClient {
* @en Leave room * @en Leave room
*/ */
async leaveRoom(): Promise<void> { async leaveRoom(): Promise<void> {
await this.call('LeaveRoom', {}) await this.call('LeaveRoom', {});
this._currentRoomId = null this._currentRoomId = null;
this._currentPlayerId = null this._currentPlayerId = null;
} }
/** /**
@@ -268,7 +271,7 @@ export class TestClient {
* @en Send message to room * @en Send message to room
*/ */
sendToRoom(type: string, data: unknown): void { sendToRoom(type: string, data: unknown): void {
this.send('RoomMessage', { type, data }) this.send('RoomMessage', { type, data });
} }
// ======================================================================== // ========================================================================
@@ -282,26 +285,26 @@ export class TestClient {
call<T = unknown>(name: string, input: unknown): Promise<T> { call<T = unknown>(name: string, input: unknown): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!this._connected || !this._ws) { if (!this._connected || !this._ws) {
reject(new Error('Not connected')) reject(new Error('Not connected'));
return return;
} }
const id = ++this._callIdCounter const id = ++this._callIdCounter;
const timer = setTimeout(() => { const timer = setTimeout(() => {
this._pendingCalls.delete(id) this._pendingCalls.delete(id);
reject(new Error(`API call '${name}' timeout after ${this._timeout}ms`)) reject(new Error(`API call '${name}' timeout after ${this._timeout}ms`));
}, this._timeout) }, this._timeout);
this._pendingCalls.set(id, { this._pendingCalls.set(id, {
resolve: resolve as (v: unknown) => void, resolve: resolve as (v: unknown) => void,
reject, reject,
timer, timer
}) });
const packet = [PacketType.ApiRequest, id, name, input] const packet = [PacketType.ApiRequest, id, name, input];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
this._ws.send(this._codec.encode(packet as any) as Buffer) this._ws.send(this._codec.encode(packet as any) as Buffer);
}) });
} }
/** /**
@@ -309,10 +312,10 @@ export class TestClient {
* @en Send message * @en Send message
*/ */
send(name: string, data: unknown): void { send(name: string, data: unknown): void {
if (!this._connected || !this._ws) return if (!this._connected || !this._ws) return;
const packet = [PacketType.Message, name, data] const packet = [PacketType.Message, name, data];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
this._ws.send(this._codec.encode(packet as any) as Buffer) this._ws.send(this._codec.encode(packet as any) as Buffer);
} }
// ======================================================================== // ========================================================================
@@ -324,13 +327,13 @@ export class TestClient {
* @en Listen for message * @en Listen for message
*/ */
on(name: string, handler: (data: unknown) => void): this { on(name: string, handler: (data: unknown) => void): this {
let handlers = this._msgHandlers.get(name) let handlers = this._msgHandlers.get(name);
if (!handlers) { if (!handlers) {
handlers = new Set() handlers = new Set();
this._msgHandlers.set(name, handlers) this._msgHandlers.set(name, handlers);
} }
handlers.add(handler) handlers.add(handler);
return this return this;
} }
/** /**
@@ -339,11 +342,11 @@ export class TestClient {
*/ */
off(name: string, handler?: (data: unknown) => void): this { off(name: string, handler?: (data: unknown) => void): this {
if (handler) { if (handler) {
this._msgHandlers.get(name)?.delete(handler) this._msgHandlers.get(name)?.delete(handler);
} else { } else {
this._msgHandlers.delete(name) this._msgHandlers.delete(name);
} }
return this return this;
} }
/** /**
@@ -352,21 +355,21 @@ export class TestClient {
*/ */
waitForMessage<T = unknown>(type: string, timeout?: number): Promise<T> { waitForMessage<T = unknown>(type: string, timeout?: number): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeoutMs = timeout ?? this._timeout const timeoutMs = timeout ?? this._timeout;
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.off(type, handler) this.off(type, handler);
reject(new Error(`Timeout waiting for message '${type}' after ${timeoutMs}ms`)) reject(new Error(`Timeout waiting for message '${type}' after ${timeoutMs}ms`));
}, timeoutMs) }, timeoutMs);
const handler = (data: unknown) => { const handler = (data: unknown) => {
clearTimeout(timer) clearTimeout(timer);
this.off(type, handler) this.off(type, handler);
resolve(data as T) resolve(data as T);
} };
this.on(type, handler) this.on(type, handler);
}) });
} }
/** /**
@@ -375,24 +378,24 @@ export class TestClient {
*/ */
waitForRoomMessage<T = unknown>(type: string, timeout?: number): Promise<T> { waitForRoomMessage<T = unknown>(type: string, timeout?: number): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeoutMs = timeout ?? this._timeout const timeoutMs = timeout ?? this._timeout;
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.off('RoomMessage', handler) this.off('RoomMessage', handler);
reject(new Error(`Timeout waiting for room message '${type}' after ${timeoutMs}ms`)) reject(new Error(`Timeout waiting for room message '${type}' after ${timeoutMs}ms`));
}, timeoutMs) }, timeoutMs);
const handler = (data: unknown) => { const handler = (data: unknown) => {
const msg = data as { type: string; data: unknown } const msg = data as { type: string; data: unknown };
if (msg.type === type) { if (msg.type === type) {
clearTimeout(timer) clearTimeout(timer);
this.off('RoomMessage', handler) this.off('RoomMessage', handler);
resolve(msg.data as T) resolve(msg.data as T);
} }
} };
this.on('RoomMessage', handler) this.on('RoomMessage', handler);
}) });
} }
// ======================================================================== // ========================================================================
@@ -404,7 +407,7 @@ export class TestClient {
* @en Whether received a specific message * @en Whether received a specific message
*/ */
hasReceivedMessage(type: string): boolean { hasReceivedMessage(type: string): boolean {
return this._receivedMessages.some((m) => m.type === type) return this._receivedMessages.some((m) => m.type === type);
} }
/** /**
@@ -414,7 +417,7 @@ export class TestClient {
getMessagesOfType<T = unknown>(type: string): T[] { getMessagesOfType<T = unknown>(type: string): T[] {
return this._receivedMessages return this._receivedMessages
.filter((m) => m.type === type) .filter((m) => m.type === type)
.map((m) => m.data as T) .map((m) => m.data as T);
} }
/** /**
@@ -424,10 +427,10 @@ export class TestClient {
getLastMessage<T = unknown>(type: string): T | undefined { getLastMessage<T = unknown>(type: string): T | undefined {
for (let i = this._receivedMessages.length - 1; i >= 0; i--) { for (let i = this._receivedMessages.length - 1; i >= 0; i--) {
if (this._receivedMessages[i].type === type) { if (this._receivedMessages[i].type === type) {
return this._receivedMessages[i].data as T return this._receivedMessages[i].data as T;
} }
} }
return undefined return undefined;
} }
/** /**
@@ -435,7 +438,7 @@ export class TestClient {
* @en Clear message records * @en Clear message records
*/ */
clearMessages(): void { clearMessages(): void {
this._receivedMessages.length = 0 this._receivedMessages.length = 0;
} }
/** /**
@@ -444,9 +447,9 @@ export class TestClient {
*/ */
getMessageCount(type?: string): number { getMessageCount(type?: string): number {
if (type) { if (type) {
return this._receivedMessages.filter((m) => m.type === type).length return this._receivedMessages.filter((m) => m.type === type).length;
} }
return this._receivedMessages.length return this._receivedMessages.length;
} }
// ======================================================================== // ========================================================================
@@ -455,40 +458,40 @@ export class TestClient {
private _handleMessage(raw: Buffer): void { private _handleMessage(raw: Buffer): void {
try { try {
const packet = this._codec.decode(raw) as unknown[] const packet = this._codec.decode(raw) as unknown[];
const type = packet[0] as number const type = packet[0] as number;
switch (type) { switch (type) {
case PacketType.ApiResponse: case PacketType.ApiResponse:
this._handleApiResponse([packet[0], packet[1], packet[2]] as [number, number, unknown]) this._handleApiResponse([packet[0], packet[1], packet[2]] as [number, number, unknown]);
break break;
case PacketType.ApiError: case PacketType.ApiError:
this._handleApiError([packet[0], packet[1], packet[2], packet[3]] as [number, number, string, string]) this._handleApiError([packet[0], packet[1], packet[2], packet[3]] as [number, number, string, string]);
break break;
case PacketType.Message: case PacketType.Message:
this._handleMsg([packet[0], packet[1], packet[2]] as [number, string, unknown]) this._handleMsg([packet[0], packet[1], packet[2]] as [number, string, unknown]);
break break;
} }
} catch (err) { } catch (err) {
console.error('[TestClient] Failed to handle message:', err) logger.error('Failed to handle message:', err);
} }
} }
private _handleApiResponse([, id, result]: [number, number, unknown]): void { private _handleApiResponse([, id, result]: [number, number, unknown]): void {
const pending = this._pendingCalls.get(id) const pending = this._pendingCalls.get(id);
if (pending) { if (pending) {
clearTimeout(pending.timer) clearTimeout(pending.timer);
this._pendingCalls.delete(id) this._pendingCalls.delete(id);
pending.resolve(result) pending.resolve(result);
} }
} }
private _handleApiError([, id, code, message]: [number, number, string, string]): void { private _handleApiError([, id, code, message]: [number, number, string, string]): void {
const pending = this._pendingCalls.get(id) const pending = this._pendingCalls.get(id);
if (pending) { if (pending) {
clearTimeout(pending.timer) clearTimeout(pending.timer);
this._pendingCalls.delete(id) this._pendingCalls.delete(id);
pending.reject(new Error(`[${code}] ${message}`)) pending.reject(new Error(`[${code}] ${message}`));
} }
} }
@@ -497,17 +500,17 @@ export class TestClient {
this._receivedMessages.push({ this._receivedMessages.push({
type: name, type: name,
data, data,
timestamp: Date.now(), timestamp: Date.now()
}) });
// 触发处理器 // 触发处理器
const handlers = this._msgHandlers.get(name) const handlers = this._msgHandlers.get(name);
if (handlers) { if (handlers) {
for (const handler of handlers) { for (const handler of handlers) {
try { try {
handler(data) handler(data);
} catch (err) { } catch (err) {
console.error('[TestClient] Handler error:', err) logger.error('Handler error:', err);
} }
} }
} }
@@ -515,9 +518,9 @@ export class TestClient {
private _rejectAllPending(reason: string): void { private _rejectAllPending(reason: string): void {
for (const [, pending] of this._pendingCalls) { for (const [, pending] of this._pendingCalls) {
clearTimeout(pending.timer) clearTimeout(pending.timer);
pending.reject(new Error(reason)) pending.reject(new Error(reason));
} }
this._pendingCalls.clear() this._pendingCalls.clear();
} }
} }

View File

@@ -3,9 +3,10 @@
* @en Test server utilities * @en Test server utilities
*/ */
import { createServer } from '../core/server.js' import { createServer } from '../core/server.js';
import type { GameServer } from '../types/index.js' import type { GameServer } from '../types/index.js';
import { TestClient, type TestClientOptions } from './TestClient.js' import { TestClient, type TestClientOptions } from './TestClient.js';
import { LoggerManager, LogLevel } from '@esengine/ecs-framework';
// ============================================================================ // ============================================================================
// Types | 类型定义 // Types | 类型定义
@@ -89,20 +90,20 @@ export interface TestEnvironment {
* @en Get a random available port * @en Get a random available port
*/ */
async function getRandomPort(): Promise<number> { async function getRandomPort(): Promise<number> {
const net = await import('node:net') const net = await import('node:net');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = net.createServer() const server = net.createServer();
server.listen(0, () => { server.listen(0, () => {
const address = server.address() const address = server.address();
if (address && typeof address === 'object') { if (address && typeof address === 'object') {
const port = address.port const port = address.port;
server.close(() => resolve(port)) server.close(() => resolve(port));
} else { } else {
server.close(() => reject(new Error('Failed to get port'))) server.close(() => reject(new Error('Failed to get port')));
} }
}) });
server.on('error', reject) server.on('error', reject);
}) });
} }
/** /**
@@ -110,7 +111,7 @@ async function getRandomPort(): Promise<number> {
* @en Wait for specified milliseconds * @en Wait for specified milliseconds
*/ */
export function wait(ms: number): Promise<void> { export function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms));
} }
// ============================================================================ // ============================================================================
@@ -137,36 +138,38 @@ export function wait(ms: number): Promise<void> {
export async function createTestServer( export async function createTestServer(
options: TestServerOptions = {} options: TestServerOptions = {}
): Promise<{ server: GameServer; port: number; cleanup: () => Promise<void> }> { ): Promise<{ server: GameServer; port: number; cleanup: () => Promise<void> }> {
const port = options.port || (await getRandomPort()) const port = options.port || (await getRandomPort());
const silent = options.silent ?? true const silent = options.silent ?? true;
// 临时禁用 console.log // 临时设置日志级别为 None禁用所有日志
const originalLog = console.log const loggerManager = LoggerManager.getInstance();
let originalLevel: LogLevel | undefined;
if (silent) { if (silent) {
console.log = () => {} originalLevel = LogLevel.Info;
loggerManager.setGlobalLevel(LogLevel.None);
} }
const server = await createServer({ const server = await createServer({
port, port,
tickRate: options.tickRate ?? 0, tickRate: options.tickRate ?? 0,
apiDir: '__non_existent_api__', apiDir: '__non_existent_api__',
msgDir: '__non_existent_msg__', msgDir: '__non_existent_msg__'
}) });
await server.start() await server.start();
// 恢复 console.log // 恢复日志级别
if (silent) { if (silent && originalLevel !== undefined) {
console.log = originalLog loggerManager.setGlobalLevel(originalLevel);
} }
return { return {
server, server,
port, port,
cleanup: async () => { cleanup: async () => {
await server.stop() await server.stop();
}, }
} };
} }
/** /**
@@ -211,8 +214,8 @@ export async function createTestServer(
* ``` * ```
*/ */
export async function createTestEnv(options: TestServerOptions = {}): Promise<TestEnvironment> { export async function createTestEnv(options: TestServerOptions = {}): Promise<TestEnvironment> {
const { server, port, cleanup: serverCleanup } = await createTestServer(options) const { server, port, cleanup: serverCleanup } = await createTestServer(options);
const clients: TestClient[] = [] const clients: TestClient[] = [];
return { return {
server, server,
@@ -220,30 +223,30 @@ export async function createTestEnv(options: TestServerOptions = {}): Promise<Te
clients, clients,
async createClient(clientOptions?: TestClientOptions): Promise<TestClient> { async createClient(clientOptions?: TestClientOptions): Promise<TestClient> {
const client = new TestClient(port, clientOptions) const client = new TestClient(port, clientOptions);
await client.connect() await client.connect();
clients.push(client) clients.push(client);
return client return client;
}, },
async createClients(count: number, clientOptions?: TestClientOptions): Promise<TestClient[]> { async createClients(count: number, clientOptions?: TestClientOptions): Promise<TestClient[]> {
const newClients: TestClient[] = [] const newClients: TestClient[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const client = new TestClient(port, clientOptions) const client = new TestClient(port, clientOptions);
await client.connect() await client.connect();
clients.push(client) clients.push(client);
newClients.push(client) newClients.push(client);
} }
return newClients return newClients;
}, },
async cleanup(): Promise<void> { async cleanup(): Promise<void> {
// 断开所有客户端 // 断开所有客户端
await Promise.all(clients.map((c) => c.disconnect().catch(() => {}))) await Promise.all(clients.map((c) => c.disconnect().catch(() => {})));
clients.length = 0 clients.length = 0;
// 停止服务器 // 停止服务器
await serverCleanup() await serverCleanup();
}, }
} };
} }

View File

@@ -27,11 +27,11 @@
* ``` * ```
*/ */
export { TestClient, type TestClientOptions } from './TestClient.js' export { TestClient, type TestClientOptions } from './TestClient.js';
export { export {
createTestServer, createTestServer,
createTestEnv, createTestEnv,
type TestServerOptions, type TestServerOptions,
type TestEnvironment, type TestEnvironment
} from './TestServer.js' } from './TestServer.js';
export { MockRoom } from './MockRoom.js' export { MockRoom } from './MockRoom.js';

View File

@@ -3,8 +3,8 @@
* @en ESEngine Server type definitions * @en ESEngine Server type definitions
*/ */
import type { Connection, ProtocolDef } from '@esengine/rpc' import type { Connection, ProtocolDef } from '@esengine/rpc';
import type { HttpRoutes, CorsOptions, HttpRequest, HttpResponse } from '../http/types.js' import type { HttpRoutes, CorsOptions, HttpRequest, HttpResponse } from '../http/types.js';
// ============================================================================ // ============================================================================
// Server Config // Server Config

View File

@@ -1,5 +1,12 @@
# @esengine/transaction # @esengine/transaction
## 2.1.1
### Patch Changes
- Updated dependencies [[`85171a0`](https://github.com/esengine/esengine/commit/85171a0a5c073ef7883705ee4daaca8bb0218f20)]:
- @esengine/database-drivers@1.1.1
## 2.1.0 ## 2.1.0
### Minor Changes ### Minor Changes

View File

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

6
pnpm-lock.yaml generated
View File

@@ -1730,13 +1730,13 @@ importers:
packages/framework/server: packages/framework/server:
dependencies: dependencies:
'@esengine/ecs-framework':
specifier: workspace:*
version: link:../core/dist
'@esengine/rpc': '@esengine/rpc':
specifier: workspace:* specifier: workspace:*
version: link:../rpc version: link:../rpc
devDependencies: devDependencies:
'@esengine/ecs-framework':
specifier: workspace:*
version: link:../core/dist
'@types/jsonwebtoken': '@types/jsonwebtoken':
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.0.10 version: 9.0.10