Compare commits
5 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
764ce67742 | ||
|
|
61a13baca2 | ||
|
|
1cfa64aa0f | ||
|
|
3b978384c7 | ||
|
|
10c3891abd |
@@ -255,6 +255,9 @@ export default defineConfig({
|
|||||||
translations: { en: 'RPC' },
|
translations: { en: 'RPC' },
|
||||||
items: [
|
items: [
|
||||||
{ label: '概述', slug: 'modules/rpc', translations: { en: 'Overview' } },
|
{ label: '概述', slug: 'modules/rpc', translations: { en: 'Overview' } },
|
||||||
|
{ label: '服务端', slug: 'modules/rpc/server', translations: { en: 'Server' } },
|
||||||
|
{ label: '客户端', slug: 'modules/rpc/client', translations: { en: 'Client' } },
|
||||||
|
{ label: '编解码', slug: 'modules/rpc/codec', translations: { en: 'Codec' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -264,10 +267,25 @@ 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: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
|
||||||
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
||||||
|
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
|
||||||
|
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },
|
||||||
|
{ label: '增量压缩', slug: 'modules/network/delta', translations: { en: 'Delta Compression' } },
|
||||||
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
|
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '事务系统',
|
||||||
|
translations: { en: 'Transaction' },
|
||||||
|
items: [
|
||||||
|
{ label: '概述', slug: 'modules/transaction', translations: { en: 'Overview' } },
|
||||||
|
{ label: '核心概念', slug: 'modules/transaction/core', translations: { en: 'Core Concepts' } },
|
||||||
|
{ label: '存储层', slug: 'modules/transaction/storage', translations: { en: 'Storage Layer' } },
|
||||||
|
{ label: '操作', slug: 'modules/transaction/operations', translations: { en: 'Operations' } },
|
||||||
|
{ label: '分布式事务', slug: 'modules/transaction/distributed', translations: { en: 'Distributed' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '世界流式加载',
|
label: '世界流式加载',
|
||||||
translations: { en: 'World Streaming' },
|
translations: { en: 'World Streaming' },
|
||||||
@@ -303,6 +321,8 @@ export default defineConfig({
|
|||||||
{ label: '@esengine/fsm', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/fsm/CHANGELOG.md', attrs: { target: '_blank' } },
|
{ label: '@esengine/fsm', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/fsm/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
{ label: '@esengine/timer', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/timer/CHANGELOG.md', attrs: { target: '_blank' } },
|
{ label: '@esengine/timer', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/timer/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
{ label: '@esengine/network', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/network/CHANGELOG.md', attrs: { target: '_blank' } },
|
{ label: '@esengine/network', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/network/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
|
{ label: '@esengine/transaction', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/transaction/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
|
{ label: '@esengine/rpc', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/rpc/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
{ label: '@esengine/cli', link: 'https://github.com/esengine/esengine/blob/master/packages/tools/cli/CHANGELOG.md', attrs: { target: '_blank' } },
|
{ label: '@esengine/cli', link: 'https://github.com/esengine/esengine/blob/master/packages/tools/cli/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
---
|
||||||
|
title: "Authentication"
|
||||||
|
description: "Add authentication to your game server with JWT and Session providers"
|
||||||
|
---
|
||||||
|
|
||||||
|
The `@esengine/server` package includes a pluggable authentication system that supports JWT, session-based auth, and custom providers.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Authentication is included in the server package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/server jsonwebtoken
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `jsonwebtoken` is an optional peer dependency, required only for JWT authentication.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### JWT Authentication
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// Create JWT provider
|
||||||
|
const jwtProvider = createJwtAuthProvider({
|
||||||
|
secret: process.env.JWT_SECRET!,
|
||||||
|
expiresIn: 3600, // 1 hour
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap server with authentication
|
||||||
|
const server = withAuth(await createServer({ port: 3000 }), {
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
const url = new URL(req.url ?? '', 'http://localhost')
|
||||||
|
return url.searchParams.get('token')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define authenticated room
|
||||||
|
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||||
|
onJoin(player) {
|
||||||
|
console.log(`${player.user?.name} joined!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Providers
|
||||||
|
|
||||||
|
### JWT Provider
|
||||||
|
|
||||||
|
Use JSON Web Tokens for stateless authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
const jwtProvider = createJwtAuthProvider({
|
||||||
|
// Required: secret key
|
||||||
|
secret: 'your-secret-key',
|
||||||
|
|
||||||
|
// Optional: algorithm (default: HS256)
|
||||||
|
algorithm: 'HS256',
|
||||||
|
|
||||||
|
// Optional: expiration in seconds (default: 3600)
|
||||||
|
expiresIn: 3600,
|
||||||
|
|
||||||
|
// Optional: issuer for validation
|
||||||
|
issuer: 'my-game-server',
|
||||||
|
|
||||||
|
// Optional: audience for validation
|
||||||
|
audience: 'my-game-client',
|
||||||
|
|
||||||
|
// Optional: custom user extraction
|
||||||
|
getUser: async (payload) => {
|
||||||
|
// Fetch user from database
|
||||||
|
return await db.users.findById(payload.sub)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sign a token (for login endpoints)
|
||||||
|
const token = jwtProvider.sign({
|
||||||
|
sub: user.id,
|
||||||
|
name: user.name,
|
||||||
|
roles: ['player'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Decode without verification (for debugging)
|
||||||
|
const payload = jwtProvider.decode(token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Provider
|
||||||
|
|
||||||
|
Use server-side sessions for stateful authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// Custom storage implementation
|
||||||
|
const storage: ISessionStorage = {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
return await redis.get(key)
|
||||||
|
},
|
||||||
|
async set<T>(key: string, value: T): Promise<void> {
|
||||||
|
await redis.set(key, value)
|
||||||
|
},
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
return await redis.del(key) > 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionProvider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
sessionTTL: 86400000, // 24 hours in ms
|
||||||
|
|
||||||
|
// Optional: validate user on each request
|
||||||
|
validateUser: (user) => !user.banned,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create session (for login endpoints)
|
||||||
|
const sessionId = await sessionProvider.createSession(user, {
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Revoke session (for logout)
|
||||||
|
await sessionProvider.revoke(sessionId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Auth Mixin
|
||||||
|
|
||||||
|
The `withAuth` function wraps your server to add authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withAuth } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
const server = withAuth(baseServer, {
|
||||||
|
// Required: auth provider
|
||||||
|
provider: jwtProvider,
|
||||||
|
|
||||||
|
// Required: extract credentials from request
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
// From query string
|
||||||
|
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||||
|
|
||||||
|
// Or from headers
|
||||||
|
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optional: handle auth failure
|
||||||
|
onAuthFailed: (conn, error) => {
|
||||||
|
console.log(`Auth failed: ${error}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Auth Context
|
||||||
|
|
||||||
|
After authentication, the auth context is available on connections:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAuthContext } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
server.onConnect = (conn) => {
|
||||||
|
const auth = getAuthContext(conn)
|
||||||
|
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
console.log(`User ${auth.userId} connected`)
|
||||||
|
console.log(`Roles: ${auth.roles}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Room Auth Mixin
|
||||||
|
|
||||||
|
The `withRoomAuth` function adds authentication checks to rooms:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
// Require authentication to join
|
||||||
|
requireAuth: true,
|
||||||
|
|
||||||
|
// Optional: require specific roles
|
||||||
|
allowedRoles: ['player', 'premium'],
|
||||||
|
|
||||||
|
// Optional: role check mode ('any' or 'all')
|
||||||
|
roleCheckMode: 'any',
|
||||||
|
}) {
|
||||||
|
// player has .auth and .user properties
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
console.log(`${player.user?.name} joined`)
|
||||||
|
console.log(`Is premium: ${player.auth.hasRole('premium')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: custom auth validation
|
||||||
|
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||||
|
// Additional validation logic
|
||||||
|
if (player.auth.hasRole('banned')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('Chat', {
|
||||||
|
from: player.user?.name ?? 'Guest',
|
||||||
|
text: data.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AuthPlayer Interface
|
||||||
|
|
||||||
|
Players in auth rooms have additional properties:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AuthPlayer<TUser> extends Player {
|
||||||
|
// Full auth context
|
||||||
|
readonly auth: IAuthContext<TUser>
|
||||||
|
|
||||||
|
// User info (shortcut for auth.user)
|
||||||
|
readonly user: TUser | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Room Auth Helpers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room) {
|
||||||
|
someMethod() {
|
||||||
|
// Get player by user ID
|
||||||
|
const player = this.getPlayerByUserId('user-123')
|
||||||
|
|
||||||
|
// Get all players with a role
|
||||||
|
const admins = this.getPlayersByRole('admin')
|
||||||
|
|
||||||
|
// Get player with auth info
|
||||||
|
const authPlayer = this.getAuthPlayer(playerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Decorators
|
||||||
|
|
||||||
|
### @requireAuth
|
||||||
|
|
||||||
|
Mark message handlers as requiring authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
class GameRoom extends withRoomAuth(Room) {
|
||||||
|
@requireAuth()
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||||
|
// Only authenticated players can trade
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ allowGuest: true })
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: ChatData, player: AuthPlayer) {
|
||||||
|
// Guests can also chat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### @requireRole
|
||||||
|
|
||||||
|
Require specific roles for message handlers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AdminRoom extends withRoomAuth(Room) {
|
||||||
|
@requireRole('admin')
|
||||||
|
@onMessage('Ban')
|
||||||
|
handleBan(data: BanData, player: AuthPlayer) {
|
||||||
|
// Only admins can ban
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['moderator', 'admin'])
|
||||||
|
@onMessage('Mute')
|
||||||
|
handleMute(data: MuteData, player: AuthPlayer) {
|
||||||
|
// Moderators OR admins can mute
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||||
|
@onMessage('SpecialFeature')
|
||||||
|
handleSpecial(data: any, player: AuthPlayer) {
|
||||||
|
// Requires BOTH verified AND premium roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Context API
|
||||||
|
|
||||||
|
The auth context provides various methods for checking authentication state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IAuthContext<TUser> {
|
||||||
|
// Authentication state
|
||||||
|
readonly isAuthenticated: boolean
|
||||||
|
readonly user: TUser | null
|
||||||
|
readonly userId: string | null
|
||||||
|
readonly roles: ReadonlyArray<string>
|
||||||
|
readonly authenticatedAt: number | null
|
||||||
|
readonly expiresAt: number | null
|
||||||
|
|
||||||
|
// Role checking
|
||||||
|
hasRole(role: string): boolean
|
||||||
|
hasAnyRole(roles: string[]): boolean
|
||||||
|
hasAllRoles(roles: string[]): boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `AuthContext` class (implementation) also provides:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||||
|
// Set authentication from result
|
||||||
|
setAuthenticated(result: AuthResult<TUser>): void
|
||||||
|
|
||||||
|
// Clear authentication state
|
||||||
|
clear(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use the mock auth provider for unit tests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||||
|
|
||||||
|
// Create mock provider with preset users
|
||||||
|
const mockProvider = createMockAuthProvider({
|
||||||
|
users: [
|
||||||
|
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||||
|
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||||
|
],
|
||||||
|
autoCreate: true, // Create users for unknown tokens
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use in tests
|
||||||
|
const server = withAuth(testServer, {
|
||||||
|
provider: mockProvider,
|
||||||
|
extractCredentials: (req) => req.headers['x-token'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify with user ID as token
|
||||||
|
const result = await mockProvider.verify('1')
|
||||||
|
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||||
|
|
||||||
|
// Add/remove users dynamically
|
||||||
|
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||||
|
mockProvider.removeUser('3')
|
||||||
|
|
||||||
|
// Revoke tokens
|
||||||
|
await mockProvider.revoke('1')
|
||||||
|
|
||||||
|
// Reset to initial state
|
||||||
|
mockProvider.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Auth errors include error codes for programmatic handling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AuthErrorCode =
|
||||||
|
| 'INVALID_CREDENTIALS' // Invalid username/password
|
||||||
|
| 'INVALID_TOKEN' // Token is malformed or invalid
|
||||||
|
| 'EXPIRED_TOKEN' // Token has expired
|
||||||
|
| 'USER_NOT_FOUND' // User lookup failed
|
||||||
|
| 'ACCOUNT_DISABLED' // User account is disabled
|
||||||
|
| 'RATE_LIMITED' // Too many requests
|
||||||
|
| 'INSUFFICIENT_PERMISSIONS' // Insufficient permissions
|
||||||
|
|
||||||
|
// In your auth failure handler
|
||||||
|
const server = withAuth(baseServer, {
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials,
|
||||||
|
onAuthFailed: (conn, error) => {
|
||||||
|
switch (error.errorCode) {
|
||||||
|
case 'EXPIRED_TOKEN':
|
||||||
|
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||||
|
break
|
||||||
|
case 'INVALID_TOKEN':
|
||||||
|
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Here's a complete example with JWT authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server.ts
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import {
|
||||||
|
withAuth,
|
||||||
|
withRoomAuth,
|
||||||
|
createJwtAuthProvider,
|
||||||
|
requireAuth,
|
||||||
|
requireRole,
|
||||||
|
type AuthPlayer,
|
||||||
|
} from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT Provider
|
||||||
|
const jwtProvider = createJwtAuthProvider<User>({
|
||||||
|
secret: process.env.JWT_SECRET!,
|
||||||
|
expiresIn: 3600,
|
||||||
|
getUser: async (payload) => ({
|
||||||
|
id: payload.sub as string,
|
||||||
|
name: payload.name as string,
|
||||||
|
roles: (payload.roles as string[]) ?? [],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create authenticated server
|
||||||
|
const server = withAuth(
|
||||||
|
await createServer({ port: 3000 }),
|
||||||
|
{
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
return new URL(req.url ?? '', 'http://localhost')
|
||||||
|
.searchParams.get('token')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Game Room with auth
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
requireAuth: true,
|
||||||
|
allowedRoles: ['player'],
|
||||||
|
}) {
|
||||||
|
onCreate() {
|
||||||
|
console.log('Game room created')
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
console.log(`${player.user?.name} joined!`)
|
||||||
|
this.broadcast('PlayerJoined', {
|
||||||
|
id: player.id,
|
||||||
|
name: player.user?.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth()
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||||
|
// Handle movement
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole('admin')
|
||||||
|
@onMessage('Kick')
|
||||||
|
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||||
|
const target = this.getPlayer(data.playerId)
|
||||||
|
if (target) {
|
||||||
|
this.kick(target, 'Kicked by admin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Secure your secrets**: Never hardcode JWT secrets. Use environment variables.
|
||||||
|
|
||||||
|
2. **Set reasonable expiration**: Balance security and user experience when setting token TTL.
|
||||||
|
|
||||||
|
3. **Validate on critical actions**: Use `@requireAuth` on sensitive message handlers.
|
||||||
|
|
||||||
|
4. **Use role-based access**: Implement proper role hierarchy for admin functions.
|
||||||
|
|
||||||
|
5. **Handle token refresh**: Implement token refresh logic for long sessions.
|
||||||
|
|
||||||
|
6. **Log auth events**: Track login attempts and failures for security monitoring.
|
||||||
|
|
||||||
|
7. **Test auth flows**: Use `MockAuthProvider` to test authentication scenarios.
|
||||||
@@ -9,6 +9,9 @@ All storage implementations must implement the `ITransactionStorage` interface:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ITransactionStorage {
|
interface ITransactionStorage {
|
||||||
|
// Lifecycle
|
||||||
|
close?(): Promise<void>;
|
||||||
|
|
||||||
// Distributed lock
|
// Distributed lock
|
||||||
acquireLock(key: string, ttl: number): Promise<string | null>;
|
acquireLock(key: string, ttl: number): Promise<string | null>;
|
||||||
releaseLock(key: string, token: string): Promise<boolean>;
|
releaseLock(key: string, token: string): Promise<boolean>;
|
||||||
@@ -62,21 +65,29 @@ console.log(storage.transactionCount);
|
|||||||
|
|
||||||
## RedisStorage
|
## RedisStorage
|
||||||
|
|
||||||
Redis storage, suitable for production distributed systems.
|
Redis storage, suitable for production distributed systems. Uses factory pattern with lazy connection.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { RedisStorage } from '@esengine/transaction';
|
import { RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
const redis = new Redis('redis://localhost:6379');
|
// Factory pattern: lazy connection, connects on first operation
|
||||||
|
|
||||||
const storage = new RedisStorage({
|
const storage = new RedisStorage({
|
||||||
client: redis,
|
factory: () => new Redis('redis://localhost:6379'),
|
||||||
prefix: 'tx:', // Key prefix
|
prefix: 'tx:', // Key prefix
|
||||||
transactionTTL: 86400, // Transaction log TTL (seconds)
|
transactionTTL: 86400, // Transaction log TTL (seconds)
|
||||||
});
|
});
|
||||||
|
|
||||||
const manager = new TransactionManager({ storage });
|
const manager = new TransactionManager({ storage });
|
||||||
|
|
||||||
|
// Close connection when done
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
// Or use await using for automatic cleanup (TypeScript 5.2+)
|
||||||
|
await using storage = new RedisStorage({
|
||||||
|
factory: () => new Redis('redis://localhost:6379')
|
||||||
|
});
|
||||||
|
// Automatically closed when scope ends
|
||||||
```
|
```
|
||||||
|
|
||||||
### Characteristics
|
### Characteristics
|
||||||
@@ -114,18 +125,20 @@ tx:data:{key} - Business data
|
|||||||
|
|
||||||
## MongoStorage
|
## MongoStorage
|
||||||
|
|
||||||
MongoDB storage, suitable for scenarios requiring persistence and complex queries.
|
MongoDB storage, suitable for scenarios requiring persistence and complex queries. Uses factory pattern with lazy connection.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { MongoClient } from 'mongodb';
|
import { MongoClient } from 'mongodb';
|
||||||
import { MongoStorage } from '@esengine/transaction';
|
import { MongoStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
const client = new MongoClient('mongodb://localhost:27017');
|
// Factory pattern: lazy connection, connects on first operation
|
||||||
await client.connect();
|
|
||||||
const db = client.db('game');
|
|
||||||
|
|
||||||
const storage = new MongoStorage({
|
const storage = new MongoStorage({
|
||||||
db,
|
factory: async () => {
|
||||||
|
const client = new MongoClient('mongodb://localhost:27017');
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
database: 'game',
|
||||||
transactionCollection: 'transactions', // Transaction log collection
|
transactionCollection: 'transactions', // Transaction log collection
|
||||||
dataCollection: 'transaction_data', // Business data collection
|
dataCollection: 'transaction_data', // Business data collection
|
||||||
lockCollection: 'transaction_locks', // Lock collection
|
lockCollection: 'transaction_locks', // Lock collection
|
||||||
@@ -135,6 +148,12 @@ const storage = new MongoStorage({
|
|||||||
await storage.ensureIndexes();
|
await storage.ensureIndexes();
|
||||||
|
|
||||||
const manager = new TransactionManager({ storage });
|
const manager = new TransactionManager({ storage });
|
||||||
|
|
||||||
|
// Close connection when done
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
// Or use await using for automatic cleanup (TypeScript 5.2+)
|
||||||
|
await using storage = new MongoStorage({ ... });
|
||||||
```
|
```
|
||||||
|
|
||||||
### Characteristics
|
### Characteristics
|
||||||
|
|||||||
506
docs/src/content/docs/modules/network/auth.md
Normal file
506
docs/src/content/docs/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
---
|
||||||
|
title: "认证系统"
|
||||||
|
description: "使用 JWT 和 Session 提供者为游戏服务器添加认证功能"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/server` 包内置了可插拔的认证系统,支持 JWT、会话认证和自定义提供者。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
认证功能已包含在 server 包中:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/server jsonwebtoken
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:`jsonwebtoken` 是可选的 peer dependency,仅在使用 JWT 认证时需要。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### JWT 认证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// 创建 JWT 提供者
|
||||||
|
const jwtProvider = createJwtAuthProvider({
|
||||||
|
secret: process.env.JWT_SECRET!,
|
||||||
|
expiresIn: 3600, // 1 小时
|
||||||
|
})
|
||||||
|
|
||||||
|
// 用认证包装服务器
|
||||||
|
const server = withAuth(await createServer({ port: 3000 }), {
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
const url = new URL(req.url ?? '', 'http://localhost')
|
||||||
|
return url.searchParams.get('token')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 定义需要认证的房间
|
||||||
|
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||||
|
onJoin(player) {
|
||||||
|
console.log(`${player.user?.name} 加入了游戏!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 认证提供者
|
||||||
|
|
||||||
|
### JWT 提供者
|
||||||
|
|
||||||
|
使用 JSON Web Tokens 实现无状态认证:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
const jwtProvider = createJwtAuthProvider({
|
||||||
|
// 必填:密钥
|
||||||
|
secret: 'your-secret-key',
|
||||||
|
|
||||||
|
// 可选:算法(默认:HS256)
|
||||||
|
algorithm: 'HS256',
|
||||||
|
|
||||||
|
// 可选:过期时间(秒,默认:3600)
|
||||||
|
expiresIn: 3600,
|
||||||
|
|
||||||
|
// 可选:签发者(用于验证)
|
||||||
|
issuer: 'my-game-server',
|
||||||
|
|
||||||
|
// 可选:受众(用于验证)
|
||||||
|
audience: 'my-game-client',
|
||||||
|
|
||||||
|
// 可选:自定义用户提取
|
||||||
|
getUser: async (payload) => {
|
||||||
|
// 从数据库获取用户
|
||||||
|
return await db.users.findById(payload.sub)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 签发令牌(用于登录接口)
|
||||||
|
const token = jwtProvider.sign({
|
||||||
|
sub: user.id,
|
||||||
|
name: user.name,
|
||||||
|
roles: ['player'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解码但不验证(用于调试)
|
||||||
|
const payload = jwtProvider.decode(token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session 提供者
|
||||||
|
|
||||||
|
使用服务端会话实现有状态认证:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// 自定义存储实现
|
||||||
|
const storage: ISessionStorage = {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
return await redis.get(key)
|
||||||
|
},
|
||||||
|
async set<T>(key: string, value: T): Promise<void> {
|
||||||
|
await redis.set(key, value)
|
||||||
|
},
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
return await redis.del(key) > 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionProvider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
sessionTTL: 86400000, // 24 小时(毫秒)
|
||||||
|
|
||||||
|
// 可选:每次请求时验证用户
|
||||||
|
validateUser: (user) => !user.banned,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建会话(用于登录接口)
|
||||||
|
const sessionId = await sessionProvider.createSession(user, {
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 撤销会话(用于登出)
|
||||||
|
await sessionProvider.revoke(sessionId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器认证 Mixin
|
||||||
|
|
||||||
|
`withAuth` 函数用于包装服务器添加认证功能:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withAuth } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
const server = withAuth(baseServer, {
|
||||||
|
// 必填:认证提供者
|
||||||
|
provider: jwtProvider,
|
||||||
|
|
||||||
|
// 必填:从请求中提取凭证
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
// 从查询字符串获取
|
||||||
|
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||||
|
|
||||||
|
// 或从请求头获取
|
||||||
|
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 可选:处理认证失败
|
||||||
|
onAuthFailed: (conn, error) => {
|
||||||
|
console.log(`认证失败: ${error}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问认证上下文
|
||||||
|
|
||||||
|
认证后,可以从连接获取认证上下文:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAuthContext } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
server.onConnect = (conn) => {
|
||||||
|
const auth = getAuthContext(conn)
|
||||||
|
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
console.log(`用户 ${auth.userId} 已连接`)
|
||||||
|
console.log(`角色: ${auth.roles}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 房间认证 Mixin
|
||||||
|
|
||||||
|
`withRoomAuth` 函数为房间添加认证检查:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
// 要求认证才能加入
|
||||||
|
requireAuth: true,
|
||||||
|
|
||||||
|
// 可选:要求特定角色
|
||||||
|
allowedRoles: ['player', 'premium'],
|
||||||
|
|
||||||
|
// 可选:角色检查模式('any' 或 'all')
|
||||||
|
roleCheckMode: 'any',
|
||||||
|
}) {
|
||||||
|
// player 拥有 .auth 和 .user 属性
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
console.log(`${player.user?.name} 加入了`)
|
||||||
|
console.log(`是否高级会员: ${player.auth.hasRole('premium')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选:自定义认证验证
|
||||||
|
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||||
|
// 额外的验证逻辑
|
||||||
|
if (player.auth.hasRole('banned')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('Chat', {
|
||||||
|
from: player.user?.name ?? '访客',
|
||||||
|
text: data.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AuthPlayer 接口
|
||||||
|
|
||||||
|
认证房间中的玩家拥有额外属性:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AuthPlayer<TUser> extends Player {
|
||||||
|
// 完整认证上下文
|
||||||
|
readonly auth: IAuthContext<TUser>
|
||||||
|
|
||||||
|
// 用户信息(auth.user 的快捷方式)
|
||||||
|
readonly user: TUser | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 房间认证辅助方法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room) {
|
||||||
|
someMethod() {
|
||||||
|
// 通过用户 ID 获取玩家
|
||||||
|
const player = this.getPlayerByUserId('user-123')
|
||||||
|
|
||||||
|
// 获取拥有特定角色的所有玩家
|
||||||
|
const admins = this.getPlayersByRole('admin')
|
||||||
|
|
||||||
|
// 获取带认证信息的玩家
|
||||||
|
const authPlayer = this.getAuthPlayer(playerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 认证装饰器
|
||||||
|
|
||||||
|
### @requireAuth
|
||||||
|
|
||||||
|
标记消息处理器需要认证:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
class GameRoom extends withRoomAuth(Room) {
|
||||||
|
@requireAuth()
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||||
|
// 只有已认证玩家才能交易
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ allowGuest: true })
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: ChatData, player: AuthPlayer) {
|
||||||
|
// 访客也可以聊天
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### @requireRole
|
||||||
|
|
||||||
|
要求特定角色才能访问消息处理器:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AdminRoom extends withRoomAuth(Room) {
|
||||||
|
@requireRole('admin')
|
||||||
|
@onMessage('Ban')
|
||||||
|
handleBan(data: BanData, player: AuthPlayer) {
|
||||||
|
// 只有管理员才能封禁
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['moderator', 'admin'])
|
||||||
|
@onMessage('Mute')
|
||||||
|
handleMute(data: MuteData, player: AuthPlayer) {
|
||||||
|
// 版主或管理员可以禁言
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||||
|
@onMessage('SpecialFeature')
|
||||||
|
handleSpecial(data: any, player: AuthPlayer) {
|
||||||
|
// 需要同时拥有 verified 和 premium 角色
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 认证上下文 API
|
||||||
|
|
||||||
|
认证上下文提供多种检查认证状态的方法:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IAuthContext<TUser> {
|
||||||
|
// 认证状态
|
||||||
|
readonly isAuthenticated: boolean
|
||||||
|
readonly user: TUser | null
|
||||||
|
readonly userId: string | null
|
||||||
|
readonly roles: ReadonlyArray<string>
|
||||||
|
readonly authenticatedAt: number | null
|
||||||
|
readonly expiresAt: number | null
|
||||||
|
|
||||||
|
// 角色检查
|
||||||
|
hasRole(role: string): boolean
|
||||||
|
hasAnyRole(roles: string[]): boolean
|
||||||
|
hasAllRoles(roles: string[]): boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AuthContext` 类(实现类)还提供:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||||
|
// 从认证结果设置认证状态
|
||||||
|
setAuthenticated(result: AuthResult<TUser>): void
|
||||||
|
|
||||||
|
// 清除认证状态
|
||||||
|
clear(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
使用模拟认证提供者进行单元测试:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||||
|
|
||||||
|
// 创建带预设用户的模拟提供者
|
||||||
|
const mockProvider = createMockAuthProvider({
|
||||||
|
users: [
|
||||||
|
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||||
|
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||||
|
],
|
||||||
|
autoCreate: true, // 为未知令牌创建用户
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在测试中使用
|
||||||
|
const server = withAuth(testServer, {
|
||||||
|
provider: mockProvider,
|
||||||
|
extractCredentials: (req) => req.headers['x-token'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用用户 ID 作为令牌进行验证
|
||||||
|
const result = await mockProvider.verify('1')
|
||||||
|
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||||
|
|
||||||
|
// 动态添加/移除用户
|
||||||
|
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||||
|
mockProvider.removeUser('3')
|
||||||
|
|
||||||
|
// 撤销令牌
|
||||||
|
await mockProvider.revoke('1')
|
||||||
|
|
||||||
|
// 重置到初始状态
|
||||||
|
mockProvider.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
认证错误包含错误码用于程序化处理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AuthErrorCode =
|
||||||
|
| 'INVALID_CREDENTIALS' // 用户名/密码无效
|
||||||
|
| 'INVALID_TOKEN' // 令牌格式错误或无效
|
||||||
|
| 'EXPIRED_TOKEN' // 令牌已过期
|
||||||
|
| 'USER_NOT_FOUND' // 用户查找失败
|
||||||
|
| 'ACCOUNT_DISABLED' // 用户账号已禁用
|
||||||
|
| 'RATE_LIMITED' // 请求过于频繁
|
||||||
|
| 'INSUFFICIENT_PERMISSIONS' // 权限不足
|
||||||
|
|
||||||
|
// 在认证失败处理器中
|
||||||
|
const server = withAuth(baseServer, {
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials,
|
||||||
|
onAuthFailed: (conn, error) => {
|
||||||
|
switch (error.errorCode) {
|
||||||
|
case 'EXPIRED_TOKEN':
|
||||||
|
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||||
|
break
|
||||||
|
case 'INVALID_TOKEN':
|
||||||
|
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
以下是使用 JWT 认证的完整示例:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server.ts
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import {
|
||||||
|
withAuth,
|
||||||
|
withRoomAuth,
|
||||||
|
createJwtAuthProvider,
|
||||||
|
requireAuth,
|
||||||
|
requireRole,
|
||||||
|
type AuthPlayer,
|
||||||
|
} from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT 提供者
|
||||||
|
const jwtProvider = createJwtAuthProvider<User>({
|
||||||
|
secret: process.env.JWT_SECRET!,
|
||||||
|
expiresIn: 3600,
|
||||||
|
getUser: async (payload) => ({
|
||||||
|
id: payload.sub as string,
|
||||||
|
name: payload.name as string,
|
||||||
|
roles: (payload.roles as string[]) ?? [],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建带认证的服务器
|
||||||
|
const server = withAuth(
|
||||||
|
await createServer({ port: 3000 }),
|
||||||
|
{
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
return new URL(req.url ?? '', 'http://localhost')
|
||||||
|
.searchParams.get('token')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 带认证的游戏房间
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
requireAuth: true,
|
||||||
|
allowedRoles: ['player'],
|
||||||
|
}) {
|
||||||
|
onCreate() {
|
||||||
|
console.log('游戏房间已创建')
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
console.log(`${player.user?.name} 加入了!`)
|
||||||
|
this.broadcast('PlayerJoined', {
|
||||||
|
id: player.id,
|
||||||
|
name: player.user?.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth()
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||||
|
// 处理移动
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole('admin')
|
||||||
|
@onMessage('Kick')
|
||||||
|
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||||
|
const target = this.getPlayer(data.playerId)
|
||||||
|
if (target) {
|
||||||
|
this.kick(target, '被管理员踢出')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **保护密钥安全**:永远不要硬编码 JWT 密钥,使用环境变量。
|
||||||
|
|
||||||
|
2. **设置合理的过期时间**:在安全性和用户体验之间平衡令牌 TTL。
|
||||||
|
|
||||||
|
3. **在关键操作上验证**:在敏感消息处理器上使用 `@requireAuth`。
|
||||||
|
|
||||||
|
4. **使用基于角色的访问控制**:为管理功能实现适当的角色层级。
|
||||||
|
|
||||||
|
5. **处理令牌刷新**:为长会话实现令牌刷新逻辑。
|
||||||
|
|
||||||
|
6. **记录认证事件**:跟踪登录尝试和失败以进行安全监控。
|
||||||
|
|
||||||
|
7. **测试认证流程**:使用 `MockAuthProvider` 测试认证场景。
|
||||||
@@ -9,6 +9,9 @@ description: "事务存储接口和实现:MemoryStorage、RedisStorage、Mongo
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ITransactionStorage {
|
interface ITransactionStorage {
|
||||||
|
// 生命周期
|
||||||
|
close?(): Promise<void>;
|
||||||
|
|
||||||
// 分布式锁
|
// 分布式锁
|
||||||
acquireLock(key: string, ttl: number): Promise<string | null>;
|
acquireLock(key: string, ttl: number): Promise<string | null>;
|
||||||
releaseLock(key: string, token: string): Promise<boolean>;
|
releaseLock(key: string, token: string): Promise<boolean>;
|
||||||
@@ -62,21 +65,29 @@ console.log(storage.transactionCount);
|
|||||||
|
|
||||||
## RedisStorage
|
## RedisStorage
|
||||||
|
|
||||||
Redis 存储,适用于生产环境的分布式系统。
|
Redis 存储,适用于生产环境的分布式系统。使用工厂模式实现惰性连接。
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { RedisStorage } from '@esengine/transaction';
|
import { RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
const redis = new Redis('redis://localhost:6379');
|
// 工厂模式:惰性连接,首次操作时才创建连接
|
||||||
|
|
||||||
const storage = new RedisStorage({
|
const storage = new RedisStorage({
|
||||||
client: redis,
|
factory: () => new Redis('redis://localhost:6379'),
|
||||||
prefix: 'tx:', // 键前缀
|
prefix: 'tx:', // 键前缀
|
||||||
transactionTTL: 86400, // 事务日志过期时间(秒)
|
transactionTTL: 86400, // 事务日志过期时间(秒)
|
||||||
});
|
});
|
||||||
|
|
||||||
const manager = new TransactionManager({ storage });
|
const manager = new TransactionManager({ storage });
|
||||||
|
|
||||||
|
// 使用后关闭连接
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
// 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||||
|
await using storage = new RedisStorage({
|
||||||
|
factory: () => new Redis('redis://localhost:6379')
|
||||||
|
});
|
||||||
|
// 作用域结束时自动关闭
|
||||||
```
|
```
|
||||||
|
|
||||||
### 特点
|
### 特点
|
||||||
@@ -114,18 +125,20 @@ tx:data:{key} - 业务数据
|
|||||||
|
|
||||||
## MongoStorage
|
## MongoStorage
|
||||||
|
|
||||||
MongoDB 存储,适用于需要持久化和复杂查询的场景。
|
MongoDB 存储,适用于需要持久化和复杂查询的场景。使用工厂模式实现惰性连接。
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { MongoClient } from 'mongodb';
|
import { MongoClient } from 'mongodb';
|
||||||
import { MongoStorage } from '@esengine/transaction';
|
import { MongoStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
const client = new MongoClient('mongodb://localhost:27017');
|
// 工厂模式:惰性连接,首次操作时才创建连接
|
||||||
await client.connect();
|
|
||||||
const db = client.db('game');
|
|
||||||
|
|
||||||
const storage = new MongoStorage({
|
const storage = new MongoStorage({
|
||||||
db,
|
factory: async () => {
|
||||||
|
const client = new MongoClient('mongodb://localhost:27017');
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
database: 'game',
|
||||||
transactionCollection: 'transactions', // 事务日志集合
|
transactionCollection: 'transactions', // 事务日志集合
|
||||||
dataCollection: 'transaction_data', // 业务数据集合
|
dataCollection: 'transaction_data', // 业务数据集合
|
||||||
lockCollection: 'transaction_locks', // 锁集合
|
lockCollection: 'transaction_locks', // 锁集合
|
||||||
@@ -135,6 +148,12 @@ const storage = new MongoStorage({
|
|||||||
await storage.ensureIndexes();
|
await storage.ensureIndexes();
|
||||||
|
|
||||||
const manager = new TransactionManager({ storage });
|
const manager = new TransactionManager({ storage });
|
||||||
|
|
||||||
|
// 使用后关闭连接
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
// 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||||
|
await using storage = new MongoStorage({ ... });
|
||||||
```
|
```
|
||||||
|
|
||||||
### 特点
|
### 特点
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { AStarPathfinder } from '../../src/core/AStarPathfinder';
|
||||||
|
import { GridMap } from '../../src/grid/GridMap';
|
||||||
|
|
||||||
|
describe('AStarPathfinder', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
let pathfinder: AStarPathfinder;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(10, 10);
|
||||||
|
pathfinder = new AStarPathfinder(grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Basic Pathfinding
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('basic pathfinding', () => {
|
||||||
|
it('should find path between adjacent nodes', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 1, 0);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBe(2);
|
||||||
|
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(result.path[1]).toEqual({ x: 1, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return start position for same start and end', () => {
|
||||||
|
const result = pathfinder.findPath(5, 5, 5, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBe(1);
|
||||||
|
expect(result.path[0]).toEqual({ x: 5, y: 5 });
|
||||||
|
expect(result.cost).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find diagonal path', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBeGreaterThan(1);
|
||||||
|
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(result.path[result.path.length - 1]).toEqual({ x: 5, y: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find path across grid', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(result.path[result.path.length - 1]).toEqual({ x: 9, y: 9 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Obstacles
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('obstacles', () => {
|
||||||
|
it('should find path around single obstacle', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
const result = pathfinder.findPath(4, 5, 6, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find path around wall', () => {
|
||||||
|
// Create vertical wall
|
||||||
|
for (let y = 2; y <= 7; y++) {
|
||||||
|
grid.setWalkable(5, y, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = pathfinder.findPath(3, 5, 7, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
// Path should go around the wall
|
||||||
|
expect(result.path.every(p => p.x !== 5 || p.y < 2 || p.y > 7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when blocked', () => {
|
||||||
|
// Block completely around start
|
||||||
|
grid.setWalkable(1, 0, false);
|
||||||
|
grid.setWalkable(0, 1, false);
|
||||||
|
grid.setWalkable(1, 1, false);
|
||||||
|
|
||||||
|
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
expect(result.path.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when start is blocked', () => {
|
||||||
|
grid.setWalkable(0, 0, false);
|
||||||
|
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when end is blocked', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Out of Bounds
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('out of bounds', () => {
|
||||||
|
it('should return empty path for out of bounds start', () => {
|
||||||
|
const result = pathfinder.findPath(-1, 0, 5, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path for out of bounds end', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 100, 100);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Cost Calculation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('cost calculation', () => {
|
||||||
|
it('should calculate correct cost for straight path', () => {
|
||||||
|
const grid4 = new GridMap(10, 10, { allowDiagonal: false });
|
||||||
|
const pathfinder4 = new AStarPathfinder(grid4);
|
||||||
|
|
||||||
|
const result = pathfinder4.findPath(0, 0, 5, 0);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.cost).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer lower cost paths', () => {
|
||||||
|
// Create high cost area
|
||||||
|
for (let y = 0; y < 10; y++) {
|
||||||
|
grid.setCost(5, y, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = pathfinder.findPath(4, 5, 6, 5);
|
||||||
|
// Should go around the high cost column if possible
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Options
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('options', () => {
|
||||||
|
it('should respect maxNodes limit', () => {
|
||||||
|
// Large grid with path
|
||||||
|
const largeGrid = new GridMap(100, 100);
|
||||||
|
const largePF = new AStarPathfinder(largeGrid);
|
||||||
|
|
||||||
|
const result = largePF.findPath(0, 0, 99, 99, { maxNodes: 10 });
|
||||||
|
// Should fail due to node limit
|
||||||
|
expect(result.nodesSearched).toBeLessThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use heuristic weight', () => {
|
||||||
|
const result1 = pathfinder.findPath(0, 0, 9, 9, { heuristicWeight: 1.0 });
|
||||||
|
const result2 = pathfinder.findPath(0, 0, 9, 9, { heuristicWeight: 2.0 });
|
||||||
|
|
||||||
|
expect(result1.found).toBe(true);
|
||||||
|
expect(result2.found).toBe(true);
|
||||||
|
// Higher weight may search fewer nodes but may not be optimal
|
||||||
|
expect(result2.nodesSearched).toBeLessThanOrEqual(result1.nodesSearched);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Clear
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should allow reuse after clear', () => {
|
||||||
|
const result1 = pathfinder.findPath(0, 0, 5, 5);
|
||||||
|
expect(result1.found).toBe(true);
|
||||||
|
|
||||||
|
pathfinder.clear();
|
||||||
|
|
||||||
|
const result2 = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result2.found).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Maze
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('maze solving', () => {
|
||||||
|
it('should solve simple maze', () => {
|
||||||
|
const mazeStr = `
|
||||||
|
..........
|
||||||
|
.########.
|
||||||
|
..........
|
||||||
|
.########.
|
||||||
|
..........
|
||||||
|
.########.
|
||||||
|
..........
|
||||||
|
.########.
|
||||||
|
..........
|
||||||
|
..........`.trim();
|
||||||
|
|
||||||
|
grid.loadFromString(mazeStr);
|
||||||
|
|
||||||
|
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
// Path should not pass through walls
|
||||||
|
for (const point of result.path) {
|
||||||
|
expect(grid.isWalkable(point.x, point.y)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Path Quality
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('path quality', () => {
|
||||||
|
it('should find shortest path in open area', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 3, 0);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
// Straight line should be 4 points
|
||||||
|
expect(result.path.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find optimal diagonal path', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 3, 3);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
// Pure diagonal should be 4 points
|
||||||
|
expect(result.path.length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Nodes Searched
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('nodesSearched', () => {
|
||||||
|
it('should track nodes searched', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result.nodesSearched).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search only 1 node for same position', () => {
|
||||||
|
const result = pathfinder.findPath(5, 5, 5, 5);
|
||||||
|
expect(result.nodesSearched).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
228
packages/framework/pathfinding/__tests__/core/BinaryHeap.test.ts
Normal file
228
packages/framework/pathfinding/__tests__/core/BinaryHeap.test.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { BinaryHeap } from '../../src/core/BinaryHeap';
|
||||||
|
|
||||||
|
describe('BinaryHeap', () => {
|
||||||
|
let heap: BinaryHeap<number>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
heap = new BinaryHeap<number>((a, b) => a - b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Basic Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('basic operations', () => {
|
||||||
|
it('should start empty', () => {
|
||||||
|
expect(heap.isEmpty).toBe(true);
|
||||||
|
expect(heap.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should push and pop single element', () => {
|
||||||
|
heap.push(5);
|
||||||
|
expect(heap.isEmpty).toBe(false);
|
||||||
|
expect(heap.size).toBe(1);
|
||||||
|
expect(heap.pop()).toBe(5);
|
||||||
|
expect(heap.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when popping empty heap', () => {
|
||||||
|
expect(heap.pop()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should peek without removing', () => {
|
||||||
|
heap.push(5);
|
||||||
|
expect(heap.peek()).toBe(5);
|
||||||
|
expect(heap.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when peeking empty heap', () => {
|
||||||
|
expect(heap.peek()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Min-Heap Property
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('min-heap property', () => {
|
||||||
|
it('should always pop minimum element', () => {
|
||||||
|
heap.push(5);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(7);
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(9);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(1);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(5);
|
||||||
|
expect(heap.pop()).toBe(7);
|
||||||
|
expect(heap.pop()).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle duplicate values', () => {
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(3);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle already sorted input', () => {
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(2);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(4);
|
||||||
|
heap.push(5);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(1);
|
||||||
|
expect(heap.pop()).toBe(2);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(4);
|
||||||
|
expect(heap.pop()).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle reverse sorted input', () => {
|
||||||
|
heap.push(5);
|
||||||
|
heap.push(4);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(2);
|
||||||
|
heap.push(1);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(1);
|
||||||
|
expect(heap.pop()).toBe(2);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(4);
|
||||||
|
expect(heap.pop()).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Update Operation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('update operation', () => {
|
||||||
|
it('should update element position after value change', () => {
|
||||||
|
interface Item { value: number }
|
||||||
|
const itemHeap = new BinaryHeap<Item>((a, b) => a.value - b.value);
|
||||||
|
|
||||||
|
const item1 = { value: 5 };
|
||||||
|
const item2 = { value: 3 };
|
||||||
|
const item3 = { value: 7 };
|
||||||
|
|
||||||
|
itemHeap.push(item1);
|
||||||
|
itemHeap.push(item2);
|
||||||
|
itemHeap.push(item3);
|
||||||
|
|
||||||
|
// Change item1 to be smallest
|
||||||
|
item1.value = 1;
|
||||||
|
itemHeap.update(item1);
|
||||||
|
|
||||||
|
expect(itemHeap.pop()).toBe(item1);
|
||||||
|
expect(itemHeap.pop()).toBe(item2);
|
||||||
|
expect(itemHeap.pop()).toBe(item3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle update of non-existent element gracefully', () => {
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(2);
|
||||||
|
heap.update(999); // Should not throw
|
||||||
|
expect(heap.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Contains Operation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('contains operation', () => {
|
||||||
|
it('should check if element exists', () => {
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(2);
|
||||||
|
heap.push(3);
|
||||||
|
|
||||||
|
expect(heap.contains(2)).toBe(true);
|
||||||
|
expect(heap.contains(5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty heap', () => {
|
||||||
|
expect(heap.contains(1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Clear Operation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('clear operation', () => {
|
||||||
|
it('should clear all elements', () => {
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(2);
|
||||||
|
heap.push(3);
|
||||||
|
|
||||||
|
heap.clear();
|
||||||
|
|
||||||
|
expect(heap.isEmpty).toBe(true);
|
||||||
|
expect(heap.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Custom Comparator
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('custom comparator', () => {
|
||||||
|
it('should work as max-heap with reversed comparator', () => {
|
||||||
|
const maxHeap = new BinaryHeap<number>((a, b) => b - a);
|
||||||
|
|
||||||
|
maxHeap.push(5);
|
||||||
|
maxHeap.push(3);
|
||||||
|
maxHeap.push(7);
|
||||||
|
maxHeap.push(1);
|
||||||
|
maxHeap.push(9);
|
||||||
|
|
||||||
|
expect(maxHeap.pop()).toBe(9);
|
||||||
|
expect(maxHeap.pop()).toBe(7);
|
||||||
|
expect(maxHeap.pop()).toBe(5);
|
||||||
|
expect(maxHeap.pop()).toBe(3);
|
||||||
|
expect(maxHeap.pop()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with object comparator', () => {
|
||||||
|
interface Task { priority: number; name: string }
|
||||||
|
const taskHeap = new BinaryHeap<Task>((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
taskHeap.push({ priority: 3, name: 'C' });
|
||||||
|
taskHeap.push({ priority: 1, name: 'A' });
|
||||||
|
taskHeap.push({ priority: 2, name: 'B' });
|
||||||
|
|
||||||
|
expect(taskHeap.pop()?.name).toBe('A');
|
||||||
|
expect(taskHeap.pop()?.name).toBe('B');
|
||||||
|
expect(taskHeap.pop()?.name).toBe('C');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Large Dataset
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('large dataset', () => {
|
||||||
|
it('should handle 1000 random elements', () => {
|
||||||
|
const elements: number[] = [];
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const value = Math.floor(Math.random() * 10000);
|
||||||
|
elements.push(value);
|
||||||
|
heap.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const expected of elements) {
|
||||||
|
expect(heap.pop()).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
219
packages/framework/pathfinding/__tests__/core/Heuristics.test.ts
Normal file
219
packages/framework/pathfinding/__tests__/core/Heuristics.test.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
manhattanDistance,
|
||||||
|
euclideanDistance,
|
||||||
|
chebyshevDistance,
|
||||||
|
octileDistance,
|
||||||
|
createPoint
|
||||||
|
} from '../../src/core/IPathfinding';
|
||||||
|
|
||||||
|
describe('Heuristic Functions', () => {
|
||||||
|
// =========================================================================
|
||||||
|
// Manhattan Distance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('manhattanDistance', () => {
|
||||||
|
it('should return 0 for same point', () => {
|
||||||
|
const p = createPoint(5, 5);
|
||||||
|
expect(manhattanDistance(p, p)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate horizontal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 0);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate vertical distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(0, 5);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate diagonal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(7); // |3| + |4| = 7
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative coordinates', () => {
|
||||||
|
const a = createPoint(-2, -3);
|
||||||
|
const b = createPoint(2, 3);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(10); // |4| + |6| = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be symmetric', () => {
|
||||||
|
const a = createPoint(1, 2);
|
||||||
|
const b = createPoint(4, 6);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(manhattanDistance(b, a));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Euclidean Distance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('euclideanDistance', () => {
|
||||||
|
it('should return 0 for same point', () => {
|
||||||
|
const p = createPoint(5, 5);
|
||||||
|
expect(euclideanDistance(p, p)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate horizontal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 0);
|
||||||
|
expect(euclideanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate vertical distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(0, 5);
|
||||||
|
expect(euclideanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate 3-4-5 triangle', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
expect(euclideanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate diagonal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(1, 1);
|
||||||
|
expect(euclideanDistance(a, b)).toBeCloseTo(Math.SQRT2, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative coordinates', () => {
|
||||||
|
const a = createPoint(-3, -4);
|
||||||
|
const b = createPoint(0, 0);
|
||||||
|
expect(euclideanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be symmetric', () => {
|
||||||
|
const a = createPoint(1, 2);
|
||||||
|
const b = createPoint(4, 6);
|
||||||
|
expect(euclideanDistance(a, b)).toBeCloseTo(euclideanDistance(b, a), 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Chebyshev Distance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('chebyshevDistance', () => {
|
||||||
|
it('should return 0 for same point', () => {
|
||||||
|
const p = createPoint(5, 5);
|
||||||
|
expect(chebyshevDistance(p, p)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate horizontal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 0);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate vertical distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(0, 5);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate diagonal as max of dx, dy', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(4); // max(3, 4) = 4
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return same value for equal dx and dy', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 5);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be symmetric', () => {
|
||||||
|
const a = createPoint(1, 2);
|
||||||
|
const b = createPoint(4, 6);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(chebyshevDistance(b, a));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Octile Distance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('octileDistance', () => {
|
||||||
|
it('should return 0 for same point', () => {
|
||||||
|
const p = createPoint(5, 5);
|
||||||
|
expect(octileDistance(p, p)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate horizontal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 0);
|
||||||
|
expect(octileDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate vertical distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(0, 5);
|
||||||
|
expect(octileDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate pure diagonal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 5);
|
||||||
|
// 5 diagonal moves = 5 * sqrt(2)
|
||||||
|
expect(octileDistance(a, b)).toBeCloseTo(5 * Math.SQRT2, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate mixed diagonal and straight', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 5);
|
||||||
|
// 3 diagonal + 2 straight = 3*sqrt(2) + 2
|
||||||
|
const expected = 3 * Math.SQRT2 + 2;
|
||||||
|
expect(octileDistance(a, b)).toBeCloseTo(expected, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be symmetric', () => {
|
||||||
|
const a = createPoint(1, 2);
|
||||||
|
const b = createPoint(4, 6);
|
||||||
|
expect(octileDistance(a, b)).toBeCloseTo(octileDistance(b, a), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be between Manhattan and Euclidean for diagonal', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
|
||||||
|
const manhattan = manhattanDistance(a, b);
|
||||||
|
const euclidean = euclideanDistance(a, b);
|
||||||
|
const octile = octileDistance(a, b);
|
||||||
|
|
||||||
|
expect(octile).toBeLessThan(manhattan);
|
||||||
|
expect(octile).toBeGreaterThan(euclidean);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// createPoint
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('createPoint', () => {
|
||||||
|
it('should create point with correct coordinates', () => {
|
||||||
|
const p = createPoint(3, 4);
|
||||||
|
expect(p.x).toBe(3);
|
||||||
|
expect(p.y).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative coordinates', () => {
|
||||||
|
const p = createPoint(-5, -10);
|
||||||
|
expect(p.x).toBe(-5);
|
||||||
|
expect(p.y).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal coordinates', () => {
|
||||||
|
const p = createPoint(3.5, 4.7);
|
||||||
|
expect(p.x).toBe(3.5);
|
||||||
|
expect(p.y).toBe(4.7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
346
packages/framework/pathfinding/__tests__/grid/GridMap.test.ts
Normal file
346
packages/framework/pathfinding/__tests__/grid/GridMap.test.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { GridMap, GridNode, DIRECTIONS_4, DIRECTIONS_8 } from '../../src/grid/GridMap';
|
||||||
|
|
||||||
|
describe('GridMap', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(10, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Construction
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('construction', () => {
|
||||||
|
it('should create grid with correct dimensions', () => {
|
||||||
|
expect(grid.width).toBe(10);
|
||||||
|
expect(grid.height).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all nodes walkable by default', () => {
|
||||||
|
for (let y = 0; y < 10; y++) {
|
||||||
|
for (let x = 0; x < 10; x++) {
|
||||||
|
expect(grid.isWalkable(x, y)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create small grid', () => {
|
||||||
|
const small = new GridMap(1, 1);
|
||||||
|
expect(small.width).toBe(1);
|
||||||
|
expect(small.height).toBe(1);
|
||||||
|
expect(small.getNodeAt(0, 0)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Node Access
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getNodeAt', () => {
|
||||||
|
it('should return node at valid position', () => {
|
||||||
|
const node = grid.getNodeAt(5, 5);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
expect(node?.position.x).toBe(5);
|
||||||
|
expect(node?.position.y).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for out of bounds', () => {
|
||||||
|
expect(grid.getNodeAt(-1, 0)).toBeNull();
|
||||||
|
expect(grid.getNodeAt(0, -1)).toBeNull();
|
||||||
|
expect(grid.getNodeAt(10, 0)).toBeNull();
|
||||||
|
expect(grid.getNodeAt(0, 10)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return node with correct id', () => {
|
||||||
|
const node = grid.getNodeAt(3, 4);
|
||||||
|
expect(node?.id).toBe('3,4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Walkability
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('walkability', () => {
|
||||||
|
it('should set and get walkability', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
expect(grid.isWalkable(5, 5)).toBe(false);
|
||||||
|
|
||||||
|
grid.setWalkable(5, 5, true);
|
||||||
|
expect(grid.isWalkable(5, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for out of bounds', () => {
|
||||||
|
expect(grid.isWalkable(-1, 0)).toBe(false);
|
||||||
|
expect(grid.isWalkable(100, 100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle setWalkable on invalid position gracefully', () => {
|
||||||
|
grid.setWalkable(-1, -1, false); // Should not throw
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Cost
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('cost', () => {
|
||||||
|
it('should set and get cost', () => {
|
||||||
|
grid.setCost(5, 5, 2.5);
|
||||||
|
const node = grid.getNodeAt(5, 5);
|
||||||
|
expect(node?.cost).toBe(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default cost to 1', () => {
|
||||||
|
const node = grid.getNodeAt(5, 5);
|
||||||
|
expect(node?.cost).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Neighbors (8-direction)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getNeighbors (8-direction)', () => {
|
||||||
|
it('should return 8 neighbors for center node', () => {
|
||||||
|
const node = grid.getNodeAt(5, 5)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 3 neighbors for corner', () => {
|
||||||
|
const node = grid.getNodeAt(0, 0)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 5 neighbors for edge', () => {
|
||||||
|
const node = grid.getNodeAt(5, 0)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include blocked neighbors', () => {
|
||||||
|
grid.setWalkable(6, 5, false);
|
||||||
|
const node = grid.getNodeAt(5, 5)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
// 8 - 1 blocked - 2 diagonals (corner cutting) = 5
|
||||||
|
expect(neighbors.length).toBe(5);
|
||||||
|
expect(neighbors.find(n => n.x === 6 && n.y === 5)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should avoid corner cutting by default', () => {
|
||||||
|
// Block horizontal neighbor
|
||||||
|
grid.setWalkable(6, 5, false);
|
||||||
|
const node = grid.getNodeAt(5, 5)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
|
||||||
|
// Should not include diagonal (6,4) and (6,6) due to corner cutting
|
||||||
|
expect(neighbors.find(n => n.x === 6 && n.y === 4)).toBeUndefined();
|
||||||
|
expect(neighbors.find(n => n.x === 6 && n.y === 6)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Neighbors (4-direction)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getNeighbors (4-direction)', () => {
|
||||||
|
let grid4: GridMap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid4 = new GridMap(10, 10, { allowDiagonal: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 4 neighbors for center node', () => {
|
||||||
|
const node = grid4.getNodeAt(5, 5)!;
|
||||||
|
const neighbors = grid4.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 2 neighbors for corner', () => {
|
||||||
|
const node = grid4.getNodeAt(0, 0)!;
|
||||||
|
const neighbors = grid4.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 3 neighbors for edge', () => {
|
||||||
|
const node = grid4.getNodeAt(5, 0)!;
|
||||||
|
const neighbors = grid4.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Movement Cost
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getMovementCost', () => {
|
||||||
|
it('should return 1 for cardinal movement', () => {
|
||||||
|
const from = grid.getNodeAt(5, 5)!;
|
||||||
|
const to = grid.getNodeAt(6, 5)!;
|
||||||
|
expect(grid.getMovementCost(from, to)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return sqrt(2) for diagonal movement', () => {
|
||||||
|
const from = grid.getNodeAt(5, 5)!;
|
||||||
|
const to = grid.getNodeAt(6, 6)!;
|
||||||
|
expect(grid.getMovementCost(from, to)).toBeCloseTo(Math.SQRT2, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should factor in destination cost', () => {
|
||||||
|
grid.setCost(6, 5, 2);
|
||||||
|
const from = grid.getNodeAt(5, 5)!;
|
||||||
|
const to = grid.getNodeAt(6, 5)!;
|
||||||
|
expect(grid.getMovementCost(from, to)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Load from Array
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('loadFromArray', () => {
|
||||||
|
it('should load walkability from 2D array', () => {
|
||||||
|
const data = [
|
||||||
|
[0, 0, 1],
|
||||||
|
[0, 1, 0],
|
||||||
|
[1, 0, 0]
|
||||||
|
];
|
||||||
|
const small = new GridMap(3, 3);
|
||||||
|
small.loadFromArray(data);
|
||||||
|
|
||||||
|
expect(small.isWalkable(0, 0)).toBe(true);
|
||||||
|
expect(small.isWalkable(2, 0)).toBe(false);
|
||||||
|
expect(small.isWalkable(1, 1)).toBe(false);
|
||||||
|
expect(small.isWalkable(0, 2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial data', () => {
|
||||||
|
const data = [[0, 1]];
|
||||||
|
grid.loadFromArray(data);
|
||||||
|
expect(grid.isWalkable(0, 0)).toBe(true);
|
||||||
|
expect(grid.isWalkable(1, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Load from String
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('loadFromString', () => {
|
||||||
|
it('should load walkability from string', () => {
|
||||||
|
const mapStr = `
|
||||||
|
..#
|
||||||
|
.#.
|
||||||
|
#..`.trim();
|
||||||
|
|
||||||
|
const small = new GridMap(3, 3);
|
||||||
|
small.loadFromString(mapStr);
|
||||||
|
|
||||||
|
expect(small.isWalkable(0, 0)).toBe(true);
|
||||||
|
expect(small.isWalkable(2, 0)).toBe(false);
|
||||||
|
expect(small.isWalkable(1, 1)).toBe(false);
|
||||||
|
expect(small.isWalkable(0, 2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// toString
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should export grid as string', () => {
|
||||||
|
const small = new GridMap(3, 2);
|
||||||
|
small.setWalkable(1, 0, false);
|
||||||
|
|
||||||
|
const expected = '.#.\n...\n';
|
||||||
|
expect(small.toString()).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Reset
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should reset all nodes to walkable', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
grid.setCost(3, 3, 5);
|
||||||
|
grid.reset();
|
||||||
|
|
||||||
|
expect(grid.isWalkable(5, 5)).toBe(true);
|
||||||
|
expect(grid.getNodeAt(3, 3)?.cost).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// setRectWalkable
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('setRectWalkable', () => {
|
||||||
|
it('should set rectangle region walkability', () => {
|
||||||
|
grid.setRectWalkable(2, 2, 3, 3, false);
|
||||||
|
|
||||||
|
for (let y = 2; y < 5; y++) {
|
||||||
|
for (let x = 2; x < 5; x++) {
|
||||||
|
expect(grid.isWalkable(x, y)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside should still be walkable
|
||||||
|
expect(grid.isWalkable(1, 2)).toBe(true);
|
||||||
|
expect(grid.isWalkable(5, 2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Bounds Checking
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('isInBounds', () => {
|
||||||
|
it('should return true for valid coordinates', () => {
|
||||||
|
expect(grid.isInBounds(0, 0)).toBe(true);
|
||||||
|
expect(grid.isInBounds(9, 9)).toBe(true);
|
||||||
|
expect(grid.isInBounds(5, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid coordinates', () => {
|
||||||
|
expect(grid.isInBounds(-1, 0)).toBe(false);
|
||||||
|
expect(grid.isInBounds(0, -1)).toBe(false);
|
||||||
|
expect(grid.isInBounds(10, 0)).toBe(false);
|
||||||
|
expect(grid.isInBounds(0, 10)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GridNode', () => {
|
||||||
|
it('should create node with correct properties', () => {
|
||||||
|
const node = new GridNode(3, 4, true, 2);
|
||||||
|
expect(node.x).toBe(3);
|
||||||
|
expect(node.y).toBe(4);
|
||||||
|
expect(node.walkable).toBe(true);
|
||||||
|
expect(node.cost).toBe(2);
|
||||||
|
expect(node.id).toBe('3,4');
|
||||||
|
expect(node.position.x).toBe(3);
|
||||||
|
expect(node.position.y).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to walkable with cost 1', () => {
|
||||||
|
const node = new GridNode(0, 0);
|
||||||
|
expect(node.walkable).toBe(true);
|
||||||
|
expect(node.cost).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Direction Constants', () => {
|
||||||
|
it('DIRECTIONS_4 should have 4 cardinal directions', () => {
|
||||||
|
expect(DIRECTIONS_4.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DIRECTIONS_8 should have 8 directions', () => {
|
||||||
|
expect(DIRECTIONS_8.length).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
386
packages/framework/pathfinding/__tests__/navmesh/NavMesh.test.ts
Normal file
386
packages/framework/pathfinding/__tests__/navmesh/NavMesh.test.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { NavMesh, createNavMesh } from '../../src/navmesh/NavMesh';
|
||||||
|
import { createPoint } from '../../src/core/IPathfinding';
|
||||||
|
|
||||||
|
describe('NavMesh', () => {
|
||||||
|
let navmesh: NavMesh;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navmesh = new NavMesh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Polygon Management
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('polygon management', () => {
|
||||||
|
it('should add polygon and return id', () => {
|
||||||
|
const id = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(id).toBe(0);
|
||||||
|
expect(navmesh.polygonCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add multiple polygons with incremental ids', () => {
|
||||||
|
const id1 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(5, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const id2 = navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(15, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(id1).toBe(0);
|
||||||
|
expect(id2).toBe(1);
|
||||||
|
expect(navmesh.polygonCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get all polygons', () => {
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(5, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(15, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const polygons = navmesh.getPolygons();
|
||||||
|
expect(polygons.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all polygons', () => {
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(5, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.clear();
|
||||||
|
expect(navmesh.polygonCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Point in Polygon
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('findPolygonAt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Square from (0,0) to (10,10)
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find polygon containing point', () => {
|
||||||
|
const polygon = navmesh.findPolygonAt(5, 5);
|
||||||
|
expect(polygon).not.toBeNull();
|
||||||
|
expect(polygon?.id).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for point outside', () => {
|
||||||
|
expect(navmesh.findPolygonAt(-1, 5)).toBeNull();
|
||||||
|
expect(navmesh.findPolygonAt(15, 5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle point on edge', () => {
|
||||||
|
const polygon = navmesh.findPolygonAt(0, 5);
|
||||||
|
// Edge behavior may vary, but should not crash
|
||||||
|
expect(polygon === null || polygon.id === 0).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Walkability
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('isWalkable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for point in polygon', () => {
|
||||||
|
expect(navmesh.isWalkable(5, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for point outside', () => {
|
||||||
|
expect(navmesh.isWalkable(15, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Connections
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('connections', () => {
|
||||||
|
it('should manually set connection between polygons', () => {
|
||||||
|
// Two adjacent squares
|
||||||
|
const id1 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const id2 = navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(20, 10),
|
||||||
|
createPoint(10, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.setConnection(id1, id2, {
|
||||||
|
left: createPoint(10, 0),
|
||||||
|
right: createPoint(10, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
const polygons = navmesh.getPolygons();
|
||||||
|
const poly1 = polygons.find(p => p.id === id1);
|
||||||
|
|
||||||
|
expect(poly1?.neighbors).toContain(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-detect shared edges with build()', () => {
|
||||||
|
// Two adjacent squares sharing edge at x=10
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(20, 10),
|
||||||
|
createPoint(10, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.build();
|
||||||
|
|
||||||
|
const polygons = navmesh.getPolygons();
|
||||||
|
expect(polygons[0].neighbors).toContain(1);
|
||||||
|
expect(polygons[1].neighbors).toContain(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Pathfinding
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('findPath', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create 3 connected squares
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(20, 10),
|
||||||
|
createPoint(10, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(30, 0),
|
||||||
|
createPoint(30, 10),
|
||||||
|
createPoint(20, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.build();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find path within same polygon', () => {
|
||||||
|
const result = navmesh.findPath(1, 1, 8, 8);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBe(2);
|
||||||
|
expect(result.path[0]).toEqual(createPoint(1, 1));
|
||||||
|
expect(result.path[1]).toEqual(createPoint(8, 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find path across polygons', () => {
|
||||||
|
const result = navmesh.findPath(5, 5, 25, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(result.path[0]).toEqual(createPoint(5, 5));
|
||||||
|
expect(result.path[result.path.length - 1]).toEqual(createPoint(25, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when start is outside', () => {
|
||||||
|
const result = navmesh.findPath(-5, 5, 15, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when end is outside', () => {
|
||||||
|
const result = navmesh.findPath(5, 5, 50, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate path cost', () => {
|
||||||
|
const result = navmesh.findPath(5, 5, 25, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.cost).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track nodes searched', () => {
|
||||||
|
const result = navmesh.findPath(5, 5, 25, 5);
|
||||||
|
expect(result.nodesSearched).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// IPathfindingMap Interface
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('IPathfindingMap interface', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Two adjacent squares with shared edge at x=10
|
||||||
|
const id1 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const id2 = navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(20, 10),
|
||||||
|
createPoint(10, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Manual connection to ensure proper setup
|
||||||
|
navmesh.setConnection(id1, id2, {
|
||||||
|
left: createPoint(10, 0),
|
||||||
|
right: createPoint(10, 10)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return node at position', () => {
|
||||||
|
const node = navmesh.getNodeAt(5, 5);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
expect(node?.id).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for position outside', () => {
|
||||||
|
const node = navmesh.getNodeAt(50, 50);
|
||||||
|
expect(node).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get neighbors from polygon directly', () => {
|
||||||
|
// NavMeshNode holds a reference to the original polygon,
|
||||||
|
// so we check via the polygons map which is updated by setConnection
|
||||||
|
const polygons = navmesh.getPolygons();
|
||||||
|
const poly0 = polygons.find(p => p.id === 0);
|
||||||
|
expect(poly0).toBeDefined();
|
||||||
|
expect(poly0!.neighbors).toContain(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate heuristic', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
expect(navmesh.heuristic(a, b)).toBe(5); // Euclidean
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate movement cost', () => {
|
||||||
|
const node1 = navmesh.getNodeAt(5, 5)!;
|
||||||
|
const node2 = navmesh.getNodeAt(15, 5)!;
|
||||||
|
const cost = navmesh.getMovementCost(node1, node2);
|
||||||
|
expect(cost).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Complex Scenarios
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('complex scenarios', () => {
|
||||||
|
it('should handle L-shaped navmesh with manual connections', () => {
|
||||||
|
// Horizontal part
|
||||||
|
const id1 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(30, 0),
|
||||||
|
createPoint(30, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Vertical part (shares partial edge, needs manual connection)
|
||||||
|
const id2 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 10),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(10, 30),
|
||||||
|
createPoint(0, 30)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Manual connection since edges don't match exactly
|
||||||
|
navmesh.setConnection(id1, id2, {
|
||||||
|
left: createPoint(0, 10),
|
||||||
|
right: createPoint(10, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = navmesh.findPath(25, 5, 5, 25);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle disconnected areas', () => {
|
||||||
|
// Area 1
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Area 2 (disconnected)
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(50, 50),
|
||||||
|
createPoint(60, 50),
|
||||||
|
createPoint(60, 60),
|
||||||
|
createPoint(50, 60)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.build();
|
||||||
|
|
||||||
|
const result = navmesh.findPath(5, 5, 55, 55);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Factory Function
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('createNavMesh', () => {
|
||||||
|
it('should create empty navmesh', () => {
|
||||||
|
const nm = createNavMesh();
|
||||||
|
expect(nm).toBeInstanceOf(NavMesh);
|
||||||
|
expect(nm.polygonCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
bresenhamLineOfSight,
|
||||||
|
raycastLineOfSight,
|
||||||
|
LineOfSightSmoother,
|
||||||
|
CatmullRomSmoother,
|
||||||
|
CombinedSmoother
|
||||||
|
} from '../../src/smoothing/PathSmoother';
|
||||||
|
import { GridMap } from '../../src/grid/GridMap';
|
||||||
|
import { createPoint, type IPoint } from '../../src/core/IPathfinding';
|
||||||
|
|
||||||
|
describe('Line of Sight Functions', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(10, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// bresenhamLineOfSight
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('bresenhamLineOfSight', () => {
|
||||||
|
it('should return true for clear line', () => {
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for same point', () => {
|
||||||
|
expect(bresenhamLineOfSight(5, 5, 5, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for horizontal line', () => {
|
||||||
|
expect(bresenhamLineOfSight(0, 5, 9, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for vertical line', () => {
|
||||||
|
expect(bresenhamLineOfSight(5, 0, 5, 9, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when blocked', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 9, 9, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when start is blocked', () => {
|
||||||
|
grid.setWalkable(0, 0, false);
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when end is blocked', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect obstacle in middle', () => {
|
||||||
|
grid.setWalkable(3, 3, false);
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 6, 6, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// raycastLineOfSight
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('raycastLineOfSight', () => {
|
||||||
|
it('should return true for clear line', () => {
|
||||||
|
expect(raycastLineOfSight(0, 0, 5, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for same point', () => {
|
||||||
|
expect(raycastLineOfSight(5, 5, 5, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when blocked', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
expect(raycastLineOfSight(0, 0, 9, 9, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with custom step size', () => {
|
||||||
|
expect(raycastLineOfSight(0, 0, 5, 5, grid, 0.1)).toBe(true);
|
||||||
|
grid.setWalkable(2, 2, false);
|
||||||
|
expect(raycastLineOfSight(0, 0, 5, 5, grid, 0.1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LineOfSightSmoother', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
let smoother: LineOfSightSmoother;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(20, 20);
|
||||||
|
smoother = new LineOfSightSmoother();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return same path for 2 or fewer points', () => {
|
||||||
|
const path1: IPoint[] = [createPoint(0, 0)];
|
||||||
|
expect(smoother.smooth(path1, grid)).toEqual(path1);
|
||||||
|
|
||||||
|
const path2: IPoint[] = [createPoint(0, 0), createPoint(5, 5)];
|
||||||
|
expect(smoother.smooth(path2, grid)).toEqual(path2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove unnecessary waypoints on straight line', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(1, 0),
|
||||||
|
createPoint(2, 0),
|
||||||
|
createPoint(3, 0),
|
||||||
|
createPoint(4, 0),
|
||||||
|
createPoint(5, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
expect(result[0]).toEqual(createPoint(0, 0));
|
||||||
|
expect(result[1]).toEqual(createPoint(5, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove unnecessary waypoints on diagonal', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(1, 1),
|
||||||
|
createPoint(2, 2),
|
||||||
|
createPoint(3, 3),
|
||||||
|
createPoint(4, 4),
|
||||||
|
createPoint(5, 5)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
expect(result[0]).toEqual(createPoint(0, 0));
|
||||||
|
expect(result[1]).toEqual(createPoint(5, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep waypoints around obstacles', () => {
|
||||||
|
// Create obstacle
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(4, 5),
|
||||||
|
createPoint(6, 5),
|
||||||
|
createPoint(10, 10)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
// Should keep at least start, one waypoint near obstacle, and end
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom line of sight function', () => {
|
||||||
|
const customLOS = (x1: number, y1: number, x2: number, y2: number) => {
|
||||||
|
// Always blocked
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const customSmoother = new LineOfSightSmoother(customLOS);
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(1, 1),
|
||||||
|
createPoint(2, 2)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = customSmoother.smooth(path, grid);
|
||||||
|
// Should not simplify because LOS always fails
|
||||||
|
expect(result).toEqual(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CatmullRomSmoother', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
let smoother: CatmullRomSmoother;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(20, 20);
|
||||||
|
smoother = new CatmullRomSmoother(5, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return same path for 2 or fewer points', () => {
|
||||||
|
const path1: IPoint[] = [createPoint(0, 0)];
|
||||||
|
expect(smoother.smooth(path1, grid)).toEqual(path1);
|
||||||
|
|
||||||
|
const path2: IPoint[] = [createPoint(0, 0), createPoint(5, 5)];
|
||||||
|
expect(smoother.smooth(path2, grid)).toEqual(path2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add interpolation points', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(5, 0),
|
||||||
|
createPoint(10, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
// Should have more points due to interpolation
|
||||||
|
expect(result.length).toBeGreaterThan(path.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve start and end points', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(5, 5),
|
||||||
|
createPoint(10, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
expect(result[0].x).toBeCloseTo(0, 1);
|
||||||
|
expect(result[0].y).toBeCloseTo(0, 1);
|
||||||
|
expect(result[result.length - 1]).toEqual(createPoint(10, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create smooth curve', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(5, 5),
|
||||||
|
createPoint(10, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
|
||||||
|
// Check that middle points are near the original waypoint
|
||||||
|
const middlePoints = result.filter(p =>
|
||||||
|
Math.abs(p.x - 5) < 2 && Math.abs(p.y - 5) < 2
|
||||||
|
);
|
||||||
|
expect(middlePoints.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with different segment counts', () => {
|
||||||
|
const smootherLow = new CatmullRomSmoother(2);
|
||||||
|
const smootherHigh = new CatmullRomSmoother(10);
|
||||||
|
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(5, 5),
|
||||||
|
createPoint(10, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const resultLow = smootherLow.smooth(path, grid);
|
||||||
|
const resultHigh = smootherHigh.smooth(path, grid);
|
||||||
|
|
||||||
|
expect(resultHigh.length).toBeGreaterThan(resultLow.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CombinedSmoother', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
let smoother: CombinedSmoother;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(20, 20);
|
||||||
|
smoother = new CombinedSmoother(5, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should first simplify then curve smooth', () => {
|
||||||
|
// Path with redundant points
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(1, 0),
|
||||||
|
createPoint(2, 0),
|
||||||
|
createPoint(3, 0),
|
||||||
|
createPoint(4, 0),
|
||||||
|
createPoint(5, 0),
|
||||||
|
createPoint(6, 3),
|
||||||
|
createPoint(7, 6),
|
||||||
|
createPoint(8, 6),
|
||||||
|
createPoint(9, 6),
|
||||||
|
createPoint(10, 6)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
|
||||||
|
// Should have smoothed the path
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result[0].x).toBeCloseTo(0, 1);
|
||||||
|
expect(result[result.length - 1]).toEqual(createPoint(10, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle simple path', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 10)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"build:watch": "tsup --watch",
|
"build:watch": "tsup --watch",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"clean": "rimraf dist"
|
"clean": "rimraf dist"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -27,7 +29,8 @@
|
|||||||
"@esengine/ecs-framework-math": "workspace:*",
|
"@esengine/ecs-framework-math": "workspace:*",
|
||||||
"@esengine/blueprint": "workspace:*",
|
"@esengine/blueprint": "workspace:*",
|
||||||
"tsup": "^8.0.0",
|
"tsup": "^8.0.0",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@esengine/ecs-framework": "workspace:*",
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
|
|||||||
7
packages/framework/pathfinding/vitest.config.ts
Normal file
7
packages/framework/pathfinding/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['__tests__/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
# @esengine/server
|
# @esengine/server
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- [#386](https://github.com/esengine/esengine/pull/386) [`61a13ba`](https://github.com/esengine/esengine/commit/61a13baca2e1e8fba14e23d439521ec0e6b7ca6e) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加可插拔认证系统 | add pluggable authentication system
|
||||||
|
- 新增 JWT 认证提供者 (`createJwtAuthProvider`)
|
||||||
|
- 新增 Session 认证提供者 (`createSessionAuthProvider`)
|
||||||
|
- 新增服务器认证 mixin (`withAuth`)
|
||||||
|
- 新增房间认证 mixin (`withRoomAuth`)
|
||||||
|
- 新增认证装饰器 (`@requireAuth`, `@requireRole`)
|
||||||
|
- 新增测试工具 (`MockAuthProvider`)
|
||||||
|
- 导出路径: `@esengine/server/auth`, `@esengine/server/auth/testing`
|
||||||
|
|
||||||
## 1.1.4
|
## 1.1.4
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/server",
|
"name": "@esengine/server",
|
||||||
"version": "1.1.4",
|
"version": "1.2.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",
|
||||||
@@ -10,6 +10,18 @@
|
|||||||
".": {
|
".": {
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"./auth": {
|
||||||
|
"import": "./dist/auth/index.js",
|
||||||
|
"types": "./dist/auth/index.d.ts"
|
||||||
|
},
|
||||||
|
"./auth/testing": {
|
||||||
|
"import": "./dist/auth/testing/index.js",
|
||||||
|
"types": "./dist/auth/testing/index.d.ts"
|
||||||
|
},
|
||||||
|
"./testing": {
|
||||||
|
"import": "./dist/testing/index.js",
|
||||||
|
"types": "./dist/testing/index.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -21,20 +33,31 @@
|
|||||||
"build:watch": "tsup --watch",
|
"build:watch": "tsup --watch",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"clean": "rimraf dist"
|
"clean": "rimraf dist",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esengine/rpc": "workspace:*"
|
"@esengine/rpc": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"ws": ">=8.0.0"
|
"ws": ">=8.0.0",
|
||||||
|
"jsonwebtoken": ">=9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"jsonwebtoken": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@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",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
"rimraf": "^5.0.0",
|
"rimraf": "^5.0.0",
|
||||||
"tsup": "^8.0.0",
|
"tsup": "^8.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.0.0",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { MockAuthProvider, createMockAuthProvider, type MockUser } from '../testing/MockAuthProvider';
|
||||||
|
|
||||||
|
describe('MockAuthProvider', () => {
|
||||||
|
const testUsers: MockUser[] = [
|
||||||
|
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||||
|
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||||
|
{ id: '3', name: 'Charlie', roles: ['guest'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
let provider: MockAuthProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
provider = createMockAuthProvider({
|
||||||
|
users: testUsers
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('basic properties', () => {
|
||||||
|
it('should have name "mock"', () => {
|
||||||
|
expect(provider.name).toBe('mock');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verify', () => {
|
||||||
|
it('should verify existing user by id (token)', async () => {
|
||||||
|
const result = await provider.verify('1');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.id).toBe('1');
|
||||||
|
expect(result.user?.name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return user roles', async () => {
|
||||||
|
const result = await provider.verify('2');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.roles).toEqual(['admin', 'player']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for unknown user', async () => {
|
||||||
|
const result = await provider.verify('unknown');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('USER_NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for empty token', async () => {
|
||||||
|
const result = await provider.verify('');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return expiresAt', async () => {
|
||||||
|
const result = await provider.verify('1');
|
||||||
|
expect(result.expiresAt).toBeTypeOf('number');
|
||||||
|
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with defaultUser', () => {
|
||||||
|
it('should return default user for empty token', async () => {
|
||||||
|
const providerWithDefault = createMockAuthProvider({
|
||||||
|
defaultUser: { id: 'default', name: 'Guest' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await providerWithDefault.verify('');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.id).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with autoCreate', () => {
|
||||||
|
it('should auto create user for unknown token', async () => {
|
||||||
|
const autoProvider = createMockAuthProvider({
|
||||||
|
autoCreate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await autoProvider.verify('new-user-123');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.id).toBe('new-user-123');
|
||||||
|
expect(result.user?.name).toBe('User_new-user-123');
|
||||||
|
expect(result.user?.roles).toEqual(['guest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist auto-created users', async () => {
|
||||||
|
const autoProvider = createMockAuthProvider({
|
||||||
|
autoCreate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await autoProvider.verify('auto-1');
|
||||||
|
const user = autoProvider.getUser('auto-1');
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.id).toBe('auto-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with validateToken', () => {
|
||||||
|
it('should validate token format', async () => {
|
||||||
|
const validatingProvider = createMockAuthProvider({
|
||||||
|
users: testUsers,
|
||||||
|
validateToken: (token) => token.length >= 1 && !token.includes('invalid')
|
||||||
|
});
|
||||||
|
|
||||||
|
const validResult = await validatingProvider.verify('1');
|
||||||
|
expect(validResult.success).toBe(true);
|
||||||
|
|
||||||
|
const invalidResult = await validatingProvider.verify('invalid-token');
|
||||||
|
expect(invalidResult.success).toBe(false);
|
||||||
|
expect(invalidResult.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with delay', () => {
|
||||||
|
it('should add artificial delay', async () => {
|
||||||
|
const delayProvider = createMockAuthProvider({
|
||||||
|
users: testUsers,
|
||||||
|
delay: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await delayProvider.verify('1');
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(45);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('should refresh token (returns same result as verify)', async () => {
|
||||||
|
const result = await provider.refresh('1');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.id).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revoke', () => {
|
||||||
|
it('should revoke token', async () => {
|
||||||
|
const result1 = await provider.verify('1');
|
||||||
|
expect(result1.success).toBe(true);
|
||||||
|
|
||||||
|
const revoked = await provider.revoke('1');
|
||||||
|
expect(revoked).toBe(true);
|
||||||
|
|
||||||
|
const result2 = await provider.verify('1');
|
||||||
|
expect(result2.success).toBe(false);
|
||||||
|
expect(result2.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('user management', () => {
|
||||||
|
it('should add user', () => {
|
||||||
|
provider.addUser({ id: '4', name: 'Dave', roles: ['tester'] });
|
||||||
|
const user = provider.getUser('4');
|
||||||
|
expect(user?.name).toBe('Dave');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove user', () => {
|
||||||
|
const removed = provider.removeUser('1');
|
||||||
|
expect(removed).toBe(true);
|
||||||
|
|
||||||
|
const user = provider.getUser('1');
|
||||||
|
expect(user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when removing non-existent user', () => {
|
||||||
|
const removed = provider.removeUser('non-existent');
|
||||||
|
expect(removed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get all users', () => {
|
||||||
|
const users = provider.getUsers();
|
||||||
|
expect(users).toHaveLength(3);
|
||||||
|
expect(users.map(u => u.id)).toContain('1');
|
||||||
|
expect(users.map(u => u.id)).toContain('2');
|
||||||
|
expect(users.map(u => u.id)).toContain('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should reset to initial state', async () => {
|
||||||
|
provider.addUser({ id: '4', name: 'Dave' });
|
||||||
|
await provider.revoke('1');
|
||||||
|
|
||||||
|
provider.clear();
|
||||||
|
|
||||||
|
const users = provider.getUsers();
|
||||||
|
expect(users).toHaveLength(3);
|
||||||
|
|
||||||
|
const result = await provider.verify('1');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateToken', () => {
|
||||||
|
it('should return user id as token', () => {
|
||||||
|
const token = provider.generateToken('user-123');
|
||||||
|
expect(token).toBe('user-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMockAuthProvider', () => {
|
||||||
|
it('should create provider with empty config', () => {
|
||||||
|
const provider = createMockAuthProvider();
|
||||||
|
expect(provider.name).toBe('mock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create provider with custom users', () => {
|
||||||
|
const provider = createMockAuthProvider({
|
||||||
|
users: [{ id: 'test', name: 'Test User' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = provider.getUser('test');
|
||||||
|
expect(user?.name).toBe('Test User');
|
||||||
|
});
|
||||||
|
});
|
||||||
242
packages/framework/server/src/auth/__tests__/context.test.ts
Normal file
242
packages/framework/server/src/auth/__tests__/context.test.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { AuthContext, createGuestContext, createAuthContext, defaultUserExtractor } from '../context';
|
||||||
|
import type { AuthResult } from '../types';
|
||||||
|
|
||||||
|
describe('AuthContext', () => {
|
||||||
|
let context: AuthContext<{ id: string; name: string; roles: string[] }>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
context = new AuthContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('should not be authenticated initially', () => {
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null user initially', () => {
|
||||||
|
expect(context.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null userId initially', () => {
|
||||||
|
expect(context.userId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have empty roles initially', () => {
|
||||||
|
expect(context.roles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null authenticatedAt initially', () => {
|
||||||
|
expect(context.authenticatedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null expiresAt initially', () => {
|
||||||
|
expect(context.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setAuthenticated', () => {
|
||||||
|
it('should set authenticated state on success', () => {
|
||||||
|
const result: AuthResult<{ id: string; name: string; roles: string[] }> = {
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: ['player'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
context.setAuthenticated(result);
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(true);
|
||||||
|
expect(context.user).toEqual({ id: '123', name: 'Alice', roles: ['player'] });
|
||||||
|
expect(context.userId).toBe('123');
|
||||||
|
expect(context.roles).toEqual(['player']);
|
||||||
|
expect(context.authenticatedAt).toBeTypeOf('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set expiresAt when provided', () => {
|
||||||
|
const expiresAt = Date.now() + 3600000;
|
||||||
|
const result: AuthResult<{ id: string; name: string; roles: string[] }> = {
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: [] },
|
||||||
|
expiresAt
|
||||||
|
};
|
||||||
|
|
||||||
|
context.setAuthenticated(result);
|
||||||
|
|
||||||
|
expect(context.expiresAt).toBe(expiresAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear state on failed result', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: ['player'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: false,
|
||||||
|
error: 'Token expired'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
expect(context.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear state when success but no user', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAuthenticated with expiry', () => {
|
||||||
|
it('should return false when token is expired', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: [] },
|
||||||
|
expiresAt: Date.now() - 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when token is not expired', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: [] },
|
||||||
|
expiresAt: Date.now() + 3600000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('role checking', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: ['player', 'premium'] }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasRole should return true for existing role', () => {
|
||||||
|
expect(context.hasRole('player')).toBe(true);
|
||||||
|
expect(context.hasRole('premium')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasRole should return false for non-existing role', () => {
|
||||||
|
expect(context.hasRole('admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAnyRole should return true if any role matches', () => {
|
||||||
|
expect(context.hasAnyRole(['admin', 'player'])).toBe(true);
|
||||||
|
expect(context.hasAnyRole(['guest', 'premium'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAnyRole should return false if no role matches', () => {
|
||||||
|
expect(context.hasAnyRole(['admin', 'moderator'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAllRoles should return true if all roles match', () => {
|
||||||
|
expect(context.hasAllRoles(['player', 'premium'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAllRoles should return false if any role is missing', () => {
|
||||||
|
expect(context.hasAllRoles(['player', 'admin'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should reset all state', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: ['player'] },
|
||||||
|
expiresAt: Date.now() + 3600000
|
||||||
|
});
|
||||||
|
|
||||||
|
context.clear();
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
expect(context.user).toBeNull();
|
||||||
|
expect(context.userId).toBeNull();
|
||||||
|
expect(context.roles).toEqual([]);
|
||||||
|
expect(context.authenticatedAt).toBeNull();
|
||||||
|
expect(context.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultUserExtractor', () => {
|
||||||
|
describe('getId', () => {
|
||||||
|
it('should extract id from user object', () => {
|
||||||
|
expect(defaultUserExtractor.getId({ id: '123' })).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract numeric id as string', () => {
|
||||||
|
expect(defaultUserExtractor.getId({ id: 456 })).toBe('456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract userId', () => {
|
||||||
|
expect(defaultUserExtractor.getId({ userId: 'abc' })).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract sub (JWT standard)', () => {
|
||||||
|
expect(defaultUserExtractor.getId({ sub: 'jwt-sub' })).toBe('jwt-sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string for invalid user', () => {
|
||||||
|
expect(defaultUserExtractor.getId(null)).toBe('');
|
||||||
|
expect(defaultUserExtractor.getId(undefined)).toBe('');
|
||||||
|
expect(defaultUserExtractor.getId({})).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRoles', () => {
|
||||||
|
it('should extract roles array', () => {
|
||||||
|
expect(defaultUserExtractor.getRoles({ roles: ['a', 'b'] })).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract single role', () => {
|
||||||
|
expect(defaultUserExtractor.getRoles({ role: 'admin' })).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter non-string roles', () => {
|
||||||
|
expect(defaultUserExtractor.getRoles({ roles: ['a', 123, 'b'] })).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for invalid user', () => {
|
||||||
|
expect(defaultUserExtractor.getRoles(null)).toEqual([]);
|
||||||
|
expect(defaultUserExtractor.getRoles({})).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGuestContext', () => {
|
||||||
|
it('should create unauthenticated context', () => {
|
||||||
|
const guest = createGuestContext();
|
||||||
|
expect(guest.isAuthenticated).toBe(false);
|
||||||
|
expect(guest.user).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAuthContext', () => {
|
||||||
|
it('should create authenticated context from result', () => {
|
||||||
|
const result: AuthResult<{ id: string }> = {
|
||||||
|
success: true,
|
||||||
|
user: { id: '123' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createAuthContext(result);
|
||||||
|
expect(ctx.isAuthenticated).toBe(true);
|
||||||
|
expect(ctx.userId).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create unauthenticated context from failed result', () => {
|
||||||
|
const result: AuthResult<{ id: string }> = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createAuthContext(result);
|
||||||
|
expect(ctx.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
165
packages/framework/server/src/auth/__tests__/decorators.test.ts
Normal file
165
packages/framework/server/src/auth/__tests__/decorators.test.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { requireAuth, AUTH_METADATA_KEY, getAuthMetadata, type AuthMetadata } from '../decorators/requireAuth';
|
||||||
|
import { requireRole } from '../decorators/requireRole';
|
||||||
|
|
||||||
|
describe('requireAuth decorator', () => {
|
||||||
|
class TestClass {
|
||||||
|
@requireAuth()
|
||||||
|
basicMethod() {
|
||||||
|
return 'basic';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ allowGuest: true })
|
||||||
|
guestAllowedMethod() {
|
||||||
|
return 'guest';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ errorMessage: 'Custom error' })
|
||||||
|
customErrorMethod() {
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
undecorated() {
|
||||||
|
return 'undecorated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance: TestClass;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instance = new TestClass();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('metadata storage', () => {
|
||||||
|
it('should store auth metadata on target', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'basicMethod');
|
||||||
|
expect(metadata).toBeDefined();
|
||||||
|
expect(metadata?.requireAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store options in metadata', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'guestAllowedMethod');
|
||||||
|
expect(metadata?.options?.allowGuest).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store custom error message', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'customErrorMethod');
|
||||||
|
expect(metadata?.options?.errorMessage).toBe('Custom error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for undecorated methods', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'undecorated');
|
||||||
|
expect(metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-existent methods', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'nonExistent');
|
||||||
|
expect(metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('method behavior', () => {
|
||||||
|
it('should not alter method behavior', () => {
|
||||||
|
expect(instance.basicMethod()).toBe('basic');
|
||||||
|
expect(instance.guestAllowedMethod()).toBe('guest');
|
||||||
|
expect(instance.customErrorMethod()).toBe('custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('metadata key', () => {
|
||||||
|
it('should use symbol for metadata storage', () => {
|
||||||
|
expect(typeof AUTH_METADATA_KEY).toBe('symbol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store metadata in a Map', () => {
|
||||||
|
const metadataMap = (TestClass.prototype as any)[AUTH_METADATA_KEY];
|
||||||
|
expect(metadataMap).toBeInstanceOf(Map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireRole decorator', () => {
|
||||||
|
class RoleTestClass {
|
||||||
|
@requireRole('admin')
|
||||||
|
adminOnly() {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['moderator', 'admin'])
|
||||||
|
modOrAdmin() {
|
||||||
|
return 'mod';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||||
|
verifiedPremium() {
|
||||||
|
return 'vip';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole('player', { mode: 'any' })
|
||||||
|
playerExplicit() {
|
||||||
|
return 'player';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('single role', () => {
|
||||||
|
it('should store single role as array', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||||
|
expect(metadata?.roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set requireAuth to true', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||||
|
expect(metadata?.requireAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to any mode', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||||
|
expect(metadata?.roleMode).toBe('any');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple roles', () => {
|
||||||
|
it('should store multiple roles', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'modOrAdmin');
|
||||||
|
expect(metadata?.roles).toEqual(['moderator', 'admin']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('role mode', () => {
|
||||||
|
it('should support all mode', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'verifiedPremium');
|
||||||
|
expect(metadata?.roleMode).toBe('all');
|
||||||
|
expect(metadata?.roles).toEqual(['verified', 'premium']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support explicit any mode', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'playerExplicit');
|
||||||
|
expect(metadata?.roleMode).toBe('any');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('method behavior', () => {
|
||||||
|
it('should not alter method behavior', () => {
|
||||||
|
const instance = new RoleTestClass();
|
||||||
|
expect(instance.adminOnly()).toBe('admin');
|
||||||
|
expect(instance.modOrAdmin()).toBe('mod');
|
||||||
|
expect(instance.verifiedPremium()).toBe('vip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combined decorators', () => {
|
||||||
|
class CombinedTestClass {
|
||||||
|
@requireAuth({ allowGuest: false })
|
||||||
|
@requireRole('admin')
|
||||||
|
combinedMethod() {
|
||||||
|
return 'combined';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should merge metadata from both decorators', () => {
|
||||||
|
const metadata = getAuthMetadata(CombinedTestClass.prototype, 'combinedMethod');
|
||||||
|
expect(metadata?.requireAuth).toBe(true);
|
||||||
|
expect(metadata?.roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
});
|
||||||
330
packages/framework/server/src/auth/__tests__/providers.test.ts
Normal file
330
packages/framework/server/src/auth/__tests__/providers.test.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { JwtAuthProvider, createJwtAuthProvider } from '../providers/JwtAuthProvider';
|
||||||
|
import { SessionAuthProvider, createSessionAuthProvider, type ISessionStorage } from '../providers/SessionAuthProvider';
|
||||||
|
|
||||||
|
describe('JwtAuthProvider', () => {
|
||||||
|
const secret = 'test-secret-key-for-testing';
|
||||||
|
let provider: JwtAuthProvider<{ id: string; name: string; roles: string[] }>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
provider = createJwtAuthProvider({
|
||||||
|
secret,
|
||||||
|
expiresIn: 3600
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sign and verify', () => {
|
||||||
|
it('should sign and verify a token', async () => {
|
||||||
|
const payload = { sub: '123', name: 'Alice', roles: ['player'] };
|
||||||
|
const token = provider.sign(payload);
|
||||||
|
|
||||||
|
expect(token).toBeTypeOf('string');
|
||||||
|
expect(token.split('.')).toHaveLength(3);
|
||||||
|
|
||||||
|
const result = await provider.verify(token);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract user from payload', async () => {
|
||||||
|
const payload = { sub: '123', name: 'Alice' };
|
||||||
|
const token = provider.sign(payload);
|
||||||
|
|
||||||
|
const result = await provider.verify(token);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect((result.user as any).sub).toBe('123');
|
||||||
|
expect((result.user as any).name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return expiration time', async () => {
|
||||||
|
const token = provider.sign({ sub: '123' });
|
||||||
|
const result = await provider.verify(token);
|
||||||
|
|
||||||
|
expect(result.expiresAt).toBeTypeOf('number');
|
||||||
|
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verify errors', () => {
|
||||||
|
it('should fail for empty token', async () => {
|
||||||
|
const result = await provider.verify('');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for invalid token', async () => {
|
||||||
|
const result = await provider.verify('invalid.token.here');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for expired token', async () => {
|
||||||
|
const shortLivedProvider = createJwtAuthProvider({
|
||||||
|
secret,
|
||||||
|
expiresIn: -1
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = shortLivedProvider.sign({ sub: '123' });
|
||||||
|
const result = await shortLivedProvider.verify(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('EXPIRED_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for wrong secret', async () => {
|
||||||
|
const token = provider.sign({ sub: '123' });
|
||||||
|
|
||||||
|
const wrongSecretProvider = createJwtAuthProvider({
|
||||||
|
secret: 'wrong-secret'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await wrongSecretProvider.verify(token);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with getUser callback', () => {
|
||||||
|
it('should use getUser to transform payload', async () => {
|
||||||
|
const customProvider = createJwtAuthProvider({
|
||||||
|
secret,
|
||||||
|
getUser: async (payload) => ({
|
||||||
|
id: payload.sub as string,
|
||||||
|
name: payload.name as string,
|
||||||
|
roles: (payload.roles as string[]) || []
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = customProvider.sign({ sub: '123', name: 'Bob', roles: ['admin'] });
|
||||||
|
const result = await customProvider.verify(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user).toEqual({
|
||||||
|
id: '123',
|
||||||
|
name: 'Bob',
|
||||||
|
roles: ['admin']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when getUser returns null', async () => {
|
||||||
|
const customProvider = createJwtAuthProvider({
|
||||||
|
secret,
|
||||||
|
getUser: async () => null
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = customProvider.sign({ sub: '123' });
|
||||||
|
const result = await customProvider.verify(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('USER_NOT_FOUND');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('should refresh a valid token', async () => {
|
||||||
|
const token = provider.sign({ sub: '123', name: 'Alice' });
|
||||||
|
|
||||||
|
// Wait a bit so iat changes
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
|
const result = await provider.refresh(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.token).toBeDefined();
|
||||||
|
expect(result.token).not.toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return new expiration time', async () => {
|
||||||
|
const token = provider.sign({ sub: '123' });
|
||||||
|
const result = await provider.refresh(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.expiresAt).toBeTypeOf('number');
|
||||||
|
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to refresh invalid token', async () => {
|
||||||
|
const result = await provider.refresh('invalid');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decode', () => {
|
||||||
|
it('should decode token without verification', () => {
|
||||||
|
const token = provider.sign({ sub: '123', name: 'Alice' });
|
||||||
|
const payload = provider.decode(token);
|
||||||
|
|
||||||
|
expect(payload).toBeDefined();
|
||||||
|
expect(payload?.sub).toBe('123');
|
||||||
|
expect(payload?.name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid token', () => {
|
||||||
|
const payload = provider.decode('not-a-token');
|
||||||
|
expect(payload).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SessionAuthProvider', () => {
|
||||||
|
let storage: ISessionStorage;
|
||||||
|
let provider: SessionAuthProvider<{ id: string; name: string }>;
|
||||||
|
let storageData: Map<string, unknown>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storageData = new Map();
|
||||||
|
|
||||||
|
storage = {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
return (storageData.get(key) as T) ?? null;
|
||||||
|
},
|
||||||
|
async set<T>(key: string, value: T): Promise<void> {
|
||||||
|
storageData.set(key, value);
|
||||||
|
},
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
return storageData.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
provider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
sessionTTL: 3600000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSession and verify', () => {
|
||||||
|
it('should create and verify a session', async () => {
|
||||||
|
const user = { id: '123', name: 'Alice' };
|
||||||
|
const sessionId = await provider.createSession(user);
|
||||||
|
|
||||||
|
expect(sessionId).toBeTypeOf('string');
|
||||||
|
expect(sessionId.length).toBeGreaterThan(10);
|
||||||
|
|
||||||
|
const result = await provider.verify(sessionId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user).toEqual(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store session data', async () => {
|
||||||
|
const user = { id: '123', name: 'Alice' };
|
||||||
|
const sessionId = await provider.createSession(user, { customField: 'value' });
|
||||||
|
|
||||||
|
const session = await provider.getSession(sessionId);
|
||||||
|
|
||||||
|
expect(session).toBeDefined();
|
||||||
|
expect(session?.user).toEqual(user);
|
||||||
|
expect(session?.data?.customField).toBe('value');
|
||||||
|
expect(session?.createdAt).toBeTypeOf('number');
|
||||||
|
expect(session?.lastActiveAt).toBeTypeOf('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verify errors', () => {
|
||||||
|
it('should fail for empty session id', async () => {
|
||||||
|
const result = await provider.verify('');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent session', async () => {
|
||||||
|
const result = await provider.verify('non-existent-session');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('EXPIRED_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with validateUser', () => {
|
||||||
|
it('should validate user on verify', async () => {
|
||||||
|
const validatingProvider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
validateUser: (user) => user.id !== 'banned'
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = await validatingProvider.createSession({ id: 'banned', name: 'Bad User' });
|
||||||
|
const result = await validatingProvider.verify(sessionId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('ACCOUNT_DISABLED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass validation for valid user', async () => {
|
||||||
|
const validatingProvider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
validateUser: (user) => user.id !== 'banned'
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = await validatingProvider.createSession({ id: '123', name: 'Good User' });
|
||||||
|
const result = await validatingProvider.verify(sessionId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('should refresh session and update lastActiveAt', async () => {
|
||||||
|
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||||
|
|
||||||
|
const session1 = await provider.getSession(sessionId);
|
||||||
|
const lastActive1 = session1?.lastActiveAt;
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const result = await provider.refresh(sessionId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const session2 = await provider.getSession(sessionId);
|
||||||
|
expect(session2?.lastActiveAt).toBeGreaterThanOrEqual(lastActive1!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revoke', () => {
|
||||||
|
it('should revoke session', async () => {
|
||||||
|
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||||
|
|
||||||
|
const revoked = await provider.revoke(sessionId);
|
||||||
|
expect(revoked).toBe(true);
|
||||||
|
|
||||||
|
const result = await provider.verify(sessionId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateSession', () => {
|
||||||
|
it('should update session data', async () => {
|
||||||
|
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||||
|
|
||||||
|
const updated = await provider.updateSession(sessionId, { newField: 'newValue' });
|
||||||
|
expect(updated).toBe(true);
|
||||||
|
|
||||||
|
const session = await provider.getSession(sessionId);
|
||||||
|
expect(session?.data?.newField).toBe('newValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existent session', async () => {
|
||||||
|
const updated = await provider.updateSession('non-existent', { field: 'value' });
|
||||||
|
expect(updated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createJwtAuthProvider', () => {
|
||||||
|
it('should create provider with default options', () => {
|
||||||
|
const provider = createJwtAuthProvider({ secret: 'test' });
|
||||||
|
expect(provider.name).toBe('jwt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSessionAuthProvider', () => {
|
||||||
|
it('should create provider with default options', () => {
|
||||||
|
const storage: ISessionStorage = {
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => {},
|
||||||
|
delete: async () => true
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider = createSessionAuthProvider({ storage });
|
||||||
|
expect(provider.name).toBe('session');
|
||||||
|
});
|
||||||
|
});
|
||||||
202
packages/framework/server/src/auth/context.ts
Normal file
202
packages/framework/server/src/auth/context.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证上下文实现
|
||||||
|
* @en Authentication context implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAuthContext, AuthResult } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息提取器
|
||||||
|
* @en User info extractor
|
||||||
|
*/
|
||||||
|
export interface UserInfoExtractor<TUser> {
|
||||||
|
/**
|
||||||
|
* @zh 提取用户 ID
|
||||||
|
* @en Extract user ID
|
||||||
|
*/
|
||||||
|
getId(user: TUser): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 提取用户角色
|
||||||
|
* @en Extract user roles
|
||||||
|
*/
|
||||||
|
getRoles(user: TUser): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认用户信息提取器
|
||||||
|
* @en Default user info extractor
|
||||||
|
*/
|
||||||
|
export const defaultUserExtractor: UserInfoExtractor<unknown> = {
|
||||||
|
getId(user: unknown): string {
|
||||||
|
if (user && typeof user === 'object') {
|
||||||
|
const u = user as Record<string, unknown>;
|
||||||
|
if (typeof u.id === 'string') return u.id;
|
||||||
|
if (typeof u.id === 'number') return String(u.id);
|
||||||
|
if (typeof u.userId === 'string') return u.userId;
|
||||||
|
if (typeof u.userId === 'number') return String(u.userId);
|
||||||
|
if (typeof u.sub === 'string') return u.sub;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
getRoles(user: unknown): string[] {
|
||||||
|
if (user && typeof user === 'object') {
|
||||||
|
const u = user as Record<string, unknown>;
|
||||||
|
if (Array.isArray(u.roles)) {
|
||||||
|
return u.roles.filter((r): r is string => typeof r === 'string');
|
||||||
|
}
|
||||||
|
if (typeof u.role === 'string') {
|
||||||
|
return [u.role];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文
|
||||||
|
* @en Authentication context
|
||||||
|
*
|
||||||
|
* @zh 存储连接的认证状态
|
||||||
|
* @en Stores authentication state for a connection
|
||||||
|
*/
|
||||||
|
export class AuthContext<TUser = unknown> implements IAuthContext<TUser> {
|
||||||
|
private _isAuthenticated: boolean = false;
|
||||||
|
private _user: TUser | null = null;
|
||||||
|
private _userId: string | null = null;
|
||||||
|
private _roles: string[] = [];
|
||||||
|
private _authenticatedAt: number | null = null;
|
||||||
|
private _expiresAt: number | null = null;
|
||||||
|
private _extractor: UserInfoExtractor<TUser>;
|
||||||
|
|
||||||
|
constructor(extractor?: UserInfoExtractor<TUser>) {
|
||||||
|
this._extractor = (extractor ?? defaultUserExtractor) as UserInfoExtractor<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否已认证
|
||||||
|
* @en Whether authenticated
|
||||||
|
*/
|
||||||
|
get isAuthenticated(): boolean {
|
||||||
|
if (this._expiresAt && Date.now() > this._expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this._isAuthenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息
|
||||||
|
* @en User information
|
||||||
|
*/
|
||||||
|
get user(): TUser | null {
|
||||||
|
return this._user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户 ID
|
||||||
|
* @en User ID
|
||||||
|
*/
|
||||||
|
get userId(): string | null {
|
||||||
|
return this._userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户角色
|
||||||
|
* @en User roles
|
||||||
|
*/
|
||||||
|
get roles(): ReadonlyArray<string> {
|
||||||
|
return this._roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证时间
|
||||||
|
* @en Authentication timestamp
|
||||||
|
*/
|
||||||
|
get authenticatedAt(): number | null {
|
||||||
|
return this._authenticatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌过期时间
|
||||||
|
* @en Token expiration time
|
||||||
|
*/
|
||||||
|
get expiresAt(): number | null {
|
||||||
|
return this._expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有指定角色
|
||||||
|
* @en Check if has specified role
|
||||||
|
*/
|
||||||
|
hasRole(role: string): boolean {
|
||||||
|
return this._roles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有任一指定角色
|
||||||
|
* @en Check if has any of specified roles
|
||||||
|
*/
|
||||||
|
hasAnyRole(roles: string[]): boolean {
|
||||||
|
return roles.some(role => this._roles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有所有指定角色
|
||||||
|
* @en Check if has all specified roles
|
||||||
|
*/
|
||||||
|
hasAllRoles(roles: string[]): boolean {
|
||||||
|
return roles.every(role => this._roles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置认证结果
|
||||||
|
* @en Set authentication result
|
||||||
|
*/
|
||||||
|
setAuthenticated(result: AuthResult<TUser>): void {
|
||||||
|
if (result.success && result.user) {
|
||||||
|
this._isAuthenticated = true;
|
||||||
|
this._user = result.user;
|
||||||
|
this._userId = this._extractor.getId(result.user);
|
||||||
|
this._roles = this._extractor.getRoles(result.user);
|
||||||
|
this._authenticatedAt = Date.now();
|
||||||
|
this._expiresAt = result.expiresAt ?? null;
|
||||||
|
} else {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清除认证状态
|
||||||
|
* @en Clear authentication state
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._isAuthenticated = false;
|
||||||
|
this._user = null;
|
||||||
|
this._userId = null;
|
||||||
|
this._roles = [];
|
||||||
|
this._authenticatedAt = null;
|
||||||
|
this._expiresAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建访客认证上下文
|
||||||
|
* @en Create guest auth context
|
||||||
|
*/
|
||||||
|
export function createGuestContext<TUser = unknown>(): IAuthContext<TUser> {
|
||||||
|
return new AuthContext<TUser>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从认证结果创建认证上下文
|
||||||
|
* @en Create auth context from auth result
|
||||||
|
*/
|
||||||
|
export function createAuthContext<TUser = unknown>(
|
||||||
|
result: AuthResult<TUser>,
|
||||||
|
extractor?: UserInfoExtractor<TUser>
|
||||||
|
): AuthContext<TUser> {
|
||||||
|
const context = new AuthContext<TUser>(extractor);
|
||||||
|
context.setAuthenticated(result);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
13
packages/framework/server/src/auth/decorators/index.ts
Normal file
13
packages/framework/server/src/auth/decorators/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证装饰器
|
||||||
|
* @en Authentication decorators
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
requireAuth,
|
||||||
|
getAuthMetadata,
|
||||||
|
AUTH_METADATA_KEY,
|
||||||
|
type AuthMetadata
|
||||||
|
} from './requireAuth.js';
|
||||||
|
|
||||||
|
export { requireRole } from './requireRole.js';
|
||||||
86
packages/framework/server/src/auth/decorators/requireAuth.ts
Normal file
86
packages/framework/server/src/auth/decorators/requireAuth.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* @zh requireAuth 装饰器
|
||||||
|
* @en requireAuth decorator
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RequireAuthOptions } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证元数据键
|
||||||
|
* @en Auth metadata key
|
||||||
|
*/
|
||||||
|
export const AUTH_METADATA_KEY = Symbol('authMetadata');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证元数据
|
||||||
|
* @en Auth metadata
|
||||||
|
*/
|
||||||
|
export interface AuthMetadata {
|
||||||
|
requireAuth: boolean;
|
||||||
|
options?: RequireAuthOptions;
|
||||||
|
roles?: string[];
|
||||||
|
roleMode?: 'any' | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取方法的认证元数据
|
||||||
|
* @en Get auth metadata for method
|
||||||
|
*/
|
||||||
|
export function getAuthMetadata(target: any, propertyKey: string): AuthMetadata | undefined {
|
||||||
|
const metadata = target[AUTH_METADATA_KEY] as Map<string, AuthMetadata> | undefined;
|
||||||
|
return metadata?.get(propertyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置方法的认证元数据
|
||||||
|
* @en Set auth metadata for method
|
||||||
|
*/
|
||||||
|
function setAuthMetadata(target: any, propertyKey: string, metadata: AuthMetadata): void {
|
||||||
|
if (!target[AUTH_METADATA_KEY]) {
|
||||||
|
target[AUTH_METADATA_KEY] = new Map<string, AuthMetadata>();
|
||||||
|
}
|
||||||
|
(target[AUTH_METADATA_KEY] as Map<string, AuthMetadata>).set(propertyKey, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 要求认证装饰器
|
||||||
|
* @en Require authentication decorator
|
||||||
|
*
|
||||||
|
* @zh 标记方法需要认证才能访问,用于消息处理器
|
||||||
|
* @en Marks method as requiring authentication, used for message handlers
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class GameRoom extends withRoomAuth(Room) {
|
||||||
|
* @requireAuth()
|
||||||
|
* @onMessage('Trade')
|
||||||
|
* handleTrade(data: TradeData, player: AuthPlayer) {
|
||||||
|
* // Only authenticated players can trade
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireAuth({ allowGuest: true })
|
||||||
|
* @onMessage('Chat')
|
||||||
|
* handleChat(data: ChatData, player: AuthPlayer) {
|
||||||
|
* // Guests can also chat
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function requireAuth(options?: RequireAuthOptions): MethodDecorator {
|
||||||
|
return function (
|
||||||
|
target: any,
|
||||||
|
propertyKey: string | symbol,
|
||||||
|
descriptor: PropertyDescriptor
|
||||||
|
) {
|
||||||
|
const key = String(propertyKey);
|
||||||
|
const existing = getAuthMetadata(target, key);
|
||||||
|
|
||||||
|
setAuthMetadata(target, key, {
|
||||||
|
...existing,
|
||||||
|
requireAuth: true,
|
||||||
|
options
|
||||||
|
});
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
73
packages/framework/server/src/auth/decorators/requireRole.ts
Normal file
73
packages/framework/server/src/auth/decorators/requireRole.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* @zh requireRole 装饰器
|
||||||
|
* @en requireRole decorator
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RequireRoleOptions } from '../types.js';
|
||||||
|
import { AUTH_METADATA_KEY, getAuthMetadata, type AuthMetadata } from './requireAuth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置方法的认证元数据
|
||||||
|
* @en Set auth metadata for method
|
||||||
|
*/
|
||||||
|
function setAuthMetadata(target: any, propertyKey: string, metadata: AuthMetadata): void {
|
||||||
|
if (!target[AUTH_METADATA_KEY]) {
|
||||||
|
target[AUTH_METADATA_KEY] = new Map<string, AuthMetadata>();
|
||||||
|
}
|
||||||
|
(target[AUTH_METADATA_KEY] as Map<string, AuthMetadata>).set(propertyKey, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 要求角色装饰器
|
||||||
|
* @en Require role decorator
|
||||||
|
*
|
||||||
|
* @zh 标记方法需要特定角色才能访问
|
||||||
|
* @en Marks method as requiring specific role(s)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class AdminRoom extends withRoomAuth(Room) {
|
||||||
|
* @requireRole('admin')
|
||||||
|
* @onMessage('Ban')
|
||||||
|
* handleBan(data: BanData, player: AuthPlayer) {
|
||||||
|
* // Only admins can ban
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireRole(['moderator', 'admin'])
|
||||||
|
* @onMessage('Mute')
|
||||||
|
* handleMute(data: MuteData, player: AuthPlayer) {
|
||||||
|
* // Moderators or admins can mute
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireRole(['verified', 'premium'], { mode: 'all' })
|
||||||
|
* @onMessage('SpecialFeature')
|
||||||
|
* handleSpecial(data: any, player: AuthPlayer) {
|
||||||
|
* // Requires both verified AND premium roles
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function requireRole(
|
||||||
|
roles: string | string[],
|
||||||
|
options?: RequireRoleOptions
|
||||||
|
): MethodDecorator {
|
||||||
|
return function (
|
||||||
|
target: any,
|
||||||
|
propertyKey: string | symbol,
|
||||||
|
descriptor: PropertyDescriptor
|
||||||
|
) {
|
||||||
|
const key = String(propertyKey);
|
||||||
|
const existing = getAuthMetadata(target, key);
|
||||||
|
const roleArray = Array.isArray(roles) ? roles : [roles];
|
||||||
|
|
||||||
|
setAuthMetadata(target, key, {
|
||||||
|
...existing,
|
||||||
|
requireAuth: true,
|
||||||
|
roles: roleArray,
|
||||||
|
roleMode: options?.mode ?? 'any',
|
||||||
|
options
|
||||||
|
});
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
129
packages/framework/server/src/auth/index.ts
Normal file
129
packages/framework/server/src/auth/index.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证模块
|
||||||
|
* @en Authentication module
|
||||||
|
*
|
||||||
|
* @zh 为 @esengine/server 提供可插拔的认证系统
|
||||||
|
* @en Provides pluggable authentication system for @esengine/server
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createServer, Room, onMessage } from '@esengine/server';
|
||||||
|
* import {
|
||||||
|
* withAuth,
|
||||||
|
* withRoomAuth,
|
||||||
|
* createJwtAuthProvider,
|
||||||
|
* requireAuth,
|
||||||
|
* requireRole,
|
||||||
|
* type AuthPlayer
|
||||||
|
* } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* // 1. Create auth provider
|
||||||
|
* const jwtProvider = createJwtAuthProvider({
|
||||||
|
* secret: process.env.JWT_SECRET!,
|
||||||
|
* expiresIn: 3600,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 2. Wrap server with auth
|
||||||
|
* const server = withAuth(await createServer({ port: 3000 }), {
|
||||||
|
* provider: jwtProvider,
|
||||||
|
* extractCredentials: (req) => {
|
||||||
|
* const url = new URL(req.url, 'http://localhost');
|
||||||
|
* return url.searchParams.get('token');
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 3. Create auth-enabled room
|
||||||
|
* class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
* requireAuth: true,
|
||||||
|
* allowedRoles: ['player'],
|
||||||
|
* }) {
|
||||||
|
* onJoin(player: AuthPlayer<User>) {
|
||||||
|
* console.log(`${player.user?.name} joined`);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireAuth()
|
||||||
|
* @onMessage('Chat')
|
||||||
|
* handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
* this.broadcast('Chat', { from: player.user?.name, text: data.text });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireRole('admin')
|
||||||
|
* @onMessage('Kick')
|
||||||
|
* handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||||
|
* this.kick(data.playerId);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* server.define('game', GameRoom);
|
||||||
|
* await server.start();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
AuthResult,
|
||||||
|
AuthErrorCode,
|
||||||
|
IAuthProvider,
|
||||||
|
IAuthContext,
|
||||||
|
AuthConnectionData,
|
||||||
|
AuthConnection,
|
||||||
|
AuthApiContext,
|
||||||
|
AuthMsgContext,
|
||||||
|
ConnectionRequest,
|
||||||
|
AuthServerConfig,
|
||||||
|
AuthGameServer,
|
||||||
|
AuthRoomConfig,
|
||||||
|
RequireAuthOptions,
|
||||||
|
RequireRoleOptions
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// Context
|
||||||
|
export {
|
||||||
|
AuthContext,
|
||||||
|
createGuestContext,
|
||||||
|
createAuthContext,
|
||||||
|
defaultUserExtractor,
|
||||||
|
type UserInfoExtractor
|
||||||
|
} from './context.js';
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
export {
|
||||||
|
JwtAuthProvider,
|
||||||
|
createJwtAuthProvider,
|
||||||
|
type JwtAuthConfig,
|
||||||
|
type JwtPayload
|
||||||
|
} from './providers/JwtAuthProvider.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
SessionAuthProvider,
|
||||||
|
createSessionAuthProvider,
|
||||||
|
type SessionAuthConfig,
|
||||||
|
type SessionData,
|
||||||
|
type ISessionStorage
|
||||||
|
} from './providers/SessionAuthProvider.js';
|
||||||
|
|
||||||
|
// Mixins
|
||||||
|
export {
|
||||||
|
withAuth,
|
||||||
|
getAuthContext,
|
||||||
|
setAuthContext,
|
||||||
|
requireAuthentication,
|
||||||
|
requireRole as requireRoleCheck
|
||||||
|
} from './mixin/withAuth.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
withRoomAuth,
|
||||||
|
AuthRoomBase,
|
||||||
|
type AuthPlayer,
|
||||||
|
type IAuthRoom,
|
||||||
|
type AuthRoomClass
|
||||||
|
} from './mixin/withRoomAuth.js';
|
||||||
|
|
||||||
|
// Decorators
|
||||||
|
export {
|
||||||
|
requireAuth,
|
||||||
|
requireRole,
|
||||||
|
getAuthMetadata,
|
||||||
|
AUTH_METADATA_KEY,
|
||||||
|
type AuthMetadata
|
||||||
|
} from './decorators/index.js';
|
||||||
20
packages/framework/server/src/auth/mixin/index.ts
Normal file
20
packages/framework/server/src/auth/mixin/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证 Mixin
|
||||||
|
* @en Authentication mixins
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
withAuth,
|
||||||
|
getAuthContext,
|
||||||
|
setAuthContext,
|
||||||
|
requireAuthentication,
|
||||||
|
requireRole
|
||||||
|
} from './withAuth.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
withRoomAuth,
|
||||||
|
AuthRoomBase,
|
||||||
|
type AuthPlayer,
|
||||||
|
type IAuthRoom,
|
||||||
|
type AuthRoomClass
|
||||||
|
} from './withRoomAuth.js';
|
||||||
221
packages/framework/server/src/auth/mixin/withAuth.ts
Normal file
221
packages/framework/server/src/auth/mixin/withAuth.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* @zh 服务器认证 Mixin
|
||||||
|
* @en Server authentication mixin
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ServerConnection, GameServer } from '../../types/index.js';
|
||||||
|
import type {
|
||||||
|
IAuthProvider,
|
||||||
|
AuthResult,
|
||||||
|
AuthServerConfig,
|
||||||
|
AuthGameServer,
|
||||||
|
IAuthContext,
|
||||||
|
ConnectionRequest
|
||||||
|
} from '../types.js';
|
||||||
|
import { AuthContext } from '../context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证数据键
|
||||||
|
* @en Auth data key
|
||||||
|
*/
|
||||||
|
const AUTH_CONTEXT_KEY = Symbol('authContext');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取连接的认证上下文
|
||||||
|
* @en Get auth context for connection
|
||||||
|
*/
|
||||||
|
export function getAuthContext<TUser = unknown>(conn: ServerConnection): IAuthContext<TUser> | null {
|
||||||
|
const data = conn.data as Record<symbol, unknown>;
|
||||||
|
return (data[AUTH_CONTEXT_KEY] as IAuthContext<TUser>) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置连接的认证上下文
|
||||||
|
* @en Set auth context for connection
|
||||||
|
*/
|
||||||
|
export function setAuthContext<TUser = unknown>(conn: ServerConnection, context: IAuthContext<TUser>): void {
|
||||||
|
const data = conn.data as Record<symbol, unknown>;
|
||||||
|
data[AUTH_CONTEXT_KEY] = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 包装服务器添加认证功能
|
||||||
|
* @en Wrap server with authentication functionality
|
||||||
|
*
|
||||||
|
* @zh 使用 mixin 模式为服务器添加认证能力,不修改原始服务器
|
||||||
|
* @en Uses mixin pattern to add authentication to server without modifying original
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createServer } from '@esengine/server';
|
||||||
|
* import { withAuth, createJwtAuthProvider } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* const jwtProvider = createJwtAuthProvider({
|
||||||
|
* secret: 'your-secret-key',
|
||||||
|
* expiresIn: 3600,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const server = withAuth(await createServer({ port: 3000 }), {
|
||||||
|
* provider: jwtProvider,
|
||||||
|
* extractCredentials: (req) => {
|
||||||
|
* const url = new URL(req.url, 'http://localhost');
|
||||||
|
* return url.searchParams.get('token');
|
||||||
|
* },
|
||||||
|
* onAuthSuccess: (conn, user) => {
|
||||||
|
* console.log(`User ${user.name} authenticated`);
|
||||||
|
* },
|
||||||
|
* onAuthFailure: (conn, error) => {
|
||||||
|
* console.log(`Auth failed: ${error.error}`);
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* await server.start();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function withAuth<TUser = unknown>(
|
||||||
|
server: GameServer,
|
||||||
|
config: AuthServerConfig<TUser>
|
||||||
|
): AuthGameServer<TUser> {
|
||||||
|
const {
|
||||||
|
provider,
|
||||||
|
extractCredentials,
|
||||||
|
autoAuthOnConnect = true,
|
||||||
|
disconnectOnAuthFailure = false,
|
||||||
|
onAuthSuccess,
|
||||||
|
onAuthFailure
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const originalConnections = server.connections;
|
||||||
|
const connectionAuthMap = new WeakMap<ServerConnection, AuthContext<TUser>>();
|
||||||
|
|
||||||
|
const authServer: AuthGameServer<TUser> = {
|
||||||
|
...server,
|
||||||
|
|
||||||
|
get authProvider(): IAuthProvider<TUser> {
|
||||||
|
return provider;
|
||||||
|
},
|
||||||
|
|
||||||
|
async authenticate(
|
||||||
|
conn: ServerConnection,
|
||||||
|
credentials: unknown
|
||||||
|
): Promise<AuthResult<TUser>> {
|
||||||
|
const result = await provider.verify(credentials as never);
|
||||||
|
|
||||||
|
let authContext = connectionAuthMap.get(conn);
|
||||||
|
if (!authContext) {
|
||||||
|
authContext = new AuthContext<TUser>();
|
||||||
|
connectionAuthMap.set(conn, authContext);
|
||||||
|
setAuthContext(conn, authContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
authContext.setAuthenticated(result);
|
||||||
|
await onAuthSuccess?.(conn, result.user!);
|
||||||
|
} else {
|
||||||
|
authContext.clear();
|
||||||
|
await onAuthFailure?.(conn, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAuthContext(conn: ServerConnection): IAuthContext<TUser> | null {
|
||||||
|
return connectionAuthMap.get(conn) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
get connections(): ReadonlyArray<ServerConnection> {
|
||||||
|
return originalConnections;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalOnConnect = (server as any)._onConnect;
|
||||||
|
(server as any)._onConnect = async (conn: ServerConnection, req?: unknown) => {
|
||||||
|
const authContext = new AuthContext<TUser>();
|
||||||
|
connectionAuthMap.set(conn, authContext);
|
||||||
|
setAuthContext(conn, authContext);
|
||||||
|
|
||||||
|
if (autoAuthOnConnect && extractCredentials && req) {
|
||||||
|
try {
|
||||||
|
const connReq = req as ConnectionRequest;
|
||||||
|
const credentials = await extractCredentials(connReq);
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
const result = await provider.verify(credentials as never);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
authContext.setAuthenticated(result);
|
||||||
|
await onAuthSuccess?.(conn, result.user!);
|
||||||
|
} else {
|
||||||
|
await onAuthFailure?.(conn, result);
|
||||||
|
|
||||||
|
if (disconnectOnAuthFailure) {
|
||||||
|
(conn as any).close?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Error during auto-authentication:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalOnConnect) {
|
||||||
|
await originalOnConnect(conn, req);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalOnDisconnect = (server as any)._onDisconnect;
|
||||||
|
(server as any)._onDisconnect = async (conn: ServerConnection) => {
|
||||||
|
connectionAuthMap.delete(conn);
|
||||||
|
|
||||||
|
if (originalOnDisconnect) {
|
||||||
|
await originalOnDisconnect(conn);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return authServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建认证中间件
|
||||||
|
* @en Create authentication middleware
|
||||||
|
*
|
||||||
|
* @zh 用于在 API 处理器中检查认证状态
|
||||||
|
* @en Used to check authentication status in API handlers
|
||||||
|
*/
|
||||||
|
export function requireAuthentication<TUser = unknown>(
|
||||||
|
conn: ServerConnection,
|
||||||
|
options?: { errorMessage?: string }
|
||||||
|
): IAuthContext<TUser> {
|
||||||
|
const auth = getAuthContext<TUser>(conn);
|
||||||
|
|
||||||
|
if (!auth || !auth.isAuthenticated) {
|
||||||
|
throw new Error(options?.errorMessage ?? 'Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建角色检查中间件
|
||||||
|
* @en Create role check middleware
|
||||||
|
*/
|
||||||
|
export function requireRole<TUser = unknown>(
|
||||||
|
conn: ServerConnection,
|
||||||
|
roles: string | string[],
|
||||||
|
options?: { mode?: 'any' | 'all'; errorMessage?: string }
|
||||||
|
): IAuthContext<TUser> {
|
||||||
|
const auth = requireAuthentication<TUser>(conn);
|
||||||
|
const roleArray = Array.isArray(roles) ? roles : [roles];
|
||||||
|
const mode = options?.mode ?? 'any';
|
||||||
|
|
||||||
|
const hasRole = mode === 'any'
|
||||||
|
? auth.hasAnyRole(roleArray)
|
||||||
|
: auth.hasAllRoles(roleArray);
|
||||||
|
|
||||||
|
if (!hasRole) {
|
||||||
|
throw new Error(options?.errorMessage ?? 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
317
packages/framework/server/src/auth/mixin/withRoomAuth.ts
Normal file
317
packages/framework/server/src/auth/mixin/withRoomAuth.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* @zh 房间认证 Mixin
|
||||||
|
* @en Room authentication mixin
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Room, Player } from '../../room/index.js';
|
||||||
|
import type { IAuthContext, AuthRoomConfig } from '../types.js';
|
||||||
|
import { getAuthContext } from './withAuth.js';
|
||||||
|
import { createGuestContext } from '../context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的玩家
|
||||||
|
* @en Player with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthPlayer<TUser = unknown, TData = Record<string, unknown>> extends Player<TData> {
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文
|
||||||
|
* @en Authentication context
|
||||||
|
*/
|
||||||
|
readonly auth: IAuthContext<TUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息(快捷访问)
|
||||||
|
* @en User info (shortcut)
|
||||||
|
*/
|
||||||
|
readonly user: TUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的房间接口
|
||||||
|
* @en Room with authentication interface
|
||||||
|
*/
|
||||||
|
export interface IAuthRoom<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 认证钩子(在 onJoin 之前调用)
|
||||||
|
* @en Auth hook (called before onJoin)
|
||||||
|
*/
|
||||||
|
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取带认证信息的玩家
|
||||||
|
* @en Get player with auth info
|
||||||
|
*/
|
||||||
|
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有带认证信息的玩家
|
||||||
|
* @en Get all players with auth info
|
||||||
|
*/
|
||||||
|
getAuthPlayers(): AuthPlayer<TUser>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按角色获取玩家
|
||||||
|
* @en Get players by role
|
||||||
|
*/
|
||||||
|
getPlayersByRole(role: string): AuthPlayer<TUser>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按用户 ID 获取玩家
|
||||||
|
* @en Get player by user ID
|
||||||
|
*/
|
||||||
|
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证房间构造器类型
|
||||||
|
* @en Auth room constructor type
|
||||||
|
*/
|
||||||
|
export type AuthRoomClass<TUser = unknown> = new (...args: any[]) => Room & IAuthRoom<TUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 玩家认证上下文存储
|
||||||
|
* @en Player auth context storage
|
||||||
|
*/
|
||||||
|
const playerAuthContexts = new WeakMap<Player, IAuthContext<unknown>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 包装玩家对象添加认证信息
|
||||||
|
* @en Wrap player object with auth info
|
||||||
|
*/
|
||||||
|
function wrapPlayerWithAuth<TUser>(player: Player, authContext: IAuthContext<TUser>): AuthPlayer<TUser> {
|
||||||
|
playerAuthContexts.set(player, authContext);
|
||||||
|
|
||||||
|
Object.defineProperty(player, 'auth', {
|
||||||
|
get: () => playerAuthContexts.get(player) ?? createGuestContext<TUser>(),
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(player, 'user', {
|
||||||
|
get: () => (playerAuthContexts.get(player) as IAuthContext<TUser> | undefined)?.user ?? null,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return player as AuthPlayer<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 包装房间类添加认证功能
|
||||||
|
* @en Wrap room class with authentication functionality
|
||||||
|
*
|
||||||
|
* @zh 使用 mixin 模式为房间添加认证检查,在玩家加入前验证认证状态
|
||||||
|
* @en Uses mixin pattern to add auth checks to room, validates auth before player joins
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { Room, onMessage } from '@esengine/server';
|
||||||
|
* import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* interface User {
|
||||||
|
* id: string;
|
||||||
|
* name: string;
|
||||||
|
* roles: string[];
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
* requireAuth: true,
|
||||||
|
* allowedRoles: ['player', 'premium'],
|
||||||
|
* }) {
|
||||||
|
* onJoin(player: AuthPlayer<User>) {
|
||||||
|
* console.log(`${player.user?.name} joined the game`);
|
||||||
|
* this.broadcast('PlayerJoined', {
|
||||||
|
* id: player.id,
|
||||||
|
* name: player.user?.name
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Optional: custom auth validation
|
||||||
|
* async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||||
|
* // Additional validation logic
|
||||||
|
* if (player.auth.hasRole('banned')) {
|
||||||
|
* return false;
|
||||||
|
* }
|
||||||
|
* return true;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @onMessage('Chat')
|
||||||
|
* handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
* this.broadcast('Chat', {
|
||||||
|
* from: player.user?.name ?? 'Guest',
|
||||||
|
* text: data.text
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[]) => Room = new (...args: any[]) => Room>(
|
||||||
|
Base: TBase,
|
||||||
|
config: AuthRoomConfig = {}
|
||||||
|
): TBase & (new (...args: any[]) => IAuthRoom<TUser>) {
|
||||||
|
const {
|
||||||
|
requireAuth = true,
|
||||||
|
allowedRoles = [],
|
||||||
|
roleCheckMode = 'any'
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
abstract class AuthRoom extends (Base as new (...args: any[]) => Room) implements IAuthRoom<TUser> {
|
||||||
|
private _originalOnJoin: ((player: Player) => void | Promise<void>) | undefined;
|
||||||
|
|
||||||
|
constructor(...args: any[]) {
|
||||||
|
super(...args);
|
||||||
|
this._originalOnJoin = this.onJoin?.bind(this);
|
||||||
|
this.onJoin = this._authOnJoin.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证钩子(可覆盖)
|
||||||
|
* @en Auth hook (can be overridden)
|
||||||
|
*/
|
||||||
|
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 包装的 onJoin 方法
|
||||||
|
* @en Wrapped onJoin method
|
||||||
|
*/
|
||||||
|
private async _authOnJoin(player: Player): Promise<void> {
|
||||||
|
const conn = (player as any).connection ?? (player as any)._conn;
|
||||||
|
const authContext = conn
|
||||||
|
? (getAuthContext<TUser>(conn) ?? createGuestContext<TUser>())
|
||||||
|
: createGuestContext<TUser>();
|
||||||
|
|
||||||
|
if (requireAuth && !authContext.isAuthenticated) {
|
||||||
|
console.warn(`[AuthRoom] Rejected unauthenticated player: ${player.id}`);
|
||||||
|
this.kick(player as any, 'Authentication required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedRoles.length > 0) {
|
||||||
|
const hasRole = roleCheckMode === 'any'
|
||||||
|
? authContext.hasAnyRole(allowedRoles)
|
||||||
|
: authContext.hasAllRoles(allowedRoles);
|
||||||
|
|
||||||
|
if (!hasRole) {
|
||||||
|
console.warn(`[AuthRoom] Rejected player ${player.id}: insufficient roles`);
|
||||||
|
this.kick(player as any, 'Insufficient permissions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authPlayer = wrapPlayerWithAuth<TUser>(player, authContext);
|
||||||
|
|
||||||
|
if (typeof this.onAuth === 'function') {
|
||||||
|
try {
|
||||||
|
const allowed = await this.onAuth(authPlayer);
|
||||||
|
if (!allowed) {
|
||||||
|
console.warn(`[AuthRoom] Rejected player ${player.id}: onAuth returned false`);
|
||||||
|
this.kick(player as any, 'Authentication rejected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AuthRoom] Error in onAuth for player ${player.id}:`, error);
|
||||||
|
this.kick(player as any, 'Authentication error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._originalOnJoin) {
|
||||||
|
await this._originalOnJoin(authPlayer as unknown as Player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取带认证信息的玩家
|
||||||
|
* @en Get player with auth info
|
||||||
|
*/
|
||||||
|
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined {
|
||||||
|
const player = this.getPlayer(id);
|
||||||
|
return player as AuthPlayer<TUser> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有带认证信息的玩家
|
||||||
|
* @en Get all players with auth info
|
||||||
|
*/
|
||||||
|
getAuthPlayers(): AuthPlayer<TUser>[] {
|
||||||
|
return this.players as AuthPlayer<TUser>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按角色获取玩家
|
||||||
|
* @en Get players by role
|
||||||
|
*/
|
||||||
|
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
|
||||||
|
return this.getAuthPlayers().filter(p => p.auth?.hasRole(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按用户 ID 获取玩家
|
||||||
|
* @en Get player by user ID
|
||||||
|
*/
|
||||||
|
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
|
||||||
|
return this.getAuthPlayers().find(p => p.auth?.userId === userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthRoom as unknown as TBase & (new (...args: any[]) => IAuthRoom<TUser>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 抽象认证房间基类
|
||||||
|
* @en Abstract auth room base class
|
||||||
|
*
|
||||||
|
* @zh 如果不想使用 mixin,可以直接继承此类
|
||||||
|
* @en If you don't want to use mixin, you can extend this class directly
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { AuthRoomBase } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* class GameRoom extends AuthRoomBase<User> {
|
||||||
|
* protected readonly authConfig = {
|
||||||
|
* requireAuth: true,
|
||||||
|
* allowedRoles: ['player']
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* onJoin(player: AuthPlayer<User>) {
|
||||||
|
* // player has .auth and .user properties
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export abstract class AuthRoomBase<TUser = unknown, TState = any, TPlayerData = Record<string, unknown>>
|
||||||
|
implements IAuthRoom<TUser> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证配置(子类可覆盖)
|
||||||
|
* @en Auth config (can be overridden by subclass)
|
||||||
|
*/
|
||||||
|
protected readonly authConfig: AuthRoomConfig = {
|
||||||
|
requireAuth: true,
|
||||||
|
allowedRoles: [],
|
||||||
|
roleCheckMode: 'any'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证钩子
|
||||||
|
* @en Auth hook
|
||||||
|
*/
|
||||||
|
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthPlayers(): AuthPlayer<TUser>[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证提供者接口
|
||||||
|
* @en Authentication provider interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||||
253
packages/framework/server/src/auth/providers/JwtAuthProvider.ts
Normal file
253
packages/framework/server/src/auth/providers/JwtAuthProvider.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* @zh JWT 认证提供者
|
||||||
|
* @en JWT authentication provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh JWT 载荷
|
||||||
|
* @en JWT payload
|
||||||
|
*/
|
||||||
|
export interface JwtPayload {
|
||||||
|
/**
|
||||||
|
* @zh 主题(用户 ID)
|
||||||
|
* @en Subject (user ID)
|
||||||
|
*/
|
||||||
|
sub?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 签发时间
|
||||||
|
* @en Issued at
|
||||||
|
*/
|
||||||
|
iat?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 过期时间
|
||||||
|
* @en Expiration time
|
||||||
|
*/
|
||||||
|
exp?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 自定义字段
|
||||||
|
* @en Custom fields
|
||||||
|
*/
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh JWT 认证配置
|
||||||
|
* @en JWT authentication configuration
|
||||||
|
*/
|
||||||
|
export interface JwtAuthConfig<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 密钥
|
||||||
|
* @en Secret key
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 算法
|
||||||
|
* @en Algorithm
|
||||||
|
* @defaultValue 'HS256'
|
||||||
|
*/
|
||||||
|
algorithm?: 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌过期时间(秒)
|
||||||
|
* @en Token expiration in seconds
|
||||||
|
* @defaultValue 3600
|
||||||
|
*/
|
||||||
|
expiresIn?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从载荷获取用户信息
|
||||||
|
* @en Get user from payload
|
||||||
|
*/
|
||||||
|
getUser?: (payload: JwtPayload) => TUser | Promise<TUser | null> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 签发者
|
||||||
|
* @en Issuer
|
||||||
|
*/
|
||||||
|
issuer?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 受众
|
||||||
|
* @en Audience
|
||||||
|
*/
|
||||||
|
audience?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh JWT 认证提供者
|
||||||
|
* @en JWT authentication provider
|
||||||
|
*
|
||||||
|
* @zh 使用 jsonwebtoken 库实现 JWT 认证
|
||||||
|
* @en Uses jsonwebtoken library for JWT authentication
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const jwtProvider = createJwtAuthProvider({
|
||||||
|
* secret: 'your-secret-key',
|
||||||
|
* expiresIn: 3600,
|
||||||
|
* getUser: async (payload) => {
|
||||||
|
* return await db.users.findById(payload.sub);
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class JwtAuthProvider<TUser = unknown> implements IAuthProvider<TUser, string> {
|
||||||
|
readonly name = 'jwt';
|
||||||
|
|
||||||
|
private _config: Required<Pick<JwtAuthConfig<TUser>, 'secret' | 'algorithm' | 'expiresIn'>> & JwtAuthConfig<TUser>;
|
||||||
|
|
||||||
|
constructor(config: JwtAuthConfig<TUser>) {
|
||||||
|
this._config = {
|
||||||
|
algorithm: 'HS256',
|
||||||
|
expiresIn: 3600,
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证令牌
|
||||||
|
* @en Verify token
|
||||||
|
*/
|
||||||
|
async verify(token: string): Promise<AuthResult<TUser>> {
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Token is required',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verifyOptions: jwt.VerifyOptions = {
|
||||||
|
algorithms: [this._config.algorithm]
|
||||||
|
};
|
||||||
|
if (this._config.issuer) {
|
||||||
|
verifyOptions.issuer = this._config.issuer;
|
||||||
|
}
|
||||||
|
if (this._config.audience) {
|
||||||
|
verifyOptions.audience = this._config.audience;
|
||||||
|
}
|
||||||
|
const payload = jwt.verify(token, this._config.secret, verifyOptions) as JwtPayload;
|
||||||
|
|
||||||
|
let user: TUser | null = null;
|
||||||
|
|
||||||
|
if (this._config.getUser) {
|
||||||
|
user = await this._config.getUser(payload);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'User not found',
|
||||||
|
errorCode: 'USER_NOT_FOUND'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user = payload as unknown as TUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
expiresAt: payload.exp ? payload.exp * 1000 : undefined
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Token has expired',
|
||||||
|
errorCode: 'EXPIRED_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message || 'Invalid token',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 刷新令牌
|
||||||
|
* @en Refresh token
|
||||||
|
*/
|
||||||
|
async refresh(token: string): Promise<AuthResult<TUser>> {
|
||||||
|
const result = await this.verify(token);
|
||||||
|
|
||||||
|
if (!result.success || !result.user) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = jwt.decode(token) as JwtPayload;
|
||||||
|
|
||||||
|
// Remove JWT standard claims that will be regenerated
|
||||||
|
const { iat, exp, nbf, ...restPayload } = payload;
|
||||||
|
|
||||||
|
const newToken = this.sign(restPayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: result.user,
|
||||||
|
token: newToken,
|
||||||
|
expiresAt: Date.now() + this._config.expiresIn * 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 生成令牌
|
||||||
|
* @en Generate token
|
||||||
|
*/
|
||||||
|
sign(payload: Record<string, unknown>): string {
|
||||||
|
const signOptions: jwt.SignOptions = {
|
||||||
|
algorithm: this._config.algorithm,
|
||||||
|
expiresIn: this._config.expiresIn
|
||||||
|
};
|
||||||
|
if (this._config.issuer) {
|
||||||
|
signOptions.issuer = this._config.issuer;
|
||||||
|
}
|
||||||
|
if (this._config.audience) {
|
||||||
|
signOptions.audience = this._config.audience;
|
||||||
|
}
|
||||||
|
return jwt.sign(payload, this._config.secret, signOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 解码令牌(不验证)
|
||||||
|
* @en Decode token (without verification)
|
||||||
|
*/
|
||||||
|
decode(token: string): JwtPayload | null {
|
||||||
|
return jwt.decode(token) as JwtPayload | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 JWT 认证提供者
|
||||||
|
* @en Create JWT authentication provider
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createJwtAuthProvider } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* const jwtProvider = createJwtAuthProvider({
|
||||||
|
* secret: process.env.JWT_SECRET!,
|
||||||
|
* expiresIn: 3600,
|
||||||
|
* getUser: async (payload) => {
|
||||||
|
* return { id: payload.sub, name: payload.name };
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createJwtAuthProvider<TUser = unknown>(
|
||||||
|
config: JwtAuthConfig<TUser>
|
||||||
|
): JwtAuthProvider<TUser> {
|
||||||
|
return new JwtAuthProvider<TUser>(config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* @zh Session 认证提供者
|
||||||
|
* @en Session authentication provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import type { IAuthProvider, AuthResult } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 存储接口(兼容 ITransactionStorage)
|
||||||
|
* @en Session storage interface (compatible with ITransactionStorage)
|
||||||
|
*/
|
||||||
|
export interface ISessionStorage {
|
||||||
|
get<T>(key: string): Promise<T | null>;
|
||||||
|
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||||
|
delete(key: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 数据
|
||||||
|
* @en Session data
|
||||||
|
*/
|
||||||
|
export interface SessionData<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 用户信息
|
||||||
|
* @en User information
|
||||||
|
*/
|
||||||
|
user: TUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建时间
|
||||||
|
* @en Created at
|
||||||
|
*/
|
||||||
|
createdAt: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 最后活跃时间
|
||||||
|
* @en Last active at
|
||||||
|
*/
|
||||||
|
lastActiveAt: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 自定义数据
|
||||||
|
* @en Custom data
|
||||||
|
*/
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 认证配置
|
||||||
|
* @en Session authentication configuration
|
||||||
|
*/
|
||||||
|
export interface SessionAuthConfig<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh Session 存储
|
||||||
|
* @en Session storage
|
||||||
|
*/
|
||||||
|
storage: ISessionStorage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 过期时间(毫秒)
|
||||||
|
* @en Session expiration in milliseconds
|
||||||
|
* @defaultValue 86400000 (24h)
|
||||||
|
*/
|
||||||
|
sessionTTL?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 键前缀
|
||||||
|
* @en Session key prefix
|
||||||
|
* @defaultValue 'session:'
|
||||||
|
*/
|
||||||
|
prefix?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证用户(可选额外验证)
|
||||||
|
* @en Validate user (optional extra validation)
|
||||||
|
*/
|
||||||
|
validateUser?: (user: TUser) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否自动续期
|
||||||
|
* @en Auto renew session
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
autoRenew?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 认证提供者
|
||||||
|
* @en Session authentication provider
|
||||||
|
*
|
||||||
|
* @zh 基于存储的会话认证,支持 Redis、MongoDB 等后端
|
||||||
|
* @en Storage-based session authentication, supports Redis, MongoDB, etc.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createSessionAuthProvider } from '@esengine/server/auth';
|
||||||
|
* import { createRedisStorage } from '@esengine/transaction';
|
||||||
|
*
|
||||||
|
* const sessionProvider = createSessionAuthProvider({
|
||||||
|
* storage: createRedisStorage({ factory: () => new Redis() }),
|
||||||
|
* sessionTTL: 86400000, // 24 hours
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class SessionAuthProvider<TUser = unknown> implements IAuthProvider<TUser, string> {
|
||||||
|
readonly name = 'session';
|
||||||
|
|
||||||
|
private _config: Required<Pick<SessionAuthConfig<TUser>, 'sessionTTL' | 'prefix' | 'autoRenew'>> & SessionAuthConfig<TUser>;
|
||||||
|
|
||||||
|
constructor(config: SessionAuthConfig<TUser>) {
|
||||||
|
this._config = {
|
||||||
|
sessionTTL: 86400000,
|
||||||
|
prefix: 'session:',
|
||||||
|
autoRenew: true,
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取存储键
|
||||||
|
* @en Get storage key
|
||||||
|
*/
|
||||||
|
private _getKey(sessionId: string): string {
|
||||||
|
return `${this._config.prefix}${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证 Session
|
||||||
|
* @en Verify session
|
||||||
|
*/
|
||||||
|
async verify(sessionId: string): Promise<AuthResult<TUser>> {
|
||||||
|
if (!sessionId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Session ID is required',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Session not found or expired',
|
||||||
|
errorCode: 'EXPIRED_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._config.validateUser) {
|
||||||
|
const isValid = await this._config.validateUser(session.user);
|
||||||
|
if (!isValid) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'User validation failed',
|
||||||
|
errorCode: 'ACCOUNT_DISABLED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._config.autoRenew) {
|
||||||
|
session.lastActiveAt = Date.now();
|
||||||
|
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: session.user,
|
||||||
|
token: sessionId,
|
||||||
|
expiresAt: session.createdAt + this._config.sessionTTL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 刷新 Session
|
||||||
|
* @en Refresh session
|
||||||
|
*/
|
||||||
|
async refresh(sessionId: string): Promise<AuthResult<TUser>> {
|
||||||
|
const result = await this.verify(sessionId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
session.lastActiveAt = Date.now();
|
||||||
|
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
expiresAt: Date.now() + this._config.sessionTTL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 撤销 Session
|
||||||
|
* @en Revoke session
|
||||||
|
*/
|
||||||
|
async revoke(sessionId: string): Promise<boolean> {
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
return await this._config.storage.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 Session
|
||||||
|
* @en Create session
|
||||||
|
*/
|
||||||
|
async createSession(user: TUser, data?: Record<string, unknown>): Promise<string> {
|
||||||
|
const sessionId = this._generateSessionId();
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
|
||||||
|
const session: SessionData<TUser> = {
|
||||||
|
user,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastActiveAt: Date.now(),
|
||||||
|
data
|
||||||
|
};
|
||||||
|
|
||||||
|
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取 Session 数据
|
||||||
|
* @en Get session data
|
||||||
|
*/
|
||||||
|
async getSession(sessionId: string): Promise<SessionData<TUser> | null> {
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
return await this._config.storage.get<SessionData<TUser>>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 更新 Session 数据
|
||||||
|
* @en Update session data
|
||||||
|
*/
|
||||||
|
async updateSession(sessionId: string, data: Record<string, unknown>): Promise<boolean> {
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.data = { ...session.data, ...data };
|
||||||
|
session.lastActiveAt = Date.now();
|
||||||
|
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 生成 Session ID(使用加密安全的随机数)
|
||||||
|
* @en Generate session ID (using cryptographically secure random)
|
||||||
|
*/
|
||||||
|
private _generateSessionId(): string {
|
||||||
|
return randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 Session 认证提供者
|
||||||
|
* @en Create session authentication provider
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createSessionAuthProvider } from '@esengine/server/auth';
|
||||||
|
* import { MemoryStorage } from '@esengine/transaction';
|
||||||
|
*
|
||||||
|
* const sessionProvider = createSessionAuthProvider({
|
||||||
|
* storage: new MemoryStorage(),
|
||||||
|
* sessionTTL: 3600000, // 1 hour
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Create a session
|
||||||
|
* const sessionId = await sessionProvider.createSession({ id: '1', name: 'Alice' });
|
||||||
|
*
|
||||||
|
* // Verify session
|
||||||
|
* const result = await sessionProvider.verify(sessionId);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createSessionAuthProvider<TUser = unknown>(
|
||||||
|
config: SessionAuthConfig<TUser>
|
||||||
|
): SessionAuthProvider<TUser> {
|
||||||
|
return new SessionAuthProvider<TUser>(config);
|
||||||
|
}
|
||||||
8
packages/framework/server/src/auth/providers/index.ts
Normal file
8
packages/framework/server/src/auth/providers/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证提供者
|
||||||
|
* @en Authentication providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { IAuthProvider, AuthResult, AuthErrorCode } from './IAuthProvider.js';
|
||||||
|
export { JwtAuthProvider, createJwtAuthProvider, type JwtAuthConfig, type JwtPayload } from './JwtAuthProvider.js';
|
||||||
|
export { SessionAuthProvider, createSessionAuthProvider, type SessionAuthConfig, type SessionData, type ISessionStorage } from './SessionAuthProvider.js';
|
||||||
278
packages/framework/server/src/auth/testing/MockAuthProvider.ts
Normal file
278
packages/framework/server/src/auth/testing/MockAuthProvider.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* @zh 模拟认证提供者
|
||||||
|
* @en Mock authentication provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟用户
|
||||||
|
* @en Mock user
|
||||||
|
*/
|
||||||
|
export interface MockUser {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
roles?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟认证配置
|
||||||
|
* @en Mock authentication configuration
|
||||||
|
*/
|
||||||
|
export interface MockAuthConfig {
|
||||||
|
/**
|
||||||
|
* @zh 预设用户列表
|
||||||
|
* @en Preset user list
|
||||||
|
*/
|
||||||
|
users?: MockUser[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认用户(无 token 时返回)
|
||||||
|
* @en Default user (returned when no token)
|
||||||
|
*/
|
||||||
|
defaultUser?: MockUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟延迟(毫秒)
|
||||||
|
* @en Simulated delay (ms)
|
||||||
|
*/
|
||||||
|
delay?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否自动创建用户
|
||||||
|
* @en Auto create users
|
||||||
|
*/
|
||||||
|
autoCreate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证令牌格式
|
||||||
|
* @en Token format validator
|
||||||
|
*/
|
||||||
|
validateToken?: (token: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟认证提供者
|
||||||
|
* @en Mock authentication provider
|
||||||
|
*
|
||||||
|
* @zh 用于测试的认证提供者,不需要真实的 JWT 或数据库
|
||||||
|
* @en Authentication provider for testing, no real JWT or database required
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createMockAuthProvider } from '@esengine/server/auth/testing';
|
||||||
|
*
|
||||||
|
* const mockProvider = createMockAuthProvider({
|
||||||
|
* users: [
|
||||||
|
* { id: '1', name: 'Alice', roles: ['player'] },
|
||||||
|
* { id: '2', name: 'Bob', roles: ['admin'] },
|
||||||
|
* ],
|
||||||
|
* autoCreate: true, // Unknown tokens create guest users
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Verify with user ID as token
|
||||||
|
* const result = await mockProvider.verify('1');
|
||||||
|
* // result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class MockAuthProvider<TUser extends MockUser = MockUser>
|
||||||
|
implements IAuthProvider<TUser, string> {
|
||||||
|
|
||||||
|
readonly name = 'mock';
|
||||||
|
|
||||||
|
private _users: Map<string, TUser>;
|
||||||
|
private _config: MockAuthConfig;
|
||||||
|
private _revokedTokens: Set<string> = new Set();
|
||||||
|
|
||||||
|
constructor(config: MockAuthConfig = {}) {
|
||||||
|
this._config = config;
|
||||||
|
this._users = new Map();
|
||||||
|
|
||||||
|
if (config.users) {
|
||||||
|
for (const user of config.users) {
|
||||||
|
this._users.set(user.id, user as TUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟延迟
|
||||||
|
* @en Simulate delay
|
||||||
|
*/
|
||||||
|
private async _delay(): Promise<void> {
|
||||||
|
if (this._config.delay && this._config.delay > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, this._config.delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证令牌
|
||||||
|
* @en Verify token
|
||||||
|
*/
|
||||||
|
async verify(token: string): Promise<AuthResult<TUser>> {
|
||||||
|
await this._delay();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (this._config.defaultUser) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: this._config.defaultUser as TUser,
|
||||||
|
token: 'default'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Token is required',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._revokedTokens.has(token)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Token has been revoked',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._config.validateToken && !this._config.validateToken(token)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid token format',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this._users.get(token);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + 3600000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._config.autoCreate) {
|
||||||
|
const newUser = {
|
||||||
|
id: token,
|
||||||
|
name: `User_${token}`,
|
||||||
|
roles: ['guest']
|
||||||
|
} as TUser;
|
||||||
|
|
||||||
|
this._users.set(token, newUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: newUser,
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + 3600000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'User not found',
|
||||||
|
errorCode: 'USER_NOT_FOUND'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 刷新令牌
|
||||||
|
* @en Refresh token
|
||||||
|
*/
|
||||||
|
async refresh(token: string): Promise<AuthResult<TUser>> {
|
||||||
|
return this.verify(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 撤销令牌
|
||||||
|
* @en Revoke token
|
||||||
|
*/
|
||||||
|
async revoke(token: string): Promise<boolean> {
|
||||||
|
this._revokedTokens.add(token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 添加用户
|
||||||
|
* @en Add user
|
||||||
|
*/
|
||||||
|
addUser(user: TUser): void {
|
||||||
|
this._users.set(user.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移除用户
|
||||||
|
* @en Remove user
|
||||||
|
*/
|
||||||
|
removeUser(id: string): boolean {
|
||||||
|
return this._users.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取用户
|
||||||
|
* @en Get user
|
||||||
|
*/
|
||||||
|
getUser(id: string): TUser | undefined {
|
||||||
|
return this._users.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有用户
|
||||||
|
* @en Get all users
|
||||||
|
*/
|
||||||
|
getUsers(): TUser[] {
|
||||||
|
return Array.from(this._users.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清空所有状态
|
||||||
|
* @en Clear all state
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._users.clear();
|
||||||
|
this._revokedTokens.clear();
|
||||||
|
|
||||||
|
if (this._config.users) {
|
||||||
|
for (const user of this._config.users) {
|
||||||
|
this._users.set(user.id, user as TUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 生成测试令牌
|
||||||
|
* @en Generate test token
|
||||||
|
*/
|
||||||
|
generateToken(userId: string): string {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建模拟认证提供者
|
||||||
|
* @en Create mock authentication provider
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const provider = createMockAuthProvider({
|
||||||
|
* users: [
|
||||||
|
* { id: 'admin', name: 'Admin', roles: ['admin'] }
|
||||||
|
* ]
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Use in tests
|
||||||
|
* const server = withAuth(await createServer({ port: 0 }), {
|
||||||
|
* provider,
|
||||||
|
* extractCredentials: (req) => req.headers['x-token']
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createMockAuthProvider<TUser extends MockUser = MockUser>(
|
||||||
|
config?: MockAuthConfig
|
||||||
|
): MockAuthProvider<TUser> {
|
||||||
|
return new MockAuthProvider<TUser>(config);
|
||||||
|
}
|
||||||
11
packages/framework/server/src/auth/testing/index.ts
Normal file
11
packages/framework/server/src/auth/testing/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证测试工具
|
||||||
|
* @en Authentication testing utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
MockAuthProvider,
|
||||||
|
createMockAuthProvider,
|
||||||
|
type MockUser,
|
||||||
|
type MockAuthConfig
|
||||||
|
} from './MockAuthProvider.js';
|
||||||
400
packages/framework/server/src/auth/types.ts
Normal file
400
packages/framework/server/src/auth/types.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证系统类型定义
|
||||||
|
* @en Authentication system type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ServerConnection, ApiContext, MsgContext, GameServer } from '../types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Codes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证错误代码
|
||||||
|
* @en Authentication error codes
|
||||||
|
*/
|
||||||
|
export type AuthErrorCode =
|
||||||
|
| 'INVALID_CREDENTIALS'
|
||||||
|
| 'EXPIRED_TOKEN'
|
||||||
|
| 'INVALID_TOKEN'
|
||||||
|
| 'USER_NOT_FOUND'
|
||||||
|
| 'ACCOUNT_DISABLED'
|
||||||
|
| 'RATE_LIMITED'
|
||||||
|
| 'INSUFFICIENT_PERMISSIONS';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Result
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证结果
|
||||||
|
* @en Authentication result
|
||||||
|
*/
|
||||||
|
export interface AuthResult<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 是否成功
|
||||||
|
* @en Whether succeeded
|
||||||
|
*/
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息
|
||||||
|
* @en User information
|
||||||
|
*/
|
||||||
|
user?: TUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 错误信息
|
||||||
|
* @en Error message
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 错误代码
|
||||||
|
* @en Error code
|
||||||
|
*/
|
||||||
|
errorCode?: AuthErrorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌
|
||||||
|
* @en Token
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌过期时间(时间戳)
|
||||||
|
* @en Token expiration time (timestamp)
|
||||||
|
*/
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Provider
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证提供者接口
|
||||||
|
* @en Authentication provider interface
|
||||||
|
*/
|
||||||
|
export interface IAuthProvider<TUser = unknown, TCredentials = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 提供者名称
|
||||||
|
* @en Provider name
|
||||||
|
*/
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证凭证
|
||||||
|
* @en Verify credentials
|
||||||
|
*
|
||||||
|
* @param credentials - @zh 凭证数据 @en Credential data
|
||||||
|
* @returns @zh 验证结果 @en Verification result
|
||||||
|
*/
|
||||||
|
verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 刷新令牌(可选)
|
||||||
|
* @en Refresh token (optional)
|
||||||
|
*/
|
||||||
|
refresh?(token: string): Promise<AuthResult<TUser>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 撤销令牌(可选)
|
||||||
|
* @en Revoke token (optional)
|
||||||
|
*/
|
||||||
|
revoke?(token: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文接口
|
||||||
|
* @en Authentication context interface
|
||||||
|
*/
|
||||||
|
export interface IAuthContext<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 是否已认证
|
||||||
|
* @en Whether authenticated
|
||||||
|
*/
|
||||||
|
readonly isAuthenticated: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息
|
||||||
|
* @en User information
|
||||||
|
*/
|
||||||
|
readonly user: TUser | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户 ID
|
||||||
|
* @en User ID
|
||||||
|
*/
|
||||||
|
readonly userId: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户角色
|
||||||
|
* @en User roles
|
||||||
|
*/
|
||||||
|
readonly roles: ReadonlyArray<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证时间
|
||||||
|
* @en Authentication timestamp
|
||||||
|
*/
|
||||||
|
readonly authenticatedAt: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌过期时间
|
||||||
|
* @en Token expiration time
|
||||||
|
*/
|
||||||
|
readonly expiresAt: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有指定角色
|
||||||
|
* @en Check if has specified role
|
||||||
|
*/
|
||||||
|
hasRole(role: string): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有任一指定角色
|
||||||
|
* @en Check if has any of specified roles
|
||||||
|
*/
|
||||||
|
hasAnyRole(roles: string[]): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有所有指定角色
|
||||||
|
* @en Check if has all specified roles
|
||||||
|
*/
|
||||||
|
hasAllRoles(roles: string[]): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Connection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证连接数据
|
||||||
|
* @en Authentication connection data
|
||||||
|
*/
|
||||||
|
export interface AuthConnectionData<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文
|
||||||
|
* @en Authentication context
|
||||||
|
*/
|
||||||
|
auth: IAuthContext<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的连接
|
||||||
|
* @en Connection with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthConnection<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||||
|
extends ServerConnection<TData> {
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文(快捷访问)
|
||||||
|
* @en Authentication context (shortcut)
|
||||||
|
*/
|
||||||
|
readonly auth: IAuthContext<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的 API 上下文
|
||||||
|
* @en API context with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthApiContext<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||||
|
extends ApiContext<TData> {
|
||||||
|
/**
|
||||||
|
* @zh 当前连接(带认证)
|
||||||
|
* @en Current connection (with auth)
|
||||||
|
*/
|
||||||
|
conn: AuthConnection<TUser, TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的消息上下文
|
||||||
|
* @en Message context with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthMsgContext<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||||
|
extends MsgContext<TData> {
|
||||||
|
/**
|
||||||
|
* @zh 当前连接(带认证)
|
||||||
|
* @en Current connection (with auth)
|
||||||
|
*/
|
||||||
|
conn: AuthConnection<TUser, TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Server Config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接请求信息
|
||||||
|
* @en Connection request info
|
||||||
|
*/
|
||||||
|
export interface ConnectionRequest {
|
||||||
|
/**
|
||||||
|
* @zh 请求 URL
|
||||||
|
* @en Request URL
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 请求头
|
||||||
|
* @en Request headers
|
||||||
|
*/
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 客户端 IP
|
||||||
|
* @en Client IP
|
||||||
|
*/
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证服务器配置
|
||||||
|
* @en Authentication server configuration
|
||||||
|
*/
|
||||||
|
export interface AuthServerConfig<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 认证提供者
|
||||||
|
* @en Authentication provider
|
||||||
|
*/
|
||||||
|
provider: IAuthProvider<TUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从连接请求提取凭证
|
||||||
|
* @en Extract credentials from connection request
|
||||||
|
*/
|
||||||
|
extractCredentials?: (req: ConnectionRequest) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接时自动认证
|
||||||
|
* @en Auto authenticate on connection
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
autoAuthOnConnect?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 未认证连接的宽限期(毫秒)
|
||||||
|
* @en Grace period for unauthenticated connections (ms)
|
||||||
|
* @defaultValue 30000
|
||||||
|
*/
|
||||||
|
authGracePeriod?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证失败是否断开连接
|
||||||
|
* @en Disconnect on auth failure
|
||||||
|
* @defaultValue false
|
||||||
|
*/
|
||||||
|
disconnectOnAuthFailure?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证成功回调
|
||||||
|
* @en Authentication success callback
|
||||||
|
*/
|
||||||
|
onAuthSuccess?: (conn: ServerConnection, user: TUser) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证失败回调
|
||||||
|
* @en Authentication failure callback
|
||||||
|
*/
|
||||||
|
onAuthFailure?: (conn: ServerConnection, error: AuthResult<TUser>) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Game Server
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的游戏服务器
|
||||||
|
* @en Game server with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthGameServer<TUser = unknown> extends GameServer {
|
||||||
|
/**
|
||||||
|
* @zh 认证提供者
|
||||||
|
* @en Authentication provider
|
||||||
|
*/
|
||||||
|
readonly authProvider: IAuthProvider<TUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 手动认证连接
|
||||||
|
* @en Manually authenticate connection
|
||||||
|
*/
|
||||||
|
authenticate(
|
||||||
|
conn: ServerConnection,
|
||||||
|
credentials: unknown
|
||||||
|
): Promise<AuthResult<TUser>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取连接的认证上下文
|
||||||
|
* @en Get auth context for connection
|
||||||
|
*/
|
||||||
|
getAuthContext(conn: ServerConnection): IAuthContext<TUser> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Room Config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的房间配置
|
||||||
|
* @en Auth room configuration
|
||||||
|
*/
|
||||||
|
export interface AuthRoomConfig {
|
||||||
|
/**
|
||||||
|
* @zh 是否要求认证才能加入
|
||||||
|
* @en Require authentication to join
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
requireAuth?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 允许的角色(空数组表示任意角色)
|
||||||
|
* @en Allowed roles (empty array means any role)
|
||||||
|
*/
|
||||||
|
allowedRoles?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 角色检查模式
|
||||||
|
* @en Role check mode
|
||||||
|
* @defaultValue 'any'
|
||||||
|
*/
|
||||||
|
roleCheckMode?: 'any' | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Decorator Options
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh requireAuth 装饰器选项
|
||||||
|
* @en requireAuth decorator options
|
||||||
|
*/
|
||||||
|
export interface RequireAuthOptions {
|
||||||
|
/**
|
||||||
|
* @zh 认证失败时的错误消息
|
||||||
|
* @en Error message on auth failure
|
||||||
|
*/
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否允许访客
|
||||||
|
* @en Allow guest access
|
||||||
|
*/
|
||||||
|
allowGuest?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh requireRole 装饰器选项
|
||||||
|
* @en requireRole decorator options
|
||||||
|
*/
|
||||||
|
export interface RequireRoleOptions extends RequireAuthOptions {
|
||||||
|
/**
|
||||||
|
* @zh 角色检查模式
|
||||||
|
* @en Role check mode
|
||||||
|
* @defaultValue 'any'
|
||||||
|
*/
|
||||||
|
mode?: 'any' | 'all';
|
||||||
|
}
|
||||||
@@ -197,9 +197,9 @@ export class RoomManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _findAvailableRoom(name: string): Room | undefined {
|
private _findAvailableRoom(name: string): Room | null {
|
||||||
const def = this._definitions.get(name)
|
const def = this._definitions.get(name)
|
||||||
if (!def) return undefined
|
if (!def) return null
|
||||||
|
|
||||||
for (const room of this._rooms.values()) {
|
for (const room of this._rooms.values()) {
|
||||||
if (
|
if (
|
||||||
@@ -212,7 +212,7 @@ export class RoomManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private _generateRoomId(): string {
|
private _generateRoomId(): string {
|
||||||
|
|||||||
134
packages/framework/server/src/testing/MockRoom.ts
Normal file
134
packages/framework/server/src/testing/MockRoom.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* @zh 模拟房间
|
||||||
|
* @en Mock room for testing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Room, onMessage, type Player } from '../room/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟房间状态
|
||||||
|
* @en Mock room state
|
||||||
|
*/
|
||||||
|
export interface MockRoomState {
|
||||||
|
messages: Array<{ type: string; data: unknown; playerId: string }>
|
||||||
|
joinCount: number
|
||||||
|
leaveCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟房间
|
||||||
|
* @en Mock room for testing
|
||||||
|
*
|
||||||
|
* @zh 记录所有事件和消息,用于测试断言
|
||||||
|
* @en Records all events and messages for test assertions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const env = await createTestEnv()
|
||||||
|
* env.server.define('mock', MockRoom)
|
||||||
|
*
|
||||||
|
* const client = await env.createClient()
|
||||||
|
* await client.joinRoom('mock')
|
||||||
|
*
|
||||||
|
* client.sendToRoom('Test', { value: 123 })
|
||||||
|
* await wait(50)
|
||||||
|
*
|
||||||
|
* // MockRoom 会广播收到的消息
|
||||||
|
* const msg = client.getLastMessage('RoomMessage')
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class MockRoom extends Room<MockRoomState> {
|
||||||
|
state: MockRoomState = {
|
||||||
|
messages: [],
|
||||||
|
joinCount: 0,
|
||||||
|
leaveCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
onCreate(): void {
|
||||||
|
// 房间创建
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: Player): void {
|
||||||
|
this.state.joinCount++
|
||||||
|
this.broadcast('PlayerJoined', {
|
||||||
|
playerId: player.id,
|
||||||
|
joinCount: this.state.joinCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLeave(player: Player): void {
|
||||||
|
this.state.leaveCount++
|
||||||
|
this.broadcast('PlayerLeft', {
|
||||||
|
playerId: player.id,
|
||||||
|
leaveCount: this.state.leaveCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('*')
|
||||||
|
handleAnyMessage(data: unknown, player: Player, type: string): void {
|
||||||
|
this.state.messages.push({
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
playerId: player.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 回显消息给所有玩家
|
||||||
|
this.broadcast('MessageReceived', {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
from: player.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Echo')
|
||||||
|
handleEcho(data: unknown, player: Player): void {
|
||||||
|
// 只回复给发送者
|
||||||
|
player.send('EchoReply', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Broadcast')
|
||||||
|
handleBroadcast(data: unknown, _player: Player): void {
|
||||||
|
this.broadcast('BroadcastMessage', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Ping')
|
||||||
|
handlePing(_data: unknown, player: Player): void {
|
||||||
|
player.send('Pong', { timestamp: Date.now() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 简单回显房间
|
||||||
|
* @en Simple echo room
|
||||||
|
*
|
||||||
|
* @zh 将收到的任何消息回显给发送者
|
||||||
|
* @en Echoes any received message back to sender
|
||||||
|
*/
|
||||||
|
export class EchoRoom extends Room {
|
||||||
|
@onMessage('*')
|
||||||
|
handleAnyMessage(data: unknown, player: Player, type: string): void {
|
||||||
|
player.send(type, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 广播房间
|
||||||
|
* @en Broadcast room
|
||||||
|
*
|
||||||
|
* @zh 将收到的任何消息广播给所有玩家
|
||||||
|
* @en Broadcasts any received message to all players
|
||||||
|
*/
|
||||||
|
export class BroadcastRoom extends Room {
|
||||||
|
onJoin(player: Player): void {
|
||||||
|
this.broadcast('PlayerJoined', { id: player.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
onLeave(player: Player): void {
|
||||||
|
this.broadcast('PlayerLeft', { id: player.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('*')
|
||||||
|
handleAnyMessage(data: unknown, player: Player, type: string): void {
|
||||||
|
this.broadcast(type, { from: player.id, data })
|
||||||
|
}
|
||||||
|
}
|
||||||
371
packages/framework/server/src/testing/Room.test.ts
Normal file
371
packages/framework/server/src/testing/Room.test.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* @zh 房间测试示例
|
||||||
|
* @en Room test examples
|
||||||
|
*
|
||||||
|
* @zh 这个文件展示了如何使用测试工具进行服务器测试
|
||||||
|
* @en This file demonstrates how to use testing utilities for server testing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { createTestEnv, type TestEnvironment, wait } from './TestServer.js'
|
||||||
|
import { MockRoom, BroadcastRoom } from './MockRoom.js'
|
||||||
|
import { Room, onMessage, type Player } from '../room/index.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Custom Room for Testing | 自定义测试房间
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface GameState {
|
||||||
|
players: Map<string, { x: number; y: number }>
|
||||||
|
scores: Map<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameRoom extends Room<GameState> {
|
||||||
|
maxPlayers = 4
|
||||||
|
|
||||||
|
state: GameState = {
|
||||||
|
players: new Map(),
|
||||||
|
scores: new Map(),
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: Player): void {
|
||||||
|
this.state.players.set(player.id, { x: 0, y: 0 })
|
||||||
|
this.state.scores.set(player.id, 0)
|
||||||
|
this.broadcast('PlayerJoined', {
|
||||||
|
playerId: player.id,
|
||||||
|
playerCount: this.state.players.size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLeave(player: Player): void {
|
||||||
|
this.state.players.delete(player.id)
|
||||||
|
this.state.scores.delete(player.id)
|
||||||
|
this.broadcast('PlayerLeft', {
|
||||||
|
playerId: player.id,
|
||||||
|
playerCount: this.state.players.size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: Player): void {
|
||||||
|
const pos = this.state.players.get(player.id)
|
||||||
|
if (pos) {
|
||||||
|
pos.x = data.x
|
||||||
|
pos.y = data.y
|
||||||
|
this.broadcast('PlayerMoved', {
|
||||||
|
playerId: player.id,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Score')
|
||||||
|
handleScore(data: { points: number }, player: Player): void {
|
||||||
|
const current = this.state.scores.get(player.id) ?? 0
|
||||||
|
this.state.scores.set(player.id, current + data.points)
|
||||||
|
player.send('ScoreUpdated', {
|
||||||
|
score: this.state.scores.get(player.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Suites | 测试套件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('Room Integration Tests', () => {
|
||||||
|
let env: TestEnvironment
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
env = await createTestEnv()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await env.cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Basic Tests | 基础测试
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Basic Room Operations', () => {
|
||||||
|
it('should create and join room', async () => {
|
||||||
|
env.server.define('game', GameRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
const result = await client.joinRoom('game')
|
||||||
|
|
||||||
|
expect(result.roomId).toBeDefined()
|
||||||
|
expect(result.playerId).toBeDefined()
|
||||||
|
expect(client.roomId).toBe(result.roomId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should leave room', async () => {
|
||||||
|
env.server.define('game', GameRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
await client.joinRoom('game')
|
||||||
|
|
||||||
|
await client.leaveRoom()
|
||||||
|
|
||||||
|
expect(client.roomId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should join existing room by id', async () => {
|
||||||
|
env.server.define('game', GameRoom)
|
||||||
|
|
||||||
|
const client1 = await env.createClient()
|
||||||
|
const { roomId } = await client1.joinRoom('game')
|
||||||
|
|
||||||
|
const client2 = await env.createClient()
|
||||||
|
const result = await client2.joinRoomById(roomId)
|
||||||
|
|
||||||
|
expect(result.roomId).toBe(roomId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Message Tests | 消息测试
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Room Messages', () => {
|
||||||
|
it('should receive room messages', async () => {
|
||||||
|
env.server.define('game', GameRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
await client.joinRoom('game')
|
||||||
|
|
||||||
|
const movePromise = client.waitForRoomMessage('PlayerMoved')
|
||||||
|
client.sendToRoom('Move', { x: 100, y: 200 })
|
||||||
|
|
||||||
|
const msg = await movePromise
|
||||||
|
expect(msg).toEqual({
|
||||||
|
playerId: client.playerId,
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should receive broadcast messages', async () => {
|
||||||
|
env.server.define('game', GameRoom)
|
||||||
|
|
||||||
|
const [client1, client2] = await env.createClients(2)
|
||||||
|
|
||||||
|
const { roomId } = await client1.joinRoom('game')
|
||||||
|
await client2.joinRoomById(roomId)
|
||||||
|
|
||||||
|
// client1 等待收到 client2 的移动消息
|
||||||
|
const movePromise = client1.waitForRoomMessage('PlayerMoved')
|
||||||
|
client2.sendToRoom('Move', { x: 50, y: 75 })
|
||||||
|
|
||||||
|
const msg = await movePromise
|
||||||
|
expect(msg).toMatchObject({
|
||||||
|
playerId: client2.playerId,
|
||||||
|
x: 50,
|
||||||
|
y: 75,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle player join/leave broadcasts', async () => {
|
||||||
|
env.server.define('broadcast', BroadcastRoom)
|
||||||
|
|
||||||
|
const client1 = await env.createClient()
|
||||||
|
const { roomId } = await client1.joinRoom('broadcast')
|
||||||
|
|
||||||
|
// 等待 client2 加入的广播
|
||||||
|
const joinPromise = client1.waitForRoomMessage<{ id: string }>('PlayerJoined')
|
||||||
|
|
||||||
|
const client2 = await env.createClient()
|
||||||
|
const client2Result = await client2.joinRoomById(roomId)
|
||||||
|
|
||||||
|
const joinMsg = await joinPromise
|
||||||
|
expect(joinMsg).toMatchObject({ id: client2Result.playerId })
|
||||||
|
|
||||||
|
// 等待 client2 离开的广播
|
||||||
|
const leavePromise = client1.waitForRoomMessage<{ id: string }>('PlayerLeft')
|
||||||
|
const client2PlayerId = client2.playerId // 保存 playerId
|
||||||
|
await client2.leaveRoom()
|
||||||
|
|
||||||
|
const leaveMsg = await leavePromise
|
||||||
|
expect(leaveMsg).toMatchObject({ id: client2PlayerId })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// MockRoom Tests | 模拟房间测试
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('MockRoom', () => {
|
||||||
|
it('should record messages', async () => {
|
||||||
|
env.server.define('mock', MockRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
await client.joinRoom('mock')
|
||||||
|
|
||||||
|
// 使用 Echo 消息,因为它是明确定义的
|
||||||
|
const echoPromise = client.waitForRoomMessage('EchoReply')
|
||||||
|
client.sendToRoom('Echo', { value: 123 })
|
||||||
|
await echoPromise
|
||||||
|
|
||||||
|
expect(client.hasReceivedMessage('RoomMessage')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle echo', async () => {
|
||||||
|
env.server.define('mock', MockRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
await client.joinRoom('mock')
|
||||||
|
|
||||||
|
const echoPromise = client.waitForRoomMessage('EchoReply')
|
||||||
|
client.sendToRoom('Echo', { message: 'hello' })
|
||||||
|
|
||||||
|
const reply = await echoPromise
|
||||||
|
expect(reply).toEqual({ message: 'hello' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle ping/pong', async () => {
|
||||||
|
env.server.define('mock', MockRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
await client.joinRoom('mock')
|
||||||
|
|
||||||
|
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
|
||||||
|
client.sendToRoom('Ping', {})
|
||||||
|
|
||||||
|
const pong = await pongPromise
|
||||||
|
expect(pong.timestamp).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Multiple Clients Tests | 多客户端测试
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Multiple Clients', () => {
|
||||||
|
it('should handle multiple clients in same room', async () => {
|
||||||
|
env.server.define('game', GameRoom)
|
||||||
|
|
||||||
|
const clients = await env.createClients(3)
|
||||||
|
const { roomId } = await clients[0].joinRoom('game')
|
||||||
|
|
||||||
|
for (let i = 1; i < clients.length; i++) {
|
||||||
|
await clients[i].joinRoomById(roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有客户端都应该能收到消息
|
||||||
|
const promises = clients.map((c) => c.waitForRoomMessage('PlayerMoved'))
|
||||||
|
|
||||||
|
clients[0].sendToRoom('Move', { x: 1, y: 2 })
|
||||||
|
|
||||||
|
const results = await Promise.all(promises)
|
||||||
|
for (const result of results) {
|
||||||
|
expect(result).toMatchObject({ x: 1, y: 2 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle concurrent room operations', async () => {
|
||||||
|
env.server.define('game', GameRoom)
|
||||||
|
|
||||||
|
const clients = await env.createClients(4) // maxPlayers = 4
|
||||||
|
|
||||||
|
// 顺序加入房间(避免并发创建多个房间)
|
||||||
|
const { roomId } = await clients[0].joinRoom('game')
|
||||||
|
|
||||||
|
// 其余客户端加入同一房间
|
||||||
|
const results = await Promise.all(
|
||||||
|
clients.slice(1).map((c) => c.joinRoomById(roomId))
|
||||||
|
)
|
||||||
|
|
||||||
|
// 验证所有客户端都在同一房间
|
||||||
|
for (const result of results) {
|
||||||
|
expect(result.roomId).toBe(roomId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Error Handling Tests | 错误处理测试
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should reject joining non-existent room type', async () => {
|
||||||
|
const client = await env.createClient()
|
||||||
|
|
||||||
|
await expect(client.joinRoom('nonexistent')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle client disconnect gracefully', async () => {
|
||||||
|
env.server.define('game', GameRoom)
|
||||||
|
|
||||||
|
const client1 = await env.createClient()
|
||||||
|
const { roomId } = await client1.joinRoom('game')
|
||||||
|
|
||||||
|
const client2 = await env.createClient()
|
||||||
|
await client2.joinRoomById(roomId)
|
||||||
|
|
||||||
|
// 等待 client2 离开的广播
|
||||||
|
const leavePromise = client1.waitForRoomMessage('PlayerLeft')
|
||||||
|
|
||||||
|
// 强制断开 client2
|
||||||
|
await client2.disconnect()
|
||||||
|
|
||||||
|
// client1 应该收到离开消息
|
||||||
|
const msg = await leavePromise
|
||||||
|
expect(msg).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Assertion Helpers Tests | 断言辅助测试
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('TestClient Assertions', () => {
|
||||||
|
it('should track received messages', async () => {
|
||||||
|
env.server.define('mock', MockRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
await client.joinRoom('mock')
|
||||||
|
|
||||||
|
// 发送多条消息
|
||||||
|
client.sendToRoom('Test', { n: 1 })
|
||||||
|
client.sendToRoom('Test', { n: 2 })
|
||||||
|
client.sendToRoom('Test', { n: 3 })
|
||||||
|
|
||||||
|
// 等待消息处理
|
||||||
|
await wait(100)
|
||||||
|
|
||||||
|
expect(client.getMessageCount()).toBeGreaterThan(0)
|
||||||
|
expect(client.hasReceivedMessage('RoomMessage')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get messages of specific type', async () => {
|
||||||
|
env.server.define('mock', MockRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
await client.joinRoom('mock')
|
||||||
|
|
||||||
|
client.sendToRoom('Ping', {})
|
||||||
|
await client.waitForRoomMessage('Pong')
|
||||||
|
|
||||||
|
const pongs = client.getMessagesOfType('RoomMessage')
|
||||||
|
expect(pongs.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear message history', async () => {
|
||||||
|
env.server.define('mock', MockRoom)
|
||||||
|
|
||||||
|
const client = await env.createClient()
|
||||||
|
await client.joinRoom('mock')
|
||||||
|
|
||||||
|
client.sendToRoom('Test', {})
|
||||||
|
await wait(50)
|
||||||
|
|
||||||
|
expect(client.getMessageCount()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
client.clearMessages()
|
||||||
|
|
||||||
|
expect(client.getMessageCount()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
523
packages/framework/server/src/testing/TestClient.ts
Normal file
523
packages/framework/server/src/testing/TestClient.ts
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
/**
|
||||||
|
* @zh 测试客户端
|
||||||
|
* @en Test client for server testing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
import { json } from '@esengine/rpc/codec'
|
||||||
|
import type { Codec } from '@esengine/rpc/codec'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 测试客户端配置
|
||||||
|
* @en Test client options
|
||||||
|
*/
|
||||||
|
export interface TestClientOptions {
|
||||||
|
/**
|
||||||
|
* @zh 编解码器
|
||||||
|
* @en Codec
|
||||||
|
* @defaultValue json()
|
||||||
|
*/
|
||||||
|
codec?: Codec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh API 调用超时(毫秒)
|
||||||
|
* @en API call timeout in milliseconds
|
||||||
|
* @defaultValue 5000
|
||||||
|
*/
|
||||||
|
timeout?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接超时(毫秒)
|
||||||
|
* @en Connection timeout in milliseconds
|
||||||
|
* @defaultValue 5000
|
||||||
|
*/
|
||||||
|
connectTimeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 房间加入结果
|
||||||
|
* @en Room join result
|
||||||
|
*/
|
||||||
|
export interface JoinRoomResult {
|
||||||
|
roomId: string
|
||||||
|
playerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 收到的消息记录
|
||||||
|
* @en Received message record
|
||||||
|
*/
|
||||||
|
export interface ReceivedMessage {
|
||||||
|
type: string
|
||||||
|
data: unknown
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants | 常量
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const PacketType = {
|
||||||
|
ApiRequest: 0,
|
||||||
|
ApiResponse: 1,
|
||||||
|
ApiError: 2,
|
||||||
|
Message: 3,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TestClient Class | 测试客户端类
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PendingCall {
|
||||||
|
resolve: (value: unknown) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 测试客户端
|
||||||
|
* @en Test client for server integration testing
|
||||||
|
*
|
||||||
|
* @zh 专为测试设计的客户端,提供便捷的断言方法和消息记录功能
|
||||||
|
* @en Client designed for testing, with convenient assertion methods and message recording
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const client = new TestClient(3000)
|
||||||
|
* await client.connect()
|
||||||
|
*
|
||||||
|
* // 加入房间
|
||||||
|
* const { roomId } = await client.joinRoom('game')
|
||||||
|
*
|
||||||
|
* // 发送消息
|
||||||
|
* client.sendToRoom('Move', { x: 10, y: 20 })
|
||||||
|
*
|
||||||
|
* // 等待收到特定消息
|
||||||
|
* const msg = await client.waitForMessage('PlayerMoved')
|
||||||
|
*
|
||||||
|
* // 断言收到消息
|
||||||
|
* expect(client.hasReceivedMessage('PlayerMoved')).toBe(true)
|
||||||
|
*
|
||||||
|
* await client.disconnect()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class TestClient {
|
||||||
|
private readonly _port: number
|
||||||
|
private readonly _codec: Codec
|
||||||
|
private readonly _timeout: number
|
||||||
|
private readonly _connectTimeout: number
|
||||||
|
|
||||||
|
private _ws: WebSocket | null = null
|
||||||
|
private _callIdCounter = 0
|
||||||
|
private _connected = false
|
||||||
|
private _currentRoomId: string | null = null
|
||||||
|
private _currentPlayerId: string | null = null
|
||||||
|
|
||||||
|
private readonly _pendingCalls = new Map<number, PendingCall>()
|
||||||
|
private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>()
|
||||||
|
private readonly _receivedMessages: ReceivedMessage[] = []
|
||||||
|
|
||||||
|
constructor(port: number, options: TestClientOptions = {}) {
|
||||||
|
this._port = port
|
||||||
|
this._codec = options.codec ?? json()
|
||||||
|
this._timeout = options.timeout ?? 5000
|
||||||
|
this._connectTimeout = options.connectTimeout ?? 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Properties | 属性
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否已连接
|
||||||
|
* @en Whether connected
|
||||||
|
*/
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this._connected
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 当前房间 ID
|
||||||
|
* @en Current room ID
|
||||||
|
*/
|
||||||
|
get roomId(): string | null {
|
||||||
|
return this._currentRoomId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 当前玩家 ID
|
||||||
|
* @en Current player ID
|
||||||
|
*/
|
||||||
|
get playerId(): string | null {
|
||||||
|
return this._currentPlayerId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 收到的所有消息
|
||||||
|
* @en All received messages
|
||||||
|
*/
|
||||||
|
get receivedMessages(): ReadonlyArray<ReceivedMessage> {
|
||||||
|
return this._receivedMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Connection | 连接管理
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接到服务器
|
||||||
|
* @en Connect to server
|
||||||
|
*/
|
||||||
|
connect(): Promise<this> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = `ws://localhost:${this._port}`
|
||||||
|
this._ws = new WebSocket(url)
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this._ws?.close()
|
||||||
|
reject(new Error(`Connection timeout after ${this._connectTimeout}ms`))
|
||||||
|
}, this._connectTimeout)
|
||||||
|
|
||||||
|
this._ws.on('open', () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
this._connected = true
|
||||||
|
resolve(this)
|
||||||
|
})
|
||||||
|
|
||||||
|
this._ws.on('close', () => {
|
||||||
|
this._connected = false
|
||||||
|
this._rejectAllPending('Connection closed')
|
||||||
|
})
|
||||||
|
|
||||||
|
this._ws.on('error', (err) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!this._connected) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this._ws.on('message', (data: Buffer) => {
|
||||||
|
this._handleMessage(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 断开连接
|
||||||
|
* @en Disconnect from server
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!this._ws || this._ws.readyState === WebSocket.CLOSED) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ws.once('close', () => {
|
||||||
|
this._connected = false
|
||||||
|
this._ws = null
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
this._ws.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Room Operations | 房间操作
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 加入房间
|
||||||
|
* @en Join a room
|
||||||
|
*/
|
||||||
|
async joinRoom(roomType: string, options?: Record<string, unknown>): Promise<JoinRoomResult> {
|
||||||
|
const result = await this.call<JoinRoomResult>('JoinRoom', { roomType, options })
|
||||||
|
this._currentRoomId = result.roomId
|
||||||
|
this._currentPlayerId = result.playerId
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 通过 ID 加入房间
|
||||||
|
* @en Join a room by ID
|
||||||
|
*/
|
||||||
|
async joinRoomById(roomId: string): Promise<JoinRoomResult> {
|
||||||
|
const result = await this.call<JoinRoomResult>('JoinRoom', { roomId })
|
||||||
|
this._currentRoomId = result.roomId
|
||||||
|
this._currentPlayerId = result.playerId
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 离开房间
|
||||||
|
* @en Leave room
|
||||||
|
*/
|
||||||
|
async leaveRoom(): Promise<void> {
|
||||||
|
await this.call('LeaveRoom', {})
|
||||||
|
this._currentRoomId = null
|
||||||
|
this._currentPlayerId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 发送消息到房间
|
||||||
|
* @en Send message to room
|
||||||
|
*/
|
||||||
|
sendToRoom(type: string, data: unknown): void {
|
||||||
|
this.send('RoomMessage', { type, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// API Calls | API 调用
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 调用 API
|
||||||
|
* @en Call API
|
||||||
|
*/
|
||||||
|
call<T = unknown>(name: string, input: unknown): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this._connected || !this._ws) {
|
||||||
|
reject(new Error('Not connected'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ++this._callIdCounter
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this._pendingCalls.delete(id)
|
||||||
|
reject(new Error(`API call '${name}' timeout after ${this._timeout}ms`))
|
||||||
|
}, this._timeout)
|
||||||
|
|
||||||
|
this._pendingCalls.set(id, {
|
||||||
|
resolve: resolve as (v: unknown) => void,
|
||||||
|
reject,
|
||||||
|
timer,
|
||||||
|
})
|
||||||
|
|
||||||
|
const packet = [PacketType.ApiRequest, id, name, input]
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this._ws.send(this._codec.encode(packet as any) as Buffer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 发送消息
|
||||||
|
* @en Send message
|
||||||
|
*/
|
||||||
|
send(name: string, data: unknown): void {
|
||||||
|
if (!this._connected || !this._ws) return
|
||||||
|
const packet = [PacketType.Message, name, data]
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this._ws.send(this._codec.encode(packet as any) as Buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Message Handling | 消息处理
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 监听消息
|
||||||
|
* @en Listen for message
|
||||||
|
*/
|
||||||
|
on(name: string, handler: (data: unknown) => void): this {
|
||||||
|
let handlers = this._msgHandlers.get(name)
|
||||||
|
if (!handlers) {
|
||||||
|
handlers = new Set()
|
||||||
|
this._msgHandlers.set(name, handlers)
|
||||||
|
}
|
||||||
|
handlers.add(handler)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 取消监听消息
|
||||||
|
* @en Remove message listener
|
||||||
|
*/
|
||||||
|
off(name: string, handler?: (data: unknown) => void): this {
|
||||||
|
if (handler) {
|
||||||
|
this._msgHandlers.get(name)?.delete(handler)
|
||||||
|
} else {
|
||||||
|
this._msgHandlers.delete(name)
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 等待收到指定消息
|
||||||
|
* @en Wait for a specific message
|
||||||
|
*/
|
||||||
|
waitForMessage<T = unknown>(type: string, timeout?: number): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeoutMs = timeout ?? this._timeout
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.off(type, handler)
|
||||||
|
reject(new Error(`Timeout waiting for message '${type}' after ${timeoutMs}ms`))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
const handler = (data: unknown) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.off(type, handler)
|
||||||
|
resolve(data as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.on(type, handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 等待收到指定房间消息
|
||||||
|
* @en Wait for a specific room message
|
||||||
|
*/
|
||||||
|
waitForRoomMessage<T = unknown>(type: string, timeout?: number): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeoutMs = timeout ?? this._timeout
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.off('RoomMessage', handler)
|
||||||
|
reject(new Error(`Timeout waiting for room message '${type}' after ${timeoutMs}ms`))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
const handler = (data: unknown) => {
|
||||||
|
const msg = data as { type: string; data: unknown }
|
||||||
|
if (msg.type === type) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.off('RoomMessage', handler)
|
||||||
|
resolve(msg.data as T)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.on('RoomMessage', handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Assertions | 断言辅助
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否收到过指定消息
|
||||||
|
* @en Whether received a specific message
|
||||||
|
*/
|
||||||
|
hasReceivedMessage(type: string): boolean {
|
||||||
|
return this._receivedMessages.some((m) => m.type === type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取指定类型的所有消息
|
||||||
|
* @en Get all messages of a specific type
|
||||||
|
*/
|
||||||
|
getMessagesOfType<T = unknown>(type: string): T[] {
|
||||||
|
return this._receivedMessages
|
||||||
|
.filter((m) => m.type === type)
|
||||||
|
.map((m) => m.data as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取最后收到的指定类型消息
|
||||||
|
* @en Get the last received message of a specific type
|
||||||
|
*/
|
||||||
|
getLastMessage<T = unknown>(type: string): T | undefined {
|
||||||
|
for (let i = this._receivedMessages.length - 1; i >= 0; i--) {
|
||||||
|
if (this._receivedMessages[i].type === type) {
|
||||||
|
return this._receivedMessages[i].data as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清空消息记录
|
||||||
|
* @en Clear message records
|
||||||
|
*/
|
||||||
|
clearMessages(): void {
|
||||||
|
this._receivedMessages.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取收到的消息数量
|
||||||
|
* @en Get received message count
|
||||||
|
*/
|
||||||
|
getMessageCount(type?: string): number {
|
||||||
|
if (type) {
|
||||||
|
return this._receivedMessages.filter((m) => m.type === type).length
|
||||||
|
}
|
||||||
|
return this._receivedMessages.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Private Methods | 私有方法
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
private _handleMessage(raw: Buffer): void {
|
||||||
|
try {
|
||||||
|
const packet = this._codec.decode(raw) as unknown[]
|
||||||
|
const type = packet[0] as number
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case PacketType.ApiResponse:
|
||||||
|
this._handleApiResponse([packet[0], packet[1], packet[2]] as [number, number, unknown])
|
||||||
|
break
|
||||||
|
case PacketType.ApiError:
|
||||||
|
this._handleApiError([packet[0], packet[1], packet[2], packet[3]] as [number, number, string, string])
|
||||||
|
break
|
||||||
|
case PacketType.Message:
|
||||||
|
this._handleMsg([packet[0], packet[1], packet[2]] as [number, string, unknown])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TestClient] Failed to handle message:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleApiResponse([, id, result]: [number, number, unknown]): void {
|
||||||
|
const pending = this._pendingCalls.get(id)
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this._pendingCalls.delete(id)
|
||||||
|
pending.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleApiError([, id, code, message]: [number, number, string, string]): void {
|
||||||
|
const pending = this._pendingCalls.get(id)
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
this._pendingCalls.delete(id)
|
||||||
|
pending.reject(new Error(`[${code}] ${message}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMsg([, name, data]: [number, string, unknown]): void {
|
||||||
|
// 记录消息
|
||||||
|
this._receivedMessages.push({
|
||||||
|
type: name,
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 触发处理器
|
||||||
|
const handlers = this._msgHandlers.get(name)
|
||||||
|
if (handlers) {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
handler(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TestClient] Handler error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _rejectAllPending(reason: string): void {
|
||||||
|
for (const [, pending] of this._pendingCalls) {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
pending.reject(new Error(reason))
|
||||||
|
}
|
||||||
|
this._pendingCalls.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
249
packages/framework/server/src/testing/TestServer.ts
Normal file
249
packages/framework/server/src/testing/TestServer.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* @zh 测试服务器工具
|
||||||
|
* @en Test server utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createServer } from '../core/server.js'
|
||||||
|
import type { GameServer } from '../types/index.js'
|
||||||
|
import { TestClient, type TestClientOptions } from './TestClient.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 测试服务器配置
|
||||||
|
* @en Test server options
|
||||||
|
*/
|
||||||
|
export interface TestServerOptions {
|
||||||
|
/**
|
||||||
|
* @zh 端口号,0 表示随机端口
|
||||||
|
* @en Port number, 0 for random port
|
||||||
|
* @defaultValue 0
|
||||||
|
*/
|
||||||
|
port?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Tick 速率
|
||||||
|
* @en Tick rate
|
||||||
|
* @defaultValue 0
|
||||||
|
*/
|
||||||
|
tickRate?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否禁用控制台日志
|
||||||
|
* @en Whether to suppress console logs
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
silent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 测试环境
|
||||||
|
* @en Test environment
|
||||||
|
*/
|
||||||
|
export interface TestEnvironment {
|
||||||
|
/**
|
||||||
|
* @zh 服务器实例
|
||||||
|
* @en Server instance
|
||||||
|
*/
|
||||||
|
server: GameServer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 服务器端口
|
||||||
|
* @en Server port
|
||||||
|
*/
|
||||||
|
port: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建测试客户端
|
||||||
|
* @en Create test client
|
||||||
|
*/
|
||||||
|
createClient(options?: TestClientOptions): Promise<TestClient>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建多个测试客户端
|
||||||
|
* @en Create multiple test clients
|
||||||
|
*/
|
||||||
|
createClients(count: number, options?: TestClientOptions): Promise<TestClient[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清理测试环境
|
||||||
|
* @en Cleanup test environment
|
||||||
|
*/
|
||||||
|
cleanup(): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 所有已创建的客户端
|
||||||
|
* @en All created clients
|
||||||
|
*/
|
||||||
|
readonly clients: ReadonlyArray<TestClient>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions | 辅助函数
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取随机可用端口
|
||||||
|
* @en Get a random available port
|
||||||
|
*/
|
||||||
|
async function getRandomPort(): Promise<number> {
|
||||||
|
const net = await import('node:net')
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address()
|
||||||
|
if (address && typeof address === 'object') {
|
||||||
|
const port = address.port
|
||||||
|
server.close(() => resolve(port))
|
||||||
|
} else {
|
||||||
|
server.close(() => reject(new Error('Failed to get port')))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 等待指定毫秒
|
||||||
|
* @en Wait for specified milliseconds
|
||||||
|
*/
|
||||||
|
export function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Functions | 工厂函数
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建测试服务器
|
||||||
|
* @en Create test server
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const { server, port, cleanup } = await createTestServer()
|
||||||
|
* server.define('game', GameRoom)
|
||||||
|
*
|
||||||
|
* const client = new TestClient(port)
|
||||||
|
* await client.connect()
|
||||||
|
*
|
||||||
|
* // ... run tests ...
|
||||||
|
*
|
||||||
|
* await cleanup()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function createTestServer(
|
||||||
|
options: TestServerOptions = {}
|
||||||
|
): Promise<{ server: GameServer; port: number; cleanup: () => Promise<void> }> {
|
||||||
|
const port = options.port || (await getRandomPort())
|
||||||
|
const silent = options.silent ?? true
|
||||||
|
|
||||||
|
// 临时禁用 console.log
|
||||||
|
const originalLog = console.log
|
||||||
|
if (silent) {
|
||||||
|
console.log = () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
port,
|
||||||
|
tickRate: options.tickRate ?? 0,
|
||||||
|
apiDir: '__non_existent_api__',
|
||||||
|
msgDir: '__non_existent_msg__',
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
// 恢复 console.log
|
||||||
|
if (silent) {
|
||||||
|
console.log = originalLog
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
port,
|
||||||
|
cleanup: async () => {
|
||||||
|
await server.stop()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建完整测试环境
|
||||||
|
* @en Create complete test environment
|
||||||
|
*
|
||||||
|
* @zh 包含服务器、客户端创建和清理功能的完整测试环境
|
||||||
|
* @en Complete test environment with server, client creation and cleanup
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* describe('GameRoom', () => {
|
||||||
|
* let env: TestEnvironment
|
||||||
|
*
|
||||||
|
* beforeEach(async () => {
|
||||||
|
* env = await createTestEnv()
|
||||||
|
* env.server.define('game', GameRoom)
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* afterEach(async () => {
|
||||||
|
* await env.cleanup()
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* it('should handle player join', async () => {
|
||||||
|
* const client = await env.createClient()
|
||||||
|
* const result = await client.joinRoom('game')
|
||||||
|
* expect(result.roomId).toBeDefined()
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* it('should broadcast to all players', async () => {
|
||||||
|
* const [client1, client2] = await env.createClients(2)
|
||||||
|
*
|
||||||
|
* await client1.joinRoom('game')
|
||||||
|
* const joinPromise = client1.waitForRoomMessage('PlayerJoined')
|
||||||
|
*
|
||||||
|
* await client2.joinRoom('game')
|
||||||
|
* const msg = await joinPromise
|
||||||
|
*
|
||||||
|
* expect(msg).toBeDefined()
|
||||||
|
* })
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function createTestEnv(options: TestServerOptions = {}): Promise<TestEnvironment> {
|
||||||
|
const { server, port, cleanup: serverCleanup } = await createTestServer(options)
|
||||||
|
const clients: TestClient[] = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
port,
|
||||||
|
clients,
|
||||||
|
|
||||||
|
async createClient(clientOptions?: TestClientOptions): Promise<TestClient> {
|
||||||
|
const client = new TestClient(port, clientOptions)
|
||||||
|
await client.connect()
|
||||||
|
clients.push(client)
|
||||||
|
return client
|
||||||
|
},
|
||||||
|
|
||||||
|
async createClients(count: number, clientOptions?: TestClientOptions): Promise<TestClient[]> {
|
||||||
|
const newClients: TestClient[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const client = new TestClient(port, clientOptions)
|
||||||
|
await client.connect()
|
||||||
|
clients.push(client)
|
||||||
|
newClients.push(client)
|
||||||
|
}
|
||||||
|
return newClients
|
||||||
|
},
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
// 断开所有客户端
|
||||||
|
await Promise.all(clients.map((c) => c.disconnect().catch(() => {})))
|
||||||
|
clients.length = 0
|
||||||
|
|
||||||
|
// 停止服务器
|
||||||
|
await serverCleanup()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/framework/server/src/testing/index.ts
Normal file
37
packages/framework/server/src/testing/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* @zh 服务器测试工具
|
||||||
|
* @en Server testing utilities
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createTestServer, TestClient } from '@esengine/server/testing'
|
||||||
|
*
|
||||||
|
* describe('GameRoom', () => {
|
||||||
|
* let env: TestEnvironment
|
||||||
|
*
|
||||||
|
* beforeEach(async () => {
|
||||||
|
* env = await createTestEnv()
|
||||||
|
* env.server.define('game', GameRoom)
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* afterEach(async () => {
|
||||||
|
* await env.cleanup()
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* it('should join room', async () => {
|
||||||
|
* const client = await env.createClient()
|
||||||
|
* const result = await client.joinRoom('game')
|
||||||
|
* expect(result.roomId).toBeDefined()
|
||||||
|
* })
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { TestClient, type TestClientOptions } from './TestClient.js'
|
||||||
|
export {
|
||||||
|
createTestServer,
|
||||||
|
createTestEnv,
|
||||||
|
type TestServerOptions,
|
||||||
|
type TestEnvironment,
|
||||||
|
} from './TestServer.js'
|
||||||
|
export { MockRoom } from './MockRoom.js'
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { defineConfig } from 'tsup'
|
import { defineConfig } from 'tsup'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: ['src/index.ts'],
|
entry: [
|
||||||
|
'src/index.ts',
|
||||||
|
'src/auth/index.ts',
|
||||||
|
'src/auth/testing/index.ts',
|
||||||
|
'src/testing/index.ts'
|
||||||
|
],
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
external: ['ws', '@esengine/rpc'],
|
external: ['ws', 'jsonwebtoken', '@esengine/rpc', '@esengine/rpc/codec'],
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
})
|
})
|
||||||
|
|||||||
11
packages/framework/server/vitest.config.ts
Normal file
11
packages/framework/server/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
testTimeout: 10000,
|
||||||
|
hookTimeout: 10000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,5 +1,61 @@
|
|||||||
# @esengine/transaction
|
# @esengine/transaction
|
||||||
|
|
||||||
|
## 2.0.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [[`61a13ba`](https://github.com/esengine/esengine/commit/61a13baca2e1e8fba14e23d439521ec0e6b7ca6e)]:
|
||||||
|
- @esengine/server@1.2.0
|
||||||
|
|
||||||
|
## 2.0.0
|
||||||
|
|
||||||
|
### Major Changes
|
||||||
|
|
||||||
|
- [#384](https://github.com/esengine/esengine/pull/384) [`3b97838`](https://github.com/esengine/esengine/commit/3b978384c7d4570f9af9d139e3bfea04c6875543) Thanks [@esengine](https://github.com/esengine)! - ## Breaking Changes
|
||||||
|
|
||||||
|
### Storage API Simplification
|
||||||
|
|
||||||
|
RedisStorage and MongoStorage now use **factory pattern only** for connection management. The direct client injection option has been removed.
|
||||||
|
|
||||||
|
**Before (removed):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Direct client injection - NO LONGER SUPPORTED
|
||||||
|
const storage = new RedisStorage({ client: redisClient });
|
||||||
|
const storage = new MongoStorage({ client: mongoClient, database: 'game' });
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (factory pattern only):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// RedisStorage
|
||||||
|
const storage = new RedisStorage({
|
||||||
|
factory: () => new Redis('redis://localhost:6379'),
|
||||||
|
prefix: 'tx:',
|
||||||
|
transactionTTL: 86400
|
||||||
|
});
|
||||||
|
|
||||||
|
// MongoStorage
|
||||||
|
const storage = new MongoStorage({
|
||||||
|
factory: async () => {
|
||||||
|
const client = new MongoClient('mongodb://localhost:27017');
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
database: 'game'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Lazy Connection**: Connection is established on first operation, not at construction time
|
||||||
|
- **Automatic Cleanup**: Support `await using` syntax (TypeScript 5.2+) for automatic resource cleanup
|
||||||
|
- **Explicit Close**: Call `storage.close()` when done, or use `await using` for automatic disposal
|
||||||
|
|
||||||
|
### Migration Guide
|
||||||
|
1. Replace `client` option with `factory` function
|
||||||
|
2. Add `storage.close()` call when done, or use `await using`
|
||||||
|
3. For MongoStorage, ensure factory returns a connected client
|
||||||
|
|
||||||
## 1.1.0
|
## 1.1.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
/**
|
||||||
|
* @zh TransactionContext 单元测试
|
||||||
|
* @en TransactionContext unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { TransactionContext, createTransactionContext } from '../../src/core/TransactionContext.js'
|
||||||
|
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||||
|
import type { ITransactionOperation, ITransactionContext, OperationResult } from '../../src/core/types.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Operations | 模拟操作
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class SuccessOperation implements ITransactionOperation<{ value: number }, { doubled: number }> {
|
||||||
|
readonly name: string
|
||||||
|
readonly data: { value: number }
|
||||||
|
|
||||||
|
private _compensated = false
|
||||||
|
|
||||||
|
constructor(name: string, value: number) {
|
||||||
|
this.name = name
|
||||||
|
this.data = { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
return this.data.value > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(_ctx: ITransactionContext): Promise<OperationResult<{ doubled: number }>> {
|
||||||
|
return { success: true, data: { doubled: this.data.value * 2 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(_ctx: ITransactionContext): Promise<void> {
|
||||||
|
this._compensated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
get compensated(): boolean {
|
||||||
|
return this._compensated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FailOperation implements ITransactionOperation {
|
||||||
|
readonly name = 'fail'
|
||||||
|
readonly data = {}
|
||||||
|
|
||||||
|
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(_ctx: ITransactionContext): Promise<OperationResult> {
|
||||||
|
return { success: false, error: 'Intentional failure', errorCode: 'FAIL' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(_ctx: ITransactionContext): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ValidationFailOperation implements ITransactionOperation {
|
||||||
|
readonly name = 'validation-fail'
|
||||||
|
readonly data = {}
|
||||||
|
|
||||||
|
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(_ctx: ITransactionContext): Promise<OperationResult> {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(_ctx: ITransactionContext): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SlowOperation implements ITransactionOperation {
|
||||||
|
readonly name = 'slow'
|
||||||
|
readonly data: { delay: number }
|
||||||
|
|
||||||
|
constructor(delay: number) {
|
||||||
|
this.data = { delay }
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(_ctx: ITransactionContext): Promise<OperationResult> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, this.data.delay))
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(_ctx: ITransactionContext): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContextDataOperation implements ITransactionOperation {
|
||||||
|
readonly name = 'context-data'
|
||||||
|
readonly data = {}
|
||||||
|
|
||||||
|
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(ctx: ITransactionContext): Promise<OperationResult> {
|
||||||
|
ctx.set('testKey', 'testValue')
|
||||||
|
ctx.set('numberKey', 42)
|
||||||
|
return { success: true, data: { stored: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(_ctx: ITransactionContext): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Suite | 测试套件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('TransactionContext', () => {
|
||||||
|
let storage: MemoryStorage
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MemoryStorage()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 构造器测试 | Constructor Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Constructor', () => {
|
||||||
|
it('should create with default options', () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
expect(ctx.id).toMatch(/^tx_/)
|
||||||
|
expect(ctx.state).toBe('pending')
|
||||||
|
expect(ctx.timeout).toBe(30000)
|
||||||
|
expect(ctx.operations).toHaveLength(0)
|
||||||
|
expect(ctx.storage).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create with custom options', () => {
|
||||||
|
const ctx = new TransactionContext({
|
||||||
|
timeout: 10000,
|
||||||
|
storage,
|
||||||
|
metadata: { userId: 'user-1' },
|
||||||
|
distributed: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ctx.timeout).toBe(10000)
|
||||||
|
expect(ctx.storage).toBe(storage)
|
||||||
|
expect(ctx.metadata.userId).toBe('user-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use createTransactionContext factory', () => {
|
||||||
|
const ctx = createTransactionContext({ timeout: 5000 })
|
||||||
|
expect(ctx).toBeDefined()
|
||||||
|
expect(ctx.timeout).toBe(5000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 添加操作测试 | Add Operation Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('addOperation()', () => {
|
||||||
|
it('should add operations', () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
const op1 = new SuccessOperation('op1', 10)
|
||||||
|
const op2 = new SuccessOperation('op2', 20)
|
||||||
|
|
||||||
|
ctx.addOperation(op1).addOperation(op2)
|
||||||
|
|
||||||
|
expect(ctx.operations).toHaveLength(2)
|
||||||
|
expect(ctx.operations[0]).toBe(op1)
|
||||||
|
expect(ctx.operations[1]).toBe(op2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support method chaining', () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
const result = ctx
|
||||||
|
.addOperation(new SuccessOperation('op1', 10))
|
||||||
|
.addOperation(new SuccessOperation('op2', 20))
|
||||||
|
|
||||||
|
expect(result).toBe(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw when adding to non-pending transaction', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new SuccessOperation('op1', 10))
|
||||||
|
await ctx.execute()
|
||||||
|
|
||||||
|
expect(() => ctx.addOperation(new SuccessOperation('op2', 20))).toThrow(
|
||||||
|
'Cannot add operation to transaction in state'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 | Execute Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute()', () => {
|
||||||
|
it('should execute all operations successfully', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new SuccessOperation('op1', 10))
|
||||||
|
ctx.addOperation(new SuccessOperation('op2', 20))
|
||||||
|
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.transactionId).toBe(ctx.id)
|
||||||
|
expect(result.results).toHaveLength(2)
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(ctx.state).toBe('committed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return combined result data', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new SuccessOperation('op1', 10))
|
||||||
|
|
||||||
|
const result = await ctx.execute<{ doubled: number }>()
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.doubled).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail if already executed', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new SuccessOperation('op1', 10))
|
||||||
|
|
||||||
|
await ctx.execute()
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('already in state')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail on validation error', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new ValidationFailOperation())
|
||||||
|
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('Validation failed')
|
||||||
|
expect(ctx.state).toBe('rolledback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail and rollback on operation failure', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
const op1 = new SuccessOperation('op1', 10)
|
||||||
|
const op2 = new FailOperation()
|
||||||
|
|
||||||
|
ctx.addOperation(op1).addOperation(op2)
|
||||||
|
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Intentional failure')
|
||||||
|
expect(ctx.state).toBe('rolledback')
|
||||||
|
expect(op1.compensated).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should timeout on slow operations', async () => {
|
||||||
|
const ctx = new TransactionContext({ timeout: 50 })
|
||||||
|
// Add two operations - timeout is checked between operations
|
||||||
|
ctx.addOperation(new SlowOperation(100))
|
||||||
|
ctx.addOperation(new SuccessOperation('second', 1))
|
||||||
|
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('timed out')
|
||||||
|
expect(ctx.state).toBe('rolledback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save transaction log with storage', async () => {
|
||||||
|
const ctx = new TransactionContext({ storage })
|
||||||
|
ctx.addOperation(new SuccessOperation('op1', 10))
|
||||||
|
|
||||||
|
await ctx.execute()
|
||||||
|
|
||||||
|
const log = await storage.getTransaction(ctx.id)
|
||||||
|
expect(log).not.toBeNull()
|
||||||
|
expect(log?.state).toBe('committed')
|
||||||
|
expect(log?.operations).toHaveLength(1)
|
||||||
|
expect(log?.operations[0].state).toBe('executed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update operation states on failure', async () => {
|
||||||
|
const ctx = new TransactionContext({ storage })
|
||||||
|
ctx.addOperation(new SuccessOperation('op1', 10))
|
||||||
|
ctx.addOperation(new FailOperation())
|
||||||
|
|
||||||
|
await ctx.execute()
|
||||||
|
|
||||||
|
const log = await storage.getTransaction(ctx.id)
|
||||||
|
expect(log?.state).toBe('rolledback')
|
||||||
|
expect(log?.operations[0].state).toBe('compensated')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 回滚测试 | Rollback Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('rollback()', () => {
|
||||||
|
it('should rollback pending transaction', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
const op1 = new SuccessOperation('op1', 10)
|
||||||
|
ctx.addOperation(op1)
|
||||||
|
|
||||||
|
await ctx.rollback()
|
||||||
|
|
||||||
|
expect(ctx.state).toBe('rolledback')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not rollback already committed transaction', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new SuccessOperation('op1', 10))
|
||||||
|
|
||||||
|
await ctx.execute()
|
||||||
|
expect(ctx.state).toBe('committed')
|
||||||
|
|
||||||
|
await ctx.rollback()
|
||||||
|
expect(ctx.state).toBe('committed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not rollback already rolledback transaction', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new FailOperation())
|
||||||
|
|
||||||
|
await ctx.execute()
|
||||||
|
expect(ctx.state).toBe('rolledback')
|
||||||
|
|
||||||
|
await ctx.rollback()
|
||||||
|
expect(ctx.state).toBe('rolledback')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 上下文数据测试 | Context Data Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Context Data', () => {
|
||||||
|
it('should get and set context data', () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
|
||||||
|
ctx.set('key1', 'value1')
|
||||||
|
ctx.set('key2', { nested: true })
|
||||||
|
|
||||||
|
expect(ctx.get<string>('key1')).toBe('value1')
|
||||||
|
expect(ctx.get<{ nested: boolean }>('key2')).toEqual({ nested: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for non-existent key', () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
expect(ctx.get('nonexistent')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow operations to share context data', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new ContextDataOperation())
|
||||||
|
|
||||||
|
await ctx.execute()
|
||||||
|
|
||||||
|
expect(ctx.get<string>('testKey')).toBe('testValue')
|
||||||
|
expect(ctx.get<number>('numberKey')).toBe(42)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 边界情况测试 | Edge Cases
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty operations list', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.results).toHaveLength(0)
|
||||||
|
expect(ctx.state).toBe('committed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle single operation', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
ctx.addOperation(new SuccessOperation('single', 5))
|
||||||
|
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.results).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle operation throwing exception', async () => {
|
||||||
|
const ctx = new TransactionContext()
|
||||||
|
const throwOp: ITransactionOperation = {
|
||||||
|
name: 'throw',
|
||||||
|
data: {},
|
||||||
|
validate: async () => true,
|
||||||
|
execute: async () => {
|
||||||
|
throw new Error('Unexpected error')
|
||||||
|
},
|
||||||
|
compensate: async () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addOperation(throwOp)
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Unexpected error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle compensate throwing exception', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const ctx = new TransactionContext({ storage })
|
||||||
|
const badCompensateOp: ITransactionOperation = {
|
||||||
|
name: 'bad-compensate',
|
||||||
|
data: {},
|
||||||
|
validate: async () => true,
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {
|
||||||
|
throw new Error('Compensation error')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const failOp = new FailOperation()
|
||||||
|
|
||||||
|
ctx.addOperation(badCompensateOp).addOperation(failOp)
|
||||||
|
const result = await ctx.execute()
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
|
||||||
|
// Check that operation state was updated with error
|
||||||
|
const log = await storage.getTransaction(ctx.id)
|
||||||
|
expect(log?.operations[0].state).toBe('failed')
|
||||||
|
expect(log?.operations[0].error).toContain('Compensation error')
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* @zh TransactionManager 单元测试
|
||||||
|
* @en TransactionManager unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { TransactionManager, createTransactionManager } from '../../src/core/TransactionManager.js'
|
||||||
|
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||||
|
import type { ITransactionOperation, ITransactionContext, OperationResult } from '../../src/core/types.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Operation | 模拟操作
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class MockOperation implements ITransactionOperation<{ value: number }, { result: number }> {
|
||||||
|
readonly name = 'mock'
|
||||||
|
readonly data: { value: number }
|
||||||
|
|
||||||
|
private _executed = false
|
||||||
|
|
||||||
|
constructor(value: number) {
|
||||||
|
this.data = { value }
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
return this.data.value > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(_ctx: ITransactionContext): Promise<OperationResult<{ result: number }>> {
|
||||||
|
this._executed = true
|
||||||
|
return { success: true, data: { result: this.data.value * 2 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(_ctx: ITransactionContext): Promise<void> {
|
||||||
|
this._executed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
get executed(): boolean {
|
||||||
|
return this._executed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FailingOperation implements ITransactionOperation<{ shouldFail: boolean }> {
|
||||||
|
readonly name = 'failing'
|
||||||
|
readonly data: { shouldFail: boolean }
|
||||||
|
|
||||||
|
constructor(shouldFail: boolean) {
|
||||||
|
this.data = { shouldFail }
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(_ctx: ITransactionContext): Promise<OperationResult> {
|
||||||
|
if (this.data.shouldFail) {
|
||||||
|
return { success: false, error: 'Intentional failure' }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(_ctx: ITransactionContext): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Suite | 测试套件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('TransactionManager', () => {
|
||||||
|
let manager: TransactionManager
|
||||||
|
let storage: MemoryStorage
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MemoryStorage()
|
||||||
|
manager = new TransactionManager({
|
||||||
|
storage,
|
||||||
|
defaultTimeout: 5000,
|
||||||
|
serverId: 'test-server',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 构造器测试 | Constructor Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Constructor', () => {
|
||||||
|
it('should create with default config', () => {
|
||||||
|
const defaultManager = new TransactionManager()
|
||||||
|
expect(defaultManager.storage).toBeNull()
|
||||||
|
expect(defaultManager.activeCount).toBe(0)
|
||||||
|
expect(defaultManager.serverId).toMatch(/^server_/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use provided config', () => {
|
||||||
|
expect(manager.storage).toBe(storage)
|
||||||
|
expect(manager.serverId).toBe('test-server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use createTransactionManager factory', () => {
|
||||||
|
const factoryManager = createTransactionManager({ serverId: 'factory-server' })
|
||||||
|
expect(factoryManager).toBeInstanceOf(TransactionManager)
|
||||||
|
expect(factoryManager.serverId).toBe('factory-server')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 事务创建测试 | Transaction Creation Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('begin()', () => {
|
||||||
|
it('should create new transaction context', () => {
|
||||||
|
const tx = manager.begin()
|
||||||
|
expect(tx.id).toMatch(/^tx_/)
|
||||||
|
expect(tx.state).toBe('pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track active transactions', () => {
|
||||||
|
expect(manager.activeCount).toBe(0)
|
||||||
|
|
||||||
|
const tx1 = manager.begin()
|
||||||
|
expect(manager.activeCount).toBe(1)
|
||||||
|
|
||||||
|
const tx2 = manager.begin()
|
||||||
|
expect(manager.activeCount).toBe(2)
|
||||||
|
|
||||||
|
expect(manager.getTransaction(tx1.id)).toBe(tx1)
|
||||||
|
expect(manager.getTransaction(tx2.id)).toBe(tx2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use custom timeout', () => {
|
||||||
|
const tx = manager.begin({ timeout: 10000 })
|
||||||
|
expect(tx.timeout).toBe(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include serverId in metadata', () => {
|
||||||
|
const tx = manager.begin()
|
||||||
|
expect(tx.metadata.serverId).toBe('test-server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should merge custom metadata', () => {
|
||||||
|
const tx = manager.begin({
|
||||||
|
metadata: { userId: 'user-1', action: 'purchase' },
|
||||||
|
})
|
||||||
|
expect(tx.metadata.serverId).toBe('test-server')
|
||||||
|
expect(tx.metadata.userId).toBe('user-1')
|
||||||
|
expect(tx.metadata.action).toBe('purchase')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// run() 便捷方法测试 | run() Convenience Method Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('run()', () => {
|
||||||
|
it('should execute transaction with builder', async () => {
|
||||||
|
const result = await manager.run((ctx) => {
|
||||||
|
ctx.addOperation(new MockOperation(10))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.transactionId).toMatch(/^tx_/)
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support async builder', async () => {
|
||||||
|
const result = await manager.run(async (ctx) => {
|
||||||
|
await Promise.resolve()
|
||||||
|
ctx.addOperation(new MockOperation(5))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clean up active transaction after run', async () => {
|
||||||
|
expect(manager.activeCount).toBe(0)
|
||||||
|
|
||||||
|
await manager.run((ctx) => {
|
||||||
|
ctx.addOperation(new MockOperation(10))
|
||||||
|
expect(manager.activeCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(manager.activeCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clean up even on failure', async () => {
|
||||||
|
await manager.run((ctx) => {
|
||||||
|
ctx.addOperation(new FailingOperation(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(manager.activeCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return typed result data', async () => {
|
||||||
|
const result = await manager.run<{ result: number }>((ctx) => {
|
||||||
|
ctx.addOperation(new MockOperation(10))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.result).toBe(20)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 分布式锁测试 | Distributed Lock Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Distributed Lock', () => {
|
||||||
|
it('should acquire and release lock', async () => {
|
||||||
|
const token = await manager.acquireLock('resource-1', 5000)
|
||||||
|
expect(token).not.toBeNull()
|
||||||
|
|
||||||
|
const released = await manager.releaseLock('resource-1', token!)
|
||||||
|
expect(released).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null without storage', async () => {
|
||||||
|
const noStorageManager = new TransactionManager()
|
||||||
|
|
||||||
|
const token = await noStorageManager.acquireLock('key', 5000)
|
||||||
|
expect(token).toBeNull()
|
||||||
|
|
||||||
|
const released = await noStorageManager.releaseLock('key', 'token')
|
||||||
|
expect(released).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute withLock successfully', async () => {
|
||||||
|
let executed = false
|
||||||
|
|
||||||
|
await manager.withLock('resource-1', async () => {
|
||||||
|
executed = true
|
||||||
|
return 'result'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(executed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw if lock acquisition fails', async () => {
|
||||||
|
// First acquire the lock
|
||||||
|
await storage.acquireLock('resource-1', 5000)
|
||||||
|
|
||||||
|
// Try to acquire with withLock - should fail
|
||||||
|
await expect(
|
||||||
|
manager.withLock('resource-1', async () => {
|
||||||
|
return 'should not reach'
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Failed to acquire lock')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release lock after withLock completes', async () => {
|
||||||
|
await manager.withLock('resource-1', async () => {
|
||||||
|
return 'done'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should be able to acquire again
|
||||||
|
const token = await manager.acquireLock('resource-1', 5000)
|
||||||
|
expect(token).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release lock even if function throws', async () => {
|
||||||
|
try {
|
||||||
|
await manager.withLock('resource-1', async () => {
|
||||||
|
throw new Error('Test error')
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be able to acquire again
|
||||||
|
const token = await manager.acquireLock('resource-1', 5000)
|
||||||
|
expect(token).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 事务恢复测试 | Transaction Recovery Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('recover()', () => {
|
||||||
|
it('should return 0 without storage', async () => {
|
||||||
|
const noStorageManager = new TransactionManager()
|
||||||
|
const count = await noStorageManager.recover()
|
||||||
|
expect(count).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should recover pending transactions', async () => {
|
||||||
|
// Save a pending transaction directly to storage
|
||||||
|
await storage.saveTransaction({
|
||||||
|
id: 'tx-pending',
|
||||||
|
state: 'executing',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
timeout: 5000,
|
||||||
|
operations: [
|
||||||
|
{ name: 'op1', state: 'executed' },
|
||||||
|
],
|
||||||
|
metadata: { serverId: 'test-server' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const count = await manager.recover()
|
||||||
|
expect(count).toBe(1)
|
||||||
|
|
||||||
|
// Check that transaction state was updated
|
||||||
|
const recovered = await storage.getTransaction('tx-pending')
|
||||||
|
expect(recovered?.state).toBe('rolledback')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 清理测试 | Cleanup Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('cleanup()', () => {
|
||||||
|
it('should return 0 without storage', async () => {
|
||||||
|
const noStorageManager = new TransactionManager()
|
||||||
|
const count = await noStorageManager.cleanup()
|
||||||
|
expect(count).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clean old completed transactions from pending list', async () => {
|
||||||
|
const oldTimestamp = Date.now() - 48 * 60 * 60 * 1000 // 48 hours ago
|
||||||
|
|
||||||
|
// Note: cleanup() uses getPendingTransactions() which only returns pending/executing state
|
||||||
|
// This test verifies the cleanup logic for transactions that are in pending state
|
||||||
|
// but have been marked committed/rolledback (edge case during recovery)
|
||||||
|
await storage.saveTransaction({
|
||||||
|
id: 'tx-old-pending',
|
||||||
|
state: 'pending', // This will be returned by getPendingTransactions
|
||||||
|
createdAt: oldTimestamp,
|
||||||
|
updatedAt: oldTimestamp,
|
||||||
|
timeout: 5000,
|
||||||
|
operations: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// The current implementation doesn't clean pending transactions
|
||||||
|
// This is a limitation - cleanup only works for committed/rolledback states
|
||||||
|
// but getPendingTransactions doesn't return those
|
||||||
|
const count = await manager.cleanup()
|
||||||
|
expect(count).toBe(0) // Nothing cleaned because state is 'pending', not 'committed'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// getTransaction 测试 | getTransaction Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('getTransaction()', () => {
|
||||||
|
it('should return active transaction', () => {
|
||||||
|
const tx = manager.begin()
|
||||||
|
expect(manager.getTransaction(tx.id)).toBe(tx)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for non-existent transaction', () => {
|
||||||
|
expect(manager.getTransaction('non-existent')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
/**
|
||||||
|
* @zh SagaOrchestrator 单元测试
|
||||||
|
* @en SagaOrchestrator unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
SagaOrchestrator,
|
||||||
|
createSagaOrchestrator,
|
||||||
|
type SagaStep,
|
||||||
|
type SagaLog,
|
||||||
|
} from '../../src/distributed/SagaOrchestrator.js'
|
||||||
|
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Suite | 测试套件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('SagaOrchestrator', () => {
|
||||||
|
let storage: MemoryStorage
|
||||||
|
let orchestrator: SagaOrchestrator
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MemoryStorage()
|
||||||
|
orchestrator = new SagaOrchestrator({
|
||||||
|
storage,
|
||||||
|
timeout: 5000,
|
||||||
|
serverId: 'test-server',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 构造器测试 | Constructor Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Constructor', () => {
|
||||||
|
it('should create with default config', () => {
|
||||||
|
const defaultOrchestrator = new SagaOrchestrator()
|
||||||
|
expect(defaultOrchestrator).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create with custom config', () => {
|
||||||
|
const customOrchestrator = new SagaOrchestrator({
|
||||||
|
storage,
|
||||||
|
timeout: 10000,
|
||||||
|
serverId: 'custom-server',
|
||||||
|
})
|
||||||
|
expect(customOrchestrator).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use createSagaOrchestrator factory', () => {
|
||||||
|
const factoryOrchestrator = createSagaOrchestrator({ serverId: 'factory-server' })
|
||||||
|
expect(factoryOrchestrator).toBeInstanceOf(SagaOrchestrator)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 成功执行测试 | Successful Execution Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - success', () => {
|
||||||
|
it('should execute single step saga', async () => {
|
||||||
|
const executeLog: string[] = []
|
||||||
|
|
||||||
|
const steps: SagaStep<{ value: number }>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async (data) => {
|
||||||
|
executeLog.push(`execute:${data.value}`)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
executeLog.push(`compensate:${data.value}`)
|
||||||
|
},
|
||||||
|
data: { value: 1 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.sagaId).toMatch(/^saga_/)
|
||||||
|
expect(result.completedSteps).toEqual(['step1'])
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(executeLog).toEqual(['execute:1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute multi-step saga', async () => {
|
||||||
|
const executeLog: string[] = []
|
||||||
|
|
||||||
|
const steps: SagaStep<{ name: string }>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async (data) => {
|
||||||
|
executeLog.push(`execute:${data.name}`)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
executeLog.push(`compensate:${data.name}`)
|
||||||
|
},
|
||||||
|
data: { name: 'A' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'step2',
|
||||||
|
execute: async (data) => {
|
||||||
|
executeLog.push(`execute:${data.name}`)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
executeLog.push(`compensate:${data.name}`)
|
||||||
|
},
|
||||||
|
data: { name: 'B' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'step3',
|
||||||
|
execute: async (data) => {
|
||||||
|
executeLog.push(`execute:${data.name}`)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
executeLog.push(`compensate:${data.name}`)
|
||||||
|
},
|
||||||
|
data: { name: 'C' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.completedSteps).toEqual(['step1', 'step2', 'step3'])
|
||||||
|
expect(executeLog).toEqual(['execute:A', 'execute:B', 'execute:C'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save saga log on success', async () => {
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId)
|
||||||
|
expect(log).not.toBeNull()
|
||||||
|
expect(log?.state).toBe('completed')
|
||||||
|
expect(log?.steps[0].state).toBe('completed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 失败和补偿测试 | Failure and Compensation Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - failure and compensation', () => {
|
||||||
|
it('should compensate on step failure', async () => {
|
||||||
|
const executeLog: string[] = []
|
||||||
|
|
||||||
|
const steps: SagaStep<{ name: string }>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async (data) => {
|
||||||
|
executeLog.push(`execute:${data.name}`)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
executeLog.push(`compensate:${data.name}`)
|
||||||
|
},
|
||||||
|
data: { name: 'A' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'step2',
|
||||||
|
execute: async (data) => {
|
||||||
|
executeLog.push(`execute:${data.name}`)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
executeLog.push(`compensate:${data.name}`)
|
||||||
|
},
|
||||||
|
data: { name: 'B' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'step3',
|
||||||
|
execute: async () => {
|
||||||
|
return { success: false, error: 'Step 3 failed' }
|
||||||
|
},
|
||||||
|
compensate: async () => {},
|
||||||
|
data: { name: 'C' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.failedStep).toBe('step3')
|
||||||
|
expect(result.error).toBe('Step 3 failed')
|
||||||
|
expect(result.completedSteps).toEqual(['step1', 'step2'])
|
||||||
|
|
||||||
|
// Compensation should be in reverse order
|
||||||
|
expect(executeLog).toEqual([
|
||||||
|
'execute:A',
|
||||||
|
'execute:B',
|
||||||
|
'compensate:B',
|
||||||
|
'compensate:A',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save saga log on failure', async () => {
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'step2',
|
||||||
|
execute: async () => ({ success: false, error: 'Failed' }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId)
|
||||||
|
expect(log?.state).toBe('compensated')
|
||||||
|
expect(log?.steps[0].state).toBe('compensated')
|
||||||
|
expect(log?.steps[1].state).toBe('failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle compensation error', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {
|
||||||
|
throw new Error('Compensation failed')
|
||||||
|
},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'step2',
|
||||||
|
execute: async () => ({ success: false, error: 'Failed' }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId)
|
||||||
|
expect(log?.steps[0].state).toBe('failed')
|
||||||
|
expect(log?.steps[0].error).toContain('Compensation failed')
|
||||||
|
|
||||||
|
consoleSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 超时测试 | Timeout Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('timeout', () => {
|
||||||
|
it('should timeout on slow saga', async () => {
|
||||||
|
const fastOrchestrator = new SagaOrchestrator({
|
||||||
|
storage,
|
||||||
|
timeout: 50, // 50ms timeout
|
||||||
|
})
|
||||||
|
|
||||||
|
// Timeout is checked between steps, so we need 2 steps
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'slow-step',
|
||||||
|
execute: async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'second-step',
|
||||||
|
execute: async () => {
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await fastOrchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toContain('timed out')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 分布式服务器测试 | Distributed Server Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('distributed servers', () => {
|
||||||
|
it('should track serverId for each step', async () => {
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
serverId: 'server-1',
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'step2',
|
||||||
|
serverId: 'server-2',
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId)
|
||||||
|
expect(log?.steps[0].serverId).toBe('server-1')
|
||||||
|
expect(log?.steps[1].serverId).toBe('server-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include orchestrator serverId in metadata', async () => {
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId)
|
||||||
|
expect(log?.metadata?.orchestratorServerId).toBe('test-server')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// getSagaLog 测试 | getSagaLog Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('getSagaLog()', () => {
|
||||||
|
it('should return saga log by id', async () => {
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId)
|
||||||
|
|
||||||
|
expect(log).not.toBeNull()
|
||||||
|
expect(log?.id).toBe(result.sagaId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for non-existent saga', async () => {
|
||||||
|
const log = await orchestrator.getSagaLog('non-existent')
|
||||||
|
expect(log).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null without storage', async () => {
|
||||||
|
const noStorageOrchestrator = new SagaOrchestrator()
|
||||||
|
const log = await noStorageOrchestrator.getSagaLog('any-id')
|
||||||
|
expect(log).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 恢复测试 | Recovery Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('recover()', () => {
|
||||||
|
it('should return 0 without storage', async () => {
|
||||||
|
const noStorageOrchestrator = new SagaOrchestrator()
|
||||||
|
const count = await noStorageOrchestrator.recover()
|
||||||
|
expect(count).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 0 when no pending sagas', async () => {
|
||||||
|
const count = await orchestrator.recover()
|
||||||
|
expect(count).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 边界情况测试 | Edge Cases
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty steps', async () => {
|
||||||
|
const result = await orchestrator.execute([])
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.completedSteps).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle execute throwing exception', async () => {
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'throwing-step',
|
||||||
|
execute: async () => {
|
||||||
|
throw new Error('Unexpected error')
|
||||||
|
},
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Unexpected error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should work without storage', async () => {
|
||||||
|
const noStorageOrchestrator = new SagaOrchestrator()
|
||||||
|
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async () => ({ success: true }),
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await noStorageOrchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track step timing', async () => {
|
||||||
|
const steps: SagaStep<{}>[] = [
|
||||||
|
{
|
||||||
|
name: 'step1',
|
||||||
|
execute: async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async () => {},
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId)
|
||||||
|
expect(log?.steps[0].startedAt).toBeGreaterThan(0)
|
||||||
|
expect(log?.steps[0].completedAt).toBeGreaterThan(log?.steps[0].startedAt!)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 实际场景测试 | Real World Scenario Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Real World Scenarios', () => {
|
||||||
|
it('should handle distributed purchase flow', async () => {
|
||||||
|
const inventory: Map<string, number> = new Map()
|
||||||
|
const wallet: Map<string, number> = new Map()
|
||||||
|
|
||||||
|
inventory.set('item-1', 10)
|
||||||
|
wallet.set('player-1', 1000)
|
||||||
|
|
||||||
|
const steps: SagaStep<{ playerId: string; itemId: string; price: number }>[] = [
|
||||||
|
{
|
||||||
|
name: 'deduct_currency',
|
||||||
|
serverId: 'wallet-server',
|
||||||
|
execute: async (data) => {
|
||||||
|
const balance = wallet.get(data.playerId) ?? 0
|
||||||
|
if (balance < data.price) {
|
||||||
|
return { success: false, error: 'Insufficient balance' }
|
||||||
|
}
|
||||||
|
wallet.set(data.playerId, balance - data.price)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
const balance = wallet.get(data.playerId) ?? 0
|
||||||
|
wallet.set(data.playerId, balance + data.price)
|
||||||
|
},
|
||||||
|
data: { playerId: 'player-1', itemId: 'item-1', price: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reserve_item',
|
||||||
|
serverId: 'inventory-server',
|
||||||
|
execute: async (data) => {
|
||||||
|
const stock = inventory.get(data.itemId) ?? 0
|
||||||
|
if (stock < 1) {
|
||||||
|
return { success: false, error: 'Out of stock' }
|
||||||
|
}
|
||||||
|
inventory.set(data.itemId, stock - 1)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
const stock = inventory.get(data.itemId) ?? 0
|
||||||
|
inventory.set(data.itemId, stock + 1)
|
||||||
|
},
|
||||||
|
data: { playerId: 'player-1', itemId: 'item-1', price: 100 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(wallet.get('player-1')).toBe(900)
|
||||||
|
expect(inventory.get('item-1')).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should rollback distributed purchase on inventory failure', async () => {
|
||||||
|
const wallet: Map<string, number> = new Map()
|
||||||
|
const inventory: Map<string, number> = new Map()
|
||||||
|
|
||||||
|
wallet.set('player-1', 1000)
|
||||||
|
inventory.set('item-1', 0) // Out of stock
|
||||||
|
|
||||||
|
const steps: SagaStep<{ playerId: string; itemId: string; price: number }>[] = [
|
||||||
|
{
|
||||||
|
name: 'deduct_currency',
|
||||||
|
execute: async (data) => {
|
||||||
|
const balance = wallet.get(data.playerId) ?? 0
|
||||||
|
wallet.set(data.playerId, balance - data.price)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
const balance = wallet.get(data.playerId) ?? 0
|
||||||
|
wallet.set(data.playerId, balance + data.price)
|
||||||
|
},
|
||||||
|
data: { playerId: 'player-1', itemId: 'item-1', price: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'reserve_item',
|
||||||
|
execute: async (data) => {
|
||||||
|
const stock = inventory.get(data.itemId) ?? 0
|
||||||
|
if (stock < 1) {
|
||||||
|
return { success: false, error: 'Out of stock' }
|
||||||
|
}
|
||||||
|
inventory.set(data.itemId, stock - 1)
|
||||||
|
return { success: true }
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
const stock = inventory.get(data.itemId) ?? 0
|
||||||
|
inventory.set(data.itemId, stock + 1)
|
||||||
|
},
|
||||||
|
data: { playerId: 'player-1', itemId: 'item-1', price: 100 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await orchestrator.execute(steps)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Out of stock')
|
||||||
|
expect(wallet.get('player-1')).toBe(1000) // Restored
|
||||||
|
expect(inventory.get('item-1')).toBe(0) // Unchanged
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* @zh CurrencyOperation 单元测试
|
||||||
|
* @en CurrencyOperation unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { CurrencyOperation, createCurrencyOperation, type ICurrencyProvider } from '../../src/operations/CurrencyOperation.js'
|
||||||
|
import { TransactionContext } from '../../src/core/TransactionContext.js'
|
||||||
|
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||||
|
import type { ITransactionContext } from '../../src/core/types.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Provider | 模拟数据提供者
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class MockCurrencyProvider implements ICurrencyProvider {
|
||||||
|
private _balances: Map<string, Map<string, number>> = new Map()
|
||||||
|
|
||||||
|
setInitialBalance(playerId: string, currency: string, amount: number): void {
|
||||||
|
if (!this._balances.has(playerId)) {
|
||||||
|
this._balances.set(playerId, new Map())
|
||||||
|
}
|
||||||
|
this._balances.get(playerId)!.set(currency, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(playerId: string, currency: string): Promise<number> {
|
||||||
|
return this._balances.get(playerId)?.get(currency) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
|
||||||
|
if (!this._balances.has(playerId)) {
|
||||||
|
this._balances.set(playerId, new Map())
|
||||||
|
}
|
||||||
|
this._balances.get(playerId)!.set(currency, amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Suite | 测试套件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('CurrencyOperation', () => {
|
||||||
|
let storage: MemoryStorage
|
||||||
|
let ctx: ITransactionContext
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MemoryStorage()
|
||||||
|
ctx = new TransactionContext({ storage })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 构造器测试 | Constructor Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Constructor', () => {
|
||||||
|
it('should create with data', () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op.name).toBe('currency')
|
||||||
|
expect(op.data.type).toBe('add')
|
||||||
|
expect(op.data.playerId).toBe('player-1')
|
||||||
|
expect(op.data.currency).toBe('gold')
|
||||||
|
expect(op.data.amount).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use createCurrencyOperation factory', () => {
|
||||||
|
const op = createCurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-2',
|
||||||
|
currency: 'diamond',
|
||||||
|
amount: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op).toBeInstanceOf(CurrencyOperation)
|
||||||
|
expect(op.data.type).toBe('deduct')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 验证测试 | Validation Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('validate()', () => {
|
||||||
|
it('should fail validation with zero amount', async () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail validation with negative amount', async () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: -10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass validation for add with positive amount', async () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail validation for deduct with insufficient balance', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 50)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass validation for deduct with sufficient balance', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 150)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use provider for validation', async () => {
|
||||||
|
const provider = new MockCurrencyProvider()
|
||||||
|
provider.setInitialBalance('player-1', 'gold', 200)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 150,
|
||||||
|
}).setProvider(provider)
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 增加货币 | Execute Tests - Add Currency
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - add', () => {
|
||||||
|
it('should add currency to empty balance', async () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.beforeBalance).toBe(0)
|
||||||
|
expect(result.data?.afterBalance).toBe(100)
|
||||||
|
|
||||||
|
const balance = await storage.get<number>('player:player-1:currency:gold')
|
||||||
|
expect(balance).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add currency to existing balance', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 50)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.beforeBalance).toBe(50)
|
||||||
|
expect(result.data?.afterBalance).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should store context data for verification', async () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(ctx.get('currency:player-1:gold:before')).toBe(0)
|
||||||
|
expect(ctx.get('currency:player-1:gold:after')).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 扣除货币 | Execute Tests - Deduct Currency
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - deduct', () => {
|
||||||
|
it('should deduct currency from balance', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 200)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 75,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.beforeBalance).toBe(200)
|
||||||
|
expect(result.data?.afterBalance).toBe(125)
|
||||||
|
|
||||||
|
const balance = await storage.get<number>('player:player-1:currency:gold')
|
||||||
|
expect(balance).toBe(125)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail deduct with insufficient balance', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 50)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Insufficient balance')
|
||||||
|
expect(result.errorCode).toBe('INSUFFICIENT_BALANCE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should deduct exact balance', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 100)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.afterBalance).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 补偿测试 | Compensate Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('compensate()', () => {
|
||||||
|
it('should restore balance after add', async () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(100)
|
||||||
|
|
||||||
|
await op.compensate(ctx)
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore balance after deduct', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 200)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 75,
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(125)
|
||||||
|
|
||||||
|
await op.compensate(ctx)
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Provider 测试 | Provider Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Provider', () => {
|
||||||
|
it('should use provider for operations', async () => {
|
||||||
|
const provider = new MockCurrencyProvider()
|
||||||
|
provider.setInitialBalance('player-1', 'gold', 1000)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 300,
|
||||||
|
}).setProvider(provider)
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.beforeBalance).toBe(1000)
|
||||||
|
expect(result.data?.afterBalance).toBe(700)
|
||||||
|
|
||||||
|
const newBalance = await provider.getBalance('player-1', 'gold')
|
||||||
|
expect(newBalance).toBe(700)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compensate using provider', async () => {
|
||||||
|
const provider = new MockCurrencyProvider()
|
||||||
|
provider.setInitialBalance('player-1', 'gold', 500)
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 200,
|
||||||
|
}).setProvider(provider)
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
expect(await provider.getBalance('player-1', 'gold')).toBe(700)
|
||||||
|
|
||||||
|
await op.compensate(ctx)
|
||||||
|
expect(await provider.getBalance('player-1', 'gold')).toBe(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support method chaining with setProvider', () => {
|
||||||
|
const provider = new MockCurrencyProvider()
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = op.setProvider(provider)
|
||||||
|
expect(result).toBe(op)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 多货币类型测试 | Multiple Currency Types Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Multiple Currency Types', () => {
|
||||||
|
it('should handle different currency types independently', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 1000)
|
||||||
|
await storage.set('player:player-1:currency:diamond', 50)
|
||||||
|
|
||||||
|
const goldOp = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
const diamondOp = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'diamond',
|
||||||
|
amount: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
await goldOp.execute(ctx)
|
||||||
|
await diamondOp.execute(ctx)
|
||||||
|
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(500)
|
||||||
|
expect(await storage.get('player:player-1:currency:diamond')).toBe(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple players independently', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 1000)
|
||||||
|
await storage.set('player:player-2:currency:gold', 500)
|
||||||
|
|
||||||
|
const op1 = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 300,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op2 = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-2',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 300,
|
||||||
|
})
|
||||||
|
|
||||||
|
await op1.execute(ctx)
|
||||||
|
await op2.execute(ctx)
|
||||||
|
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(700)
|
||||||
|
expect(await storage.get('player:player-2:currency:gold')).toBe(800)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 边界情况测试 | Edge Cases
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle zero balance for new player', async () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'new-player',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
expect(result.data?.beforeBalance).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle context without storage', async () => {
|
||||||
|
const noStorageCtx = new TransactionContext()
|
||||||
|
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(noStorageCtx)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.beforeBalance).toBe(0)
|
||||||
|
expect(result.data?.afterBalance).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include reason in data', () => {
|
||||||
|
const op = new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player-1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
reason: 'purchase_item',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op.data.reason).toBe('purchase_item')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
/**
|
||||||
|
* @zh InventoryOperation 单元测试
|
||||||
|
* @en InventoryOperation unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
InventoryOperation,
|
||||||
|
createInventoryOperation,
|
||||||
|
type IInventoryProvider,
|
||||||
|
type ItemData,
|
||||||
|
} from '../../src/operations/InventoryOperation.js'
|
||||||
|
import { TransactionContext } from '../../src/core/TransactionContext.js'
|
||||||
|
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||||
|
import type { ITransactionContext } from '../../src/core/types.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Provider | 模拟数据提供者
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class MockInventoryProvider implements IInventoryProvider {
|
||||||
|
private _inventory: Map<string, Map<string, ItemData>> = new Map()
|
||||||
|
private _capacity: Map<string, number> = new Map()
|
||||||
|
|
||||||
|
addItem(playerId: string, itemId: string, item: ItemData): void {
|
||||||
|
if (!this._inventory.has(playerId)) {
|
||||||
|
this._inventory.set(playerId, new Map())
|
||||||
|
}
|
||||||
|
this._inventory.get(playerId)!.set(itemId, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCapacity(playerId: string, capacity: number): void {
|
||||||
|
this._capacity.set(playerId, capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
|
||||||
|
return this._inventory.get(playerId)?.get(itemId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
|
||||||
|
if (!this._inventory.has(playerId)) {
|
||||||
|
this._inventory.set(playerId, new Map())
|
||||||
|
}
|
||||||
|
if (item) {
|
||||||
|
this._inventory.get(playerId)!.set(itemId, item)
|
||||||
|
} else {
|
||||||
|
this._inventory.get(playerId)!.delete(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasCapacity(playerId: string, count: number): Promise<boolean> {
|
||||||
|
const capacity = this._capacity.get(playerId)
|
||||||
|
if (capacity === undefined) return true
|
||||||
|
const currentCount = this._inventory.get(playerId)?.size ?? 0
|
||||||
|
return currentCount + count <= capacity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Suite | 测试套件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('InventoryOperation', () => {
|
||||||
|
let storage: MemoryStorage
|
||||||
|
let ctx: ITransactionContext
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MemoryStorage()
|
||||||
|
ctx = new TransactionContext({ storage })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 构造器测试 | Constructor Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Constructor', () => {
|
||||||
|
it('should create with data', () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword-001',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op.name).toBe('inventory')
|
||||||
|
expect(op.data.type).toBe('add')
|
||||||
|
expect(op.data.playerId).toBe('player-1')
|
||||||
|
expect(op.data.itemId).toBe('sword-001')
|
||||||
|
expect(op.data.quantity).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use createInventoryOperation factory', () => {
|
||||||
|
const op = createInventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-2',
|
||||||
|
itemId: 'potion-hp',
|
||||||
|
quantity: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op).toBeInstanceOf(InventoryOperation)
|
||||||
|
expect(op.data.type).toBe('remove')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 验证测试 | Validation Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('validate()', () => {
|
||||||
|
it('should fail validation with zero quantity', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail validation with negative quantity', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: -1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass validation for add with positive quantity', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail validation for remove with insufficient quantity', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail validation for remove with non-existent item', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'nonexistent',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass validation for remove with sufficient quantity', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check capacity with provider', async () => {
|
||||||
|
const provider = new MockInventoryProvider()
|
||||||
|
provider.setCapacity('player-1', 1)
|
||||||
|
provider.addItem('player-1', 'existing', { itemId: 'existing', quantity: 1 })
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'new-item',
|
||||||
|
quantity: 1,
|
||||||
|
}).setProvider(provider)
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 添加物品 | Execute Tests - Add Item
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - add', () => {
|
||||||
|
it('should add new item to empty inventory', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.beforeItem).toBeUndefined()
|
||||||
|
expect(result.data?.afterItem).toEqual({
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
properties: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const item = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||||
|
expect(item?.quantity).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stack items when adding to existing', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:potion', {
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.beforeItem?.quantity).toBe(5)
|
||||||
|
expect(result.data?.afterItem?.quantity).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add item with properties', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'enchanted-sword',
|
||||||
|
quantity: 1,
|
||||||
|
properties: { damage: 100, enchant: 'fire' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.afterItem?.properties).toEqual({
|
||||||
|
damage: 100,
|
||||||
|
enchant: 'fire',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should store context data', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(ctx.get('inventory:player-1:sword:before')).toBeNull()
|
||||||
|
expect(ctx.get('inventory:player-1:sword:after')).toEqual({
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
properties: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 移除物品 | Execute Tests - Remove Item
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - remove', () => {
|
||||||
|
it('should remove partial quantity', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:potion', {
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.beforeItem?.quantity).toBe(10)
|
||||||
|
expect(result.data?.afterItem?.quantity).toBe(7)
|
||||||
|
|
||||||
|
const item = await storage.get<ItemData>('player:player-1:inventory:potion')
|
||||||
|
expect(item?.quantity).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delete item when removing all quantity', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.afterItem).toBeUndefined()
|
||||||
|
|
||||||
|
const item = await storage.get('player:player-1:inventory:sword')
|
||||||
|
expect(item).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail when item not found', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'nonexistent',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Insufficient item quantity')
|
||||||
|
expect(result.errorCode).toBe('INSUFFICIENT_ITEM')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail when quantity insufficient', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:potion', {
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.errorCode).toBe('INSUFFICIENT_ITEM')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 更新物品 | Execute Tests - Update Item
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - update', () => {
|
||||||
|
it('should update item properties', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
properties: { damage: 10 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'update',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 0, // keep existing
|
||||||
|
properties: { damage: 20, enchant: 'ice' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.afterItem?.properties).toEqual({
|
||||||
|
damage: 20,
|
||||||
|
enchant: 'ice',
|
||||||
|
})
|
||||||
|
expect(result.data?.afterItem?.quantity).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update item quantity', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:potion', {
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'update',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.afterItem?.quantity).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail when updating non-existent item', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'update',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'nonexistent',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.error).toBe('Item not found')
|
||||||
|
expect(result.errorCode).toBe('ITEM_NOT_FOUND')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 补偿测试 | Compensate Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('compensate()', () => {
|
||||||
|
it('should restore state after add', async () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
expect(await storage.get('player:player-1:inventory:sword')).not.toBeNull()
|
||||||
|
|
||||||
|
await op.compensate(ctx)
|
||||||
|
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore state after remove', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:potion', {
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
const afterRemove = await storage.get<ItemData>('player:player-1:inventory:potion')
|
||||||
|
expect(afterRemove?.quantity).toBe(2)
|
||||||
|
|
||||||
|
await op.compensate(ctx)
|
||||||
|
const afterCompensate = await storage.get<ItemData>('player:player-1:inventory:potion')
|
||||||
|
expect(afterCompensate?.quantity).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore deleted item after remove all', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||||
|
|
||||||
|
await op.compensate(ctx)
|
||||||
|
const restored = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||||
|
expect(restored?.quantity).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore state after update', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
properties: { damage: 10 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'update',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 0,
|
||||||
|
properties: { damage: 50 },
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
await op.compensate(ctx)
|
||||||
|
|
||||||
|
const restored = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||||
|
expect(restored?.properties).toEqual({ damage: 10 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Provider 测试 | Provider Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Provider', () => {
|
||||||
|
it('should use provider for operations', async () => {
|
||||||
|
const provider = new MockInventoryProvider()
|
||||||
|
provider.addItem('player-1', 'sword', { itemId: 'sword', quantity: 1 })
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 2,
|
||||||
|
}).setProvider(provider)
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.afterItem?.quantity).toBe(3)
|
||||||
|
|
||||||
|
const item = await provider.getItem('player-1', 'sword')
|
||||||
|
expect(item?.quantity).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compensate using provider', async () => {
|
||||||
|
const provider = new MockInventoryProvider()
|
||||||
|
provider.addItem('player-1', 'potion', { itemId: 'potion', quantity: 10 })
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 3,
|
||||||
|
}).setProvider(provider)
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
expect((await provider.getItem('player-1', 'potion'))?.quantity).toBe(7)
|
||||||
|
|
||||||
|
await op.compensate(ctx)
|
||||||
|
expect((await provider.getItem('player-1', 'potion'))?.quantity).toBe(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 边界情况测试 | Edge Cases
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle context without storage', async () => {
|
||||||
|
const noStorageCtx = new TransactionContext()
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(noStorageCtx)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include reason in data', () => {
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'reward-sword',
|
||||||
|
quantity: 1,
|
||||||
|
reason: 'quest_reward',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op.data.reason).toBe('quest_reward')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve item properties when stacking', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:potion', {
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 5,
|
||||||
|
properties: { quality: 'rare' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player-1',
|
||||||
|
itemId: 'potion',
|
||||||
|
quantity: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.data?.afterItem?.properties).toEqual({ quality: 'rare' })
|
||||||
|
expect(result.data?.afterItem?.quantity).toBe(8)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
/**
|
||||||
|
* @zh TradeOperation 单元测试
|
||||||
|
* @en TradeOperation unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { TradeOperation, createTradeOperation, type ITradeProvider } from '../../src/operations/TradeOperation.js'
|
||||||
|
import { TransactionContext } from '../../src/core/TransactionContext.js'
|
||||||
|
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||||
|
import type { ITransactionContext } from '../../src/core/types.js'
|
||||||
|
import type { ICurrencyProvider } from '../../src/operations/CurrencyOperation.js'
|
||||||
|
import type { IInventoryProvider, ItemData } from '../../src/operations/InventoryOperation.js'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Providers | 模拟数据提供者
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class MockCurrencyProvider implements ICurrencyProvider {
|
||||||
|
private _balances: Map<string, Map<string, number>> = new Map()
|
||||||
|
|
||||||
|
initBalance(playerId: string, currency: string, amount: number): void {
|
||||||
|
if (!this._balances.has(playerId)) {
|
||||||
|
this._balances.set(playerId, new Map())
|
||||||
|
}
|
||||||
|
this._balances.get(playerId)!.set(currency, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(playerId: string, currency: string): Promise<number> {
|
||||||
|
return this._balances.get(playerId)?.get(currency) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
|
||||||
|
if (!this._balances.has(playerId)) {
|
||||||
|
this._balances.set(playerId, new Map())
|
||||||
|
}
|
||||||
|
this._balances.get(playerId)!.set(currency, amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockInventoryProvider implements IInventoryProvider {
|
||||||
|
private _inventory: Map<string, Map<string, ItemData>> = new Map()
|
||||||
|
|
||||||
|
addItem(playerId: string, itemId: string, item: ItemData): void {
|
||||||
|
if (!this._inventory.has(playerId)) {
|
||||||
|
this._inventory.set(playerId, new Map())
|
||||||
|
}
|
||||||
|
this._inventory.get(playerId)!.set(itemId, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
|
||||||
|
return this._inventory.get(playerId)?.get(itemId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
|
||||||
|
if (!this._inventory.has(playerId)) {
|
||||||
|
this._inventory.set(playerId, new Map())
|
||||||
|
}
|
||||||
|
if (item) {
|
||||||
|
this._inventory.get(playerId)!.set(itemId, item)
|
||||||
|
} else {
|
||||||
|
this._inventory.get(playerId)!.delete(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test Suite | 测试套件
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
describe('TradeOperation', () => {
|
||||||
|
let storage: MemoryStorage
|
||||||
|
let ctx: ITransactionContext
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MemoryStorage()
|
||||||
|
ctx = new TransactionContext({ storage })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 构造器测试 | Constructor Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Constructor', () => {
|
||||||
|
it('should create with data', () => {
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op.name).toBe('trade')
|
||||||
|
expect(op.data.tradeId).toBe('trade-001')
|
||||||
|
expect(op.data.partyA.playerId).toBe('player-1')
|
||||||
|
expect(op.data.partyB.playerId).toBe('player-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use createTradeOperation factory', () => {
|
||||||
|
const op = createTradeOperation({
|
||||||
|
tradeId: 'trade-002',
|
||||||
|
partyA: { playerId: 'player-1' },
|
||||||
|
partyB: { playerId: 'player-2' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op).toBeInstanceOf(TradeOperation)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 验证测试 | Validation Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('validate()', () => {
|
||||||
|
it('should validate item trade', async () => {
|
||||||
|
// Player 1 has sword, Player 2 has gold
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
await storage.set('player:player-2:currency:gold', 1000)
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail validation when party A lacks items', async () => {
|
||||||
|
await storage.set('player:player-2:currency:gold', 1000)
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail validation when party B lacks currency', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
await storage.set('player:player-2:currency:gold', 500) // Not enough
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = await op.validate(ctx)
|
||||||
|
expect(isValid).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 物品换货币 | Execute Tests - Item for Currency
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - item for currency', () => {
|
||||||
|
it('should trade item for currency', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
await storage.set('player:player-2:currency:gold', 1000)
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.tradeId).toBe('trade-001')
|
||||||
|
expect(result.data?.completed).toBe(true)
|
||||||
|
|
||||||
|
// Player 1: no sword, got gold
|
||||||
|
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(1000)
|
||||||
|
|
||||||
|
// Player 2: got sword, no gold
|
||||||
|
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
|
||||||
|
expect(p2Sword?.quantity).toBe(1)
|
||||||
|
expect(await storage.get('player:player-2:currency:gold')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 物品换物品 | Execute Tests - Item for Item
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - item for item', () => {
|
||||||
|
it('should trade items between players', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
await storage.set('player:player-2:inventory:shield', {
|
||||||
|
itemId: 'shield',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-002',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
items: [{ itemId: 'shield', quantity: 1 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
// Player 1: got shield, no sword
|
||||||
|
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||||
|
const p1Shield = await storage.get<ItemData>('player:player-1:inventory:shield')
|
||||||
|
expect(p1Shield?.quantity).toBe(1)
|
||||||
|
|
||||||
|
// Player 2: got sword, no shield
|
||||||
|
expect(await storage.get('player:player-2:inventory:shield')).toBeNull()
|
||||||
|
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
|
||||||
|
expect(p2Sword?.quantity).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 货币换货币 | Execute Tests - Currency for Currency
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - currency for currency', () => {
|
||||||
|
it('should trade currencies between players', async () => {
|
||||||
|
await storage.set('player:player-1:currency:gold', 1000)
|
||||||
|
await storage.set('player:player-2:currency:diamond', 100)
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-003',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'diamond', amount: 100 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
// Player 1: no gold, got diamonds
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
|
||||||
|
expect(await storage.get('player:player-1:currency:diamond')).toBe(100)
|
||||||
|
|
||||||
|
// Player 2: got gold, no diamonds
|
||||||
|
expect(await storage.get('player:player-2:currency:gold')).toBe(1000)
|
||||||
|
expect(await storage.get('player:player-2:currency:diamond')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 执行测试 - 复杂交易 | Execute Tests - Complex Trade
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('execute() - complex trade', () => {
|
||||||
|
it('should handle multiple items and currencies', async () => {
|
||||||
|
// Setup
|
||||||
|
await storage.set('player:player-1:inventory:sword', { itemId: 'sword', quantity: 2 })
|
||||||
|
await storage.set('player:player-1:inventory:potion', { itemId: 'potion', quantity: 10 })
|
||||||
|
await storage.set('player:player-2:currency:gold', 5000)
|
||||||
|
await storage.set('player:player-2:currency:diamond', 50)
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-004',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [
|
||||||
|
{ itemId: 'sword', quantity: 1 },
|
||||||
|
{ itemId: 'potion', quantity: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [
|
||||||
|
{ currency: 'gold', amount: 2000 },
|
||||||
|
{ currency: 'diamond', amount: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
// Player 1
|
||||||
|
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||||
|
expect(p1Sword?.quantity).toBe(1) // Had 2, gave 1
|
||||||
|
const p1Potion = await storage.get<ItemData>('player:player-1:inventory:potion')
|
||||||
|
expect(p1Potion?.quantity).toBe(5) // Had 10, gave 5
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(2000)
|
||||||
|
expect(await storage.get('player:player-1:currency:diamond')).toBe(20)
|
||||||
|
|
||||||
|
// Player 2
|
||||||
|
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
|
||||||
|
expect(p2Sword?.quantity).toBe(1)
|
||||||
|
const p2Potion = await storage.get<ItemData>('player:player-2:inventory:potion')
|
||||||
|
expect(p2Potion?.quantity).toBe(5)
|
||||||
|
expect(await storage.get('player:player-2:currency:gold')).toBe(3000) // Had 5000, gave 2000
|
||||||
|
expect(await storage.get('player:player-2:currency:diamond')).toBe(30) // Had 50, gave 20
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 失败和补偿测试 | Failure and Compensation Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('failure and compensation', () => {
|
||||||
|
it('should rollback on partial failure', async () => {
|
||||||
|
// Player 1 has sword, Player 2 does NOT have enough gold
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
await storage.set('player:player-2:currency:gold', 500) // Not enough
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-fail',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(result.errorCode).toBe('TRADE_FAILED')
|
||||||
|
|
||||||
|
// Everything should be restored
|
||||||
|
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||||
|
expect(p1Sword?.quantity).toBe(1) // Restored
|
||||||
|
expect(await storage.get('player:player-2:inventory:sword')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compensate after successful execute', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:sword', {
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
await storage.set('player:player-2:currency:gold', 1000)
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-compensate',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await op.execute(ctx)
|
||||||
|
|
||||||
|
// Verify trade happened
|
||||||
|
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(1000)
|
||||||
|
|
||||||
|
// Compensate
|
||||||
|
await op.compensate(ctx)
|
||||||
|
|
||||||
|
// Everything should be restored
|
||||||
|
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
|
||||||
|
expect(p1Sword?.quantity).toBe(1)
|
||||||
|
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
|
||||||
|
expect(await storage.get('player:player-2:inventory:sword')).toBeNull()
|
||||||
|
expect(await storage.get('player:player-2:currency:gold')).toBe(1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Provider 测试 | Provider Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Provider', () => {
|
||||||
|
it('should use providers for trade', async () => {
|
||||||
|
const currencyProvider = new MockCurrencyProvider()
|
||||||
|
const inventoryProvider = new MockInventoryProvider()
|
||||||
|
|
||||||
|
currencyProvider.initBalance('player-1', 'gold', 0)
|
||||||
|
currencyProvider.initBalance('player-2', 'gold', 1000)
|
||||||
|
inventoryProvider.addItem('player-1', 'sword', { itemId: 'sword', quantity: 1 })
|
||||||
|
|
||||||
|
const provider: ITradeProvider = {
|
||||||
|
currencyProvider,
|
||||||
|
inventoryProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-provider',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player-2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
}).setProvider(provider)
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
|
||||||
|
// Verify using providers
|
||||||
|
expect(await inventoryProvider.getItem('player-1', 'sword')).toBeNull()
|
||||||
|
expect(await currencyProvider.getBalance('player-1', 'gold')).toBe(1000)
|
||||||
|
expect((await inventoryProvider.getItem('player-2', 'sword'))?.quantity).toBe(1)
|
||||||
|
expect(await currencyProvider.getBalance('player-2', 'gold')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 边界情况测试 | Edge Cases
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty trade', async () => {
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-empty',
|
||||||
|
partyA: { playerId: 'player-1' },
|
||||||
|
partyB: { playerId: 'player-2' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.completed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle one-sided gift', async () => {
|
||||||
|
await storage.set('player:player-1:inventory:gift', {
|
||||||
|
itemId: 'gift',
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-gift',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player-1',
|
||||||
|
items: [{ itemId: 'gift', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: { playerId: 'player-2' }, // Gives nothing
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await op.execute(ctx)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(await storage.get('player:player-1:inventory:gift')).toBeNull()
|
||||||
|
const p2Gift = await storage.get<ItemData>('player:player-2:inventory:gift')
|
||||||
|
expect(p2Gift?.quantity).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include reason in data', () => {
|
||||||
|
const op = new TradeOperation({
|
||||||
|
tradeId: 'trade-reason',
|
||||||
|
partyA: { playerId: 'player-1' },
|
||||||
|
partyB: { playerId: 'player-2' },
|
||||||
|
reason: 'player_trade',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(op.data.reason).toBe('player_trade')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* @zh MemoryStorage 单元测试
|
||||||
|
* @en MemoryStorage unit tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
|
||||||
|
import type { TransactionLog } from '../../src/core/types.js'
|
||||||
|
|
||||||
|
describe('MemoryStorage', () => {
|
||||||
|
let storage: MemoryStorage
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = new MemoryStorage()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 分布式锁测试 | Distributed Lock Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Distributed Lock', () => {
|
||||||
|
it('should acquire lock successfully', async () => {
|
||||||
|
const token = await storage.acquireLock('test-key', 5000)
|
||||||
|
expect(token).not.toBeNull()
|
||||||
|
expect(typeof token).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail to acquire same lock twice', async () => {
|
||||||
|
const token1 = await storage.acquireLock('test-key', 5000)
|
||||||
|
const token2 = await storage.acquireLock('test-key', 5000)
|
||||||
|
|
||||||
|
expect(token1).not.toBeNull()
|
||||||
|
expect(token2).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release lock with correct token', async () => {
|
||||||
|
const token = await storage.acquireLock('test-key', 5000)
|
||||||
|
expect(token).not.toBeNull()
|
||||||
|
|
||||||
|
const released = await storage.releaseLock('test-key', token!)
|
||||||
|
expect(released).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail to release lock with wrong token', async () => {
|
||||||
|
const token = await storage.acquireLock('test-key', 5000)
|
||||||
|
expect(token).not.toBeNull()
|
||||||
|
|
||||||
|
const released = await storage.releaseLock('test-key', 'wrong-token')
|
||||||
|
expect(released).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow re-acquiring after release', async () => {
|
||||||
|
const token1 = await storage.acquireLock('test-key', 5000)
|
||||||
|
await storage.releaseLock('test-key', token1!)
|
||||||
|
|
||||||
|
const token2 = await storage.acquireLock('test-key', 5000)
|
||||||
|
expect(token2).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expire lock after TTL', async () => {
|
||||||
|
await storage.acquireLock('test-key', 50) // 50ms TTL
|
||||||
|
|
||||||
|
// 等待锁过期
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
const token2 = await storage.acquireLock('test-key', 5000)
|
||||||
|
expect(token2).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 事务日志测试 | Transaction Log Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Transaction Log', () => {
|
||||||
|
const createMockLog = (id: string): TransactionLog => ({
|
||||||
|
id,
|
||||||
|
state: 'pending',
|
||||||
|
operations: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save and retrieve transaction', async () => {
|
||||||
|
const log = createMockLog('tx-1')
|
||||||
|
await storage.saveTransaction(log)
|
||||||
|
|
||||||
|
const retrieved = await storage.getTransaction('tx-1')
|
||||||
|
expect(retrieved).not.toBeNull()
|
||||||
|
expect(retrieved!.id).toBe('tx-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for non-existent transaction', async () => {
|
||||||
|
const retrieved = await storage.getTransaction('non-existent')
|
||||||
|
expect(retrieved).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update transaction state', async () => {
|
||||||
|
const log = createMockLog('tx-1')
|
||||||
|
await storage.saveTransaction(log)
|
||||||
|
|
||||||
|
await storage.updateTransactionState('tx-1', 'committed')
|
||||||
|
|
||||||
|
const retrieved = await storage.getTransaction('tx-1')
|
||||||
|
expect(retrieved!.state).toBe('committed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update operation state', async () => {
|
||||||
|
const log: TransactionLog = {
|
||||||
|
...createMockLog('tx-1'),
|
||||||
|
operations: [
|
||||||
|
{ name: 'op1', state: 'pending' },
|
||||||
|
{ name: 'op2', state: 'pending' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
await storage.saveTransaction(log)
|
||||||
|
|
||||||
|
await storage.updateOperationState('tx-1', 0, 'completed')
|
||||||
|
await storage.updateOperationState('tx-1', 1, 'failed', 'Some error')
|
||||||
|
|
||||||
|
const retrieved = await storage.getTransaction('tx-1')
|
||||||
|
expect(retrieved!.operations[0].state).toBe('completed')
|
||||||
|
expect(retrieved!.operations[1].state).toBe('failed')
|
||||||
|
expect(retrieved!.operations[1].error).toBe('Some error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delete transaction', async () => {
|
||||||
|
const log = createMockLog('tx-1')
|
||||||
|
await storage.saveTransaction(log)
|
||||||
|
|
||||||
|
await storage.deleteTransaction('tx-1')
|
||||||
|
|
||||||
|
const retrieved = await storage.getTransaction('tx-1')
|
||||||
|
expect(retrieved).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get pending transactions', async () => {
|
||||||
|
await storage.saveTransaction({ ...createMockLog('tx-1'), state: 'pending' })
|
||||||
|
await storage.saveTransaction({ ...createMockLog('tx-2'), state: 'executing' })
|
||||||
|
await storage.saveTransaction({ ...createMockLog('tx-3'), state: 'committed' })
|
||||||
|
|
||||||
|
const pending = await storage.getPendingTransactions()
|
||||||
|
expect(pending.length).toBe(2) // pending and executing
|
||||||
|
expect(pending.map((p) => p.id).sort()).toEqual(['tx-1', 'tx-2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter pending transactions by serverId', async () => {
|
||||||
|
await storage.saveTransaction({
|
||||||
|
...createMockLog('tx-1'),
|
||||||
|
state: 'pending',
|
||||||
|
metadata: { serverId: 'server-1' },
|
||||||
|
})
|
||||||
|
await storage.saveTransaction({
|
||||||
|
...createMockLog('tx-2'),
|
||||||
|
state: 'pending',
|
||||||
|
metadata: { serverId: 'server-2' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = await storage.getPendingTransactions('server-1')
|
||||||
|
expect(pending.length).toBe(1)
|
||||||
|
expect(pending[0].id).toBe('tx-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 数据操作测试 | Data Operations Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Data Operations', () => {
|
||||||
|
it('should set and get data', async () => {
|
||||||
|
await storage.set('key1', { value: 123 })
|
||||||
|
|
||||||
|
const data = await storage.get<{ value: number }>('key1')
|
||||||
|
expect(data).toEqual({ value: 123 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for non-existent key', async () => {
|
||||||
|
const data = await storage.get('non-existent')
|
||||||
|
expect(data).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delete data', async () => {
|
||||||
|
await storage.set('key1', { value: 123 })
|
||||||
|
const deleted = await storage.delete('key1')
|
||||||
|
|
||||||
|
expect(deleted).toBe(true)
|
||||||
|
expect(await storage.get('key1')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when deleting non-existent key', async () => {
|
||||||
|
const deleted = await storage.delete('non-existent')
|
||||||
|
expect(deleted).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expire data after TTL', async () => {
|
||||||
|
await storage.set('key1', { value: 123 }, 50) // 50ms TTL
|
||||||
|
|
||||||
|
// 数据应该存在
|
||||||
|
expect(await storage.get('key1')).toEqual({ value: 123 })
|
||||||
|
|
||||||
|
// 等待过期
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
expect(await storage.get('key1')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should overwrite existing data', async () => {
|
||||||
|
await storage.set('key1', { value: 1 })
|
||||||
|
await storage.set('key1', { value: 2 })
|
||||||
|
|
||||||
|
const data = await storage.get<{ value: number }>('key1')
|
||||||
|
expect(data).toEqual({ value: 2 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 辅助方法测试 | Helper Methods Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
describe('Helper Methods', () => {
|
||||||
|
it('should clear all data', async () => {
|
||||||
|
await storage.set('key1', 'value1')
|
||||||
|
await storage.set('key2', 'value2')
|
||||||
|
await storage.saveTransaction({
|
||||||
|
id: 'tx-1',
|
||||||
|
state: 'pending',
|
||||||
|
operations: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
storage.clear()
|
||||||
|
|
||||||
|
expect(await storage.get('key1')).toBeNull()
|
||||||
|
expect(await storage.get('key2')).toBeNull()
|
||||||
|
expect(await storage.getTransaction('tx-1')).toBeNull()
|
||||||
|
expect(storage.transactionCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track transaction count', async () => {
|
||||||
|
expect(storage.transactionCount).toBe(0)
|
||||||
|
|
||||||
|
await storage.saveTransaction({
|
||||||
|
id: 'tx-1',
|
||||||
|
state: 'pending',
|
||||||
|
operations: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(storage.transactionCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/transaction",
|
"name": "@esengine/transaction",
|
||||||
"version": "1.1.0",
|
"version": "2.0.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",
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
||||||
"build:watch": "tsup --watch",
|
"build:watch": "tsup --watch",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"clean": "rimraf dist"
|
"clean": "rimraf dist",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esengine/server": "workspace:*"
|
"@esengine/server": "workspace:*"
|
||||||
@@ -42,7 +44,8 @@
|
|||||||
"@esengine/build-config": "workspace:*",
|
"@esengine/build-config": "workspace:*",
|
||||||
"tsup": "^8.0.0",
|
"tsup": "^8.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"rimraf": "^5.0.0"
|
"rimraf": "^5.0.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ import type {
|
|||||||
TransactionOptions,
|
TransactionOptions,
|
||||||
TransactionLog,
|
TransactionLog,
|
||||||
OperationLog,
|
OperationLog,
|
||||||
OperationResult,
|
OperationResult
|
||||||
} from './types.js'
|
} from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 生成唯一 ID
|
* @zh 生成唯一 ID
|
||||||
* @en Generate unique ID
|
* @en Generate unique ID
|
||||||
*/
|
*/
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return `tx_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`
|
return `tx_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,22 +39,22 @@ function generateId(): string {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class TransactionContext implements ITransactionContext {
|
export class TransactionContext implements ITransactionContext {
|
||||||
private _id: string
|
private _id: string;
|
||||||
private _state: TransactionState = 'pending'
|
private _state: TransactionState = 'pending';
|
||||||
private _timeout: number
|
private _timeout: number;
|
||||||
private _operations: ITransactionOperation[] = []
|
private _operations: ITransactionOperation[] = [];
|
||||||
private _storage: ITransactionStorage | null
|
private _storage: ITransactionStorage | null;
|
||||||
private _metadata: Record<string, unknown>
|
private _metadata: Record<string, unknown>;
|
||||||
private _contextData: Map<string, unknown> = new Map()
|
private _contextData: Map<string, unknown> = new Map();
|
||||||
private _startTime: number = 0
|
private _startTime: number = 0;
|
||||||
private _distributed: boolean
|
private _distributed: boolean;
|
||||||
|
|
||||||
constructor(options: TransactionOptions & { storage?: ITransactionStorage } = {}) {
|
constructor(options: TransactionOptions & { storage?: ITransactionStorage } = {}) {
|
||||||
this._id = generateId()
|
this._id = generateId();
|
||||||
this._timeout = options.timeout ?? 30000
|
this._timeout = options.timeout ?? 30000;
|
||||||
this._storage = options.storage ?? null
|
this._storage = options.storage ?? null;
|
||||||
this._metadata = options.metadata ?? {}
|
this._metadata = options.metadata ?? {};
|
||||||
this._distributed = options.distributed ?? false
|
this._distributed = options.distributed ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -62,27 +62,27 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
get id(): string {
|
get id(): string {
|
||||||
return this._id
|
return this._id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get state(): TransactionState {
|
get state(): TransactionState {
|
||||||
return this._state
|
return this._state;
|
||||||
}
|
}
|
||||||
|
|
||||||
get timeout(): number {
|
get timeout(): number {
|
||||||
return this._timeout
|
return this._timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
get operations(): ReadonlyArray<ITransactionOperation> {
|
get operations(): ReadonlyArray<ITransactionOperation> {
|
||||||
return this._operations
|
return this._operations;
|
||||||
}
|
}
|
||||||
|
|
||||||
get storage(): ITransactionStorage | null {
|
get storage(): ITransactionStorage | null {
|
||||||
return this._storage
|
return this._storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
get metadata(): Record<string, unknown> {
|
get metadata(): Record<string, unknown> {
|
||||||
return this._metadata
|
return this._metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -95,10 +95,10 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
*/
|
*/
|
||||||
addOperation<T extends ITransactionOperation>(operation: T): this {
|
addOperation<T extends ITransactionOperation>(operation: T): this {
|
||||||
if (this._state !== 'pending') {
|
if (this._state !== 'pending') {
|
||||||
throw new Error(`Cannot add operation to transaction in state: ${this._state}`)
|
throw new Error(`Cannot add operation to transaction in state: ${this._state}`);
|
||||||
}
|
}
|
||||||
this._operations.push(operation)
|
this._operations.push(operation);
|
||||||
return this
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,64 +112,64 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
transactionId: this._id,
|
transactionId: this._id,
|
||||||
results: [],
|
results: [],
|
||||||
error: `Transaction already in state: ${this._state}`,
|
error: `Transaction already in state: ${this._state}`,
|
||||||
duration: 0,
|
duration: 0
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this._startTime = Date.now()
|
this._startTime = Date.now();
|
||||||
this._state = 'executing'
|
this._state = 'executing';
|
||||||
|
|
||||||
const results: OperationResult[] = []
|
const results: OperationResult[] = [];
|
||||||
let executedCount = 0
|
let executedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this._saveLog()
|
await this._saveLog();
|
||||||
|
|
||||||
for (let i = 0; i < this._operations.length; i++) {
|
for (let i = 0; i < this._operations.length; i++) {
|
||||||
if (this._isTimedOut()) {
|
if (this._isTimedOut()) {
|
||||||
throw new Error('Transaction timed out')
|
throw new Error('Transaction timed out');
|
||||||
}
|
}
|
||||||
|
|
||||||
const op = this._operations[i]
|
const op = this._operations[i];
|
||||||
|
|
||||||
const isValid = await op.validate(this)
|
const isValid = await op.validate(this);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
throw new Error(`Validation failed for operation: ${op.name}`)
|
throw new Error(`Validation failed for operation: ${op.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await op.execute(this)
|
const result = await op.execute(this);
|
||||||
results.push(result)
|
results.push(result);
|
||||||
executedCount++
|
executedCount++;
|
||||||
|
|
||||||
await this._updateOperationLog(i, 'executed')
|
await this._updateOperationLog(i, 'executed');
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error ?? `Operation ${op.name} failed`)
|
throw new Error(result.error ?? `Operation ${op.name} failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._state = 'committed'
|
this._state = 'committed';
|
||||||
await this._updateTransactionState('committed')
|
await this._updateTransactionState('committed');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
transactionId: this._id,
|
transactionId: this._id,
|
||||||
results,
|
results,
|
||||||
data: this._collectResultData(results) as T,
|
data: this._collectResultData(results) as T,
|
||||||
duration: Date.now() - this._startTime,
|
duration: Date.now() - this._startTime
|
||||||
}
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
await this._compensate(executedCount - 1)
|
await this._compensate(executedCount - 1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
transactionId: this._id,
|
transactionId: this._id,
|
||||||
results,
|
results,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
duration: Date.now() - this._startTime,
|
duration: Date.now() - this._startTime
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,10 +179,10 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
*/
|
*/
|
||||||
async rollback(): Promise<void> {
|
async rollback(): Promise<void> {
|
||||||
if (this._state === 'committed' || this._state === 'rolledback') {
|
if (this._state === 'committed' || this._state === 'rolledback') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._compensate(this._operations.length - 1)
|
await this._compensate(this._operations.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,7 +190,7 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
* @en Get context data
|
* @en Get context data
|
||||||
*/
|
*/
|
||||||
get<T>(key: string): T | undefined {
|
get<T>(key: string): T | undefined {
|
||||||
return this._contextData.get(key) as T | undefined
|
return this._contextData.get(key) as T | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,7 +198,7 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
* @en Set context data
|
* @en Set context data
|
||||||
*/
|
*/
|
||||||
set<T>(key: string, value: T): void {
|
set<T>(key: string, value: T): void {
|
||||||
this._contextData.set(key, value)
|
this._contextData.set(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -206,28 +206,28 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private _isTimedOut(): boolean {
|
private _isTimedOut(): boolean {
|
||||||
return Date.now() - this._startTime > this._timeout
|
return Date.now() - this._startTime > this._timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _compensate(fromIndex: number): Promise<void> {
|
private async _compensate(fromIndex: number): Promise<void> {
|
||||||
this._state = 'rolledback'
|
this._state = 'rolledback';
|
||||||
|
|
||||||
for (let i = fromIndex; i >= 0; i--) {
|
for (let i = fromIndex; i >= 0; i--) {
|
||||||
const op = this._operations[i]
|
const op = this._operations[i];
|
||||||
try {
|
try {
|
||||||
await op.compensate(this)
|
await op.compensate(this);
|
||||||
await this._updateOperationLog(i, 'compensated')
|
await this._updateOperationLog(i, 'compensated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
await this._updateOperationLog(i, 'failed', errorMessage)
|
await this._updateOperationLog(i, 'failed', errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._updateTransactionState('rolledback')
|
await this._updateTransactionState('rolledback');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _saveLog(): Promise<void> {
|
private async _saveLog(): Promise<void> {
|
||||||
if (!this._storage) return
|
if (!this._storage) return;
|
||||||
|
|
||||||
const log: TransactionLog = {
|
const log: TransactionLog = {
|
||||||
id: this._id,
|
id: this._id,
|
||||||
@@ -238,19 +238,19 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
operations: this._operations.map((op) => ({
|
operations: this._operations.map((op) => ({
|
||||||
name: op.name,
|
name: op.name,
|
||||||
data: op.data,
|
data: op.data,
|
||||||
state: 'pending' as const,
|
state: 'pending' as const
|
||||||
})),
|
})),
|
||||||
metadata: this._metadata,
|
metadata: this._metadata,
|
||||||
distributed: this._distributed,
|
distributed: this._distributed
|
||||||
}
|
};
|
||||||
|
|
||||||
await this._storage.saveTransaction(log)
|
await this._storage.saveTransaction(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _updateTransactionState(state: TransactionState): Promise<void> {
|
private async _updateTransactionState(state: TransactionState): Promise<void> {
|
||||||
this._state = state
|
this._state = state;
|
||||||
if (this._storage) {
|
if (this._storage) {
|
||||||
await this._storage.updateTransactionState(this._id, state)
|
await this._storage.updateTransactionState(this._id, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,18 +260,18 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
error?: string
|
error?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this._storage) {
|
if (this._storage) {
|
||||||
await this._storage.updateOperationState(this._id, index, state, error)
|
await this._storage.updateOperationState(this._id, index, state, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _collectResultData(results: OperationResult[]): unknown {
|
private _collectResultData(results: OperationResult[]): unknown {
|
||||||
const data: Record<string, unknown> = {}
|
const data: Record<string, unknown> = {};
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.data !== undefined) {
|
if (result.data !== undefined) {
|
||||||
Object.assign(data, result.data)
|
Object.assign(data, result.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Object.keys(data).length > 0 ? data : undefined
|
return Object.keys(data).length > 0 ? data : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,5 +282,5 @@ export class TransactionContext implements ITransactionContext {
|
|||||||
export function createTransactionContext(
|
export function createTransactionContext(
|
||||||
options: TransactionOptions & { storage?: ITransactionStorage } = {}
|
options: TransactionOptions & { storage?: ITransactionStorage } = {}
|
||||||
): ITransactionContext {
|
): ITransactionContext {
|
||||||
return new TransactionContext(options)
|
return new TransactionContext(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import type {
|
|||||||
TransactionManagerConfig,
|
TransactionManagerConfig,
|
||||||
TransactionOptions,
|
TransactionOptions,
|
||||||
TransactionLog,
|
TransactionLog,
|
||||||
TransactionResult,
|
TransactionResult
|
||||||
} from './types.js'
|
} from './types.js';
|
||||||
import { TransactionContext } from './TransactionContext.js'
|
import { TransactionContext } from './TransactionContext.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 事务管理器
|
* @zh 事务管理器
|
||||||
@@ -35,17 +35,17 @@ import { TransactionContext } from './TransactionContext.js'
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class TransactionManager {
|
export class TransactionManager {
|
||||||
private _storage: ITransactionStorage | null
|
private _storage: ITransactionStorage | null;
|
||||||
private _defaultTimeout: number
|
private _defaultTimeout: number;
|
||||||
private _serverId: string
|
private _serverId: string;
|
||||||
private _autoRecover: boolean
|
private _autoRecover: boolean;
|
||||||
private _activeTransactions: Map<string, ITransactionContext> = new Map()
|
private _activeTransactions: Map<string, ITransactionContext> = new Map();
|
||||||
|
|
||||||
constructor(config: TransactionManagerConfig = {}) {
|
constructor(config: TransactionManagerConfig = {}) {
|
||||||
this._storage = config.storage ?? null
|
this._storage = config.storage ?? null;
|
||||||
this._defaultTimeout = config.defaultTimeout ?? 30000
|
this._defaultTimeout = config.defaultTimeout ?? 30000;
|
||||||
this._serverId = config.serverId ?? this._generateServerId()
|
this._serverId = config.serverId ?? this._generateServerId();
|
||||||
this._autoRecover = config.autoRecover ?? true
|
this._autoRecover = config.autoRecover ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -57,7 +57,7 @@ export class TransactionManager {
|
|||||||
* @en Server ID
|
* @en Server ID
|
||||||
*/
|
*/
|
||||||
get serverId(): string {
|
get serverId(): string {
|
||||||
return this._serverId
|
return this._serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,7 +65,7 @@ export class TransactionManager {
|
|||||||
* @en Storage instance
|
* @en Storage instance
|
||||||
*/
|
*/
|
||||||
get storage(): ITransactionStorage | null {
|
get storage(): ITransactionStorage | null {
|
||||||
return this._storage
|
return this._storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +73,7 @@ export class TransactionManager {
|
|||||||
* @en Active transaction count
|
* @en Active transaction count
|
||||||
*/
|
*/
|
||||||
get activeCount(): number {
|
get activeCount(): number {
|
||||||
return this._activeTransactions.size
|
return this._activeTransactions.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -93,14 +93,14 @@ export class TransactionManager {
|
|||||||
storage: this._storage ?? undefined,
|
storage: this._storage ?? undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
...options.metadata,
|
...options.metadata,
|
||||||
serverId: this._serverId,
|
serverId: this._serverId
|
||||||
},
|
},
|
||||||
distributed: options.distributed,
|
distributed: options.distributed
|
||||||
})
|
});
|
||||||
|
|
||||||
this._activeTransactions.set(ctx.id, ctx)
|
this._activeTransactions.set(ctx.id, ctx);
|
||||||
|
|
||||||
return ctx
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,14 +115,14 @@ export class TransactionManager {
|
|||||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||||
options: TransactionOptions = {}
|
options: TransactionOptions = {}
|
||||||
): Promise<TransactionResult<T>> {
|
): Promise<TransactionResult<T>> {
|
||||||
const ctx = this.begin(options)
|
const ctx = this.begin(options);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await builder(ctx)
|
await builder(ctx);
|
||||||
const result = await ctx.execute<T>()
|
const result = await ctx.execute<T>();
|
||||||
return result
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
this._activeTransactions.delete(ctx.id)
|
this._activeTransactions.delete(ctx.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ export class TransactionManager {
|
|||||||
* @en Get active transaction
|
* @en Get active transaction
|
||||||
*/
|
*/
|
||||||
getTransaction(id: string): ITransactionContext | undefined {
|
getTransaction(id: string): ITransactionContext | undefined {
|
||||||
return this._activeTransactions.get(id)
|
return this._activeTransactions.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,21 +139,21 @@ export class TransactionManager {
|
|||||||
* @en Recover pending transactions
|
* @en Recover pending transactions
|
||||||
*/
|
*/
|
||||||
async recover(): Promise<number> {
|
async recover(): Promise<number> {
|
||||||
if (!this._storage) return 0
|
if (!this._storage) return 0;
|
||||||
|
|
||||||
const pendingTransactions = await this._storage.getPendingTransactions(this._serverId)
|
const pendingTransactions = await this._storage.getPendingTransactions(this._serverId);
|
||||||
let recoveredCount = 0
|
let recoveredCount = 0;
|
||||||
|
|
||||||
for (const log of pendingTransactions) {
|
for (const log of pendingTransactions) {
|
||||||
try {
|
try {
|
||||||
await this._recoverTransaction(log)
|
await this._recoverTransaction(log);
|
||||||
recoveredCount++
|
recoveredCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to recover transaction ${log.id}:`, error)
|
console.error(`Failed to recover transaction ${log.id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return recoveredCount
|
return recoveredCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,8 +161,8 @@ export class TransactionManager {
|
|||||||
* @en Acquire distributed lock
|
* @en Acquire distributed lock
|
||||||
*/
|
*/
|
||||||
async acquireLock(key: string, ttl: number = 10000): Promise<string | null> {
|
async acquireLock(key: string, ttl: number = 10000): Promise<string | null> {
|
||||||
if (!this._storage) return null
|
if (!this._storage) return null;
|
||||||
return this._storage.acquireLock(key, ttl)
|
return this._storage.acquireLock(key, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,8 +170,8 @@ export class TransactionManager {
|
|||||||
* @en Release distributed lock
|
* @en Release distributed lock
|
||||||
*/
|
*/
|
||||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||||
if (!this._storage) return false
|
if (!this._storage) return false;
|
||||||
return this._storage.releaseLock(key, token)
|
return this._storage.releaseLock(key, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,15 +183,15 @@ export class TransactionManager {
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
ttl: number = 10000
|
ttl: number = 10000
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token = await this.acquireLock(key, ttl)
|
const token = await this.acquireLock(key, ttl);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(`Failed to acquire lock for key: ${key}`)
|
throw new Error(`Failed to acquire lock for key: ${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn()
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
await this.releaseLock(key, token)
|
await this.releaseLock(key, token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,24 +200,24 @@ export class TransactionManager {
|
|||||||
* @en Clean up completed transaction logs
|
* @en Clean up completed transaction logs
|
||||||
*/
|
*/
|
||||||
async cleanup(beforeTimestamp?: number): Promise<number> {
|
async cleanup(beforeTimestamp?: number): Promise<number> {
|
||||||
if (!this._storage) return 0
|
if (!this._storage) return 0;
|
||||||
|
|
||||||
const timestamp = beforeTimestamp ?? Date.now() - 24 * 60 * 60 * 1000 // 默认清理24小时前
|
const timestamp = beforeTimestamp ?? Date.now() - 24 * 60 * 60 * 1000; // 默认清理24小时前
|
||||||
|
|
||||||
const pendingTransactions = await this._storage.getPendingTransactions()
|
const pendingTransactions = await this._storage.getPendingTransactions();
|
||||||
let cleanedCount = 0
|
let cleanedCount = 0;
|
||||||
|
|
||||||
for (const log of pendingTransactions) {
|
for (const log of pendingTransactions) {
|
||||||
if (
|
if (
|
||||||
log.createdAt < timestamp &&
|
log.createdAt < timestamp &&
|
||||||
(log.state === 'committed' || log.state === 'rolledback')
|
(log.state === 'committed' || log.state === 'rolledback')
|
||||||
) {
|
) {
|
||||||
await this._storage.deleteTransaction(log.id)
|
await this._storage.deleteTransaction(log.id);
|
||||||
cleanedCount++
|
cleanedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanedCount
|
return cleanedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -225,20 +225,20 @@ export class TransactionManager {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private _generateServerId(): string {
|
private _generateServerId(): string {
|
||||||
return `server_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`
|
return `server_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _recoverTransaction(log: TransactionLog): Promise<void> {
|
private async _recoverTransaction(log: TransactionLog): Promise<void> {
|
||||||
if (log.state === 'executing') {
|
if (log.state === 'executing') {
|
||||||
const executedOps = log.operations.filter((op) => op.state === 'executed')
|
const executedOps = log.operations.filter((op) => op.state === 'executed');
|
||||||
|
|
||||||
if (executedOps.length > 0 && this._storage) {
|
if (executedOps.length > 0 && this._storage) {
|
||||||
for (let i = executedOps.length - 1; i >= 0; i--) {
|
for (let i = executedOps.length - 1; i >= 0; i--) {
|
||||||
await this._storage.updateOperationState(log.id, i, 'compensated')
|
await this._storage.updateOperationState(log.id, i, 'compensated');
|
||||||
}
|
}
|
||||||
await this._storage.updateTransactionState(log.id, 'rolledback')
|
await this._storage.updateTransactionState(log.id, 'rolledback');
|
||||||
} else {
|
} else {
|
||||||
await this._storage?.updateTransactionState(log.id, 'failed')
|
await this._storage?.updateTransactionState(log.id, 'failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,5 +251,5 @@ export class TransactionManager {
|
|||||||
export function createTransactionManager(
|
export function createTransactionManager(
|
||||||
config: TransactionManagerConfig = {}
|
config: TransactionManagerConfig = {}
|
||||||
): TransactionManager {
|
): TransactionManager {
|
||||||
return new TransactionManager(config)
|
return new TransactionManager(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export type {
|
|||||||
TransactionManagerConfig,
|
TransactionManagerConfig,
|
||||||
ITransactionStorage,
|
ITransactionStorage,
|
||||||
ITransactionOperation,
|
ITransactionOperation,
|
||||||
ITransactionContext,
|
ITransactionContext
|
||||||
} from './types.js'
|
} from './types.js';
|
||||||
|
|
||||||
export { TransactionContext, createTransactionContext } from './TransactionContext.js'
|
export { TransactionContext, createTransactionContext } from './TransactionContext.js';
|
||||||
export { TransactionManager, createTransactionManager } from './TransactionManager.js'
|
export { TransactionManager, createTransactionManager } from './TransactionManager.js';
|
||||||
|
|||||||
@@ -279,6 +279,15 @@ export interface TransactionManagerConfig {
|
|||||||
* @en Transaction storage interface
|
* @en Transaction storage interface
|
||||||
*/
|
*/
|
||||||
export interface ITransactionStorage {
|
export interface ITransactionStorage {
|
||||||
|
/**
|
||||||
|
* @zh 关闭存储连接
|
||||||
|
* @en Close storage connection
|
||||||
|
*
|
||||||
|
* @zh 释放所有资源,关闭数据库连接
|
||||||
|
* @en Release all resources, close database connections
|
||||||
|
*/
|
||||||
|
close?(): Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 获取分布式锁
|
* @zh 获取分布式锁
|
||||||
* @en Acquire distributed lock
|
* @en Acquire distributed lock
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type {
|
|||||||
ITransactionStorage,
|
ITransactionStorage,
|
||||||
TransactionLog,
|
TransactionLog,
|
||||||
TransactionState,
|
TransactionState,
|
||||||
OperationResult,
|
OperationResult
|
||||||
} from '../core/types.js'
|
} from '../core/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh Saga 步骤状态
|
* @zh Saga 步骤状态
|
||||||
@@ -123,7 +123,7 @@ export interface SagaOrchestratorConfig {
|
|||||||
* @en Generate Saga ID
|
* @en Generate Saga ID
|
||||||
*/
|
*/
|
||||||
function generateSagaId(): string {
|
function generateSagaId(): string {
|
||||||
return `saga_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`
|
return `saga_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,14 +169,14 @@ function generateSagaId(): string {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class SagaOrchestrator {
|
export class SagaOrchestrator {
|
||||||
private _storage: ITransactionStorage | null
|
private _storage: ITransactionStorage | null;
|
||||||
private _timeout: number
|
private _timeout: number;
|
||||||
private _serverId: string
|
private _serverId: string;
|
||||||
|
|
||||||
constructor(config: SagaOrchestratorConfig = {}) {
|
constructor(config: SagaOrchestratorConfig = {}) {
|
||||||
this._storage = config.storage ?? null
|
this._storage = config.storage ?? null;
|
||||||
this._timeout = config.timeout ?? 30000
|
this._timeout = config.timeout ?? 30000;
|
||||||
this._serverId = config.serverId ?? 'default'
|
this._serverId = config.serverId ?? 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -184,9 +184,9 @@ export class SagaOrchestrator {
|
|||||||
* @en Execute Saga
|
* @en Execute Saga
|
||||||
*/
|
*/
|
||||||
async execute<T>(steps: SagaStep<T>[]): Promise<SagaResult> {
|
async execute<T>(steps: SagaStep<T>[]): Promise<SagaResult> {
|
||||||
const sagaId = generateSagaId()
|
const sagaId = generateSagaId();
|
||||||
const startTime = Date.now()
|
const startTime = Date.now();
|
||||||
const completedSteps: string[] = []
|
const completedSteps: string[] = [];
|
||||||
|
|
||||||
const sagaLog: SagaLog = {
|
const sagaLog: SagaLog = {
|
||||||
id: sagaId,
|
id: sagaId,
|
||||||
@@ -194,84 +194,84 @@ export class SagaOrchestrator {
|
|||||||
steps: steps.map((s) => ({
|
steps: steps.map((s) => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
serverId: s.serverId,
|
serverId: s.serverId,
|
||||||
state: 'pending' as SagaStepState,
|
state: 'pending' as SagaStepState
|
||||||
})),
|
})),
|
||||||
createdAt: startTime,
|
createdAt: startTime,
|
||||||
updatedAt: startTime,
|
updatedAt: startTime,
|
||||||
metadata: { orchestratorServerId: this._serverId },
|
metadata: { orchestratorServerId: this._serverId }
|
||||||
}
|
};
|
||||||
|
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sagaLog.state = 'running'
|
sagaLog.state = 'running';
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
const step = steps[i]
|
const step = steps[i];
|
||||||
|
|
||||||
if (Date.now() - startTime > this._timeout) {
|
if (Date.now() - startTime > this._timeout) {
|
||||||
throw new Error('Saga execution timed out')
|
throw new Error('Saga execution timed out');
|
||||||
}
|
}
|
||||||
|
|
||||||
sagaLog.steps[i].state = 'executing'
|
sagaLog.steps[i].state = 'executing';
|
||||||
sagaLog.steps[i].startedAt = Date.now()
|
sagaLog.steps[i].startedAt = Date.now();
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
|
|
||||||
const result = await step.execute(step.data)
|
const result = await step.execute(step.data);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
sagaLog.steps[i].state = 'failed'
|
sagaLog.steps[i].state = 'failed';
|
||||||
sagaLog.steps[i].error = result.error
|
sagaLog.steps[i].error = result.error;
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
|
|
||||||
throw new Error(result.error ?? `Step ${step.name} failed`)
|
throw new Error(result.error ?? `Step ${step.name} failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
sagaLog.steps[i].state = 'completed'
|
sagaLog.steps[i].state = 'completed';
|
||||||
sagaLog.steps[i].completedAt = Date.now()
|
sagaLog.steps[i].completedAt = Date.now();
|
||||||
completedSteps.push(step.name)
|
completedSteps.push(step.name);
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
sagaLog.state = 'completed'
|
sagaLog.state = 'completed';
|
||||||
sagaLog.updatedAt = Date.now()
|
sagaLog.updatedAt = Date.now();
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
sagaId,
|
sagaId,
|
||||||
completedSteps,
|
completedSteps,
|
||||||
duration: Date.now() - startTime,
|
duration: Date.now() - startTime
|
||||||
}
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
const failedStepIndex = completedSteps.length
|
const failedStepIndex = completedSteps.length;
|
||||||
|
|
||||||
sagaLog.state = 'compensating'
|
sagaLog.state = 'compensating';
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
|
|
||||||
for (let i = completedSteps.length - 1; i >= 0; i--) {
|
for (let i = completedSteps.length - 1; i >= 0; i--) {
|
||||||
const step = steps[i]
|
const step = steps[i];
|
||||||
|
|
||||||
sagaLog.steps[i].state = 'compensating'
|
sagaLog.steps[i].state = 'compensating';
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await step.compensate(step.data)
|
await step.compensate(step.data);
|
||||||
sagaLog.steps[i].state = 'compensated'
|
sagaLog.steps[i].state = 'compensated';
|
||||||
} catch (compError) {
|
} catch (compError) {
|
||||||
const compErrorMessage = compError instanceof Error ? compError.message : String(compError)
|
const compErrorMessage = compError instanceof Error ? compError.message : String(compError);
|
||||||
sagaLog.steps[i].state = 'failed'
|
sagaLog.steps[i].state = 'failed';
|
||||||
sagaLog.steps[i].error = `Compensation failed: ${compErrorMessage}`
|
sagaLog.steps[i].error = `Compensation failed: ${compErrorMessage}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
sagaLog.state = 'compensated'
|
sagaLog.state = 'compensated';
|
||||||
sagaLog.updatedAt = Date.now()
|
sagaLog.updatedAt = Date.now();
|
||||||
await this._saveSagaLog(sagaLog)
|
await this._saveSagaLog(sagaLog);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -279,8 +279,8 @@ export class SagaOrchestrator {
|
|||||||
completedSteps,
|
completedSteps,
|
||||||
failedStep: steps[failedStepIndex]?.name,
|
failedStep: steps[failedStepIndex]?.name,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
duration: Date.now() - startTime,
|
duration: Date.now() - startTime
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,21 +289,21 @@ export class SagaOrchestrator {
|
|||||||
* @en Recover pending Sagas
|
* @en Recover pending Sagas
|
||||||
*/
|
*/
|
||||||
async recover(): Promise<number> {
|
async recover(): Promise<number> {
|
||||||
if (!this._storage) return 0
|
if (!this._storage) return 0;
|
||||||
|
|
||||||
const pendingSagas = await this._getPendingSagas()
|
const pendingSagas = await this._getPendingSagas();
|
||||||
let recoveredCount = 0
|
let recoveredCount = 0;
|
||||||
|
|
||||||
for (const saga of pendingSagas) {
|
for (const saga of pendingSagas) {
|
||||||
try {
|
try {
|
||||||
await this._recoverSaga(saga)
|
await this._recoverSaga(saga);
|
||||||
recoveredCount++
|
recoveredCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to recover saga ${saga.id}:`, error)
|
console.error(`Failed to recover saga ${saga.id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return recoveredCount
|
return recoveredCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -311,31 +311,31 @@ export class SagaOrchestrator {
|
|||||||
* @en Get Saga log
|
* @en Get Saga log
|
||||||
*/
|
*/
|
||||||
async getSagaLog(sagaId: string): Promise<SagaLog | null> {
|
async getSagaLog(sagaId: string): Promise<SagaLog | null> {
|
||||||
if (!this._storage) return null
|
if (!this._storage) return null;
|
||||||
return this._storage.get<SagaLog>(`saga:${sagaId}`)
|
return this._storage.get<SagaLog>(`saga:${sagaId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _saveSagaLog(log: SagaLog): Promise<void> {
|
private async _saveSagaLog(log: SagaLog): Promise<void> {
|
||||||
if (!this._storage) return
|
if (!this._storage) return;
|
||||||
log.updatedAt = Date.now()
|
log.updatedAt = Date.now();
|
||||||
await this._storage.set(`saga:${log.id}`, log)
|
await this._storage.set(`saga:${log.id}`, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getPendingSagas(): Promise<SagaLog[]> {
|
private async _getPendingSagas(): Promise<SagaLog[]> {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _recoverSaga(saga: SagaLog): Promise<void> {
|
private async _recoverSaga(saga: SagaLog): Promise<void> {
|
||||||
if (saga.state === 'running' || saga.state === 'compensating') {
|
if (saga.state === 'running' || saga.state === 'compensating') {
|
||||||
const completedSteps = saga.steps
|
const completedSteps = saga.steps
|
||||||
.filter((s) => s.state === 'completed')
|
.filter((s) => s.state === 'completed')
|
||||||
.map((s) => s.name)
|
.map((s) => s.name);
|
||||||
|
|
||||||
saga.state = 'compensated'
|
saga.state = 'compensated';
|
||||||
saga.updatedAt = Date.now()
|
saga.updatedAt = Date.now();
|
||||||
|
|
||||||
if (this._storage) {
|
if (this._storage) {
|
||||||
await this._storage.set(`saga:${saga.id}`, saga)
|
await this._storage.set(`saga:${saga.id}`, saga);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,5 +346,5 @@ export class SagaOrchestrator {
|
|||||||
* @en Create Saga orchestrator
|
* @en Create Saga orchestrator
|
||||||
*/
|
*/
|
||||||
export function createSagaOrchestrator(config: SagaOrchestratorConfig = {}): SagaOrchestrator {
|
export function createSagaOrchestrator(config: SagaOrchestratorConfig = {}): SagaOrchestrator {
|
||||||
return new SagaOrchestrator(config)
|
return new SagaOrchestrator(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ export {
|
|||||||
type SagaStepState,
|
type SagaStepState,
|
||||||
type SagaStepLog,
|
type SagaStepLog,
|
||||||
type SagaLog,
|
type SagaLog,
|
||||||
type SagaResult,
|
type SagaResult
|
||||||
} from './SagaOrchestrator.js'
|
} from './SagaOrchestrator.js';
|
||||||
|
|||||||
@@ -55,18 +55,18 @@ export type {
|
|||||||
TransactionManagerConfig,
|
TransactionManagerConfig,
|
||||||
ITransactionStorage,
|
ITransactionStorage,
|
||||||
ITransactionOperation,
|
ITransactionOperation,
|
||||||
ITransactionContext,
|
ITransactionContext
|
||||||
} from './core/types.js'
|
} from './core/types.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
TransactionContext,
|
TransactionContext,
|
||||||
createTransactionContext,
|
createTransactionContext
|
||||||
} from './core/TransactionContext.js'
|
} from './core/TransactionContext.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
TransactionManager,
|
TransactionManager,
|
||||||
createTransactionManager,
|
createTransactionManager
|
||||||
} from './core/TransactionManager.js'
|
} from './core/TransactionManager.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Storage | 存储
|
// Storage | 存储
|
||||||
@@ -75,29 +75,29 @@ export {
|
|||||||
export {
|
export {
|
||||||
MemoryStorage,
|
MemoryStorage,
|
||||||
createMemoryStorage,
|
createMemoryStorage,
|
||||||
type MemoryStorageConfig,
|
type MemoryStorageConfig
|
||||||
} from './storage/MemoryStorage.js'
|
} from './storage/MemoryStorage.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
RedisStorage,
|
RedisStorage,
|
||||||
createRedisStorage,
|
createRedisStorage,
|
||||||
type RedisStorageConfig,
|
type RedisStorageConfig,
|
||||||
type RedisClient,
|
type RedisClient
|
||||||
} from './storage/RedisStorage.js'
|
} from './storage/RedisStorage.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
MongoStorage,
|
MongoStorage,
|
||||||
createMongoStorage,
|
createMongoStorage,
|
||||||
type MongoStorageConfig,
|
type MongoStorageConfig,
|
||||||
type MongoDb,
|
type MongoDb,
|
||||||
type MongoCollection,
|
type MongoCollection
|
||||||
} from './storage/MongoStorage.js'
|
} from './storage/MongoStorage.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Operations | 操作
|
// Operations | 操作
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export { BaseOperation } from './operations/BaseOperation.js'
|
export { BaseOperation } from './operations/BaseOperation.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CurrencyOperation,
|
CurrencyOperation,
|
||||||
@@ -105,8 +105,8 @@ export {
|
|||||||
type CurrencyOperationType,
|
type CurrencyOperationType,
|
||||||
type CurrencyOperationData,
|
type CurrencyOperationData,
|
||||||
type CurrencyOperationResult,
|
type CurrencyOperationResult,
|
||||||
type ICurrencyProvider,
|
type ICurrencyProvider
|
||||||
} from './operations/CurrencyOperation.js'
|
} from './operations/CurrencyOperation.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
InventoryOperation,
|
InventoryOperation,
|
||||||
@@ -115,8 +115,8 @@ export {
|
|||||||
type InventoryOperationData,
|
type InventoryOperationData,
|
||||||
type InventoryOperationResult,
|
type InventoryOperationResult,
|
||||||
type IInventoryProvider,
|
type IInventoryProvider,
|
||||||
type ItemData,
|
type ItemData
|
||||||
} from './operations/InventoryOperation.js'
|
} from './operations/InventoryOperation.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
TradeOperation,
|
TradeOperation,
|
||||||
@@ -126,8 +126,8 @@ export {
|
|||||||
type TradeItem,
|
type TradeItem,
|
||||||
type TradeCurrency,
|
type TradeCurrency,
|
||||||
type TradeParty,
|
type TradeParty,
|
||||||
type ITradeProvider,
|
type ITradeProvider
|
||||||
} from './operations/TradeOperation.js'
|
} from './operations/TradeOperation.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Distributed | 分布式
|
// Distributed | 分布式
|
||||||
@@ -141,8 +141,8 @@ export {
|
|||||||
type SagaStepState,
|
type SagaStepState,
|
||||||
type SagaStepLog,
|
type SagaStepLog,
|
||||||
type SagaLog,
|
type SagaLog,
|
||||||
type SagaResult,
|
type SagaResult
|
||||||
} from './distributed/SagaOrchestrator.js'
|
} from './distributed/SagaOrchestrator.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Integration | 集成
|
// Integration | 集成
|
||||||
@@ -152,8 +152,8 @@ export {
|
|||||||
withTransactions,
|
withTransactions,
|
||||||
TransactionRoom,
|
TransactionRoom,
|
||||||
type TransactionRoomConfig,
|
type TransactionRoomConfig,
|
||||||
type ITransactionRoom,
|
type ITransactionRoom
|
||||||
} from './integration/RoomTransactionMixin.js'
|
} from './integration/RoomTransactionMixin.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tokens | 令牌
|
// Tokens | 令牌
|
||||||
@@ -161,5 +161,5 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
TransactionManagerToken,
|
TransactionManagerToken,
|
||||||
TransactionStorageToken,
|
TransactionStorageToken
|
||||||
} from './tokens.js'
|
} from './tokens.js';
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import type {
|
|||||||
ITransactionStorage,
|
ITransactionStorage,
|
||||||
ITransactionContext,
|
ITransactionContext,
|
||||||
TransactionOptions,
|
TransactionOptions,
|
||||||
TransactionResult,
|
TransactionResult
|
||||||
} from '../core/types.js'
|
} from '../core/types.js';
|
||||||
import { TransactionManager } from '../core/TransactionManager.js'
|
import { TransactionManager } from '../core/TransactionManager.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 事务 Room 配置
|
* @zh 事务 Room 配置
|
||||||
@@ -96,32 +96,32 @@ export function withTransactions<TBase extends new (...args: any[]) => any>(
|
|||||||
config: TransactionRoomConfig = {}
|
config: TransactionRoomConfig = {}
|
||||||
): TBase & (new (...args: any[]) => ITransactionRoom) {
|
): TBase & (new (...args: any[]) => ITransactionRoom) {
|
||||||
return class TransactionRoom extends Base implements ITransactionRoom {
|
return class TransactionRoom extends Base implements ITransactionRoom {
|
||||||
private _transactionManager: TransactionManager
|
private _transactionManager: TransactionManager;
|
||||||
|
|
||||||
constructor(...args: any[]) {
|
constructor(...args: any[]) {
|
||||||
super(...args)
|
super(...args);
|
||||||
this._transactionManager = new TransactionManager({
|
this._transactionManager = new TransactionManager({
|
||||||
storage: config.storage,
|
storage: config.storage,
|
||||||
defaultTimeout: config.defaultTimeout,
|
defaultTimeout: config.defaultTimeout,
|
||||||
serverId: config.serverId,
|
serverId: config.serverId
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get transactions(): TransactionManager {
|
get transactions(): TransactionManager {
|
||||||
return this._transactionManager
|
return this._transactionManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
beginTransaction(options?: TransactionOptions): ITransactionContext {
|
beginTransaction(options?: TransactionOptions): ITransactionContext {
|
||||||
return this._transactionManager.begin(options)
|
return this._transactionManager.begin(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
runTransaction<T = unknown>(
|
runTransaction<T = unknown>(
|
||||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||||
options?: TransactionOptions
|
options?: TransactionOptions
|
||||||
): Promise<TransactionResult<T>> {
|
): Promise<TransactionResult<T>> {
|
||||||
return this._transactionManager.run<T>(builder, options)
|
return this._transactionManager.run<T>(builder, options);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,28 +147,28 @@ export function withTransactions<TBase extends new (...args: any[]) => any>(
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export abstract class TransactionRoom implements ITransactionRoom {
|
export abstract class TransactionRoom implements ITransactionRoom {
|
||||||
private _transactionManager: TransactionManager
|
private _transactionManager: TransactionManager;
|
||||||
|
|
||||||
constructor(config: TransactionRoomConfig = {}) {
|
constructor(config: TransactionRoomConfig = {}) {
|
||||||
this._transactionManager = new TransactionManager({
|
this._transactionManager = new TransactionManager({
|
||||||
storage: config.storage,
|
storage: config.storage,
|
||||||
defaultTimeout: config.defaultTimeout,
|
defaultTimeout: config.defaultTimeout,
|
||||||
serverId: config.serverId,
|
serverId: config.serverId
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get transactions(): TransactionManager {
|
get transactions(): TransactionManager {
|
||||||
return this._transactionManager
|
return this._transactionManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
beginTransaction(options?: TransactionOptions): ITransactionContext {
|
beginTransaction(options?: TransactionOptions): ITransactionContext {
|
||||||
return this._transactionManager.begin(options)
|
return this._transactionManager.begin(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
runTransaction<T = unknown>(
|
runTransaction<T = unknown>(
|
||||||
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
builder: (ctx: ITransactionContext) => void | Promise<void>,
|
||||||
options?: TransactionOptions
|
options?: TransactionOptions
|
||||||
): Promise<TransactionResult<T>> {
|
): Promise<TransactionResult<T>> {
|
||||||
return this._transactionManager.run<T>(builder, options)
|
return this._transactionManager.run<T>(builder, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export {
|
|||||||
withTransactions,
|
withTransactions,
|
||||||
TransactionRoom,
|
TransactionRoom,
|
||||||
type TransactionRoomConfig,
|
type TransactionRoomConfig,
|
||||||
type ITransactionRoom,
|
type ITransactionRoom
|
||||||
} from './RoomTransactionMixin.js'
|
} from './RoomTransactionMixin.js';
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
import type {
|
import type {
|
||||||
ITransactionOperation,
|
ITransactionOperation,
|
||||||
ITransactionContext,
|
ITransactionContext,
|
||||||
OperationResult,
|
OperationResult
|
||||||
} from '../core/types.js'
|
} from '../core/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 操作基类
|
* @zh 操作基类
|
||||||
@@ -17,13 +17,13 @@ import type {
|
|||||||
* @en Provides common operation implementation template
|
* @en Provides common operation implementation template
|
||||||
*/
|
*/
|
||||||
export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
||||||
implements ITransactionOperation<TData, TResult>
|
implements ITransactionOperation<TData, TResult>
|
||||||
{
|
{
|
||||||
abstract readonly name: string
|
abstract readonly name: string
|
||||||
readonly data: TData
|
readonly data: TData;
|
||||||
|
|
||||||
constructor(data: TData) {
|
constructor(data: TData) {
|
||||||
this.data = data
|
this.data = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +31,7 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
|||||||
* @en Validate preconditions (passes by default)
|
* @en Validate preconditions (passes by default)
|
||||||
*/
|
*/
|
||||||
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
async validate(_ctx: ITransactionContext): Promise<boolean> {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +51,7 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
|||||||
* @en Create success result
|
* @en Create success result
|
||||||
*/
|
*/
|
||||||
protected success(data?: TResult): OperationResult<TResult> {
|
protected success(data?: TResult): OperationResult<TResult> {
|
||||||
return { success: true, data }
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +59,6 @@ export abstract class BaseOperation<TData = unknown, TResult = unknown>
|
|||||||
* @en Create failure result
|
* @en Create failure result
|
||||||
*/
|
*/
|
||||||
protected failure(error: string, errorCode?: string): OperationResult<TResult> {
|
protected failure(error: string, errorCode?: string): OperationResult<TResult> {
|
||||||
return { success: false, error, errorCode }
|
return { success: false, error, errorCode };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* @en Currency operation
|
* @en Currency operation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ITransactionContext, OperationResult } from '../core/types.js'
|
import type { ITransactionContext, OperationResult } from '../core/types.js';
|
||||||
import { BaseOperation } from './BaseOperation.js'
|
import { BaseOperation } from './BaseOperation.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 货币操作类型
|
* @zh 货币操作类型
|
||||||
@@ -112,89 +112,89 @@ export interface ICurrencyProvider {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class CurrencyOperation extends BaseOperation<CurrencyOperationData, CurrencyOperationResult> {
|
export class CurrencyOperation extends BaseOperation<CurrencyOperationData, CurrencyOperationResult> {
|
||||||
readonly name = 'currency'
|
readonly name = 'currency';
|
||||||
|
|
||||||
private _provider: ICurrencyProvider | null = null
|
private _provider: ICurrencyProvider | null = null;
|
||||||
private _beforeBalance: number = 0
|
private _beforeBalance: number = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 设置货币数据提供者
|
* @zh 设置货币数据提供者
|
||||||
* @en Set currency data provider
|
* @en Set currency data provider
|
||||||
*/
|
*/
|
||||||
setProvider(provider: ICurrencyProvider): this {
|
setProvider(provider: ICurrencyProvider): this {
|
||||||
this._provider = provider
|
this._provider = provider;
|
||||||
return this
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||||
if (this.data.amount <= 0) {
|
if (this.data.amount <= 0) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.data.type === 'deduct') {
|
if (this.data.type === 'deduct') {
|
||||||
const balance = await this._getBalance(ctx)
|
const balance = await this._getBalance(ctx);
|
||||||
return balance >= this.data.amount
|
return balance >= this.data.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(ctx: ITransactionContext): Promise<OperationResult<CurrencyOperationResult>> {
|
async execute(ctx: ITransactionContext): Promise<OperationResult<CurrencyOperationResult>> {
|
||||||
const { type, playerId, currency, amount } = this.data
|
const { type, playerId, currency, amount } = this.data;
|
||||||
|
|
||||||
this._beforeBalance = await this._getBalance(ctx)
|
this._beforeBalance = await this._getBalance(ctx);
|
||||||
|
|
||||||
let afterBalance: number
|
let afterBalance: number;
|
||||||
|
|
||||||
if (type === 'add') {
|
if (type === 'add') {
|
||||||
afterBalance = this._beforeBalance + amount
|
afterBalance = this._beforeBalance + amount;
|
||||||
} else {
|
} else {
|
||||||
if (this._beforeBalance < amount) {
|
if (this._beforeBalance < amount) {
|
||||||
return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE')
|
return this.failure('Insufficient balance', 'INSUFFICIENT_BALANCE');
|
||||||
}
|
}
|
||||||
afterBalance = this._beforeBalance - amount
|
afterBalance = this._beforeBalance - amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._setBalance(ctx, afterBalance)
|
await this._setBalance(ctx, afterBalance);
|
||||||
|
|
||||||
ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance)
|
ctx.set(`currency:${playerId}:${currency}:before`, this._beforeBalance);
|
||||||
ctx.set(`currency:${playerId}:${currency}:after`, afterBalance)
|
ctx.set(`currency:${playerId}:${currency}:after`, afterBalance);
|
||||||
|
|
||||||
return this.success({
|
return this.success({
|
||||||
beforeBalance: this._beforeBalance,
|
beforeBalance: this._beforeBalance,
|
||||||
afterBalance,
|
afterBalance
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||||
await this._setBalance(ctx, this._beforeBalance)
|
await this._setBalance(ctx, this._beforeBalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getBalance(ctx: ITransactionContext): Promise<number> {
|
private async _getBalance(ctx: ITransactionContext): Promise<number> {
|
||||||
const { playerId, currency } = this.data
|
const { playerId, currency } = this.data;
|
||||||
|
|
||||||
if (this._provider) {
|
if (this._provider) {
|
||||||
return this._provider.getBalance(playerId, currency)
|
return this._provider.getBalance(playerId, currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.storage) {
|
if (ctx.storage) {
|
||||||
const balance = await ctx.storage.get<number>(`player:${playerId}:currency:${currency}`)
|
const balance = await ctx.storage.get<number>(`player:${playerId}:currency:${currency}`);
|
||||||
return balance ?? 0
|
return balance ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setBalance(ctx: ITransactionContext, amount: number): Promise<void> {
|
private async _setBalance(ctx: ITransactionContext, amount: number): Promise<void> {
|
||||||
const { playerId, currency } = this.data
|
const { playerId, currency } = this.data;
|
||||||
|
|
||||||
if (this._provider) {
|
if (this._provider) {
|
||||||
await this._provider.setBalance(playerId, currency, amount)
|
await this._provider.setBalance(playerId, currency, amount);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.storage) {
|
if (ctx.storage) {
|
||||||
await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount)
|
await ctx.storage.set(`player:${playerId}:currency:${currency}`, amount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,5 +204,5 @@ export class CurrencyOperation extends BaseOperation<CurrencyOperationData, Curr
|
|||||||
* @en Create currency operation
|
* @en Create currency operation
|
||||||
*/
|
*/
|
||||||
export function createCurrencyOperation(data: CurrencyOperationData): CurrencyOperation {
|
export function createCurrencyOperation(data: CurrencyOperationData): CurrencyOperation {
|
||||||
return new CurrencyOperation(data)
|
return new CurrencyOperation(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* @en Inventory operation
|
* @en Inventory operation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ITransactionContext, OperationResult } from '../core/types.js'
|
import type { ITransactionContext, OperationResult } from '../core/types.js';
|
||||||
import { BaseOperation } from './BaseOperation.js'
|
import { BaseOperation } from './BaseOperation.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 背包操作类型
|
* @zh 背包操作类型
|
||||||
@@ -147,136 +147,136 @@ export interface IInventoryProvider {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class InventoryOperation extends BaseOperation<InventoryOperationData, InventoryOperationResult> {
|
export class InventoryOperation extends BaseOperation<InventoryOperationData, InventoryOperationResult> {
|
||||||
readonly name = 'inventory'
|
readonly name = 'inventory';
|
||||||
|
|
||||||
private _provider: IInventoryProvider | null = null
|
private _provider: IInventoryProvider | null = null;
|
||||||
private _beforeItem: ItemData | null = null
|
private _beforeItem: ItemData | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 设置背包数据提供者
|
* @zh 设置背包数据提供者
|
||||||
* @en Set inventory data provider
|
* @en Set inventory data provider
|
||||||
*/
|
*/
|
||||||
setProvider(provider: IInventoryProvider): this {
|
setProvider(provider: IInventoryProvider): this {
|
||||||
this._provider = provider
|
this._provider = provider;
|
||||||
return this
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||||
const { type, quantity } = this.data
|
const { type, quantity } = this.data;
|
||||||
|
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'remove') {
|
if (type === 'remove') {
|
||||||
const item = await this._getItem(ctx)
|
const item = await this._getItem(ctx);
|
||||||
return item !== null && item.quantity >= quantity
|
return item !== null && item.quantity >= quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'add' && this._provider?.hasCapacity) {
|
if (type === 'add' && this._provider?.hasCapacity) {
|
||||||
return this._provider.hasCapacity(this.data.playerId, 1)
|
return this._provider.hasCapacity(this.data.playerId, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(ctx: ITransactionContext): Promise<OperationResult<InventoryOperationResult>> {
|
async execute(ctx: ITransactionContext): Promise<OperationResult<InventoryOperationResult>> {
|
||||||
const { type, playerId, itemId, quantity, properties } = this.data
|
const { type, playerId, itemId, quantity, properties } = this.data;
|
||||||
|
|
||||||
this._beforeItem = await this._getItem(ctx)
|
this._beforeItem = await this._getItem(ctx);
|
||||||
|
|
||||||
let afterItem: ItemData | null = null
|
let afterItem: ItemData | null = null;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'add': {
|
case 'add': {
|
||||||
if (this._beforeItem) {
|
if (this._beforeItem) {
|
||||||
afterItem = {
|
afterItem = {
|
||||||
...this._beforeItem,
|
...this._beforeItem,
|
||||||
quantity: this._beforeItem.quantity + quantity,
|
quantity: this._beforeItem.quantity + quantity
|
||||||
}
|
};
|
||||||
} else {
|
} else {
|
||||||
afterItem = {
|
afterItem = {
|
||||||
itemId,
|
itemId,
|
||||||
quantity,
|
quantity,
|
||||||
properties,
|
properties
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'remove': {
|
case 'remove': {
|
||||||
if (!this._beforeItem || this._beforeItem.quantity < quantity) {
|
if (!this._beforeItem || this._beforeItem.quantity < quantity) {
|
||||||
return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM')
|
return this.failure('Insufficient item quantity', 'INSUFFICIENT_ITEM');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newQuantity = this._beforeItem.quantity - quantity
|
const newQuantity = this._beforeItem.quantity - quantity;
|
||||||
if (newQuantity > 0) {
|
if (newQuantity > 0) {
|
||||||
afterItem = {
|
afterItem = {
|
||||||
...this._beforeItem,
|
...this._beforeItem,
|
||||||
quantity: newQuantity,
|
quantity: newQuantity
|
||||||
}
|
};
|
||||||
} else {
|
} else {
|
||||||
afterItem = null
|
afterItem = null;
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'update': {
|
case 'update': {
|
||||||
if (!this._beforeItem) {
|
if (!this._beforeItem) {
|
||||||
return this.failure('Item not found', 'ITEM_NOT_FOUND')
|
return this.failure('Item not found', 'ITEM_NOT_FOUND');
|
||||||
}
|
}
|
||||||
|
|
||||||
afterItem = {
|
afterItem = {
|
||||||
...this._beforeItem,
|
...this._beforeItem,
|
||||||
quantity: quantity > 0 ? quantity : this._beforeItem.quantity,
|
quantity: quantity > 0 ? quantity : this._beforeItem.quantity,
|
||||||
properties: properties ?? this._beforeItem.properties,
|
properties: properties ?? this._beforeItem.properties
|
||||||
}
|
};
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._setItem(ctx, afterItem)
|
await this._setItem(ctx, afterItem);
|
||||||
|
|
||||||
ctx.set(`inventory:${playerId}:${itemId}:before`, this._beforeItem)
|
ctx.set(`inventory:${playerId}:${itemId}:before`, this._beforeItem);
|
||||||
ctx.set(`inventory:${playerId}:${itemId}:after`, afterItem)
|
ctx.set(`inventory:${playerId}:${itemId}:after`, afterItem);
|
||||||
|
|
||||||
return this.success({
|
return this.success({
|
||||||
beforeItem: this._beforeItem ?? undefined,
|
beforeItem: this._beforeItem ?? undefined,
|
||||||
afterItem: afterItem ?? undefined,
|
afterItem: afterItem ?? undefined
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||||
await this._setItem(ctx, this._beforeItem)
|
await this._setItem(ctx, this._beforeItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getItem(ctx: ITransactionContext): Promise<ItemData | null> {
|
private async _getItem(ctx: ITransactionContext): Promise<ItemData | null> {
|
||||||
const { playerId, itemId } = this.data
|
const { playerId, itemId } = this.data;
|
||||||
|
|
||||||
if (this._provider) {
|
if (this._provider) {
|
||||||
return this._provider.getItem(playerId, itemId)
|
return this._provider.getItem(playerId, itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.storage) {
|
if (ctx.storage) {
|
||||||
return ctx.storage.get<ItemData>(`player:${playerId}:inventory:${itemId}`)
|
return ctx.storage.get<ItemData>(`player:${playerId}:inventory:${itemId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setItem(ctx: ITransactionContext, item: ItemData | null): Promise<void> {
|
private async _setItem(ctx: ITransactionContext, item: ItemData | null): Promise<void> {
|
||||||
const { playerId, itemId } = this.data
|
const { playerId, itemId } = this.data;
|
||||||
|
|
||||||
if (this._provider) {
|
if (this._provider) {
|
||||||
await this._provider.setItem(playerId, itemId, item)
|
await this._provider.setItem(playerId, itemId, item);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.storage) {
|
if (ctx.storage) {
|
||||||
if (item) {
|
if (item) {
|
||||||
await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item)
|
await ctx.storage.set(`player:${playerId}:inventory:${itemId}`, item);
|
||||||
} else {
|
} else {
|
||||||
await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`)
|
await ctx.storage.delete(`player:${playerId}:inventory:${itemId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,5 +287,5 @@ export class InventoryOperation extends BaseOperation<InventoryOperationData, In
|
|||||||
* @en Create inventory operation
|
* @en Create inventory operation
|
||||||
*/
|
*/
|
||||||
export function createInventoryOperation(data: InventoryOperationData): InventoryOperation {
|
export function createInventoryOperation(data: InventoryOperationData): InventoryOperation {
|
||||||
return new InventoryOperation(data)
|
return new InventoryOperation(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
* @en Trade operation
|
* @en Trade operation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ITransactionContext, OperationResult } from '../core/types.js'
|
import type { ITransactionContext, OperationResult } from '../core/types.js';
|
||||||
import { BaseOperation } from './BaseOperation.js'
|
import { BaseOperation } from './BaseOperation.js';
|
||||||
import { CurrencyOperation, type CurrencyOperationData, type ICurrencyProvider } from './CurrencyOperation.js'
|
import { CurrencyOperation, type CurrencyOperationData, type ICurrencyProvider } from './CurrencyOperation.js';
|
||||||
import { InventoryOperation, type InventoryOperationData, type IInventoryProvider, type ItemData } from './InventoryOperation.js'
|
import { InventoryOperation, type InventoryOperationData, type IInventoryProvider, type ItemData } from './InventoryOperation.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 交易物品
|
* @zh 交易物品
|
||||||
@@ -148,67 +148,67 @@ export interface ITradeProvider {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class TradeOperation extends BaseOperation<TradeOperationData, TradeOperationResult> {
|
export class TradeOperation extends BaseOperation<TradeOperationData, TradeOperationResult> {
|
||||||
readonly name = 'trade'
|
readonly name = 'trade';
|
||||||
|
|
||||||
private _provider: ITradeProvider | null = null
|
private _provider: ITradeProvider | null = null;
|
||||||
private _subOperations: (CurrencyOperation | InventoryOperation)[] = []
|
private _subOperations: (CurrencyOperation | InventoryOperation)[] = [];
|
||||||
private _executedCount = 0
|
private _executedCount = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 设置交易数据提供者
|
* @zh 设置交易数据提供者
|
||||||
* @en Set trade data provider
|
* @en Set trade data provider
|
||||||
*/
|
*/
|
||||||
setProvider(provider: ITradeProvider): this {
|
setProvider(provider: ITradeProvider): this {
|
||||||
this._provider = provider
|
this._provider = provider;
|
||||||
return this
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(ctx: ITransactionContext): Promise<boolean> {
|
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||||
this._buildSubOperations()
|
this._buildSubOperations();
|
||||||
|
|
||||||
for (const op of this._subOperations) {
|
for (const op of this._subOperations) {
|
||||||
const isValid = await op.validate(ctx)
|
const isValid = await op.validate(ctx);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(ctx: ITransactionContext): Promise<OperationResult<TradeOperationResult>> {
|
async execute(ctx: ITransactionContext): Promise<OperationResult<TradeOperationResult>> {
|
||||||
this._buildSubOperations()
|
this._buildSubOperations();
|
||||||
this._executedCount = 0
|
this._executedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const op of this._subOperations) {
|
for (const op of this._subOperations) {
|
||||||
const result = await op.execute(ctx)
|
const result = await op.execute(ctx);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
await this._compensateExecuted(ctx)
|
await this._compensateExecuted(ctx);
|
||||||
return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED')
|
return this.failure(result.error ?? 'Trade operation failed', 'TRADE_FAILED');
|
||||||
}
|
}
|
||||||
this._executedCount++
|
this._executedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.success({
|
return this.success({
|
||||||
tradeId: this.data.tradeId,
|
tradeId: this.data.tradeId,
|
||||||
completed: true,
|
completed: true
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this._compensateExecuted(ctx)
|
await this._compensateExecuted(ctx);
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
return this.failure(errorMessage, 'TRADE_ERROR')
|
return this.failure(errorMessage, 'TRADE_ERROR');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async compensate(ctx: ITransactionContext): Promise<void> {
|
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||||
await this._compensateExecuted(ctx)
|
await this._compensateExecuted(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildSubOperations(): void {
|
private _buildSubOperations(): void {
|
||||||
if (this._subOperations.length > 0) return
|
if (this._subOperations.length > 0) return;
|
||||||
|
|
||||||
const { partyA, partyB } = this.data
|
const { partyA, partyB } = this.data;
|
||||||
|
|
||||||
if (partyA.items) {
|
if (partyA.items) {
|
||||||
for (const item of partyA.items) {
|
for (const item of partyA.items) {
|
||||||
@@ -217,22 +217,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
|||||||
playerId: partyA.playerId,
|
playerId: partyA.playerId,
|
||||||
itemId: item.itemId,
|
itemId: item.itemId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
reason: `trade:${this.data.tradeId}:give`,
|
reason: `trade:${this.data.tradeId}:give`
|
||||||
})
|
});
|
||||||
const addOp = new InventoryOperation({
|
const addOp = new InventoryOperation({
|
||||||
type: 'add',
|
type: 'add',
|
||||||
playerId: partyB.playerId,
|
playerId: partyB.playerId,
|
||||||
itemId: item.itemId,
|
itemId: item.itemId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
reason: `trade:${this.data.tradeId}:receive`,
|
reason: `trade:${this.data.tradeId}:receive`
|
||||||
})
|
});
|
||||||
|
|
||||||
if (this._provider?.inventoryProvider) {
|
if (this._provider?.inventoryProvider) {
|
||||||
removeOp.setProvider(this._provider.inventoryProvider)
|
removeOp.setProvider(this._provider.inventoryProvider);
|
||||||
addOp.setProvider(this._provider.inventoryProvider)
|
addOp.setProvider(this._provider.inventoryProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._subOperations.push(removeOp, addOp)
|
this._subOperations.push(removeOp, addOp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,22 +243,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
|||||||
playerId: partyA.playerId,
|
playerId: partyA.playerId,
|
||||||
currency: curr.currency,
|
currency: curr.currency,
|
||||||
amount: curr.amount,
|
amount: curr.amount,
|
||||||
reason: `trade:${this.data.tradeId}:give`,
|
reason: `trade:${this.data.tradeId}:give`
|
||||||
})
|
});
|
||||||
const addOp = new CurrencyOperation({
|
const addOp = new CurrencyOperation({
|
||||||
type: 'add',
|
type: 'add',
|
||||||
playerId: partyB.playerId,
|
playerId: partyB.playerId,
|
||||||
currency: curr.currency,
|
currency: curr.currency,
|
||||||
amount: curr.amount,
|
amount: curr.amount,
|
||||||
reason: `trade:${this.data.tradeId}:receive`,
|
reason: `trade:${this.data.tradeId}:receive`
|
||||||
})
|
});
|
||||||
|
|
||||||
if (this._provider?.currencyProvider) {
|
if (this._provider?.currencyProvider) {
|
||||||
deductOp.setProvider(this._provider.currencyProvider)
|
deductOp.setProvider(this._provider.currencyProvider);
|
||||||
addOp.setProvider(this._provider.currencyProvider)
|
addOp.setProvider(this._provider.currencyProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._subOperations.push(deductOp, addOp)
|
this._subOperations.push(deductOp, addOp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,22 +269,22 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
|||||||
playerId: partyB.playerId,
|
playerId: partyB.playerId,
|
||||||
itemId: item.itemId,
|
itemId: item.itemId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
reason: `trade:${this.data.tradeId}:give`,
|
reason: `trade:${this.data.tradeId}:give`
|
||||||
})
|
});
|
||||||
const addOp = new InventoryOperation({
|
const addOp = new InventoryOperation({
|
||||||
type: 'add',
|
type: 'add',
|
||||||
playerId: partyA.playerId,
|
playerId: partyA.playerId,
|
||||||
itemId: item.itemId,
|
itemId: item.itemId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
reason: `trade:${this.data.tradeId}:receive`,
|
reason: `trade:${this.data.tradeId}:receive`
|
||||||
})
|
});
|
||||||
|
|
||||||
if (this._provider?.inventoryProvider) {
|
if (this._provider?.inventoryProvider) {
|
||||||
removeOp.setProvider(this._provider.inventoryProvider)
|
removeOp.setProvider(this._provider.inventoryProvider);
|
||||||
addOp.setProvider(this._provider.inventoryProvider)
|
addOp.setProvider(this._provider.inventoryProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._subOperations.push(removeOp, addOp)
|
this._subOperations.push(removeOp, addOp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,29 +295,29 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
|||||||
playerId: partyB.playerId,
|
playerId: partyB.playerId,
|
||||||
currency: curr.currency,
|
currency: curr.currency,
|
||||||
amount: curr.amount,
|
amount: curr.amount,
|
||||||
reason: `trade:${this.data.tradeId}:give`,
|
reason: `trade:${this.data.tradeId}:give`
|
||||||
})
|
});
|
||||||
const addOp = new CurrencyOperation({
|
const addOp = new CurrencyOperation({
|
||||||
type: 'add',
|
type: 'add',
|
||||||
playerId: partyA.playerId,
|
playerId: partyA.playerId,
|
||||||
currency: curr.currency,
|
currency: curr.currency,
|
||||||
amount: curr.amount,
|
amount: curr.amount,
|
||||||
reason: `trade:${this.data.tradeId}:receive`,
|
reason: `trade:${this.data.tradeId}:receive`
|
||||||
})
|
});
|
||||||
|
|
||||||
if (this._provider?.currencyProvider) {
|
if (this._provider?.currencyProvider) {
|
||||||
deductOp.setProvider(this._provider.currencyProvider)
|
deductOp.setProvider(this._provider.currencyProvider);
|
||||||
addOp.setProvider(this._provider.currencyProvider)
|
addOp.setProvider(this._provider.currencyProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._subOperations.push(deductOp, addOp)
|
this._subOperations.push(deductOp, addOp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _compensateExecuted(ctx: ITransactionContext): Promise<void> {
|
private async _compensateExecuted(ctx: ITransactionContext): Promise<void> {
|
||||||
for (let i = this._executedCount - 1; i >= 0; i--) {
|
for (let i = this._executedCount - 1; i >= 0; i--) {
|
||||||
await this._subOperations[i].compensate(ctx)
|
await this._subOperations[i].compensate(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,5 +327,5 @@ export class TradeOperation extends BaseOperation<TradeOperationData, TradeOpera
|
|||||||
* @en Create trade operation
|
* @en Create trade operation
|
||||||
*/
|
*/
|
||||||
export function createTradeOperation(data: TradeOperationData): TradeOperation {
|
export function createTradeOperation(data: TradeOperationData): TradeOperation {
|
||||||
return new TradeOperation(data)
|
return new TradeOperation(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @en Operations module exports
|
* @en Operations module exports
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { BaseOperation } from './BaseOperation.js'
|
export { BaseOperation } from './BaseOperation.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CurrencyOperation,
|
CurrencyOperation,
|
||||||
@@ -11,8 +11,8 @@ export {
|
|||||||
type CurrencyOperationType,
|
type CurrencyOperationType,
|
||||||
type CurrencyOperationData,
|
type CurrencyOperationData,
|
||||||
type CurrencyOperationResult,
|
type CurrencyOperationResult,
|
||||||
type ICurrencyProvider,
|
type ICurrencyProvider
|
||||||
} from './CurrencyOperation.js'
|
} from './CurrencyOperation.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
InventoryOperation,
|
InventoryOperation,
|
||||||
@@ -21,8 +21,8 @@ export {
|
|||||||
type InventoryOperationData,
|
type InventoryOperationData,
|
||||||
type InventoryOperationResult,
|
type InventoryOperationResult,
|
||||||
type IInventoryProvider,
|
type IInventoryProvider,
|
||||||
type ItemData,
|
type ItemData
|
||||||
} from './InventoryOperation.js'
|
} from './InventoryOperation.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
TradeOperation,
|
TradeOperation,
|
||||||
@@ -32,5 +32,5 @@ export {
|
|||||||
type TradeItem,
|
type TradeItem,
|
||||||
type TradeCurrency,
|
type TradeCurrency,
|
||||||
type TradeParty,
|
type TradeParty,
|
||||||
type ITradeProvider,
|
type ITradeProvider
|
||||||
} from './TradeOperation.js'
|
} from './TradeOperation.js';
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type {
|
|||||||
ITransactionStorage,
|
ITransactionStorage,
|
||||||
TransactionLog,
|
TransactionLog,
|
||||||
TransactionState,
|
TransactionState,
|
||||||
OperationLog,
|
OperationLog
|
||||||
} from '../core/types.js'
|
} from '../core/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 内存存储配置
|
* @zh 内存存储配置
|
||||||
@@ -33,13 +33,13 @@ export interface MemoryStorageConfig {
|
|||||||
* @en Suitable for single-machine development and testing, data is stored in memory only
|
* @en Suitable for single-machine development and testing, data is stored in memory only
|
||||||
*/
|
*/
|
||||||
export class MemoryStorage implements ITransactionStorage {
|
export class MemoryStorage implements ITransactionStorage {
|
||||||
private _transactions: Map<string, TransactionLog> = new Map()
|
private _transactions: Map<string, TransactionLog> = new Map();
|
||||||
private _data: Map<string, { value: unknown; expireAt?: number }> = new Map()
|
private _data: Map<string, { value: unknown; expireAt?: number }> = new Map();
|
||||||
private _locks: Map<string, { token: string; expireAt: number }> = new Map()
|
private _locks: Map<string, { token: string; expireAt: number }> = new Map();
|
||||||
private _maxTransactions: number
|
private _maxTransactions: number;
|
||||||
|
|
||||||
constructor(config: MemoryStorageConfig = {}) {
|
constructor(config: MemoryStorageConfig = {}) {
|
||||||
this._maxTransactions = config.maxTransactions ?? 1000
|
this._maxTransactions = config.maxTransactions ?? 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -47,30 +47,30 @@ export class MemoryStorage implements ITransactionStorage {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||||
this._cleanExpiredLocks()
|
this._cleanExpiredLocks();
|
||||||
|
|
||||||
const existing = this._locks.get(key)
|
const existing = this._locks.get(key);
|
||||||
if (existing && existing.expireAt > Date.now()) {
|
if (existing && existing.expireAt > Date.now()) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = `lock_${Date.now()}_${Math.random().toString(36).substring(2)}`
|
const token = `lock_${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||||
this._locks.set(key, {
|
this._locks.set(key, {
|
||||||
token,
|
token,
|
||||||
expireAt: Date.now() + ttl,
|
expireAt: Date.now() + ttl
|
||||||
})
|
});
|
||||||
|
|
||||||
return token
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||||
const lock = this._locks.get(key)
|
const lock = this._locks.get(key);
|
||||||
if (!lock || lock.token !== token) {
|
if (!lock || lock.token !== token) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._locks.delete(key)
|
this._locks.delete(key);
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -79,22 +79,22 @@ export class MemoryStorage implements ITransactionStorage {
|
|||||||
|
|
||||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||||
if (this._transactions.size >= this._maxTransactions) {
|
if (this._transactions.size >= this._maxTransactions) {
|
||||||
this._cleanOldTransactions()
|
this._cleanOldTransactions();
|
||||||
}
|
}
|
||||||
|
|
||||||
this._transactions.set(tx.id, { ...tx })
|
this._transactions.set(tx.id, { ...tx });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||||
const tx = this._transactions.get(id)
|
const tx = this._transactions.get(id);
|
||||||
return tx ? { ...tx } : null
|
return tx ? { ...tx } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||||
const tx = this._transactions.get(id)
|
const tx = this._transactions.get(id);
|
||||||
if (tx) {
|
if (tx) {
|
||||||
tx.state = state
|
tx.state = state;
|
||||||
tx.updatedAt = Date.now()
|
tx.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,37 +104,37 @@ export class MemoryStorage implements ITransactionStorage {
|
|||||||
state: OperationLog['state'],
|
state: OperationLog['state'],
|
||||||
error?: string
|
error?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tx = this._transactions.get(transactionId)
|
const tx = this._transactions.get(transactionId);
|
||||||
if (tx && tx.operations[operationIndex]) {
|
if (tx && tx.operations[operationIndex]) {
|
||||||
tx.operations[operationIndex].state = state
|
tx.operations[operationIndex].state = state;
|
||||||
if (error) {
|
if (error) {
|
||||||
tx.operations[operationIndex].error = error
|
tx.operations[operationIndex].error = error;
|
||||||
}
|
}
|
||||||
if (state === 'executed') {
|
if (state === 'executed') {
|
||||||
tx.operations[operationIndex].executedAt = Date.now()
|
tx.operations[operationIndex].executedAt = Date.now();
|
||||||
} else if (state === 'compensated') {
|
} else if (state === 'compensated') {
|
||||||
tx.operations[operationIndex].compensatedAt = Date.now()
|
tx.operations[operationIndex].compensatedAt = Date.now();
|
||||||
}
|
}
|
||||||
tx.updatedAt = Date.now()
|
tx.updatedAt = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||||
const result: TransactionLog[] = []
|
const result: TransactionLog[] = [];
|
||||||
|
|
||||||
for (const tx of this._transactions.values()) {
|
for (const tx of this._transactions.values()) {
|
||||||
if (tx.state === 'pending' || tx.state === 'executing') {
|
if (tx.state === 'pending' || tx.state === 'executing') {
|
||||||
if (!serverId || tx.metadata?.serverId === serverId) {
|
if (!serverId || tx.metadata?.serverId === serverId) {
|
||||||
result.push({ ...tx })
|
result.push({ ...tx });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTransaction(id: string): Promise<void> {
|
async deleteTransaction(id: string): Promise<void> {
|
||||||
this._transactions.delete(id)
|
this._transactions.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -142,28 +142,28 @@ export class MemoryStorage implements ITransactionStorage {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
async get<T>(key: string): Promise<T | null> {
|
||||||
this._cleanExpiredData()
|
this._cleanExpiredData();
|
||||||
|
|
||||||
const entry = this._data.get(key)
|
const entry = this._data.get(key);
|
||||||
if (!entry) return null
|
if (!entry) return null;
|
||||||
|
|
||||||
if (entry.expireAt && entry.expireAt < Date.now()) {
|
if (entry.expireAt && entry.expireAt < Date.now()) {
|
||||||
this._data.delete(key)
|
this._data.delete(key);
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.value as T
|
return entry.value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||||
this._data.set(key, {
|
this._data.set(key, {
|
||||||
value,
|
value,
|
||||||
expireAt: ttl ? Date.now() + ttl : undefined,
|
expireAt: ttl ? Date.now() + ttl : undefined
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<boolean> {
|
async delete(key: string): Promise<boolean> {
|
||||||
return this._data.delete(key)
|
return this._data.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -175,9 +175,9 @@ export class MemoryStorage implements ITransactionStorage {
|
|||||||
* @en Clear all data (for testing)
|
* @en Clear all data (for testing)
|
||||||
*/
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this._transactions.clear()
|
this._transactions.clear();
|
||||||
this._data.clear()
|
this._data.clear();
|
||||||
this._locks.clear()
|
this._locks.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,37 +185,37 @@ export class MemoryStorage implements ITransactionStorage {
|
|||||||
* @en Get transaction count
|
* @en Get transaction count
|
||||||
*/
|
*/
|
||||||
get transactionCount(): number {
|
get transactionCount(): number {
|
||||||
return this._transactions.size
|
return this._transactions.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _cleanExpiredLocks(): void {
|
private _cleanExpiredLocks(): void {
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
for (const [key, lock] of this._locks) {
|
for (const [key, lock] of this._locks) {
|
||||||
if (lock.expireAt < now) {
|
if (lock.expireAt < now) {
|
||||||
this._locks.delete(key)
|
this._locks.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _cleanExpiredData(): void {
|
private _cleanExpiredData(): void {
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
for (const [key, entry] of this._data) {
|
for (const [key, entry] of this._data) {
|
||||||
if (entry.expireAt && entry.expireAt < now) {
|
if (entry.expireAt && entry.expireAt < now) {
|
||||||
this._data.delete(key)
|
this._data.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _cleanOldTransactions(): void {
|
private _cleanOldTransactions(): void {
|
||||||
const sorted = Array.from(this._transactions.entries())
|
const sorted = Array.from(this._transactions.entries())
|
||||||
.sort((a, b) => a[1].createdAt - b[1].createdAt)
|
.sort((a, b) => a[1].createdAt - b[1].createdAt);
|
||||||
|
|
||||||
const toRemove = sorted
|
const toRemove = sorted
|
||||||
.slice(0, Math.floor(this._maxTransactions * 0.2))
|
.slice(0, Math.floor(this._maxTransactions * 0.2))
|
||||||
.filter(([_, tx]) => tx.state === 'committed' || tx.state === 'rolledback')
|
.filter(([_, tx]) => tx.state === 'committed' || tx.state === 'rolledback');
|
||||||
|
|
||||||
for (const [id] of toRemove) {
|
for (const [id] of toRemove) {
|
||||||
this._transactions.delete(id)
|
this._transactions.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,5 +225,5 @@ export class MemoryStorage implements ITransactionStorage {
|
|||||||
* @en Create memory storage
|
* @en Create memory storage
|
||||||
*/
|
*/
|
||||||
export function createMemoryStorage(config: MemoryStorageConfig = {}): MemoryStorage {
|
export function createMemoryStorage(config: MemoryStorageConfig = {}): MemoryStorage {
|
||||||
return new MemoryStorage(config)
|
return new MemoryStorage(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type {
|
|||||||
ITransactionStorage,
|
ITransactionStorage,
|
||||||
TransactionLog,
|
TransactionLog,
|
||||||
TransactionState,
|
TransactionState,
|
||||||
OperationLog,
|
OperationLog
|
||||||
} from '../core/types.js'
|
} from '../core/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh MongoDB Collection 接口
|
* @zh MongoDB Collection 接口
|
||||||
@@ -36,16 +36,50 @@ export interface MongoDb {
|
|||||||
collection<T = unknown>(name: string): MongoCollection<T>
|
collection<T = unknown>(name: string): MongoCollection<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh MongoDB 客户端接口
|
||||||
|
* @en MongoDB client interface
|
||||||
|
*/
|
||||||
|
export interface MongoClient {
|
||||||
|
db(name?: string): MongoDb
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh MongoDB 连接工厂
|
||||||
|
* @en MongoDB connection factory
|
||||||
|
*/
|
||||||
|
export type MongoClientFactory = () => MongoClient | Promise<MongoClient>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh MongoDB 存储配置
|
* @zh MongoDB 存储配置
|
||||||
* @en MongoDB storage configuration
|
* @en MongoDB storage configuration
|
||||||
*/
|
*/
|
||||||
export interface MongoStorageConfig {
|
export interface MongoStorageConfig {
|
||||||
/**
|
/**
|
||||||
* @zh MongoDB 数据库实例
|
* @zh MongoDB 客户端工厂(惰性连接)
|
||||||
* @en MongoDB database instance
|
* @en MongoDB client factory (lazy connection)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { MongoClient } from 'mongodb'
|
||||||
|
* const storage = new MongoStorage({
|
||||||
|
* factory: async () => {
|
||||||
|
* const client = new MongoClient('mongodb://localhost:27017')
|
||||||
|
* await client.connect()
|
||||||
|
* return client
|
||||||
|
* },
|
||||||
|
* database: 'game'
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
db: MongoDb
|
factory: MongoClientFactory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 数据库名称
|
||||||
|
* @en Database name
|
||||||
|
*/
|
||||||
|
database: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 事务日志集合名称
|
* @zh 事务日志集合名称
|
||||||
@@ -82,32 +116,94 @@ interface DataDocument {
|
|||||||
* @zh MongoDB 存储
|
* @zh MongoDB 存储
|
||||||
* @en MongoDB storage
|
* @en MongoDB storage
|
||||||
*
|
*
|
||||||
* @zh 基于 MongoDB 的事务存储,支持持久化和复杂查询
|
* @zh 基于 MongoDB 的事务存储,支持持久化、复杂查询和惰性连接
|
||||||
* @en MongoDB-based transaction storage with persistence and complex query support
|
* @en MongoDB-based transaction storage with persistence, complex queries and lazy connection
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* import { MongoClient } from 'mongodb'
|
* import { MongoClient } from 'mongodb'
|
||||||
*
|
*
|
||||||
* const client = new MongoClient('mongodb://localhost:27017')
|
* // 创建存储(惰性连接,首次操作时才连接)
|
||||||
* await client.connect()
|
* const storage = new MongoStorage({
|
||||||
* const db = client.db('game')
|
* factory: async () => {
|
||||||
|
* const client = new MongoClient('mongodb://localhost:27017')
|
||||||
|
* await client.connect()
|
||||||
|
* return client
|
||||||
|
* },
|
||||||
|
* database: 'game'
|
||||||
|
* })
|
||||||
*
|
*
|
||||||
* const storage = new MongoStorage({ db })
|
|
||||||
* await storage.ensureIndexes()
|
* await storage.ensureIndexes()
|
||||||
|
*
|
||||||
|
* // 使用后手动关闭
|
||||||
|
* await storage.close()
|
||||||
|
*
|
||||||
|
* // 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||||
|
* await using storage = new MongoStorage({ ... })
|
||||||
|
* // 作用域结束时自动关闭
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class MongoStorage implements ITransactionStorage {
|
export class MongoStorage implements ITransactionStorage {
|
||||||
private _db: MongoDb
|
private _client: MongoClient | null = null;
|
||||||
private _transactionCollection: string
|
private _db: MongoDb | null = null;
|
||||||
private _dataCollection: string
|
private _factory: MongoClientFactory;
|
||||||
private _lockCollection: string
|
private _database: string;
|
||||||
|
private _transactionCollection: string;
|
||||||
|
private _dataCollection: string;
|
||||||
|
private _lockCollection: string;
|
||||||
|
private _closed: boolean = false;
|
||||||
|
|
||||||
constructor(config: MongoStorageConfig) {
|
constructor(config: MongoStorageConfig) {
|
||||||
this._db = config.db
|
this._factory = config.factory;
|
||||||
this._transactionCollection = config.transactionCollection ?? 'transactions'
|
this._database = config.database;
|
||||||
this._dataCollection = config.dataCollection ?? 'transaction_data'
|
this._transactionCollection = config.transactionCollection ?? 'transactions';
|
||||||
this._lockCollection = config.lockCollection ?? 'transaction_locks'
|
this._dataCollection = config.dataCollection ?? 'transaction_data';
|
||||||
|
this._lockCollection = config.lockCollection ?? 'transaction_locks';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 生命周期 | Lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取数据库实例(惰性连接)
|
||||||
|
* @en Get database instance (lazy connection)
|
||||||
|
*/
|
||||||
|
private async _getDb(): Promise<MongoDb> {
|
||||||
|
if (this._closed) {
|
||||||
|
throw new Error('MongoStorage is closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._db) {
|
||||||
|
this._client = await this._factory();
|
||||||
|
this._db = this._client.db(this._database);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 关闭存储连接
|
||||||
|
* @en Close storage connection
|
||||||
|
*/
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this._closed) return;
|
||||||
|
|
||||||
|
this._closed = true;
|
||||||
|
|
||||||
|
if (this._client) {
|
||||||
|
await this._client.close();
|
||||||
|
this._client = null;
|
||||||
|
this._db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 支持 await using 语法
|
||||||
|
* @en Support await using syntax
|
||||||
|
*/
|
||||||
|
async [Symbol.asyncDispose](): Promise<void> {
|
||||||
|
await this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,16 +211,17 @@ export class MongoStorage implements ITransactionStorage {
|
|||||||
* @en Ensure indexes exist
|
* @en Ensure indexes exist
|
||||||
*/
|
*/
|
||||||
async ensureIndexes(): Promise<void> {
|
async ensureIndexes(): Promise<void> {
|
||||||
const txColl = this._db.collection<TransactionLog>(this._transactionCollection)
|
const db = await this._getDb();
|
||||||
await txColl.createIndex({ state: 1 })
|
const txColl = db.collection<TransactionLog>(this._transactionCollection);
|
||||||
await txColl.createIndex({ 'metadata.serverId': 1 })
|
await txColl.createIndex({ state: 1 });
|
||||||
await txColl.createIndex({ createdAt: 1 })
|
await txColl.createIndex({ 'metadata.serverId': 1 });
|
||||||
|
await txColl.createIndex({ createdAt: 1 });
|
||||||
|
|
||||||
const lockColl = this._db.collection<LockDocument>(this._lockCollection)
|
const lockColl = db.collection<LockDocument>(this._lockCollection);
|
||||||
await lockColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
|
await lockColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
|
|
||||||
const dataColl = this._db.collection<DataDocument>(this._dataCollection)
|
const dataColl = db.collection<DataDocument>(this._dataCollection);
|
||||||
await dataColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
|
await dataColl.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -132,36 +229,38 @@ export class MongoStorage implements ITransactionStorage {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||||
const coll = this._db.collection<LockDocument>(this._lockCollection)
|
const db = await this._getDb();
|
||||||
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`
|
const coll = db.collection<LockDocument>(this._lockCollection);
|
||||||
const expireAt = new Date(Date.now() + ttl)
|
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||||
|
const expireAt = new Date(Date.now() + ttl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await coll.insertOne({
|
await coll.insertOne({
|
||||||
_id: key,
|
_id: key,
|
||||||
token,
|
token,
|
||||||
expireAt,
|
expireAt
|
||||||
})
|
});
|
||||||
return token
|
return token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const existing = await coll.findOne({ _id: key })
|
const existing = await coll.findOne({ _id: key });
|
||||||
if (existing && existing.expireAt < new Date()) {
|
if (existing && existing.expireAt < new Date()) {
|
||||||
const result = await coll.updateOne(
|
const result = await coll.updateOne(
|
||||||
{ _id: key, expireAt: { $lt: new Date() } },
|
{ _id: key, expireAt: { $lt: new Date() } },
|
||||||
{ $set: { token, expireAt } }
|
{ $set: { token, expireAt } }
|
||||||
)
|
);
|
||||||
if (result.modifiedCount > 0) {
|
if (result.modifiedCount > 0) {
|
||||||
return token
|
return token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||||
const coll = this._db.collection<LockDocument>(this._lockCollection)
|
const db = await this._getDb();
|
||||||
const result = await coll.deleteOne({ _id: key, token })
|
const coll = db.collection<LockDocument>(this._lockCollection);
|
||||||
return result.deletedCount > 0
|
const result = await coll.deleteOne({ _id: key, token });
|
||||||
|
return result.deletedCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -169,35 +268,38 @@ export class MongoStorage implements ITransactionStorage {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
const db = await this._getDb();
|
||||||
|
const coll = db.collection<TransactionLog & { _id: string }>(this._transactionCollection);
|
||||||
|
|
||||||
const existing = await coll.findOne({ _id: tx.id })
|
const existing = await coll.findOne({ _id: tx.id });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await coll.updateOne(
|
await coll.updateOne(
|
||||||
{ _id: tx.id },
|
{ _id: tx.id },
|
||||||
{ $set: { ...tx, _id: tx.id } }
|
{ $set: { ...tx, _id: tx.id } }
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
await coll.insertOne({ ...tx, _id: tx.id })
|
await coll.insertOne({ ...tx, _id: tx.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
const db = await this._getDb();
|
||||||
const doc = await coll.findOne({ _id: id })
|
const coll = db.collection<TransactionLog & { _id: string }>(this._transactionCollection);
|
||||||
|
const doc = await coll.findOne({ _id: id });
|
||||||
|
|
||||||
if (!doc) return null
|
if (!doc) return null;
|
||||||
|
|
||||||
const { _id, ...tx } = doc
|
const { _id, ...tx } = doc;
|
||||||
return tx as TransactionLog
|
return tx as TransactionLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||||
const coll = this._db.collection(this._transactionCollection)
|
const db = await this._getDb();
|
||||||
|
const coll = db.collection(this._transactionCollection);
|
||||||
await coll.updateOne(
|
await coll.updateOne(
|
||||||
{ _id: id },
|
{ _id: id },
|
||||||
{ $set: { state, updatedAt: Date.now() } }
|
{ $set: { state, updatedAt: Date.now() } }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOperationState(
|
async updateOperationState(
|
||||||
@@ -206,47 +308,50 @@ export class MongoStorage implements ITransactionStorage {
|
|||||||
state: OperationLog['state'],
|
state: OperationLog['state'],
|
||||||
error?: string
|
error?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const coll = this._db.collection(this._transactionCollection)
|
const db = await this._getDb();
|
||||||
|
const coll = db.collection(this._transactionCollection);
|
||||||
|
|
||||||
const update: Record<string, unknown> = {
|
const update: Record<string, unknown> = {
|
||||||
[`operations.${operationIndex}.state`]: state,
|
[`operations.${operationIndex}.state`]: state,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now()
|
||||||
}
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
update[`operations.${operationIndex}.error`] = error
|
update[`operations.${operationIndex}.error`] = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'executed') {
|
if (state === 'executed') {
|
||||||
update[`operations.${operationIndex}.executedAt`] = Date.now()
|
update[`operations.${operationIndex}.executedAt`] = Date.now();
|
||||||
} else if (state === 'compensated') {
|
} else if (state === 'compensated') {
|
||||||
update[`operations.${operationIndex}.compensatedAt`] = Date.now()
|
update[`operations.${operationIndex}.compensatedAt`] = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
await coll.updateOne(
|
await coll.updateOne(
|
||||||
{ _id: transactionId },
|
{ _id: transactionId },
|
||||||
{ $set: update }
|
{ $set: update }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||||
const coll = this._db.collection<TransactionLog & { _id: string }>(this._transactionCollection)
|
const db = await this._getDb();
|
||||||
|
const coll = db.collection<TransactionLog & { _id: string }>(this._transactionCollection);
|
||||||
|
|
||||||
const filter: Record<string, unknown> = {
|
const filter: Record<string, unknown> = {
|
||||||
state: { $in: ['pending', 'executing'] },
|
state: { $in: ['pending', 'executing'] }
|
||||||
}
|
};
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
filter['metadata.serverId'] = serverId
|
filter['metadata.serverId'] = serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const docs = await coll.find(filter).toArray()
|
const docs = await coll.find(filter).toArray();
|
||||||
return docs.map(({ _id, ...tx }) => tx as TransactionLog)
|
return docs.map(({ _id, ...tx }) => tx as TransactionLog);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTransaction(id: string): Promise<void> {
|
async deleteTransaction(id: string): Promise<void> {
|
||||||
const coll = this._db.collection(this._transactionCollection)
|
const db = await this._getDb();
|
||||||
await coll.deleteOne({ _id: id })
|
const coll = db.collection(this._transactionCollection);
|
||||||
|
await coll.deleteOne({ _id: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -254,43 +359,46 @@ export class MongoStorage implements ITransactionStorage {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
async get<T>(key: string): Promise<T | null> {
|
||||||
const coll = this._db.collection<DataDocument>(this._dataCollection)
|
const db = await this._getDb();
|
||||||
const doc = await coll.findOne({ _id: key })
|
const coll = db.collection<DataDocument>(this._dataCollection);
|
||||||
|
const doc = await coll.findOne({ _id: key });
|
||||||
|
|
||||||
if (!doc) return null
|
if (!doc) return null;
|
||||||
|
|
||||||
if (doc.expireAt && doc.expireAt < new Date()) {
|
if (doc.expireAt && doc.expireAt < new Date()) {
|
||||||
await coll.deleteOne({ _id: key })
|
await coll.deleteOne({ _id: key });
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return doc.value as T
|
return doc.value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||||
const coll = this._db.collection<DataDocument>(this._dataCollection)
|
const db = await this._getDb();
|
||||||
|
const coll = db.collection<DataDocument>(this._dataCollection);
|
||||||
|
|
||||||
const doc: DataDocument = {
|
const doc: DataDocument = {
|
||||||
_id: key,
|
_id: key,
|
||||||
value,
|
value
|
||||||
}
|
};
|
||||||
|
|
||||||
if (ttl) {
|
if (ttl) {
|
||||||
doc.expireAt = new Date(Date.now() + ttl)
|
doc.expireAt = new Date(Date.now() + ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await coll.findOne({ _id: key })
|
const existing = await coll.findOne({ _id: key });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await coll.updateOne({ _id: key }, { $set: doc })
|
await coll.updateOne({ _id: key }, { $set: doc });
|
||||||
} else {
|
} else {
|
||||||
await coll.insertOne(doc)
|
await coll.insertOne(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<boolean> {
|
async delete(key: string): Promise<boolean> {
|
||||||
const coll = this._db.collection(this._dataCollection)
|
const db = await this._getDb();
|
||||||
const result = await coll.deleteOne({ _id: key })
|
const coll = db.collection(this._dataCollection);
|
||||||
return result.deletedCount > 0
|
const result = await coll.deleteOne({ _id: key });
|
||||||
|
return result.deletedCount > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,5 +407,5 @@ export class MongoStorage implements ITransactionStorage {
|
|||||||
* @en Create MongoDB storage
|
* @en Create MongoDB storage
|
||||||
*/
|
*/
|
||||||
export function createMongoStorage(config: MongoStorageConfig): MongoStorage {
|
export function createMongoStorage(config: MongoStorageConfig): MongoStorage {
|
||||||
return new MongoStorage(config)
|
return new MongoStorage(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type {
|
|||||||
ITransactionStorage,
|
ITransactionStorage,
|
||||||
TransactionLog,
|
TransactionLog,
|
||||||
TransactionState,
|
TransactionState,
|
||||||
OperationLog,
|
OperationLog
|
||||||
} from '../core/types.js'
|
} from '../core/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh Redis 客户端接口(兼容 ioredis)
|
* @zh Redis 客户端接口(兼容 ioredis)
|
||||||
@@ -28,18 +28,33 @@ export interface RedisClient {
|
|||||||
hgetall(key: string): Promise<Record<string, string>>
|
hgetall(key: string): Promise<Record<string, string>>
|
||||||
keys(pattern: string): Promise<string[]>
|
keys(pattern: string): Promise<string[]>
|
||||||
expire(key: string, seconds: number): Promise<number>
|
expire(key: string, seconds: number): Promise<number>
|
||||||
|
quit(): Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Redis 连接工厂
|
||||||
|
* @en Redis connection factory
|
||||||
|
*/
|
||||||
|
export type RedisClientFactory = () => RedisClient | Promise<RedisClient>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh Redis 存储配置
|
* @zh Redis 存储配置
|
||||||
* @en Redis storage configuration
|
* @en Redis storage configuration
|
||||||
*/
|
*/
|
||||||
export interface RedisStorageConfig {
|
export interface RedisStorageConfig {
|
||||||
/**
|
/**
|
||||||
* @zh Redis 客户端实例
|
* @zh Redis 客户端工厂(惰性连接)
|
||||||
* @en Redis client instance
|
* @en Redis client factory (lazy connection)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import Redis from 'ioredis'
|
||||||
|
* const storage = new RedisStorage({
|
||||||
|
* factory: () => new Redis('redis://localhost:6379')
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
client: RedisClient
|
factory: RedisClientFactory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 键前缀
|
* @zh 键前缀
|
||||||
@@ -60,32 +75,88 @@ if redis.call("get", KEYS[1]) == ARGV[1] then
|
|||||||
else
|
else
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
`
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh Redis 存储
|
* @zh Redis 存储
|
||||||
* @en Redis storage
|
* @en Redis storage
|
||||||
*
|
*
|
||||||
* @zh 基于 Redis 的分布式事务存储,支持分布式锁
|
* @zh 基于 Redis 的分布式事务存储,支持分布式锁和惰性连接
|
||||||
* @en Redis-based distributed transaction storage with distributed locking support
|
* @en Redis-based distributed transaction storage with distributed locking and lazy connection
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* import Redis from 'ioredis'
|
* import Redis from 'ioredis'
|
||||||
*
|
*
|
||||||
* const redis = new Redis('redis://localhost:6379')
|
* // 创建存储(惰性连接,首次操作时才连接)
|
||||||
* const storage = new RedisStorage({ client: redis })
|
* const storage = new RedisStorage({
|
||||||
|
* factory: () => new Redis('redis://localhost:6379')
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 使用后手动关闭
|
||||||
|
* await storage.close()
|
||||||
|
*
|
||||||
|
* // 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||||
|
* await using storage = new RedisStorage({
|
||||||
|
* factory: () => new Redis('redis://localhost:6379')
|
||||||
|
* })
|
||||||
|
* // 作用域结束时自动关闭
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class RedisStorage implements ITransactionStorage {
|
export class RedisStorage implements ITransactionStorage {
|
||||||
private _client: RedisClient
|
private _client: RedisClient | null = null;
|
||||||
private _prefix: string
|
private _factory: RedisClientFactory;
|
||||||
private _transactionTTL: number
|
private _prefix: string;
|
||||||
|
private _transactionTTL: number;
|
||||||
|
private _closed: boolean = false;
|
||||||
|
|
||||||
constructor(config: RedisStorageConfig) {
|
constructor(config: RedisStorageConfig) {
|
||||||
this._client = config.client
|
this._factory = config.factory;
|
||||||
this._prefix = config.prefix ?? 'tx:'
|
this._prefix = config.prefix ?? 'tx:';
|
||||||
this._transactionTTL = config.transactionTTL ?? 86400 // 24 hours
|
this._transactionTTL = config.transactionTTL ?? 86400; // 24 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 生命周期 | Lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取 Redis 客户端(惰性连接)
|
||||||
|
* @en Get Redis client (lazy connection)
|
||||||
|
*/
|
||||||
|
private async _getClient(): Promise<RedisClient> {
|
||||||
|
if (this._closed) {
|
||||||
|
throw new Error('RedisStorage is closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._client) {
|
||||||
|
this._client = await this._factory();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 关闭存储连接
|
||||||
|
* @en Close storage connection
|
||||||
|
*/
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this._closed) return;
|
||||||
|
|
||||||
|
this._closed = true;
|
||||||
|
|
||||||
|
if (this._client) {
|
||||||
|
await this._client.quit();
|
||||||
|
this._client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 支持 await using 语法
|
||||||
|
* @en Support await using syntax
|
||||||
|
*/
|
||||||
|
async [Symbol.asyncDispose](): Promise<void> {
|
||||||
|
await this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -93,20 +164,22 @@ export class RedisStorage implements ITransactionStorage {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||||
const lockKey = `${this._prefix}lock:${key}`
|
const client = await this._getClient();
|
||||||
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`
|
const lockKey = `${this._prefix}lock:${key}`;
|
||||||
const ttlSeconds = Math.ceil(ttl / 1000)
|
const token = `${Date.now()}_${Math.random().toString(36).substring(2)}`;
|
||||||
|
const ttlSeconds = Math.ceil(ttl / 1000);
|
||||||
|
|
||||||
const result = await this._client.set(lockKey, token, 'NX', 'EX', String(ttlSeconds))
|
const result = await client.set(lockKey, token, 'NX', 'EX', String(ttlSeconds));
|
||||||
|
|
||||||
return result === 'OK' ? token : null
|
return result === 'OK' ? token : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async releaseLock(key: string, token: string): Promise<boolean> {
|
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||||
const lockKey = `${this._prefix}lock:${key}`
|
const client = await this._getClient();
|
||||||
|
const lockKey = `${this._prefix}lock:${key}`;
|
||||||
|
|
||||||
const result = await this._client.eval(LOCK_SCRIPT, 1, lockKey, token)
|
const result = await client.eval(LOCK_SCRIPT, 1, lockKey, token);
|
||||||
return result === 1
|
return result === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -114,30 +187,32 @@ export class RedisStorage implements ITransactionStorage {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async saveTransaction(tx: TransactionLog): Promise<void> {
|
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||||
const key = `${this._prefix}tx:${tx.id}`
|
const client = await this._getClient();
|
||||||
|
const key = `${this._prefix}tx:${tx.id}`;
|
||||||
|
|
||||||
await this._client.set(key, JSON.stringify(tx))
|
await client.set(key, JSON.stringify(tx));
|
||||||
await this._client.expire(key, this._transactionTTL)
|
await client.expire(key, this._transactionTTL);
|
||||||
|
|
||||||
if (tx.metadata?.serverId) {
|
if (tx.metadata?.serverId) {
|
||||||
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`
|
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`;
|
||||||
await this._client.hset(serverKey, tx.id, String(tx.createdAt))
|
await client.hset(serverKey, tx.id, String(tx.createdAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransaction(id: string): Promise<TransactionLog | null> {
|
async getTransaction(id: string): Promise<TransactionLog | null> {
|
||||||
const key = `${this._prefix}tx:${id}`
|
const client = await this._getClient();
|
||||||
const data = await this._client.get(key)
|
const key = `${this._prefix}tx:${id}`;
|
||||||
|
const data = await client.get(key);
|
||||||
|
|
||||||
return data ? JSON.parse(data) : null
|
return data ? JSON.parse(data) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
async updateTransactionState(id: string, state: TransactionState): Promise<void> {
|
||||||
const tx = await this.getTransaction(id)
|
const tx = await this.getTransaction(id);
|
||||||
if (tx) {
|
if (tx) {
|
||||||
tx.state = state
|
tx.state = state;
|
||||||
tx.updatedAt = Date.now()
|
tx.updatedAt = Date.now();
|
||||||
await this.saveTransaction(tx)
|
await this.saveTransaction(tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,62 +222,64 @@ export class RedisStorage implements ITransactionStorage {
|
|||||||
state: OperationLog['state'],
|
state: OperationLog['state'],
|
||||||
error?: string
|
error?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tx = await this.getTransaction(transactionId)
|
const tx = await this.getTransaction(transactionId);
|
||||||
if (tx && tx.operations[operationIndex]) {
|
if (tx && tx.operations[operationIndex]) {
|
||||||
tx.operations[operationIndex].state = state
|
tx.operations[operationIndex].state = state;
|
||||||
if (error) {
|
if (error) {
|
||||||
tx.operations[operationIndex].error = error
|
tx.operations[operationIndex].error = error;
|
||||||
}
|
}
|
||||||
if (state === 'executed') {
|
if (state === 'executed') {
|
||||||
tx.operations[operationIndex].executedAt = Date.now()
|
tx.operations[operationIndex].executedAt = Date.now();
|
||||||
} else if (state === 'compensated') {
|
} else if (state === 'compensated') {
|
||||||
tx.operations[operationIndex].compensatedAt = Date.now()
|
tx.operations[operationIndex].compensatedAt = Date.now();
|
||||||
}
|
}
|
||||||
tx.updatedAt = Date.now()
|
tx.updatedAt = Date.now();
|
||||||
await this.saveTransaction(tx)
|
await this.saveTransaction(tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
async getPendingTransactions(serverId?: string): Promise<TransactionLog[]> {
|
||||||
const result: TransactionLog[] = []
|
const client = await this._getClient();
|
||||||
|
const result: TransactionLog[] = [];
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
const serverKey = `${this._prefix}server:${serverId}:txs`
|
const serverKey = `${this._prefix}server:${serverId}:txs`;
|
||||||
const txIds = await this._client.hgetall(serverKey)
|
const txIds = await client.hgetall(serverKey);
|
||||||
|
|
||||||
for (const id of Object.keys(txIds)) {
|
for (const id of Object.keys(txIds)) {
|
||||||
const tx = await this.getTransaction(id)
|
const tx = await this.getTransaction(id);
|
||||||
if (tx && (tx.state === 'pending' || tx.state === 'executing')) {
|
if (tx && (tx.state === 'pending' || tx.state === 'executing')) {
|
||||||
result.push(tx)
|
result.push(tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pattern = `${this._prefix}tx:*`
|
const pattern = `${this._prefix}tx:*`;
|
||||||
const keys = await this._client.keys(pattern)
|
const keys = await client.keys(pattern);
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const data = await this._client.get(key)
|
const data = await client.get(key);
|
||||||
if (data) {
|
if (data) {
|
||||||
const tx: TransactionLog = JSON.parse(data)
|
const tx: TransactionLog = JSON.parse(data);
|
||||||
if (tx.state === 'pending' || tx.state === 'executing') {
|
if (tx.state === 'pending' || tx.state === 'executing') {
|
||||||
result.push(tx)
|
result.push(tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTransaction(id: string): Promise<void> {
|
async deleteTransaction(id: string): Promise<void> {
|
||||||
const key = `${this._prefix}tx:${id}`
|
const client = await this._getClient();
|
||||||
const tx = await this.getTransaction(id)
|
const key = `${this._prefix}tx:${id}`;
|
||||||
|
const tx = await this.getTransaction(id);
|
||||||
|
|
||||||
await this._client.del(key)
|
await client.del(key);
|
||||||
|
|
||||||
if (tx?.metadata?.serverId) {
|
if (tx?.metadata?.serverId) {
|
||||||
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`
|
const serverKey = `${this._prefix}server:${tx.metadata.serverId}:txs`;
|
||||||
await this._client.hdel(serverKey, id)
|
await client.hdel(serverKey, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,27 +288,30 @@ export class RedisStorage implements ITransactionStorage {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
async get<T>(key: string): Promise<T | null> {
|
||||||
const fullKey = `${this._prefix}data:${key}`
|
const client = await this._getClient();
|
||||||
const data = await this._client.get(fullKey)
|
const fullKey = `${this._prefix}data:${key}`;
|
||||||
|
const data = await client.get(fullKey);
|
||||||
|
|
||||||
return data ? JSON.parse(data) : null
|
return data ? JSON.parse(data) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||||
const fullKey = `${this._prefix}data:${key}`
|
const client = await this._getClient();
|
||||||
|
const fullKey = `${this._prefix}data:${key}`;
|
||||||
|
|
||||||
if (ttl) {
|
if (ttl) {
|
||||||
const ttlSeconds = Math.ceil(ttl / 1000)
|
const ttlSeconds = Math.ceil(ttl / 1000);
|
||||||
await this._client.set(fullKey, JSON.stringify(value), 'EX', String(ttlSeconds))
|
await client.set(fullKey, JSON.stringify(value), 'EX', String(ttlSeconds));
|
||||||
} else {
|
} else {
|
||||||
await this._client.set(fullKey, JSON.stringify(value))
|
await client.set(fullKey, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<boolean> {
|
async delete(key: string): Promise<boolean> {
|
||||||
const fullKey = `${this._prefix}data:${key}`
|
const client = await this._getClient();
|
||||||
const result = await this._client.del(fullKey)
|
const fullKey = `${this._prefix}data:${key}`;
|
||||||
return result > 0
|
const result = await client.del(fullKey);
|
||||||
|
return result > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,5 +320,5 @@ export class RedisStorage implements ITransactionStorage {
|
|||||||
* @en Create Redis storage
|
* @en Create Redis storage
|
||||||
*/
|
*/
|
||||||
export function createRedisStorage(config: RedisStorageConfig): RedisStorage {
|
export function createRedisStorage(config: RedisStorageConfig): RedisStorage {
|
||||||
return new RedisStorage(config)
|
return new RedisStorage(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
* @en Storage module exports
|
* @en Storage module exports
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { MemoryStorage, createMemoryStorage, type MemoryStorageConfig } from './MemoryStorage.js'
|
export { MemoryStorage, createMemoryStorage, type MemoryStorageConfig } from './MemoryStorage.js';
|
||||||
export { RedisStorage, createRedisStorage, type RedisStorageConfig, type RedisClient } from './RedisStorage.js'
|
export { RedisStorage, createRedisStorage, type RedisStorageConfig, type RedisClient } from './RedisStorage.js';
|
||||||
export { MongoStorage, createMongoStorage, type MongoStorageConfig, type MongoDb, type MongoCollection } from './MongoStorage.js'
|
export { MongoStorage, createMongoStorage, type MongoStorageConfig, type MongoDb, type MongoCollection } from './MongoStorage.js';
|
||||||
|
|||||||
@@ -3,18 +3,18 @@
|
|||||||
* @en Transaction module service tokens
|
* @en Transaction module service tokens
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServiceToken } from '@esengine/ecs-framework'
|
import { createServiceToken } from '@esengine/ecs-framework';
|
||||||
import type { TransactionManager } from './core/TransactionManager.js'
|
import type { TransactionManager } from './core/TransactionManager.js';
|
||||||
import type { ITransactionStorage } from './core/types.js'
|
import type { ITransactionStorage } from './core/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 事务管理器令牌
|
* @zh 事务管理器令牌
|
||||||
* @en Transaction manager token
|
* @en Transaction manager token
|
||||||
*/
|
*/
|
||||||
export const TransactionManagerToken = createServiceToken<TransactionManager>('transactionManager')
|
export const TransactionManagerToken = createServiceToken<TransactionManager>('transactionManager');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 事务存储令牌
|
* @zh 事务存储令牌
|
||||||
* @en Transaction storage token
|
* @en Transaction storage token
|
||||||
*/
|
*/
|
||||||
export const TransactionStorageToken = createServiceToken<ITransactionStorage>('transactionStorage')
|
export const TransactionStorageToken = createServiceToken<ITransactionStorage>('transactionStorage');
|
||||||
|
|||||||
@@ -2,15 +2,8 @@
|
|||||||
"extends": "../../../tsconfig.base.json",
|
"extends": "../../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src"
|
||||||
"composite": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"],
|
"exclude": ["node_modules", "dist"]
|
||||||
"references": [
|
|
||||||
{ "path": "../../core" },
|
|
||||||
{ "path": "../server" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
10
packages/framework/transaction/vitest.config.ts
Normal file
10
packages/framework/transaction/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['__tests__/**/*.test.ts'],
|
||||||
|
testTimeout: 10000,
|
||||||
|
},
|
||||||
|
})
|
||||||
420
pnpm-lock.yaml
generated
420
pnpm-lock.yaml
generated
@@ -1631,6 +1631,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.9
|
||||||
|
version: 2.1.9(@types/node@22.19.3)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
|
||||||
packages/framework/procgen:
|
packages/framework/procgen:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1685,12 +1688,18 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../rpc
|
version: link:../rpc
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/jsonwebtoken':
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.10
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.0.0
|
specifier: ^20.0.0
|
||||||
version: 20.19.27
|
version: 20.19.27
|
||||||
'@types/ws':
|
'@types/ws':
|
||||||
specifier: ^8.5.13
|
specifier: ^8.5.13
|
||||||
version: 8.18.1
|
version: 8.18.1
|
||||||
|
jsonwebtoken:
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.3
|
||||||
rimraf:
|
rimraf:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.10
|
version: 5.0.10
|
||||||
@@ -1700,6 +1709,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.0
|
specifier: ^5.7.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.9(@types/node@20.19.27)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.18.0
|
specifier: ^8.18.0
|
||||||
version: 8.18.3
|
version: 8.18.3
|
||||||
@@ -1790,6 +1802,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.9(@types/node@22.19.3)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
|
||||||
packages/framework/world-streaming:
|
packages/framework/world-streaming:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5174,6 +5189,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0':
|
'@types/linkify-it@5.0.0':
|
||||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||||
|
|
||||||
@@ -5382,6 +5400,35 @@ packages:
|
|||||||
vite: ^5.0.0 || ^6.0.0
|
vite: ^5.0.0 || ^6.0.0
|
||||||
vue: ^3.2.25
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@vitest/expect@2.1.9':
|
||||||
|
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||||
|
|
||||||
|
'@vitest/mocker@2.1.9':
|
||||||
|
resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^5.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/pretty-format@2.1.9':
|
||||||
|
resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
|
||||||
|
|
||||||
|
'@vitest/runner@2.1.9':
|
||||||
|
resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@2.1.9':
|
||||||
|
resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
|
||||||
|
|
||||||
|
'@vitest/spy@2.1.9':
|
||||||
|
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
|
||||||
|
|
||||||
|
'@vitest/utils@2.1.9':
|
||||||
|
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
|
||||||
|
|
||||||
'@volar/language-core@1.11.1':
|
'@volar/language-core@1.11.1':
|
||||||
resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==}
|
resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==}
|
||||||
|
|
||||||
@@ -5717,6 +5764,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
astring@1.9.0:
|
astring@1.9.0:
|
||||||
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -5869,6 +5920,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||||
engines: {node: '>=16.20.1'}
|
engines: {node: '>=16.20.1'}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@@ -5931,6 +5985,10 @@ packages:
|
|||||||
ccount@2.0.1:
|
ccount@2.0.1:
|
||||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
chalk@2.4.2:
|
chalk@2.4.2:
|
||||||
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -5969,6 +6027,10 @@ packages:
|
|||||||
chardet@2.1.1:
|
chardet@2.1.1:
|
||||||
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
|
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
|
||||||
|
|
||||||
|
check-error@2.1.1:
|
||||||
|
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
@@ -6385,6 +6447,10 @@ packages:
|
|||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
deep-eql@5.0.2:
|
||||||
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
deep-extend@0.6.0:
|
deep-extend@0.6.0:
|
||||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
@@ -6550,6 +6616,9 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
editorconfig@0.15.3:
|
editorconfig@0.15.3:
|
||||||
resolution: {integrity: sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==}
|
resolution: {integrity: sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -6806,6 +6875,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
|
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
expect-type@1.3.0:
|
||||||
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
expect@29.7.0:
|
expect@29.7.0:
|
||||||
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
|
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -7910,6 +7983,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||||
engines: {'0': node >= 0.2.0}
|
engines: {'0': node >= 0.2.0}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
@@ -7919,6 +7996,12 @@ packages:
|
|||||||
just-diff@6.0.2:
|
just-diff@6.0.2:
|
||||||
resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==}
|
resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||||
|
|
||||||
k8w-crypto@0.2.0:
|
k8w-crypto@0.2.0:
|
||||||
resolution: {integrity: sha512-M6u4eQ6CQaU5xO3s4zaUUp9G79xNDhXtTU0X7N80tDcBhQC5ggowlyOzj95v7WiCuk7xkV0aFsTmCpuf0m0djw==}
|
resolution: {integrity: sha512-M6u4eQ6CQaU5xO3s4zaUUp9G79xNDhXtTU0X7N80tDcBhQC5ggowlyOzj95v7WiCuk7xkV0aFsTmCpuf0m0djw==}
|
||||||
|
|
||||||
@@ -8118,9 +8201,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
|
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
|
||||||
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
|
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
|
||||||
|
|
||||||
|
lodash.includes@4.3.0:
|
||||||
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0:
|
lodash.isarguments@3.1.0:
|
||||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3:
|
||||||
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
lodash.isequal@4.5.0:
|
lodash.isequal@4.5.0:
|
||||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||||
@@ -8128,9 +8217,15 @@ packages:
|
|||||||
lodash.isfunction@3.0.9:
|
lodash.isfunction@3.0.9:
|
||||||
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
|
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4:
|
||||||
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
lodash.ismatch@4.4.0:
|
lodash.ismatch@4.4.0:
|
||||||
resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==}
|
resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6:
|
lodash.isplainobject@4.0.6:
|
||||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
@@ -8149,6 +8244,9 @@ packages:
|
|||||||
lodash.mergewith@4.6.2:
|
lodash.mergewith@4.6.2:
|
||||||
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
|
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lodash.snakecase@4.1.1:
|
lodash.snakecase@4.1.1:
|
||||||
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
||||||
|
|
||||||
@@ -8181,6 +8279,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
loupe@3.2.1:
|
||||||
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
@@ -9213,9 +9314,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==}
|
resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
pathe@1.1.2:
|
||||||
|
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||||
|
|
||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pathval@2.0.1:
|
||||||
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
|
engines: {node: '>= 14.16'}
|
||||||
|
|
||||||
pegjs@0.10.0:
|
pegjs@0.10.0:
|
||||||
resolution: {integrity: sha512-qI5+oFNEGi3L5HAxDwN2LA4Gg7irF70Zs25edhjld9QemOgp0CbvMtbFcMvFtEo1OityPrcCzkQFB8JP/hxgow==}
|
resolution: {integrity: sha512-qI5+oFNEGi3L5HAxDwN2LA4Gg7irF70Zs25edhjld9QemOgp0CbvMtbFcMvFtEo1OityPrcCzkQFB8JP/hxgow==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
@@ -9855,6 +9963,9 @@ packages:
|
|||||||
shiki@3.20.0:
|
shiki@3.20.0:
|
||||||
resolution: {integrity: sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==}
|
resolution: {integrity: sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg==}
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
sigmund@1.0.1:
|
sigmund@1.0.1:
|
||||||
resolution: {integrity: sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==}
|
resolution: {integrity: sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==}
|
||||||
|
|
||||||
@@ -9995,12 +10106,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
standard-as-callback@2.1.0:
|
standard-as-callback@2.1.0:
|
||||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
state-local@1.0.7:
|
state-local@1.0.7:
|
||||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||||
|
|
||||||
|
std-env@3.10.0:
|
||||||
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
stream-combiner2@1.1.1:
|
stream-combiner2@1.1.1:
|
||||||
resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==}
|
resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==}
|
||||||
|
|
||||||
@@ -10205,6 +10322,9 @@ packages:
|
|||||||
tiny-inflate@1.0.3:
|
tiny-inflate@1.0.3:
|
||||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||||
|
|
||||||
|
tinybench@2.9.0:
|
||||||
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
tinyexec@0.3.2:
|
tinyexec@0.3.2:
|
||||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
@@ -10220,6 +10340,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinypool@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
||||||
|
tinyrainbow@1.2.0:
|
||||||
|
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tinyspy@3.0.2:
|
||||||
|
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
tmp@0.0.33:
|
tmp@0.0.33:
|
||||||
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
||||||
engines: {node: '>=0.6.0'}
|
engines: {node: '>=0.6.0'}
|
||||||
@@ -10805,6 +10937,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
|
vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
|
||||||
|
|
||||||
|
vite-node@2.1.9:
|
||||||
|
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vite-plugin-dts@3.9.1:
|
vite-plugin-dts@3.9.1:
|
||||||
resolution: {integrity: sha512-rVp2KM9Ue22NGWB8dNtWEr+KekN3rIgz1tWD050QnRGlriUCmaDwa7qA5zDEjbXg5lAXhYMSBJtx3q3hQIJZSg==}
|
resolution: {integrity: sha512-rVp2KM9Ue22NGWB8dNtWEr+KekN3rIgz1tWD050QnRGlriUCmaDwa7qA5zDEjbXg5lAXhYMSBJtx3q3hQIJZSg==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -10952,6 +11089,31 @@ packages:
|
|||||||
postcss:
|
postcss:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@2.1.9:
|
||||||
|
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@types/node': ^18.0.0 || >=20.0.0
|
||||||
|
'@vitest/browser': 2.1.9
|
||||||
|
'@vitest/ui': 2.1.9
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
void-elements@3.1.0:
|
void-elements@3.1.0:
|
||||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -11041,6 +11203,11 @@ packages:
|
|||||||
engines: {node: ^16.13.0 || >=18.0.0}
|
engines: {node: ^16.13.0 || >=18.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
wide-align@1.1.5:
|
wide-align@1.1.5:
|
||||||
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
|
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
|
||||||
|
|
||||||
@@ -14762,6 +14929,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
dependencies:
|
||||||
|
'@types/ms': 2.1.0
|
||||||
|
'@types/node': 20.19.27
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0': {}
|
'@types/linkify-it@5.0.0': {}
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
@@ -15017,6 +15189,54 @@ snapshots:
|
|||||||
vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1)
|
vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
vue: 3.5.26(typescript@5.9.3)
|
vue: 3.5.26(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@vitest/expect@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
'@vitest/utils': 2.1.9
|
||||||
|
chai: 5.3.3
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
|
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
|
||||||
|
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
|
||||||
|
'@vitest/pretty-format@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
|
'@vitest/runner@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 2.1.9
|
||||||
|
pathe: 1.1.2
|
||||||
|
|
||||||
|
'@vitest/snapshot@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 2.1.9
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 1.1.2
|
||||||
|
|
||||||
|
'@vitest/spy@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
tinyspy: 3.0.2
|
||||||
|
|
||||||
|
'@vitest/utils@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 2.1.9
|
||||||
|
loupe: 3.2.1
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
'@volar/language-core@1.11.1':
|
'@volar/language-core@1.11.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/source-map': 1.11.1
|
'@volar/source-map': 1.11.1
|
||||||
@@ -15407,6 +15627,8 @@ snapshots:
|
|||||||
|
|
||||||
arrify@2.0.1: {}
|
arrify@2.0.1: {}
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
astring@1.9.0: {}
|
astring@1.9.0: {}
|
||||||
|
|
||||||
astro-expressive-code@0.41.5(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)):
|
astro-expressive-code@0.41.5(astro@5.16.6(@types/node@22.19.3)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)):
|
||||||
@@ -15702,6 +15924,8 @@ snapshots:
|
|||||||
|
|
||||||
bson@6.10.4: {}
|
bson@6.10.4: {}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
@@ -15762,6 +15986,14 @@ snapshots:
|
|||||||
|
|
||||||
ccount@2.0.1: {}
|
ccount@2.0.1: {}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
dependencies:
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
check-error: 2.1.1
|
||||||
|
deep-eql: 5.0.2
|
||||||
|
loupe: 3.2.1
|
||||||
|
pathval: 2.0.1
|
||||||
|
|
||||||
chalk@2.4.2:
|
chalk@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 3.2.1
|
ansi-styles: 3.2.1
|
||||||
@@ -15794,6 +16026,8 @@ snapshots:
|
|||||||
|
|
||||||
chardet@2.1.1: {}
|
chardet@2.1.1: {}
|
||||||
|
|
||||||
|
check-error@2.1.1: {}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
@@ -16191,6 +16425,8 @@ snapshots:
|
|||||||
|
|
||||||
dedent@1.7.1: {}
|
dedent@1.7.1: {}
|
||||||
|
|
||||||
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
deep-extend@0.6.0: {}
|
deep-extend@0.6.0: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
@@ -16325,6 +16561,10 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
editorconfig@0.15.3:
|
editorconfig@0.15.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 2.20.3
|
commander: 2.20.3
|
||||||
@@ -16708,6 +16948,8 @@ snapshots:
|
|||||||
|
|
||||||
exit@0.1.2: {}
|
exit@0.1.2: {}
|
||||||
|
|
||||||
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
expect@29.7.0:
|
expect@29.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/expect-utils': 29.7.0
|
'@jest/expect-utils': 29.7.0
|
||||||
@@ -18186,6 +18428,19 @@ snapshots:
|
|||||||
|
|
||||||
jsonparse@1.3.1: {}
|
jsonparse@1.3.1: {}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
dependencies:
|
||||||
|
jws: 4.0.1
|
||||||
|
lodash.includes: 4.3.0
|
||||||
|
lodash.isboolean: 3.0.3
|
||||||
|
lodash.isinteger: 4.0.4
|
||||||
|
lodash.isnumber: 3.0.3
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
lodash.isstring: 4.0.1
|
||||||
|
lodash.once: 4.1.1
|
||||||
|
ms: 2.1.3
|
||||||
|
semver: 7.7.3
|
||||||
|
|
||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
lie: 3.3.0
|
lie: 3.3.0
|
||||||
@@ -18197,6 +18452,17 @@ snapshots:
|
|||||||
|
|
||||||
just-diff@6.0.2: {}
|
just-diff@6.0.2: {}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
jwa: 2.0.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
k8w-crypto@0.2.0: {}
|
k8w-crypto@0.2.0: {}
|
||||||
|
|
||||||
k8w-extend-native@1.4.6:
|
k8w-extend-native@1.4.6:
|
||||||
@@ -18462,14 +18728,22 @@ snapshots:
|
|||||||
|
|
||||||
lodash.get@4.4.2: {}
|
lodash.get@4.4.2: {}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0: {}
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
lodash.isequal@4.5.0: {}
|
lodash.isequal@4.5.0: {}
|
||||||
|
|
||||||
lodash.isfunction@3.0.9: {}
|
lodash.isfunction@3.0.9: {}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
lodash.ismatch@4.4.0: {}
|
lodash.ismatch@4.4.0: {}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3: {}
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6: {}
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
lodash.isstring@4.0.1: {}
|
lodash.isstring@4.0.1: {}
|
||||||
@@ -18482,6 +18756,8 @@ snapshots:
|
|||||||
|
|
||||||
lodash.mergewith@4.6.2: {}
|
lodash.mergewith@4.6.2: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lodash.snakecase@4.1.1: {}
|
lodash.snakecase@4.1.1: {}
|
||||||
|
|
||||||
lodash.startcase@4.4.0: {}
|
lodash.startcase@4.4.0: {}
|
||||||
@@ -18507,6 +18783,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
lru-cache@4.1.5:
|
lru-cache@4.1.5:
|
||||||
@@ -19832,8 +20110,12 @@ snapshots:
|
|||||||
|
|
||||||
path-type@6.0.0: {}
|
path-type@6.0.0: {}
|
||||||
|
|
||||||
|
pathe@1.1.2: {}
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
pathval@2.0.1: {}
|
||||||
|
|
||||||
pegjs@0.10.0: {}
|
pegjs@0.10.0: {}
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
@@ -20622,6 +20904,8 @@ snapshots:
|
|||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
sigmund@1.0.1: {}
|
sigmund@1.0.1: {}
|
||||||
|
|
||||||
signal-exit@3.0.7: {}
|
signal-exit@3.0.7: {}
|
||||||
@@ -20770,10 +21054,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 2.0.0
|
escape-string-regexp: 2.0.0
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
standard-as-callback@2.1.0: {}
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
state-local@1.0.7: {}
|
state-local@1.0.7: {}
|
||||||
|
|
||||||
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
stream-combiner2@1.1.1:
|
stream-combiner2@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
duplexer2: 0.1.4
|
duplexer2: 0.1.4
|
||||||
@@ -20992,6 +21280,8 @@ snapshots:
|
|||||||
|
|
||||||
tiny-inflate@1.0.3: {}
|
tiny-inflate@1.0.3: {}
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
tinyexec@1.0.2: {}
|
tinyexec@1.0.2: {}
|
||||||
@@ -21006,6 +21296,12 @@ snapshots:
|
|||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
tinypool@1.1.1: {}
|
||||||
|
|
||||||
|
tinyrainbow@1.2.0: {}
|
||||||
|
|
||||||
|
tinyspy@3.0.2: {}
|
||||||
|
|
||||||
tmp@0.0.33:
|
tmp@0.0.33:
|
||||||
dependencies:
|
dependencies:
|
||||||
os-tmpdir: 1.0.2
|
os-tmpdir: 1.0.2
|
||||||
@@ -21581,6 +21877,42 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
|
vite-node@2.1.9(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1):
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
debug: 4.4.3
|
||||||
|
es-module-lexer: 1.7.0
|
||||||
|
pathe: 1.1.2
|
||||||
|
vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
|
vite-node@2.1.9(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1):
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
debug: 4.4.3
|
||||||
|
es-module-lexer: 1.7.0
|
||||||
|
pathe: 1.1.2
|
||||||
|
vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
vite-plugin-dts@3.9.1(@types/node@20.19.27)(rollup@4.54.0)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
|
vite-plugin-dts@3.9.1(@types/node@20.19.27)(rollup@4.54.0)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@microsoft/api-extractor': 7.43.0(@types/node@20.19.27)
|
'@microsoft/api-extractor': 7.43.0(@types/node@20.19.27)
|
||||||
@@ -21699,6 +22031,17 @@ snapshots:
|
|||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
terser: 5.44.1
|
terser: 5.44.1
|
||||||
|
|
||||||
|
vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1):
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.21.5
|
||||||
|
postcss: 8.5.6
|
||||||
|
rollup: 4.54.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.3
|
||||||
|
fsevents: 2.3.3
|
||||||
|
lightningcss: 1.30.2
|
||||||
|
terser: 5.44.1
|
||||||
|
|
||||||
vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
|
vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
@@ -21786,6 +22129,78 @@ snapshots:
|
|||||||
- typescript
|
- typescript
|
||||||
- universal-cookie
|
- universal-cookie
|
||||||
|
|
||||||
|
vitest@2.1.9(@types/node@20.19.27)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1):
|
||||||
|
dependencies:
|
||||||
|
'@vitest/expect': 2.1.9
|
||||||
|
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1))
|
||||||
|
'@vitest/pretty-format': 2.1.9
|
||||||
|
'@vitest/runner': 2.1.9
|
||||||
|
'@vitest/snapshot': 2.1.9
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
'@vitest/utils': 2.1.9
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.4.3
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 1.1.2
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
vite-node: 2.1.9(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.27
|
||||||
|
jsdom: 20.0.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
|
vitest@2.1.9(@types/node@22.19.3)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1):
|
||||||
|
dependencies:
|
||||||
|
'@vitest/expect': 2.1.9
|
||||||
|
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1))
|
||||||
|
'@vitest/pretty-format': 2.1.9
|
||||||
|
'@vitest/runner': 2.1.9
|
||||||
|
'@vitest/snapshot': 2.1.9
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
'@vitest/utils': 2.1.9
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.4.3
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 1.1.2
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
vite-node: 2.1.9(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.3
|
||||||
|
jsdom: 20.0.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
void-elements@3.1.0: {}
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
@@ -21869,6 +22284,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 3.1.1
|
isexe: 3.1.1
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
|
|
||||||
wide-align@1.1.5:
|
wide-align@1.1.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
|
|||||||
Reference in New Issue
Block a user